diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 37b086b..33274b2 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": [ diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index cdbf0d9..0cd304b 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -2192,7 +2192,6 @@ export interface components { /** @description Open tickets labelled good-first-issue */ goodFirst: components["schemas"]["ShowcaseIssueDto"][]; }; - Object: Record; /** @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: { diff --git a/apps/mcp/src/app/app.module.ts b/apps/mcp/src/app/app.module.ts index 09d4e8d..4fd2767 100644 --- a/apps/mcp/src/app/app.module.ts +++ b/apps/mcp/src/app/app.module.ts @@ -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, ], }) diff --git a/apps/mcp/src/app/mcp.spec.ts b/apps/mcp/src/app/mcp.spec.ts index f6915d9..ca732a4 100644 --- a/apps/mcp/src/app/mcp.spec.ts +++ b/apps/mcp/src/app/mcp.spec.ts @@ -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): Promise { const { port } = new URL(url); diff --git a/apps/mcp/src/app/tools/backlog.resolver.ts b/apps/mcp/src/app/tools/backlog.resolver.ts new file mode 100644 index 0000000..666f86b --- /dev/null +++ b/apps/mcp/src/app/tools/backlog.resolver.ts @@ -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; + 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; + } +} diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index 8ca5525..4e93e91 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -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; 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 } }, + }), + ), + ); + } } diff --git a/apps/mcp/src/app/tools/notifications.resolver.ts b/apps/mcp/src/app/tools/notifications.resolver.ts new file mode 100644 index 0000000..50bf828 --- /dev/null +++ b/apps/mcp/src/app/tools/notifications.resolver.ts @@ -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 } }), + ), + ); + } +} diff --git a/apps/mcp/src/app/tools/params.ts b/apps/mcp/src/app/tools/params.ts index 88b8011..f64eae1 100644 --- a/apps/mcp/src/app/tools/params.ts +++ b/apps/mcp/src/app/tools/params.ts @@ -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() diff --git a/apps/mcp/src/app/tools/projects.resolver.ts b/apps/mcp/src/app/tools/projects.resolver.ts index b046e5a..7b456d3 100644 --- a/apps/mcp/src/app/tools/projects.resolver.ts +++ b/apps/mcp/src/app/tools/projects.resolver.ts @@ -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, diff --git a/apps/mcp/src/app/tools/releases.resolver.ts b/apps/mcp/src/app/tools/releases.resolver.ts index 7339f75..a1cdfaf 100644 --- a/apps/mcp/src/app/tools/releases.resolver.ts +++ b/apps/mcp/src/app/tools/releases.resolver.ts @@ -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 }, + }), + ), + ); + } } diff --git a/apps/mcp/src/app/tools/sprints.resolver.ts b/apps/mcp/src/app/tools/sprints.resolver.ts new file mode 100644 index 0000000..5c51e0b --- /dev/null +++ b/apps/mcp/src/app/tools/sprints.resolver.ts @@ -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 }, + }), + ), + ); + } +} diff --git a/apps/mcp/src/app/tools/summaries.ts b/apps/mcp/src/app/tools/summaries.ts index e672cf7..7ec6263 100644 --- a/apps/mcp/src/app/tools/summaries.ts +++ b/apps/mcp/src/app/tools/summaries.ts @@ -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, +}); diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 6bfdd26..65ad4b3 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -32,35 +32,56 @@ flowchart LR ## 9.2 Herramientas -| Ámbito | Herramienta | Qué hace | -| --------- | ----------------------- | ------------------------------------------------------------------------------------------- | -| Usuario | `whoami` | El usuario del token | -| | `search_users` | Buscar personas de Pireon (para asignar) | -| Proyectos | `list_my_projects` | Mis proyectos | -| | `list_public_projects` | Proyectos públicos | -| | `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_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… | -| | `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`…) | -| 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 | -| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) | +54 herramientas, agrupadas por ámbito: -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. +| Á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_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 (é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` / `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, 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, 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 diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 948f9db..aa75130 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index 56f09a6..436d276 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index db75657..b4497b8 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -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()