From 276b9a4d01fe9c525439b28a4a10c59d8b3f2118 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sun, 25 Oct 2026 09:49:55 +0100 Subject: [PATCH 1/2] fix(mcp): align cycle and milestone tools with the api contract --- mcp/README.md | 16 +++--- .../src/tools/__tests__/writes.test.ts | 50 +++++++++++++++++++ .../ferrtrack-mcp/src/tools/cycles.ts | 29 +++-------- .../ferrtrack-mcp/src/tools/milestones.ts | 42 +++++----------- 4 files changed, 77 insertions(+), 60 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 8e9a98a..2c47f29 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -24,14 +24,14 @@ Tools marked **destructive** are irreversible or high-impact. MCP clients should ### `@ferrtrack/mcp` -| Tool | Notes | -| ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | -| `list_track_projects`, `get_project`, `create_project`, `update_project` | projects | -| `list_track_issues`, `get_issue`, `create_issue`, `update_issue`, `list_issue_links` | issues | -| `list_issue_comments`, `create_issue_comment`, `update_issue_comment`, `delete_issue_comment` | `delete_issue_comment` is **destructive** | -| `list_cycles`, `get_cycle`, `create_cycle`, `update_cycle`, `delete_cycle`, `list_cycle_issues`, `plan_next_cycle` | `delete_cycle` is **destructive** | -| `list_milestones`, `get_milestone`, `create_milestone`, `update_milestone`, `delete_milestone` | `delete_milestone` is **destructive** | -| `list_track_users`, `search_track` | read | +| Tool | Notes | +| ----------------------------------------------------------------------------------------------------- | ----------------------------------------- | +| `list_track_projects`, `get_project`, `create_project`, `update_project` | projects | +| `list_track_issues`, `get_issue`, `create_issue`, `update_issue`, `list_issue_links` | issues | +| `list_issue_comments`, `create_issue_comment`, `update_issue_comment`, `delete_issue_comment` | `delete_issue_comment` is **destructive** | +| `list_cycles`, `create_cycle`, `update_cycle`, `delete_cycle`, `list_cycle_issues`, `plan_next_cycle` | `delete_cycle` is **destructive** | +| `list_milestones`, `create_milestone`, `update_milestone`, `delete_milestone` | `delete_milestone` is **destructive** | +| `list_track_users`, `search_track` | read | ## Resources and prompts 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..c7b7425 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts @@ -54,9 +54,11 @@ describe('ferrtrack write tools', () => { mockFetch.mockResolvedValue(ok()); const { registerIssueTools } = await import('../issues.js'); const { registerCycleTools } = await import('../cycles.js'); + const { registerMilestoneTools } = await import('../milestones.js'); const { registerCommentTools } = await import('../comments.js'); registerIssueTools(mockServer); registerCycleTools(mockServer); + registerMilestoneTools(mockServer); registerCommentTools(mockServer); }); @@ -130,4 +132,52 @@ describe('ferrtrack write tools', () => { expect(status.safeParse('closed').success).toBe(true); expect(status.safeParse('done').success).toBe(false); }); + + it('does not expose get tools for routes the API does not serve', () => { + expect(handlers.has('get_cycle')).toBe(false); + expect(handlers.has('get_milestone')).toBe(false); + }); + + it('create_cycle requires ISO datetimes, since the API rejects plain dates', () => { + const { starts_at, ends_at } = schemas.get('create_cycle')!; + expect(starts_at.safeParse('2026-01-05T00:00:00Z').success).toBe(true); + expect(starts_at.safeParse('2026-01-05').success).toBe(false); + expect(ends_at.safeParse('2026-01-19').success).toBe(false); + }); + + it('update_cycle has no status field, since status is derived from dates', () => { + expect(schemas.get('update_cycle')).not.toHaveProperty('status'); + }); + + it('create_milestone sends summary and target_date', async () => { + await handlers.get('create_milestone')!({ + project_slug: 'web', + name: 'v1', + summary: 'first release', + target_date: '2026-03-01', + }); + + const call = lastCall(); + expect(call.url).toBe(`${TRACK}/projects/web/milestones`); + expect(call.body).toEqual({ name: 'v1', summary: 'first release', target_date: '2026-03-01' }); + }); + + it('update_milestone patches summary and target_date and nothing else', async () => { + await handlers.get('update_milestone')!({ milestone_id: 'm1', target_date: null }); + + const call = lastCall(); + expect(call.url).toBe(`${TRACK}/milestones/m1`); + expect(call.method).toBe('PATCH'); + expect(call.body).toEqual({ target_date: null }); + }); + + it('update_milestone accepts the API status values only', () => { + const { status } = schemas.get('update_milestone')!; + for (const ok of ['planned', 'in_progress', 'shipped', 'canceled']) { + expect(status.safeParse(ok).success).toBe(true); + } + for (const bad of ['open', 'completed', 'cancelled']) { + expect(status.safeParse(bad).success).toBe(false); + } + }); }); diff --git a/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts b/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts index 75d059e..579ccdc 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts @@ -10,6 +10,7 @@ interface Cycle { starts_at: string; ends_at: string; status: string; + created_by: string; created_at: string; updated_at: string; } @@ -23,21 +24,6 @@ interface CycleIssue { } export function registerCycleTools(server: McpServer) { - server.tool( - 'get_cycle', - 'Get a single FerrTrack cycle by id.', - { - cycle_id: z.string().min(1).describe('Cycle id'), - }, - async ({ cycle_id }) => { - const token = await getToken(); - const cycle = await trackRequest(`/cycles/${encodeURIComponent(cycle_id)}`, { token }); - return { - content: [{ type: 'text' as const, text: toToolText(cycle) }], - }; - }, - ); - server.tool( 'list_cycles', 'List cycles (sprints) for a FerrTrack project.', @@ -76,12 +62,12 @@ export function registerCycleTools(server: McpServer) { server.tool( 'create_cycle', - 'Create a new cycle in a project. Dates are ISO 8601 (YYYY-MM-DD).', + 'Create a new cycle in a project. Dates are ISO 8601 datetimes (e.g. 2026-01-05T00:00:00Z).', { project_slug: z.string().min(1).describe('Project slug'), name: z.string().min(1).max(100), - starts_at: z.string().describe('ISO date — cycle start.'), - ends_at: z.string().describe('ISO date — cycle end.'), + starts_at: z.iso.datetime().describe('ISO datetime — cycle start.'), + ends_at: z.iso.datetime().describe('ISO datetime — cycle end.'), }, async ({ project_slug, name, starts_at, ends_at }) => { const token = await getToken(); @@ -119,13 +105,12 @@ export function registerCycleTools(server: McpServer) { server.tool( 'update_cycle', - 'Patch a cycle — rename it, shift dates, or close it. Only fields you pass are touched.', + 'Patch a cycle — rename it, or shift dates. Status is derived from the dates. Only fields you pass are touched.', { cycle_id: z.string().min(1).describe('Cycle id'), name: z.string().min(1).max(100).optional(), - starts_at: z.string().optional(), - ends_at: z.string().optional(), - status: z.enum(['planned', 'active', 'completed']).optional(), + starts_at: z.iso.datetime().optional(), + ends_at: z.iso.datetime().optional(), }, async ({ cycle_id, ...patch }) => { const token = await getToken(); diff --git a/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts b/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts index ee202cd..1bf7b42 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/milestones.ts @@ -6,32 +6,14 @@ interface Milestone { id: string; project_id: string; name: string; - description: string | null; - due_at: string | null; + summary: string | null; + target_date: string | null; status: string; created_at: string; updated_at: string; } export function registerMilestoneTools(server: McpServer) { - server.tool( - 'get_milestone', - 'Get a single FerrTrack milestone by id.', - { - milestone_id: z.string().min(1).describe('Milestone id'), - }, - async ({ milestone_id }) => { - const token = await getToken(); - const milestone = await trackRequest( - `/milestones/${encodeURIComponent(milestone_id)}`, - { token }, - ); - return { - content: [{ type: 'text' as const, text: toToolText(milestone) }], - }; - }, - ); - server.tool( 'list_milestones', 'List milestones for a FerrTrack project.', @@ -52,14 +34,14 @@ export function registerMilestoneTools(server: McpServer) { server.tool( 'create_milestone', - 'Create a milestone on a project. Due date is optional.', + 'Create a milestone on a project. Target date is optional.', { project_slug: z.string().min(1).describe('Project slug'), name: z.string().min(1).max(100), - description: z.string().max(2000).optional(), - due_at: z.string().optional().describe('ISO date.'), + summary: z.string().max(2000).optional(), + target_date: z.iso.date().optional().describe('ISO date (YYYY-MM-DD).'), }, - async ({ project_slug, name, description, due_at }) => { + async ({ project_slug, name, summary, target_date }) => { const token = await getToken(); const milestone = await trackRequest( `/projects/${encodeURIComponent(project_slug)}/milestones`, @@ -68,8 +50,8 @@ export function registerMilestoneTools(server: McpServer) { method: 'POST', body: { name, - description: description ?? null, - due_at: due_at ?? null, + summary: summary ?? null, + target_date: target_date ?? null, }, }, ); @@ -81,13 +63,13 @@ export function registerMilestoneTools(server: McpServer) { server.tool( 'update_milestone', - 'Patch a milestone — rename, retitle description, shift due date, flip status.', + 'Patch a milestone — rename, edit summary, shift target date, flip status.', { milestone_id: z.string().min(1).describe('Milestone id'), name: z.string().min(1).max(100).optional(), - description: z.string().max(2000).nullable().optional(), - due_at: z.string().nullable().optional(), - status: z.enum(['open', 'completed', 'cancelled']).optional(), + summary: z.string().max(2000).nullable().optional(), + target_date: z.iso.date().nullable().optional(), + status: z.enum(['planned', 'in_progress', 'shipped', 'canceled']).optional(), }, async ({ milestone_id, ...patch }) => { const token = await getToken(); From 913a0b5ed69494528f1660135e132ce634ebabe9 Mon Sep 17 00:00:00 2001 From: BryanFRD Date: Sun, 25 Oct 2026 09:58:22 +0100 Subject: [PATCH 2/2] fix(mcp): accept offset datetimes for cycle dates --- .../ferrtrack-mcp/src/tools/__tests__/writes.test.ts | 1 + mcp/packages/ferrtrack-mcp/src/tools/cycles.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) 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 c7b7425..43a98b8 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts @@ -141,6 +141,7 @@ describe('ferrtrack write tools', () => { it('create_cycle requires ISO datetimes, since the API rejects plain dates', () => { const { starts_at, ends_at } = schemas.get('create_cycle')!; expect(starts_at.safeParse('2026-01-05T00:00:00Z').success).toBe(true); + expect(starts_at.safeParse('2026-01-05T00:00:00+02:00').success).toBe(true); expect(starts_at.safeParse('2026-01-05').success).toBe(false); expect(ends_at.safeParse('2026-01-19').success).toBe(false); }); diff --git a/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts b/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts index 579ccdc..1b97b51 100644 --- a/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts +++ b/mcp/packages/ferrtrack-mcp/src/tools/cycles.ts @@ -66,8 +66,8 @@ export function registerCycleTools(server: McpServer) { { project_slug: z.string().min(1).describe('Project slug'), name: z.string().min(1).max(100), - starts_at: z.iso.datetime().describe('ISO datetime — cycle start.'), - ends_at: z.iso.datetime().describe('ISO datetime — cycle end.'), + starts_at: z.iso.datetime({ offset: true }).describe('ISO datetime — cycle start.'), + ends_at: z.iso.datetime({ offset: true }).describe('ISO datetime — cycle end.'), }, async ({ project_slug, name, starts_at, ends_at }) => { const token = await getToken(); @@ -109,8 +109,8 @@ export function registerCycleTools(server: McpServer) { { cycle_id: z.string().min(1).describe('Cycle id'), name: z.string().min(1).max(100).optional(), - starts_at: z.iso.datetime().optional(), - ends_at: z.iso.datetime().optional(), + starts_at: z.iso.datetime({ offset: true }).optional(), + ends_at: z.iso.datetime({ offset: true }).optional(), }, async ({ cycle_id, ...patch }) => { const token = await getToken();