import { McpContext, Resolver, Tool } from '@nestjs-mcp/server'; import { z } from 'zod'; import { TaskStreamApi } from '../api/taskstream-api'; import { issueRef, limit, 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 REMOVE = { readOnlyHint: false, destructiveHint: true, openWorldHint: false, } as const; const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']); const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']); const LINK_TYPE = z.enum([ 'BLOCKS', 'DEPENDS_ON', 'TESTS', 'DUPLICATES', 'RELATES_TO', ]); const ids = (what: string) => z .array(z.string()) .optional() .describe(`${what} ids of the project's settings (see get_project)`); const issueFields = { description: z.string().max(50000).optional(), area: z.string().optional().describe('Area id (see get_project)'), priority: PRIORITY.optional(), labels: z.array(z.string().max(40)).max(20).optional(), figmaUrl: z.string().url().optional().describe('Figma frame (https)'), storyPoints: z.number().int().min(0).max(100).optional(), startDate: z .string() .optional() .describe('ISO date when work should start (roadmap)'), dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'), }; type IssueFields = { description?: string; area?: string; priority?: z.infer; labels?: string[]; figmaUrl?: string; storyPoints?: number; startDate?: string; dueDate?: string; }; @Resolver() export class IssuesResolver { constructor(private readonly api: TaskStreamApi) {} /* ---- Reading ---- */ @Tool({ name: 'my_issues', description: "Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).", paramsSchema: z.object({ assignedToMe: z.boolean().default(true), categories: z .array(CATEGORY) .optional() .describe('Default: open ones (TODO, IN_PROGRESS, QA)'), limit: limit(200, 50), }), annotations: READ, }) mine( params: { assignedToMe: boolean; categories?: z.infer[]; limit: number; }, ctx: McpContext, ) { return toolResult(async () => { const page = await this.api.data( this.api.as(ctx).GET('/api/me/issues', { params: { query: { assignedToMe: params.assignedToMe, category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], limit: params.limit, }, }, }), ); return { total: page.total, items: page.items.map(issueLine) }; }); } @Tool({ name: 'search_issues', description: 'Search the tickets of a project by text (key, title, description) and filters. The backlog: noSprint with sort RANK.', paramsSchema: z.object({ project: projectRef, search: z.string().max(200).optional(), statuses: ids('Status'), categories: z.array(CATEGORY).optional(), types: ids('Ticket type'), areas: ids('Area'), priorities: z.array(PRIORITY).optional(), assigneeId: z.string().optional().describe('User id'), unassigned: z.boolean().optional(), releaseId: z.string().optional(), labels: z.array(z.string()).optional(), parent: z .string() .optional() .describe('Only the tickets hanging from this epic or ticket (key)'), sprintId: z.string().optional().describe('Only this sprint'), noSprint: z .boolean() .optional() .describe('Only tickets in no sprint (the backlog)'), dueFrom: z.string().optional().describe('Due on or after (ISO date)'), dueTo: z.string().optional().describe('Due on or before (ISO date)'), sort: z .enum(['RECENT', 'RANK']) .default('RECENT') .describe('RECENT: last updated first. RANK: backlog order'), limit: limit(200, 30), offset: z.number().int().min(0).default(0), }), annotations: READ, }) search( params: { project: string; search?: string; statuses?: string[]; categories?: z.infer[]; types?: string[]; areas?: string[]; priorities?: z.infer[]; assigneeId?: string; unassigned?: boolean; releaseId?: string; labels?: string[]; parent?: string; sprintId?: string; noSprint?: boolean; dueFrom?: string; dueTo?: string; sort: 'RECENT' | 'RANK'; limit: number; offset: number; }, ctx: McpContext, ) { return toolResult(async () => { const page = await this.api.data( this.api.as(ctx).GET('/api/projects/{project}/issues', { params: { path: { project: params.project }, query: { search: params.search, status: params.statuses, category: params.categories, type: params.types, area: params.areas, priority: params.priorities, assigneeId: params.assigneeId, unassigned: params.unassigned, releaseId: params.releaseId, label: params.labels, parent: params.parent, sprintId: params.sprintId, noSprint: params.noSprint, dueFrom: params.dueFrom, dueTo: params.dueTo, sort: params.sort, limit: params.limit, offset: params.offset, }, }, }), ); return { total: page.total, items: page.items.map(issueLine) }; }); } @Tool({ name: 'get_issue', description: 'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, and optionally its comments and history.', paramsSchema: z.object({ issue: issueRef, withComments: z.boolean().default(true), withHistory: z.boolean().default(false), }), annotations: READ, }) getIssue( params: { issue: string; withComments: boolean; withHistory: boolean }, ctx: McpContext, ) { return toolResult(async () => { const client = this.api.as(ctx); const path = { issue: params.issue }; const issue = await this.api.data( client.GET('/api/issues/{issue}', { params: { path } }), ); const comments = params.withComments ? ( await this.api.data( client.GET('/api/issues/{issue}/comments', { params: { path, query: { limit: 100 } }, }), ) ).items.map((c) => ({ id: c.id, replyTo: c.replyTo, author: c.deleted ? null : c.author.name, at: c.createdAt, body: c.deleted ? '(deleted; kept for its replies)' : c.body, })) : undefined; const history = params.withHistory ? ( await this.api.data( client.GET('/api/issues/{issue}/history', { params: { path, query: { limit: 100 } }, }), ) ).map((a) => ({ at: a.occurredAt, summary: a.summary })) : undefined; return { ...issueLine(issue), description: issue.description, reporter: issue.reporter.name, figmaUrl: issue.figmaUrl, startDate: issue.startDate, allowedTransitions: issue.allowedTransitions, parent: issue.parent, checklist: issue.checklist, links: issue.links, devLinks: issue.devLinks.map((d) => ({ kind: d.kind, repository: d.repository, ref: d.ref, title: d.title, state: d.state, url: d.url, })), watchers: issue.watchers.map((w) => w.name), attachments: issue.attachments.length, trashedAt: issue.trashedAt, createdAt: issue.createdAt, comments, history, }; }); } /* ---- Writing ---- */ @Tool({ name: 'create_issue', description: 'Create a ticket. Type and area are ids of the project settings (see get_project); it starts in the first pending status.', paramsSchema: z.object({ project: projectRef, type: z.string().describe('Ticket type id, e.g. BUG'), title: z.string().min(1).max(255), assigneeId: z.string().optional().describe('User id (a project member)'), releaseId: z.string().optional(), parent: z .string() .optional() .describe('Epic or ticket of the same project it breaks down (key)'), ...issueFields, }), annotations: WRITE, }) create( params: IssueFields & { project: string; type: string; title: string; assigneeId?: string; releaseId?: string; parent?: string; }, ctx: McpContext, ) { const { project, ...body } = params; return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/projects/{project}/issues', { params: { path: { project } }, body, }), ), ); } @Tool({ name: 'update_issue', description: 'Change the title, description, type, area, priority, labels…', paramsSchema: z.object({ issue: issueRef, title: z.string().min(1).max(255).optional(), type: z.string().optional().describe('Ticket type id'), ...issueFields, }), annotations: { ...WRITE, idempotentHint: true }, }) update( params: IssueFields & { issue: string; title?: string; type?: string }, ctx: McpContext, ) { const { issue, ...body } = params; return toolResult(() => this.api.data( this.api.as(ctx).PATCH('/api/issues/{issue}', { params: { path: { issue } }, body, }), ), ); } @Tool({ name: 'change_issue_status', description: "Move a ticket to another status. It must be one of the ticket's allowedTransitions (see get_issue); some statuses need a reason.", paramsSchema: z.object({ issue: issueRef, status: z.string().describe('Status id, e.g. IN_PROGRESS'), reason: z.string().max(5000).optional(), }), annotations: WRITE, }) changeStatus( { issue, status, reason, }: { issue: string; status: string; reason?: string }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).PUT('/api/issues/{issue}/status', { params: { path: { issue } }, body: { status, reason }, }), ), ); } @Tool({ name: 'assign_issue', description: 'Assign a ticket to a project member, or unassign it (null).', paramsSchema: z.object({ issue: issueRef, assigneeId: z.string().nullable().describe('User id, or null'), }), annotations: { ...WRITE, idempotentHint: true }, }) assign( { issue, assigneeId }: { issue: string; assigneeId: string | null }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).PUT('/api/issues/{issue}/assignee', { params: { path: { issue } }, body: { assigneeId }, }), ), ); } @Tool({ name: 'set_issue_release', description: 'Put a ticket in a planned release, or take it out (null).', paramsSchema: z.object({ issue: issueRef, releaseId: z.string().nullable(), }), annotations: { ...WRITE, idempotentHint: true }, }) setRelease( { issue, releaseId }: { issue: string; releaseId: string | null }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).PUT('/api/issues/{issue}/release', { params: { path: { issue } }, body: { releaseId }, }), ), ); } @Tool({ name: 'add_comment', description: 'Comment on a ticket (decisions, explanations, steps), or answer a comment with replyTo (one level: answering a reply joins its thread).', paramsSchema: z.object({ issue: issueRef, body: z.string().min(1).max(20000), replyTo: z .string() .optional() .describe('Id of the comment it answers (see get_issue)'), }), annotations: WRITE, }) comment( { issue, body, replyTo }: { issue: string; body: string; replyTo?: string }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/issues/{issue}/comments', { params: { path: { issue } }, body: { body, replyTo }, }), ), ); } @Tool({ name: 'link_issues', description: 'Relate two tickets, e.g. WEB-3 DEPENDS_ON API-7 (they may be in different projects).', paramsSchema: z.object({ issue: issueRef, type: LINK_TYPE, target: issueRef, }), annotations: WRITE, }) link( params: { issue: string; type: z.infer; target: string }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/issues/{issue}/links', { params: { path: { issue: params.issue } }, body: { type: params.type, targetIssue: params.target }, }), ), ); } @Tool({ name: 'unlink_issues', description: 'Remove a relation between two tickets (as link_issues made it).', paramsSchema: z.object({ issue: issueRef, type: LINK_TYPE, target: issueRef, }), annotations: REMOVE, }) unlink( params: { issue: string; type: z.infer; target: string }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).DELETE('/api/issues/{issue}/links/{type}/{target}', { params: { path: params }, }), ), ); } @Tool({ name: 'watch_issue', description: 'Follow a ticket (notifications of its changes and comments) or stop following it.', paramsSchema: z.object({ issue: issueRef, follow: z.boolean().default(true), }), annotations: { ...WRITE, idempotentHint: true }, }) watch( { issue, follow }: { issue: string; follow: boolean }, ctx: McpContext, ) { const client = this.api.as(ctx); const options = { params: { path: { issue } } }; return toolResult(() => this.api.data( follow ? client.PUT('/api/issues/{issue}/watch', options) : client.DELETE('/api/issues/{issue}/watch', options), ), ); } @Tool({ name: 'edit_comment', description: 'Reword a comment (only its author). Comment ids come with get_issue.', paramsSchema: z.object({ commentId: z.string().min(1), body: z.string().min(1).max(20000), }), annotations: { ...WRITE, idempotentHint: true }, }) editComment( { commentId, body }: { commentId: string; body: string }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).PATCH('/api/comments/{commentId}', { params: { path: { commentId } }, body: { body }, }), ), ); } @Tool({ name: 'delete_comment', description: 'Delete a comment (its author or a project admin).', paramsSchema: z.object({ commentId: z.string().min(1) }), annotations: REMOVE, }) deleteComment({ commentId }: { commentId: string }, ctx: McpContext) { return toolResult(() => this.api.data( this.api.as(ctx).DELETE('/api/comments/{commentId}', { params: { path: { commentId } }, }), ), ); } /* ---- Checklist ---- */ @Tool({ name: 'add_checklist_item', description: "Add a step to a ticket's checklist.", paramsSchema: z.object({ issue: issueRef, text: z.string().min(1).max(300), }), annotations: WRITE, }) addChecklistItem( { issue, text }: { issue: string; text: string }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/issues/{issue}/checklist', { params: { path: { issue } }, body: { text }, }), ), ); } @Tool({ name: 'update_checklist_item', description: 'Tick, untick or reword a checklist step. Item ids come with get_issue.', paramsSchema: z.object({ issue: issueRef, item: z.string().min(1).describe('Checklist item id'), done: z.boolean().optional(), text: z.string().min(1).max(300).optional(), }), annotations: { ...WRITE, idempotentHint: true }, }) updateChecklistItem( params: { issue: string; item: string; done?: boolean; text?: string }, ctx: McpContext, ) { const { issue, item, ...body } = params; return toolResult(() => this.api.data( this.api.as(ctx).PATCH('/api/issues/{issue}/checklist/{item}', { params: { path: { issue, item } }, body, }), ), ); } @Tool({ name: 'remove_checklist_item', description: "Remove a step from a ticket's checklist.", paramsSchema: z.object({ issue: issueRef, item: z.string().min(1).describe('Checklist item id'), }), annotations: REMOVE, }) removeChecklistItem( { issue, item }: { issue: string; item: string }, ctx: McpContext, ) { return toolResult(() => this.api.data( this.api.as(ctx).DELETE('/api/issues/{issue}/checklist/{item}', { params: { path: { issue, item } }, }), ), ); } }