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:
2026-10-09 10:17:10 +02:00
parent 58fe5e8301
commit e6ac02d328
16 changed files with 1129 additions and 54 deletions

View File

@@ -6314,11 +6314,7 @@
"description": "Only unread ones",
"schema": {
"default": false,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "boolean"
}
},
{
@@ -8952,11 +8948,7 @@
"description": "Only tell what would be created",
"schema": {
"default": false,
"allOf": [
{
"$ref": "#/components/schemas/Object"
}
]
"type": "boolean"
}
}
],
@@ -11788,10 +11780,6 @@
"goodFirst"
]
},
"Object": {
"type": "object",
"properties": {}
},
"NotificationKind": {
"type": "string",
"enum": [

View File

@@ -2192,7 +2192,6 @@ export interface components {
/** @description Open tickets labelled good-first-issue */
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
};
Object: Record<string, never>;
/** @enum {string} */
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
NotificationDto: {
@@ -6766,7 +6765,7 @@ export interface operations {
parameters: {
query?: {
/** @description Only unread ones */
unreadOnly?: components["schemas"]["Object"];
unreadOnly?: boolean;
limit?: number;
};
header?: never;
@@ -8679,7 +8678,7 @@ export interface operations {
parameters: {
query?: {
/** @description Only tell what would be created */
dryRun?: components["schemas"]["Object"];
dryRun?: boolean;
};
header?: never;
path: {

View File

@@ -4,16 +4,21 @@ import { McpModule } from '@nestjs-mcp/server';
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
import { HealthController } from './health.controller';
import { McpHostsMiddleware } from './mcp-hosts.middleware';
import { BacklogResolver } from './tools/backlog.resolver';
import { IssuesResolver } from './tools/issues.resolver';
import { NotificationsResolver } from './tools/notifications.resolver';
import { ProjectsResolver } from './tools/projects.resolver';
import { ReleasesResolver } from './tools/releases.resolver';
import { SprintsResolver } from './tools/sprints.resolver';
import { UsersResolver } from './tools/users.resolver';
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, QA, releases).
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, epics, sprints, QA, releases).
Projects and tickets go by key (WEB, WEB-12). Statuses, ticket types and areas are per
project: read their ids with get_project before creating, updating or moving tickets, and
use a ticket's allowedTransitions (get_issue) to move it. Tools act as the user whose Pireon
token the client sends; without one, only public projects can be read.`;
use a ticket's allowedTransitions (get_issue) to move it. Sprints and releases go by id
(list_sprints, list_releases); the backlog is search_issues with noSprint and sort RANK.
Tools act as the user whose Pireon token the client sends; without one, only public
projects can be read.`;
@Module({
imports: [
@@ -44,7 +49,10 @@ token the client sends; without one, only public projects can be read.`;
TaskStreamApi,
ProjectsResolver,
IssuesResolver,
BacklogResolver,
SprintsResolver,
ReleasesResolver,
NotificationsResolver,
UsersResolver,
],
})

View File

@@ -10,6 +10,9 @@ import { AddressInfo } from 'node:net';
import { TASKSTREAM_API_URL } from './api/taskstream-api';
import { AppModule } from './app.module';
const ISSUE_1 = '11111111-1111-4111-8111-111111111111';
const ISSUE_2 = '22222222-2222-4222-8222-222222222222';
/** What the fake API received. */
interface Received {
method: string;
@@ -75,6 +78,38 @@ function fakeApi(received: Received[]): Server {
code: 'BUSINESS_RULE',
message: 'Cannot move',
});
if (req.method === 'POST' && req.url === '/api/projects')
return reply(201, { id: 'p2', key: 'DOCS', version: 1 });
if (req.url === '/api/issues/WEB-1') return reply(200, { id: ISSUE_1 });
if (req.url === '/api/issues/WEB-2') return reply(200, { id: ISSUE_2 });
if (req.url === '/api/issues/bulk')
return reply(200, {
updated: 1,
results: [
{ issueId: ISSUE_1, ok: true, error: null },
{ issueId: ISSUE_2, ok: false, error: 'Cannot move WEB-2' },
],
});
if (req.url === '/api/sprints/s1/close')
return reply(201, { id: 's1', version: 3 });
if (req.url?.startsWith('/api/me/notifications?'))
return reply(200, {
unread: 1,
items: [
{
id: 'n1',
kind: 'MENTIONED',
projectId: 'p1',
projectKey: 'WEB',
issueId: ISSUE_1,
issueKey: 'WEB-1',
actor: { id: '101', name: 'Ana' },
summary: 'Ana te ha mencionado en WEB-1',
readAt: null,
createdAt: '2026-10-09T10:00:00Z',
},
],
});
return reply(404, { statusCode: 404, message: 'Not found' });
});
}
@@ -152,11 +187,24 @@ describe('TaskStream MCP', () => {
'change_issue_status',
'get_release',
'whoami',
'create_project',
'list_epics',
'bulk_update_issues',
'delete_issue',
'list_sprints',
'get_sprint',
'close_sprint',
'draft_release_notes',
'add_checklist_item',
'my_notifications',
]),
);
const byName = new Map(tools.map((t) => [t.name, t]));
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
expect(byName.get('delete_issue')?.annotations).toMatchObject({
destructiveHint: true,
});
});
it("calls the API with the caller's own token and compacts the answer", async () => {
@@ -217,6 +265,68 @@ describe('TaskStream MCP', () => {
expect(received).toHaveLength(0);
});
it('creates projects with the key in capitals', async () => {
const result = await call(
'create_project',
{ key: 'docs', name: 'Documentación', isPublic: true },
'pireon-token',
);
expect(result.isError).toBeUndefined();
expect(received[0]).toMatchObject({
method: 'POST',
url: '/api/projects',
body: { key: 'DOCS', name: 'Documentación', isPublic: true },
});
});
it('changes many tickets by key and says which refused', async () => {
const result = await call(
'bulk_update_issues',
{ issues: ['WEB-1', 'WEB-2'], status: 'DONE' },
'pireon-token',
);
expect(received.at(-1)).toMatchObject({
method: 'POST',
url: '/api/issues/bulk',
body: { issueIds: [ISSUE_1, ISSUE_2], status: 'DONE' },
});
expect(JSON.parse(result.content[0].text)).toEqual({
updated: 1,
refused: [{ issue: 'WEB-2', error: 'Cannot move WEB-2' }],
});
});
it('closes a sprint carrying the unfinished tickets over', async () => {
await call(
'close_sprint',
{ sprintId: 's1', carryOverTo: 's2' },
'pireon-token',
);
expect(received[0]).toMatchObject({
method: 'POST',
url: '/api/sprints/s1/close',
body: { carryOverTo: 's2' },
});
});
it('reads the unread notifications', async () => {
const result = await call('my_notifications', {}, 'pireon-token');
expect(received[0].url).toBe(
'/api/me/notifications?unreadOnly=true&limit=30',
);
expect(JSON.parse(result.content[0].text)).toEqual({
unread: 1,
items: [
expect.objectContaining({
kind: 'MENTIONED',
issue: 'WEB-1',
who: 'Ana',
read: false,
}),
],
});
});
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
function rawPost(headers: Record<string, string>): Promise<number> {
const { port } = new URL(url);

View 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;
}
}

View File

@@ -11,6 +11,11 @@ const WRITE = {
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']);
@@ -35,6 +40,10 @@ const issueFields = {
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'),
};
@@ -45,6 +54,7 @@ type IssueFields = {
labels?: string[];
figmaUrl?: string;
storyPoints?: number;
startDate?: string;
dueDate?: string;
};
@@ -95,7 +105,7 @@ export class IssuesResolver {
@Tool({
name: 'search_issues',
description:
'Search the tickets of a project by text (key, title, description) and filters.',
'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(),
@@ -108,6 +118,21 @@ export class IssuesResolver {
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)'),
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
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),
}),
@@ -126,6 +151,12 @@ export class IssuesResolver {
unassigned?: boolean;
releaseId?: string;
labels?: string[];
parent?: string;
sprintId?: string;
noSprint?: boolean;
dueFrom?: string;
dueTo?: string;
sort: 'RECENT' | 'RANK';
limit: number;
offset: number;
},
@@ -147,6 +178,12 @@ export class IssuesResolver {
unassigned: params.unassigned,
releaseId: params.releaseId,
label: params.labels,
parent: params.parent,
sprintId: params.sprintId,
noSprint: params.noSprint,
dueFrom: params.dueFrom,
dueTo: params.dueTo,
sort: params.sort,
limit: params.limit,
offset: params.offset,
},
@@ -160,7 +197,7 @@ export class IssuesResolver {
@Tool({
name: 'get_issue',
description:
'A ticket in full: description, links, allowed next statuses, and optionally its comments and history.',
'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),
@@ -206,10 +243,21 @@ export class IssuesResolver {
description: issue.description,
reporter: issue.reporter.name,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
startDate: issue.startDate,
allowedTransitions: issue.allowedTransitions,
parent: issue.parent,
checklist: issue.checklist,
links: issue.links,
devLinks: issue.devLinks.map((d) => ({
kind: d.kind,
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,
@@ -229,6 +277,10 @@ export class IssuesResolver {
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,
@@ -240,6 +292,7 @@ export class IssuesResolver {
title: string;
assigneeId?: string;
releaseId?: string;
parent?: string;
},
ctx: McpContext,
) {
@@ -399,4 +452,167 @@ export class IssuesResolver {
),
);
}
@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 } },
}),
),
);
}
}

View File

@@ -0,0 +1,70 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi } from '../api/taskstream-api';
import { limit } from './params';
import { toolResult } from './tool-result';
@Resolver()
export class NotificationsResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'my_notifications',
description:
"The user's notifications, newest first: assignments, status changes, failed QA, comments, mentions, followed tickets, reminders.",
paramsSchema: z.object({
unreadOnly: z.boolean().default(true),
limit: limit(100, 30),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
})
mine(
{ unreadOnly, limit }: { unreadOnly: boolean; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const page = await this.api.data(
this.api.as(ctx).GET('/api/me/notifications', {
params: { query: { unreadOnly, limit } },
}),
);
return {
unread: page.unread,
items: page.items.map((n) => ({
id: n.id,
kind: n.kind,
project: n.projectKey,
issue: n.issueKey,
who: n.actor?.name ?? null,
summary: n.summary,
read: n.readAt !== null,
at: n.createdAt,
})),
};
});
}
@Tool({
name: 'mark_notifications_read',
description: 'Mark notifications as read: these ids, or all of them.',
paramsSchema: z.object({
ids: z
.array(z.string())
.optional()
.describe('Notification ids; absent: all'),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
})
markRead({ ids }: { ids?: string[] }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/me/notifications/read', { body: { ids } }),
),
);
}
}

View File

@@ -13,6 +13,11 @@ export const issueRef = z
export const releaseId = z.string().min(1).describe('Release id');
export const sprintId = z
.string()
.min(1)
.describe('Sprint id (see list_sprints)');
export const limit = (max: number, fallback: number) =>
z
.number()

View File

@@ -37,6 +37,59 @@ export class ProjectsResolver {
);
}
@Tool({
name: 'create_project',
description:
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later.',
paramsSchema: z.object({
key: z
.string()
.regex(/^[A-Za-z][A-Za-z0-9]{1,9}$/)
.describe('2-10 letters or digits, starting with a letter; unique'),
name: z.string().min(1).max(120),
description: z.string().max(5000).optional(),
isPublic: z
.boolean()
.default(false)
.describe('Anyone can read it, also without signing in'),
}),
annotations: { readOnlyHint: false, destructiveHint: false },
})
create(
params: {
key: string;
name: string;
description?: string;
isPublic: boolean;
},
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/projects', {
body: { ...params, key: params.key.toUpperCase() },
}),
),
);
}
@Tool({
name: 'get_project_showcase',
description:
'The public summary of a public project: epics on the roadmap with dates and progress, latest releases (notes of shipped ones), the sprint under way and open good-first-issue tickets. Paths point into the web app.',
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
showcase({ project }: { project: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).GET('/api/explore/projects/{project}/showcase', {
params: { path: { project } },
}),
),
);
}
@Tool({
name: 'get_project',
description:
@@ -72,6 +125,7 @@ export class ProjectsResolver {
assigneeId: z.string().optional().describe('Only this person (user id)'),
releaseId: z.string().optional().describe('Only this release'),
area: z.string().optional().describe('Only this area id'),
sprintId: z.string().optional().describe('Only this sprint'),
perColumn: limit(200, 25),
}),
annotations: READ,
@@ -82,6 +136,7 @@ export class ProjectsResolver {
assigneeId?: string;
releaseId?: string;
area?: string;
sprintId?: string;
perColumn: number;
},
ctx: McpContext,

View File

@@ -54,7 +54,7 @@ export class ReleasesResolver {
@Tool({
name: 'get_release',
description:
'A release with its progress (finished out of total), its tickets and, optionally, the day-by-day burnup.',
'A release with its progress (finished out of total), its notes, its tickets and, optionally, the day-by-day burnup.',
paramsSchema: z.object({
releaseId,
withBurnup: z.boolean().default(false),
@@ -90,6 +90,7 @@ export class ReleasesResolver {
return {
...releaseLine(release),
description: release.description,
notes: release.notes,
progress,
issues: issues.items.map(issueLine),
burnup,
@@ -148,4 +149,104 @@ export class ReleasesResolver {
),
);
}
@Tool({
name: 'update_release',
description: 'Rename a release, describe it or move its target date.',
paramsSchema: z.object({
releaseId,
name: z.string().min(1).max(60).optional(),
description: z.string().max(5000).nullable().optional(),
targetDate: z.string().nullable().optional().describe('ISO date'),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
update(
params: {
releaseId: string;
name?: string;
description?: string | null;
targetDate?: string | null;
},
ctx: McpContext,
) {
const { releaseId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/releases/{releaseId}', {
params: { path: { releaseId } },
body,
}),
),
);
}
@Tool({
name: 'archive_release',
description:
'Archive a planned release that will not ship; its tickets leave it.',
paramsSchema: z.object({ releaseId }),
annotations: { readOnlyHint: false, destructiveHint: true },
})
archive({ releaseId }: { releaseId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
params: { path: { releaseId } },
}),
),
);
}
@Tool({
name: 'draft_release_notes',
description:
"A Markdown draft of a release's notes from its finished tickets, grouped by type. Nothing is saved: polish it and save it with set_release_notes.",
paramsSchema: z.object({ releaseId }),
annotations: READ,
})
draftNotes({ releaseId }: { releaseId: string }, ctx: McpContext) {
return toolResult(
async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/releases/{releaseId}/notes/draft', {
params: { path: { releaseId } },
}),
)
).markdown,
);
}
@Tool({
name: 'set_release_notes',
description:
'Write the notes of a release (Markdown, for people: what shipped). null clears them.',
paramsSchema: z.object({
releaseId,
notes: z.string().max(20000).nullable(),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
setNotes(
{ releaseId, notes }: { releaseId: string; notes: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/releases/{releaseId}/notes', {
params: { path: { releaseId } },
body: { notes },
}),
),
);
}
}

View File

@@ -0,0 +1,240 @@
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi } from '../api/taskstream-api';
import { issueRef, limit, projectRef, sprintId } from './params';
import { issueLine, sprintLine } 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 date = (what: string) => z.string().describe(`${what} (ISO date)`);
@Resolver()
export class SprintsResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'list_sprints',
description:
"A project's sprints (planned, active and closed) with their dates and goal.",
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
list({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/sprints', {
params: { path: { project } },
}),
)
).map(sprintLine),
);
}
@Tool({
name: 'get_sprint',
description:
'A sprint with its progress (tickets and story points finished), its tickets in backlog order and, optionally, the day-by-day burndown.',
paramsSchema: z.object({
sprintId,
withBurndown: z.boolean().default(false),
limit: limit(200, 100),
}),
annotations: READ,
})
get(
params: { sprintId: string; withBurndown: boolean; limit: number },
ctx: McpContext,
) {
return toolResult(async () => {
const client = this.api.as(ctx);
const path = { sprint: params.sprintId };
const [sprint, progress, issues] = await Promise.all([
this.api.data(
client.GET('/api/sprints/{sprint}', { params: { path } }),
),
this.api.data(
client.GET('/api/sprints/{sprint}/progress', { params: { path } }),
),
this.api.data(
client.GET('/api/sprints/{sprint}/issues', {
params: { path, query: { limit: params.limit } },
}),
),
]);
const burndown = params.withBurndown
? await this.api.data(
client.GET('/api/sprints/{sprint}/burndown', { params: { path } }),
)
: undefined;
return {
...sprintLine(sprint),
progress,
total: issues.total,
issues: issues.items.map(issueLine),
burndown,
};
});
}
@Tool({
name: 'create_sprint',
description: 'Plan a sprint in a project (owners and admins).',
paramsSchema: z.object({
project: projectRef,
name: z.string().min(1).max(80).describe('e.g. Sprint 14'),
goal: z.string().max(500).optional(),
startDate: date('First day'),
endDate: date('Last day'),
}),
annotations: WRITE,
})
create(
params: {
project: string;
name: string;
goal?: string;
startDate: string;
endDate: string;
},
ctx: McpContext,
) {
const { project, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/projects/{project}/sprints', {
params: { path: { project } },
body,
}),
),
);
}
@Tool({
name: 'update_sprint',
description:
'Rename a sprint, change its goal or its dates (not once closed).',
paramsSchema: z.object({
sprintId,
name: z.string().min(1).max(80).optional(),
goal: z.string().max(500).nullable().optional(),
startDate: date('First day').optional(),
endDate: date('Last day').optional(),
}),
annotations: { ...WRITE, idempotentHint: true },
})
update(
params: {
sprintId: string;
name?: string;
goal?: string | null;
startDate?: string;
endDate?: string;
},
ctx: McpContext,
) {
const { sprintId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/sprints/{sprint}', {
params: { path: { sprint: sprintId } },
body,
}),
),
);
}
@Tool({
name: 'start_sprint',
description:
'Start a planned sprint (only one can be under way per project).',
paramsSchema: z.object({ sprintId }),
annotations: WRITE,
})
start({ sprintId }: { sprintId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/sprints/{sprint}/start', {
params: { path: { sprint: sprintId } },
}),
),
);
}
@Tool({
name: 'close_sprint',
description:
'Close the sprint under way. Unfinished tickets move to carryOverTo (another open sprint of the project) or back to the backlog; finished ones stay as its record.',
paramsSchema: z.object({
sprintId,
carryOverTo: z
.string()
.nullable()
.optional()
.describe('Sprint id for the unfinished tickets; absent: the backlog'),
}),
annotations: WRITE,
})
close(
{
sprintId,
carryOverTo,
}: { sprintId: string; carryOverTo?: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/sprints/{sprint}/close', {
params: { path: { sprint: sprintId } },
body: { carryOverTo },
}),
),
);
}
@Tool({
name: 'delete_sprint',
description: 'Delete a sprint that never started.',
paramsSchema: z.object({ sprintId }),
annotations: { readOnlyHint: false, destructiveHint: true },
})
delete({ sprintId }: { sprintId: string }, ctx: McpContext) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).DELETE('/api/sprints/{sprint}', {
params: { path: { sprint: sprintId } },
}),
),
);
}
@Tool({
name: 'set_issue_sprint',
description:
'Put a ticket in an open sprint of its project, or back to the backlog (null). For many at once: bulk_update_issues.',
paramsSchema: z.object({
issue: issueRef,
sprintId: z.string().nullable().describe('Sprint id, or null'),
}),
annotations: { ...WRITE, idempotentHint: true },
})
setSprint(
{ issue, sprintId }: { issue: string; sprintId: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/issues/{issue}/sprint', {
params: { path: { issue } },
body: { sprintId },
}),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import type { Schemas } from '../api/taskstream-api';
type Issue = Schemas['IssueDto'];
type Project = Schemas['ProjectDto'];
type Release = Schemas['ReleaseDto'];
type Sprint = Schemas['SprintDto'];
export const issueLine = (issue: Issue) => ({
key: issue.key,
@@ -19,6 +20,10 @@ export const issueLine = (issue: Issue) => ({
area: issue.area,
assignee: issue.assignee?.name ?? null,
release: issue.release?.name ?? null,
sprint: issue.sprint?.name ?? null,
parent: issue.parent?.key ?? null,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
labels: issue.labels,
updatedAt: issue.updatedAt,
});
@@ -61,3 +66,12 @@ export const releaseLine = (release: Release) => ({
targetDate: release.targetDate,
releasedAt: release.releasedAt,
});
export const sprintLine = (sprint: Sprint) => ({
id: sprint.id,
name: sprint.name,
goal: sprint.goal,
status: sprint.status,
startDate: sprint.startDate,
endDate: sprint.endDate,
});

View File

@@ -32,35 +32,56 @@ flowchart LR
## 9.2 Herramientas
54 herramientas, agrupadas por ámbito:
| Ámbito | Herramienta | Qué hace |
| --------- | ----------------------- | ------------------------------------------------------------------------------------------- |
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| Usuario | `whoami` | El usuario del token |
| | `search_users` | Buscar personas de Pireon (para asignar) |
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
| | `mark_notifications_read` | Marcar como leídas unas o todas |
| Proyectos | `list_my_projects` | Mis proyectos |
| | `list_public_projects` | Proyectos públicos |
| | `create_project` | Crear un proyecto (quien lo crea es su dueño) |
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
| | `get_board` | Tablero por columnas (filtros por persona, release, área) |
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
| | `get_project_stats` | Cifras de seguimiento |
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
| | `get_project_activity` | Actividad reciente |
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) |
| | `search_issues` | Buscar por texto y filtros |
| | `get_issue` | Detalle, transiciones permitidas, enlaces, comentarios e historial |
| | `create_issue` | Crear un ticket |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas… |
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
| | `create_issue` | Crear un ticket (también colgado de una épica) |
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas… |
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
| | `assign_issue` | Asignar o desasignar |
| | `set_issue_release` | Meter en una release o sacar |
| | `add_comment` | Comentar |
| | `link_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) |
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar, corregir o borrar un comentario |
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
| | `rank_issue` | Mover un ticket en el backlog |
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
| Sprints | `list_sprints` | Sprints de un proyecto |
| | `get_sprint` | Progreso, tickets y burndown |
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
| Releases | `list_releases` | Releases de un proyecto |
| | `upcoming_releases` | Próximas releases de mis proyectos |
| | `get_release` | Progreso, tickets y burnup |
| | `create_release` | Planificar una release |
| | `get_release` | Progreso, notas, tickets y burnup |
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
| | `archive_release` | Archivar una release que no saldrá (sus tickets la dejan) |
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas. Lo que es de
administración (miembros, configuración, visibilidad, borrar proyectos) no se expone: se hace desde la web.
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
borrar proyectos, webhooks, integración con git, importar y exportar CSV) no se expone: se hace desde la web.
## 9.3 Conectar un cliente

Binary file not shown.

Binary file not shown.

View File

@@ -802,6 +802,7 @@ export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
export class ImportQueryDto {
@ApiPropertyOptional({
type: Boolean,
default: false,
description: 'Only tell what would be created',
})
@@ -1069,7 +1070,11 @@ export class PublishReleaseDto {
// ---- Notifications ----------------------------------------------------------
export class MyNotificationsQueryDto {
@ApiPropertyOptional({ default: false, description: 'Only unread ones' })
@ApiPropertyOptional({
type: Boolean,
default: false,
description: 'Only unread ones',
})
@IsOptional()
@Transform(toBoolean)
@IsBoolean()