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>
This commit is contained in:
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal file
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user