import { McpContext, Resolver, Tool } from '@nestjs-mcp/server'; import { z } from 'zod'; import { TaskStreamApi, type TaskStreamClient } from '../api/taskstream-api'; import { issueRef, projectRef } from './params'; import { issueLine } 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 PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']); const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** * How the work is organised: epics and the tickets hanging from them, the * backlog order, changes to many tickets at once and the trash. */ @Resolver() export class BacklogResolver { constructor(private readonly api: TaskStreamApi) {} @Tool({ name: 'list_epics', description: 'Epics of a project (top-level tickets that group others) with the progress of the tickets hanging from each one.', paramsSchema: z.object({ project: projectRef }), annotations: READ, }) epics({ project }: { project: string }, ctx: McpContext) { return toolResult(async () => ( await this.api.data( this.api.as(ctx).GET('/api/projects/{project}/epics', { params: { path: { project } }, }), ) ).map((epic) => ({ ...issueLine(epic.issue), startDate: epic.issue.startDate, progress: epic.progress, })), ); } @Tool({ name: 'set_issue_parent', description: 'Place a ticket under an epic or another ticket of the same project (three levels at most: epic → ticket → subtask), or back to the top (null).', paramsSchema: z.object({ issue: issueRef, parent: z.string().nullable().describe('Epic or ticket key, or null'), }), annotations: { ...WRITE, idempotentHint: true }, }) setParent( { issue, parent }: { issue: string; parent: string | null }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).PUT('/api/issues/{issue}/parent', { params: { path: { issue } }, body: { parent }, }), ), ); } @Tool({ name: 'rank_issue', description: 'Move a ticket in the backlog (board columns follow the same order): say which ticket ends up right above it or right below it; above null puts it at the top, below null at the bottom.', paramsSchema: z.object({ issue: issueRef, above: z .string() .nullable() .optional() .describe('Ticket that ends up right above (null: to the top)'), below: z .string() .nullable() .optional() .describe('Ticket that ends up right below (null: to the bottom)'), }), annotations: { ...WRITE, idempotentHint: true }, }) rank( params: { issue: string; above?: string | null; below?: string | null }, ctx: McpContext, ) { const { issue, ...body } = params; return toolResult(() => this.api.data( this.api.as(ctx).PUT('/api/issues/{issue}/rank', { params: { path: { issue } }, body, }), ), ); } @Tool({ name: 'bulk_update_issues', description: 'The same change on up to 100 tickets: status, assignee, sprint, release, priority, extra labels. Each ticket follows the normal rules and may refuse on its own; the answer says which.', paramsSchema: z.object({ issues: z.array(issueRef).min(1).max(100), status: z.string().optional().describe('Status id'), assigneeId: z.string().optional().describe('User id of a member'), unassign: z.boolean().optional(), sprintId: z.string().optional(), noSprint: z.boolean().optional().describe('Back to the backlog'), releaseId: z.string().optional(), noRelease: z.boolean().optional().describe('Out of their release'), priority: PRIORITY.optional(), addLabels: z.array(z.string().max(40)).max(20).optional(), }), annotations: WRITE, }) bulk( params: { issues: string[]; status?: string; assigneeId?: string; unassign?: boolean; sprintId?: string; noSprint?: boolean; releaseId?: string; noRelease?: boolean; priority?: z.infer; addLabels?: string[]; }, ctx: McpContext, ) { const { issues, ...change } = params; return toolResult(async () => { const client = this.api.as(ctx); const refs = await this.ids(client, issues); const answer = await this.api.data( client.POST('/api/issues/bulk', { body: { issueIds: refs.map((r) => r.id), ...change }, }), ); const keyOf = new Map(refs.map((r) => [r.id, r.ref])); return { updated: answer.updated, refused: answer.results .filter((r) => !r.ok) .map((r) => ({ issue: keyOf.get(r.issueId), error: r.error })), }; }); } @Tool({ name: 'delete_issue', description: 'Move a ticket to the trash (whoever opened it, or an owner/admin). It disappears everywhere; restore_issue brings it back within 30 days, then it is deleted for good.', paramsSchema: z.object({ issue: issueRef }), annotations: { readOnlyHint: false, destructiveHint: true }, }) trash({ issue }: { issue: string }, ctx: McpContext) { return toolResult(() => this.api.data( this.api.as(ctx).DELETE('/api/issues/{issue}', { params: { path: { issue } }, }), ), ); } @Tool({ name: 'restore_issue', description: 'Bring a ticket back from the trash.', paramsSchema: z.object({ issue: issueRef }), annotations: { ...WRITE, idempotentHint: true }, }) restore({ issue }: { issue: string }, ctx: McpContext) { return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/issues/{issue}/restore', { params: { path: { issue } }, }), ), ); } @Tool({ name: 'list_trash', description: "A project's tickets in the trash, newest first.", paramsSchema: z.object({ project: projectRef }), annotations: READ, }) listTrash({ project }: { project: string }, ctx: McpContext) { return toolResult(async () => ( await this.api.data( this.api.as(ctx).GET('/api/projects/{project}/trash', { params: { path: { project } }, }), ) ).map((issue) => ({ ...issueLine(issue), trashedAt: issue.trashedAt })), ); } /** * The bulk endpoint takes ids; people and models talk in keys. Keys are * looked up a few at a time, ids pass as they are. */ private async ids( client: TaskStreamClient, refs: string[], ): Promise<{ ref: string; id: string }[]> { const found: { ref: string; id: string }[] = []; for (let i = 0; i < refs.length; i += 10) { const batch = refs.slice(i, i + 10); found.push( ...(await Promise.all( batch.map(async (ref) => UUID.test(ref) ? { ref, id: ref } : { ref, id: ( await this.api.data( client.GET('/api/issues/{issue}', { params: { path: { issue: ref } }, }), ) ).id, }, ), )), ); } return found; } }