Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ const READ_ONLY = [
'list_issue_links',
'list_issue_comments',
'list_cycles',
'get_cycle',
'list_cycle_issues',
'list_milestones',
'get_milestone',
'list_track_users',
'search_track',
];
Expand Down
51 changes: 51 additions & 0 deletions mcp/packages/ferrtrack-mcp/src/tools/__tests__/writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -130,4 +132,53 @@ 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-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);
});

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);
}
});
});
30 changes: 7 additions & 23 deletions mcp/packages/ferrtrack-mcp/src/tools/cycles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface Cycle {
starts_at: string;
ends_at: string;
status: string;
created_by: string;
created_at: string;
updated_at: string;
}
Expand All @@ -23,22 +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'),
},
{ readOnlyHint: true },
async ({ cycle_id }) => {
const token = await getToken();
const cycle = await trackRequest<Cycle>(`/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.',
Expand Down Expand Up @@ -79,12 +64,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({ 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();
Expand Down Expand Up @@ -122,13 +107,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({ offset: true }).optional(),
ends_at: z.iso.datetime({ offset: true }).optional(),
},
async ({ cycle_id, ...patch }) => {
const token = await getToken();
Expand Down
43 changes: 12 additions & 31 deletions mcp/packages/ferrtrack-mcp/src/tools/milestones.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +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'),
},
{ readOnlyHint: true },
async ({ milestone_id }) => {
const token = await getToken();
const milestone = await trackRequest<Milestone>(
`/milestones/${encodeURIComponent(milestone_id)}`,
{ token },
);
return {
content: [{ type: 'text' as const, text: toToolText(milestone) }],
};
},
);

server.tool(
'list_milestones',
'List milestones for a FerrTrack project.',
Expand All @@ -54,14 +35,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<Milestone>(
`/projects/${encodeURIComponent(project_slug)}/milestones`,
Expand All @@ -70,8 +51,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,
},
},
);
Expand All @@ -83,13 +64,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();
Expand Down
Loading