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 REACTION = z.enum(['πŸ‘', 'πŸ‘Ž', 'πŸ˜„', 'πŸŽ‰', 'πŸ˜•', '❀️', 'πŸš€', 'πŸ‘€']); 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)'), team: z .string() .optional() .describe('Team id (see get_project settings.teams)'), 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'), customFields: z .record(z.string(), z.string().nullable()) .optional() .describe( 'Custom field id β†’ value (see get_project settings.customFields): SELECT one of its options, DATE YYYY-MM-DD, NUMBER a number. Only the fields to change; null clears one', ), }; type IssueFields = { description?: string; area?: string; team?: string; priority?: z.infer; labels?: string[]; figmaUrl?: string; storyPoints?: number; startDate?: string; dueDate?: string; customFields?: Record; }; @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); watching: only the ones they follow.", paramsSchema: z.object({ assignedToMe: z.boolean().default(true), watching: z .boolean() .default(false) .describe( 'Only the tickets the user follows (use with assignedToMe false)', ), categories: z .array(CATEGORY) .optional() .describe('Default: open ones (TODO, IN_PROGRESS, QA)'), priorities: z.array(PRIORITY).optional(), bugsOnly: z .boolean() .optional() .describe('Only types drawn with the bug icon, in each project'), overdue: z .boolean() .optional() .describe('Only open tickets past their due date'), limit: limit(200, 50), }), annotations: READ, }) mine( params: { assignedToMe: boolean; watching: boolean; categories?: z.infer[]; priorities?: z.infer[]; bugsOnly?: boolean; overdue?: boolean; 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, watching: params.watching, category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], priority: params.priorities, bugsOnly: params.bugsOnly, overdue: params.overdue, 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'), teams: ids('Team'), 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)'), watching: z .boolean() .optional() .describe('Only the tickets the user follows'), dueFrom: z.string().optional().describe('Due on or after (ISO date)'), dueTo: z.string().optional().describe('Due on or before (ISO date)'), customFields: z .record(z.string(), z.string()) .optional() .describe( 'Only tickets holding these custom values (field id β†’ value)', ), 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[]; teams?: string[]; priorities?: z.infer[]; assigneeId?: string; unassigned?: boolean; releaseId?: string; labels?: string[]; parent?: string; sprintId?: string; noSprint?: boolean; watching?: boolean; dueFrom?: string; dueTo?: string; customFields?: Record; 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, team: params.teams, priority: params.priorities, assigneeId: params.assigneeId, unassigned: params.unassigned, releaseId: params.releaseId, label: params.labels, parent: params.parent, sprintId: params.sprintId, noSprint: params.noSprint, watching: params.watching, dueFrom: params.dueFrom, dueTo: params.dueTo, field: params.customFields ? Object.entries(params.customFields).map( ([id, value]) => `${id}:${value}`, ) : undefined, 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, reactions: c.reactions.map((r) => ({ emoji: r.emoji, by: r.users.map((u) => u.name), })), })) : 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 } }, }), ), ); } @Tool({ name: 'duplicate_issue', description: "A new ticket like this one: what it says (type, title, description, area, priority, labels, Figma, estimate, dates, parent, checklist steps unticked), not where it stands (first status, unassigned, no sprint, release, comments or files). Answers the copy's key.", paramsSchema: z.object({ issue: issueRef }), annotations: WRITE, }) duplicate({ issue }: { issue: string }, ctx: McpContext) { return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/issues/{issue}/duplicate', { params: { path: { issue } }, }), ), ); } @Tool({ name: 'react_to_comment', description: 'React to a comment (πŸ‘ πŸ‘Ž πŸ˜„ πŸŽ‰ πŸ˜• ❀️ πŸš€ πŸ‘€) or take the reaction back (on: false). Comment ids come with get_issue.', paramsSchema: z.object({ commentId: z.string().min(1), emoji: REACTION, on: z.boolean().default(true), }), annotations: { ...WRITE, idempotentHint: true }, }) react( params: { commentId: string; emoji: z.infer; on: boolean; }, ctx: McpContext, ) { const { commentId, ...body } = params; return toolResult(() => this.api.data( this.api.as(ctx).POST('/api/comments/{commentId}/reactions', { params: { path: { commentId } }, body, }), ), ); } @Tool({ name: 'vote_issue', description: 'Vote for a ticket (on) or take the vote back: anyone signed in on a public project, members on a private one. Votes say what people want done.', paramsSchema: z.object({ issue: issueRef, on: z.boolean().default(true) }), annotations: { ...WRITE, idempotentHint: true }, }) vote({ issue, on }: { issue: string; on: boolean }, ctx: McpContext) { const client = this.api.as(ctx); const options = { params: { path: { issue } } }; return toolResult(() => this.api.data( on ? client.PUT('/api/issues/{issue}/vote', options) : client.DELETE('/api/issues/{issue}/vote', options), ), ); } }