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>
244 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|