From 16e6d315346e3fa04e0bfa7bb9ea44e85cb905eb Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sun, 25 Oct 2026 10:39:19 +0100 Subject: [PATCH] fix(mcp): annotate destructive and read-only tools --- .../src/tools/__tests__/annotations.test.ts | 69 +++++++++++++++++++ .../src/tools/__tests__/writes.test.ts | 4 +- .../ferrtrack-mcp/src/tools/comments.ts | 2 + .../ferrtrack-mcp/src/tools/cycles.ts | 4 ++ .../ferrtrack-mcp/src/tools/issue-details.ts | 1 + .../ferrtrack-mcp/src/tools/issues.ts | 2 + .../ferrtrack-mcp/src/tools/milestones.ts | 3 + .../ferrtrack-mcp/src/tools/projects.ts | 2 + .../ferrtrack-mcp/src/tools/search.ts | 2 + 9 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 mcp/packages/ferrtrack-mcp/src/tools/__tests__/annotations.test.ts diff --git a/mcp/packages/ferrtrack-mcp/src/tools/__tests__/annotations.test.ts b/mcp/packages/ferrtrack-mcp/src/tools/__tests__/annotations.test.ts new file mode 100644 index 0000000..61b28f9 --- /dev/null +++ b/mcp/packages/ferrtrack-mcp/src/tools/__tests__/annotations.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { McpServer } from '@ferrlabs/mcp-core'; + +process.env.FERRLABS_API_TOKEN = 'test-token'; + +type Annotations = { readOnlyHint?: boolean; destructiveHint?: boolean }; + +const annotations = new Map(); +const mockServer = { + tool: vi.fn((name: string, _desc: string, _schema: unknown, ...rest: unknown[]) => { + annotations.set(name, rest.length > 1 ? (rest[0] as Annotations) : undefined); + }), +} as unknown as McpServer; + +const DESTRUCTIVE = ['delete_issue_comment', 'delete_cycle', 'delete_milestone']; +const READ_ONLY = [ + 'list_track_projects', + 'get_project', + 'list_track_issues', + 'get_issue', + 'list_issue_links', + 'list_issue_comments', + 'list_cycles', + 'get_cycle', + 'list_cycle_issues', + 'list_milestones', + 'get_milestone', + 'list_track_users', + 'search_track', +]; + +describe('tool annotations', async () => { + const { registerProjectTools } = await import('../projects.js'); + const { registerIssueDetailsTool } = await import('../issue-details.js'); + const { registerIssueTools } = await import('../issues.js'); + const { registerCommentTools } = await import('../comments.js'); + const { registerCycleTools } = await import('../cycles.js'); + const { registerMilestoneTools } = await import('../milestones.js'); + const { registerSearchTools } = await import('../search.js'); + registerProjectTools(mockServer); + registerIssueDetailsTool(mockServer); + registerIssueTools(mockServer); + registerCommentTools(mockServer); + registerCycleTools(mockServer); + registerMilestoneTools(mockServer); + registerSearchTools(mockServer); + + it.each(DESTRUCTIVE)('%s is marked destructive', (name) => { + expect(annotations.get(name)?.destructiveHint).toBe(true); + }); + + it.each(READ_ONLY)('%s is marked read-only', (name) => { + expect(annotations.get(name)?.readOnlyHint).toBe(true); + }); + + it('marks no other tool destructive or read-only', () => { + const flagged = new Set([...DESTRUCTIVE, ...READ_ONLY]); + for (const [name, a] of annotations) { + if (!flagged.has(name)) { + expect(a?.destructiveHint, name).toBeUndefined(); + expect(a?.readOnlyHint, name).toBeUndefined(); + } + } + }); + + it('covers every registered tool', () => { + expect(annotations.size).toBe(DESTRUCTIVE.length + READ_ONLY.length + 11); + }); +}); diff --git a/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts b/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts index 61d41be..cbe3885 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts @@ -10,8 +10,8 @@ const handlers = new Map(); const schemas = new Map>(); const mockServer = { tool: vi.fn( - (name: string, _desc: string, schema: Record, handler: Handler) => { - handlers.set(name, handler); + (name: string, _desc: string, schema: Record, ...rest: unknown[]) => { + handlers.set(name, rest[rest.length - 1] as Handler); schemas.set(name, schema); }, ), diff --git a/mcp/packages/ferrtrack-mcp/src/tools/comments.ts b/mcp/packages/ferrtrack-mcp/src/tools/comments.ts index 54f151e..8350824 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/comments.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/comments.ts @@ -18,6 +18,7 @@ export function registerCommentTools(server: McpServer) { { issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'), }, + { readOnlyHint: true }, async ({ issue_ref }) => { const token = await getToken(); const comments = await trackRequest( @@ -76,6 +77,7 @@ export function registerCommentTools(server: McpServer) { issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'), comment_id: z.string().min(1).describe('Comment id'), }, + { destructiveHint: true }, async ({ issue_ref, comment_id }) => { const token = await getToken(); await trackRequest( diff --git a/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts b/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts index 75d059e..499b2bb 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts @@ -29,6 +29,7 @@ export function registerCycleTools(server: McpServer) { { cycle_id: z.string().min(1).describe('Cycle id'), }, + { readOnlyHint: true }, async ({ cycle_id }) => { const token = await getToken(); const cycle = await trackRequest(`/cycles/${encodeURIComponent(cycle_id)}`, { token }); @@ -44,6 +45,7 @@ export function registerCycleTools(server: McpServer) { { project_slug: z.string().min(1).describe('Project slug'), }, + { readOnlyHint: true }, async ({ project_slug }) => { const token = await getToken(); const cycles = await trackRequest( @@ -62,6 +64,7 @@ export function registerCycleTools(server: McpServer) { { cycle_id: z.string().min(1).describe('Cycle id'), }, + { readOnlyHint: true }, async ({ cycle_id }) => { const token = await getToken(); const issues = await trackRequest( @@ -150,6 +153,7 @@ export function registerCycleTools(server: McpServer) { { cycle_id: z.string().min(1).describe('Cycle id'), }, + { destructiveHint: true }, async ({ cycle_id }) => { const token = await getToken(); await trackRequest(`/cycles/${encodeURIComponent(cycle_id)}`, { diff --git a/mcp/packages/ferrtrack-mcp/src/tools/issue-details.ts b/mcp/packages/ferrtrack-mcp/src/tools/issue-details.ts index a4464f5..cde10d6 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/issue-details.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/issue-details.ts @@ -26,6 +26,7 @@ export function registerIssueDetailsTool(server: McpServer) { { issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'), }, + { readOnlyHint: true }, async ({ issue_ref }) => { const token = await getToken(); const issue = await trackRequest(`/issues/${encodeURIComponent(issue_ref)}`, { diff --git a/mcp/packages/ferrtrack-mcp/src/tools/issues.ts b/mcp/packages/ferrtrack-mcp/src/tools/issues.ts index 86dff07..83ee424 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/issues.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/issues.ts @@ -65,6 +65,7 @@ export function registerIssueTools(server: McpServer) { .optional() .describe('Max issues to return (default 50).'), }, + { readOnlyHint: true }, async ({ project_slug, status, kind, assignee_id, limit }) => { const issues = await fetchProjectIssues(project_slug, { status, kind, assignee_id, limit }); return { @@ -149,6 +150,7 @@ export function registerIssueTools(server: McpServer) { { issue_ref: z.string().min(1).describe('Issue ref, e.g. "FT-12"'), }, + { readOnlyHint: true }, async ({ issue_ref }) => { const token = await getToken(); const links = await trackRequest(`/issues/${encodeURIComponent(issue_ref)}/links`, { diff --git a/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts b/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts index ee202cd..10a5ec5 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts @@ -20,6 +20,7 @@ export function registerMilestoneTools(server: McpServer) { { milestone_id: z.string().min(1).describe('Milestone id'), }, + { readOnlyHint: true }, async ({ milestone_id }) => { const token = await getToken(); const milestone = await trackRequest( @@ -38,6 +39,7 @@ export function registerMilestoneTools(server: McpServer) { { project_slug: z.string().min(1).describe('Project slug'), }, + { readOnlyHint: true }, async ({ project_slug }) => { const token = await getToken(); const milestones = await trackRequest( @@ -111,6 +113,7 @@ export function registerMilestoneTools(server: McpServer) { { milestone_id: z.string().min(1).describe('Milestone id'), }, + { destructiveHint: true }, async ({ milestone_id }) => { const token = await getToken(); await trackRequest(`/milestones/${encodeURIComponent(milestone_id)}`, { diff --git a/mcp/packages/ferrtrack-mcp/src/tools/projects.ts b/mcp/packages/ferrtrack-mcp/src/tools/projects.ts index dbc14a9..2e5cafa 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/projects.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/projects.ts @@ -22,6 +22,7 @@ export function registerProjectTools(server: McpServer) { 'list_track_projects', "List FerrTrack projects in the caller's active organization.", {}, + { readOnlyHint: true }, async () => { const projects = await fetchProjects(); return { @@ -36,6 +37,7 @@ export function registerProjectTools(server: McpServer) { { project_slug: z.string().min(1).describe('Project slug'), }, + { readOnlyHint: true }, async ({ project_slug }) => { const token = await getToken(); const project = await trackRequest(`/projects/${encodeURIComponent(project_slug)}`, { diff --git a/mcp/packages/ferrtrack-mcp/src/tools/search.ts b/mcp/packages/ferrtrack-mcp/src/tools/search.ts index 547a0c1..1e74726 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/search.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/search.ts @@ -32,6 +32,7 @@ export function registerSearchTools(server: McpServer) { .optional() .describe('Max hits to return (default 25).'), }, + { readOnlyHint: true }, async ({ q, limit }) => { const token = await getToken(); const params = new URLSearchParams({ q }); @@ -47,6 +48,7 @@ export function registerSearchTools(server: McpServer) { 'list_track_users', "List FerrTrack users in the caller's org — useful for resolving an `assignee_id` before calling create_issue or update_issue.", {}, + { readOnlyHint: true }, async () => { const token = await getToken(); const users = await trackRequest('/users', { token });