Files
task-stream/apps/mcp/src/app/tools/issues.resolver.ts
Josep Piguillem 869acd3f1f feat: teams within a project
Projects keep teams of members in their settings (settings.teams; people
outside the project are dropped, teams are archived, not removed; absent
when saving means unchanged). Tickets go to a team (`team`, also filtered
in the list and in MCP search_issues), and a comment mentioning
@[Frontend](team-FRONT) tells each of its members. Settings has the teams
with their members, the ticket a «Equipo» select, the list a team filter,
and «@» offers the teams after the people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:35 +02:00

756 lines
22 KiB
TypeScript

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<typeof PRIORITY>;
labels?: string[];
figmaUrl?: string;
storyPoints?: number;
startDate?: string;
dueDate?: string;
customFields?: Record<string, string | null>;
};
@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<typeof CATEGORY>[];
priorities?: z.infer<typeof PRIORITY>[];
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<typeof CATEGORY>[];
types?: string[];
areas?: string[];
teams?: string[];
priorities?: z.infer<typeof PRIORITY>[];
assigneeId?: string;
unassigned?: boolean;
releaseId?: string;
labels?: string[];
parent?: string;
sprintId?: string;
noSprint?: boolean;
watching?: boolean;
dueFrom?: string;
dueTo?: string;
customFields?: Record<string, 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,
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<typeof LINK_TYPE>; 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<typeof LINK_TYPE>; 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<typeof REACTION>;
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),
),
);
}
}