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 }, }), ), ); } }