feat(mcp): sprints, epics, backlog, trash, checklists and release notes
Thirty new tools (54 in all) for what the API gained since the MCP: create a project and read its public showcase; sprints (list, progress and burndown, plan, change, start, close with carry-over, delete, move a ticket in or out); epics and the backlog (list epics with progress, set a ticket's parent, rank); bulk edit of up to 100 tickets by key, saying which refused; the trash; checklists; following a ticket, removing a relation, editing and deleting comments; editing and archiving releases, drafting and saving their notes; notifications. Searching filters by epic, sprint and dates and sorts by backlog order; get_issue shows parent, checklist, commits and followers; create and update take parent and start date. API: `unreadOnly` and `dryRun` were documented as objects in the OpenAPI; they are booleans now. Chapter 9 lists every tool; Word and PDF regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal file
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||
import { z } from 'zod';
|
||||
import { TaskStreamApi } from '../api/taskstream-api';
|
||||
import { issueRef, limit, projectRef, sprintId } from './params';
|
||||
import { issueLine, sprintLine } from './summaries';
|
||||
import { toolResult } from './tool-result';
|
||||
|
||||
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||
const WRITE = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
const date = (what: string) => z.string().describe(`${what} (ISO date)`);
|
||||
|
||||
@Resolver()
|
||||
export class SprintsResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_sprints',
|
||||
description:
|
||||
"A project's sprints (planned, active and closed) with their dates and goal.",
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
annotations: READ,
|
||||
})
|
||||
list({ project }: { project: string }, ctx: McpContext) {
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/sprints', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
)
|
||||
).map(sprintLine),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_sprint',
|
||||
description:
|
||||
'A sprint with its progress (tickets and story points finished), its tickets in backlog order and, optionally, the day-by-day burndown.',
|
||||
paramsSchema: z.object({
|
||||
sprintId,
|
||||
withBurndown: z.boolean().default(false),
|
||||
limit: limit(200, 100),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
get(
|
||||
params: { sprintId: string; withBurndown: boolean; limit: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const client = this.api.as(ctx);
|
||||
const path = { sprint: params.sprintId };
|
||||
const [sprint, progress, issues] = await Promise.all([
|
||||
this.api.data(
|
||||
client.GET('/api/sprints/{sprint}', { params: { path } }),
|
||||
),
|
||||
this.api.data(
|
||||
client.GET('/api/sprints/{sprint}/progress', { params: { path } }),
|
||||
),
|
||||
this.api.data(
|
||||
client.GET('/api/sprints/{sprint}/issues', {
|
||||
params: { path, query: { limit: params.limit } },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
const burndown = params.withBurndown
|
||||
? await this.api.data(
|
||||
client.GET('/api/sprints/{sprint}/burndown', { params: { path } }),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...sprintLine(sprint),
|
||||
progress,
|
||||
total: issues.total,
|
||||
issues: issues.items.map(issueLine),
|
||||
burndown,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_sprint',
|
||||
description: 'Plan a sprint in a project (owners and admins).',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
name: z.string().min(1).max(80).describe('e.g. Sprint 14'),
|
||||
goal: z.string().max(500).optional(),
|
||||
startDate: date('First day'),
|
||||
endDate: date('Last day'),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
create(
|
||||
params: {
|
||||
project: string;
|
||||
name: string;
|
||||
goal?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { project, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/projects/{project}/sprints', {
|
||||
params: { path: { project } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_sprint',
|
||||
description:
|
||||
'Rename a sprint, change its goal or its dates (not once closed).',
|
||||
paramsSchema: z.object({
|
||||
sprintId,
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
goal: z.string().max(500).nullable().optional(),
|
||||
startDate: date('First day').optional(),
|
||||
endDate: date('Last day').optional(),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
update(
|
||||
params: {
|
||||
sprintId: string;
|
||||
name?: string;
|
||||
goal?: string | null;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { sprintId, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PATCH('/api/sprints/{sprint}', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'start_sprint',
|
||||
description:
|
||||
'Start a planned sprint (only one can be under way per project).',
|
||||
paramsSchema: z.object({ sprintId }),
|
||||
annotations: WRITE,
|
||||
})
|
||||
start({ sprintId }: { sprintId: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/sprints/{sprint}/start', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'close_sprint',
|
||||
description:
|
||||
'Close the sprint under way. Unfinished tickets move to carryOverTo (another open sprint of the project) or back to the backlog; finished ones stay as its record.',
|
||||
paramsSchema: z.object({
|
||||
sprintId,
|
||||
carryOverTo: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Sprint id for the unfinished tickets; absent: the backlog'),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
close(
|
||||
{
|
||||
sprintId,
|
||||
carryOverTo,
|
||||
}: { sprintId: string; carryOverTo?: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/sprints/{sprint}/close', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
body: { carryOverTo },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_sprint',
|
||||
description: 'Delete a sprint that never started.',
|
||||
paramsSchema: z.object({ sprintId }),
|
||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||
})
|
||||
delete({ sprintId }: { sprintId: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).DELETE('/api/sprints/{sprint}', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_issue_sprint',
|
||||
description:
|
||||
'Put a ticket in an open sprint of its project, or back to the backlog (null). For many at once: bulk_update_issues.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
sprintId: z.string().nullable().describe('Sprint id, or null'),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
setSprint(
|
||||
{ issue, sprintId }: { issue: string; sprintId: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PUT('/api/issues/{issue}/sprint', {
|
||||
params: { path: { issue } },
|
||||
body: { sprintId },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user