Files
task-stream/apps/mcp/src/app/tools/backlog.resolver.ts
Josep Piguillem e6ac02d328 feat(mcp): sprints, epics, backlog, trash, checklists and release notes
Thirty new tools (54 in all) for what the API gained since the MCP:
create a project and read its public showcase; sprints (list, progress
and burndown, plan, change, start, close with carry-over, delete, move a
ticket in or out); epics and the backlog (list epics with progress, set
a ticket's parent, rank); bulk edit of up to 100 tickets by key, saying
which refused; the trash; checklists; following a ticket, removing a
relation, editing and deleting comments; editing and archiving releases,
drafting and saving their notes; notifications.

Searching filters by epic, sprint and dates and sorts by backlog order;
get_issue shows parent, checklist, commits and followers; create and
update take parent and start date.

API: `unreadOnly` and `dryRun` were documented as objects in the
OpenAPI; they are booleans now. Chapter 9 lists every tool; Word and PDF
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:17:10 +02:00

244 lines
7.4 KiB
TypeScript

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<typeof PRIORITY>;
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;
}
}