feat(mcp): MCP server for AI clients, consuming the REST API
New app apps/mcp (NestJS + @nestjs-mcp/server 2): Streamable HTTP at POST /mcp, stateless. - 24 tools: projects (mine, public, details with settings, board, stats, analytics, activity), tickets (my work, search, details with comments and history, create, update, move, assign, release, comment, link), releases (list, upcoming, details with progress and burnup, create, publish) and users (whoami, search). Admin operations stay in the web. - No credentials of its own: each call's Authorization header (a Pireon token) is forwarded to the API; without it only public projects are read. - Typed REST client generated from apps/api/openapi.json (openapi-fetch + openapi-typescript via `nx run mcp:openapi-types`); API refusals become readable tool errors; parameters validated with zod. - Tests: the real MCP against a simulated API (token forwarding, errors, validation, writes). - Docker target `mcp` (Node 24) and an optional compose service under the `mcp` profile; zod declared as a root dependency. - Docs: chapter 9 «Servidor MCP», references in 01/06/07 and the index; Word/PDF regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
402
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
402
apps/mcp/src/app/tools/issues.resolver.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||
import { z } from 'zod';
|
||||
import { TaskStreamApi } from '../api/taskstream-api';
|
||||
import { issueRef, limit, 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 CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
|
||||
const LINK_TYPE = z.enum([
|
||||
'BLOCKS',
|
||||
'DEPENDS_ON',
|
||||
'TESTS',
|
||||
'DUPLICATES',
|
||||
'RELATES_TO',
|
||||
]);
|
||||
|
||||
const ids = (what: string) =>
|
||||
z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(`${what} ids of the project's settings (see get_project)`);
|
||||
|
||||
const issueFields = {
|
||||
description: z.string().max(50000).optional(),
|
||||
area: z.string().optional().describe('Area id (see get_project)'),
|
||||
priority: PRIORITY.optional(),
|
||||
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(),
|
||||
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
|
||||
};
|
||||
|
||||
type IssueFields = {
|
||||
description?: string;
|
||||
area?: string;
|
||||
priority?: z.infer<typeof PRIORITY>;
|
||||
labels?: string[];
|
||||
figmaUrl?: string;
|
||||
storyPoints?: number;
|
||||
dueDate?: string;
|
||||
};
|
||||
|
||||
@Resolver()
|
||||
export class IssuesResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
/* ---- Reading ---- */
|
||||
|
||||
@Tool({
|
||||
name: 'my_issues',
|
||||
description:
|
||||
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).",
|
||||
paramsSchema: z.object({
|
||||
assignedToMe: z.boolean().default(true),
|
||||
categories: z
|
||||
.array(CATEGORY)
|
||||
.optional()
|
||||
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'),
|
||||
limit: limit(200, 50),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
mine(
|
||||
params: {
|
||||
assignedToMe: boolean;
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
limit: number;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const page = await this.api.data(
|
||||
this.api.as(ctx).GET('/api/me/issues', {
|
||||
params: {
|
||||
query: {
|
||||
assignedToMe: params.assignedToMe,
|
||||
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
||||
limit: params.limit,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { total: page.total, items: page.items.map(issueLine) };
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'search_issues',
|
||||
description:
|
||||
'Search the tickets of a project by text (key, title, description) and filters.',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
search: z.string().max(200).optional(),
|
||||
statuses: ids('Status'),
|
||||
categories: z.array(CATEGORY).optional(),
|
||||
types: ids('Ticket type'),
|
||||
areas: ids('Area'),
|
||||
priorities: z.array(PRIORITY).optional(),
|
||||
assigneeId: z.string().optional().describe('User id'),
|
||||
unassigned: z.boolean().optional(),
|
||||
releaseId: z.string().optional(),
|
||||
labels: z.array(z.string()).optional(),
|
||||
limit: limit(200, 30),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
search(
|
||||
params: {
|
||||
project: string;
|
||||
search?: string;
|
||||
statuses?: string[];
|
||||
categories?: z.infer<typeof CATEGORY>[];
|
||||
types?: string[];
|
||||
areas?: string[];
|
||||
priorities?: z.infer<typeof PRIORITY>[];
|
||||
assigneeId?: string;
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
labels?: string[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const page = await this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/issues', {
|
||||
params: {
|
||||
path: { project: params.project },
|
||||
query: {
|
||||
search: params.search,
|
||||
status: params.statuses,
|
||||
category: params.categories,
|
||||
type: params.types,
|
||||
area: params.areas,
|
||||
priority: params.priorities,
|
||||
assigneeId: params.assigneeId,
|
||||
unassigned: params.unassigned,
|
||||
releaseId: params.releaseId,
|
||||
label: params.labels,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { total: page.total, items: page.items.map(issueLine) };
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_issue',
|
||||
description:
|
||||
'A ticket in full: description, links, allowed next statuses, and optionally its comments and history.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
withComments: z.boolean().default(true),
|
||||
withHistory: z.boolean().default(false),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
getIssue(
|
||||
params: { issue: string; withComments: boolean; withHistory: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const client = this.api.as(ctx);
|
||||
const path = { issue: params.issue };
|
||||
const issue = await this.api.data(
|
||||
client.GET('/api/issues/{issue}', { params: { path } }),
|
||||
);
|
||||
const comments = params.withComments
|
||||
? (
|
||||
await this.api.data(
|
||||
client.GET('/api/issues/{issue}/comments', {
|
||||
params: { path, query: { limit: 100 } },
|
||||
}),
|
||||
)
|
||||
).items.map((c) => ({
|
||||
id: c.id,
|
||||
author: c.author.name,
|
||||
at: c.createdAt,
|
||||
body: c.body,
|
||||
}))
|
||||
: undefined;
|
||||
const history = params.withHistory
|
||||
? (
|
||||
await this.api.data(
|
||||
client.GET('/api/issues/{issue}/history', {
|
||||
params: { path, query: { limit: 100 } },
|
||||
}),
|
||||
)
|
||||
).map((a) => ({ at: a.occurredAt, summary: a.summary }))
|
||||
: undefined;
|
||||
return {
|
||||
...issueLine(issue),
|
||||
description: issue.description,
|
||||
reporter: issue.reporter.name,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
dueDate: issue.dueDate,
|
||||
allowedTransitions: issue.allowedTransitions,
|
||||
links: issue.links,
|
||||
createdAt: issue.createdAt,
|
||||
comments,
|
||||
history,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Writing ---- */
|
||||
|
||||
@Tool({
|
||||
name: 'create_issue',
|
||||
description:
|
||||
'Create a ticket. Type and area are ids of the project settings (see get_project); it starts in the first pending status.',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
type: z.string().describe('Ticket type id, e.g. BUG'),
|
||||
title: z.string().min(1).max(255),
|
||||
assigneeId: z.string().optional().describe('User id (a project member)'),
|
||||
releaseId: z.string().optional(),
|
||||
...issueFields,
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
create(
|
||||
params: IssueFields & {
|
||||
project: string;
|
||||
type: string;
|
||||
title: string;
|
||||
assigneeId?: string;
|
||||
releaseId?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { project, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/projects/{project}/issues', {
|
||||
params: { path: { project } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_issue',
|
||||
description: 'Change the title, description, type, area, priority, labels…',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
type: z.string().optional().describe('Ticket type id'),
|
||||
...issueFields,
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
update(
|
||||
params: IssueFields & { issue: string; title?: string; type?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { issue, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PATCH('/api/issues/{issue}', {
|
||||
params: { path: { issue } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'change_issue_status',
|
||||
description:
|
||||
"Move a ticket to another status. It must be one of the ticket's allowedTransitions (see get_issue); some statuses need a reason.",
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
status: z.string().describe('Status id, e.g. IN_PROGRESS'),
|
||||
reason: z.string().max(5000).optional(),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
changeStatus(
|
||||
{
|
||||
issue,
|
||||
status,
|
||||
reason,
|
||||
}: { issue: string; status: string; reason?: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PUT('/api/issues/{issue}/status', {
|
||||
params: { path: { issue } },
|
||||
body: { status, reason },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'assign_issue',
|
||||
description: 'Assign a ticket to a project member, or unassign it (null).',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
assigneeId: z.string().nullable().describe('User id, or null'),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
assign(
|
||||
{ issue, assigneeId }: { issue: string; assigneeId: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PUT('/api/issues/{issue}/assignee', {
|
||||
params: { path: { issue } },
|
||||
body: { assigneeId },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_issue_release',
|
||||
description: 'Put a ticket in a planned release, or take it out (null).',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
releaseId: z.string().nullable(),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
setRelease(
|
||||
{ issue, releaseId }: { issue: string; releaseId: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PUT('/api/issues/{issue}/release', {
|
||||
params: { path: { issue } },
|
||||
body: { releaseId },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'add_comment',
|
||||
description: 'Comment on a ticket (decisions, explanations, steps).',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
body: z.string().min(1).max(20000),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
comment({ issue, body }: { issue: string; body: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
||||
params: { path: { issue } },
|
||||
body: { body },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'link_issues',
|
||||
description:
|
||||
'Relate two tickets, e.g. WEB-3 DEPENDS_ON API-7 (they may be in different projects).',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
type: LINK_TYPE,
|
||||
target: issueRef,
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
link(
|
||||
params: { issue: string; type: z.infer<typeof LINK_TYPE>; target: string },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/links', {
|
||||
params: { path: { issue: params.issue } },
|
||||
body: { type: params.type, targetIssue: params.target },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
23
apps/mcp/src/app/tools/params.ts
Normal file
23
apps/mcp/src/app/tools/params.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Shared parameter schemas (descriptions are what the model reads). */
|
||||
export const projectRef = z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Project key (e.g. "WEB") or id');
|
||||
|
||||
export const issueRef = z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Ticket key (e.g. "WEB-12") or id');
|
||||
|
||||
export const releaseId = z.string().min(1).describe('Release id');
|
||||
|
||||
export const limit = (max: number, fallback: number) =>
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(max)
|
||||
.default(fallback)
|
||||
.describe(`How many (1-${max})`);
|
||||
170
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
170
apps/mcp/src/app/tools/projects.resolver.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||
import { z } from 'zod';
|
||||
import { TaskStreamApi } from '../api/taskstream-api';
|
||||
import { limit, projectRef } from './params';
|
||||
import { issueLine, projectLine, projectSettings } from './summaries';
|
||||
import { toolResult } from './tool-result';
|
||||
|
||||
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||
|
||||
@Resolver()
|
||||
export class ProjectsResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_my_projects',
|
||||
description: 'Projects where the signed-in user is a member.',
|
||||
annotations: READ,
|
||||
})
|
||||
listMine(ctx: McpContext) {
|
||||
return toolResult(async () =>
|
||||
(await this.api.data(this.api.as(ctx).GET('/api/projects'))).map(
|
||||
projectLine,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_public_projects',
|
||||
description: 'Public projects: anyone can read them, also without a token.',
|
||||
annotations: READ,
|
||||
})
|
||||
listPublic(ctx: McpContext) {
|
||||
return toolResult(async () =>
|
||||
(await this.api.data(this.api.as(ctx).GET('/api/explore/projects'))).map(
|
||||
projectLine,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project',
|
||||
description:
|
||||
'A project with its team and settings: the status, ticket type and area ids that the other tools accept, and which statuses need a reason.',
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
annotations: READ,
|
||||
})
|
||||
getProject({ project }: { project: string }, ctx: McpContext) {
|
||||
return toolResult(async () => {
|
||||
const found = await this.api.data(
|
||||
this.api
|
||||
.as(ctx)
|
||||
.GET('/api/projects/{project}', { params: { path: { project } } }),
|
||||
);
|
||||
return {
|
||||
...projectLine(found),
|
||||
members: found.members.map((m) => ({
|
||||
id: m.user.id,
|
||||
name: m.user.name,
|
||||
role: m.role,
|
||||
})),
|
||||
...projectSettings(found),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_board',
|
||||
description:
|
||||
'Kanban board: one column per status (in board order) with its tickets.',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
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'),
|
||||
perColumn: limit(200, 25),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
board(
|
||||
params: {
|
||||
project: string;
|
||||
assigneeId?: string;
|
||||
releaseId?: string;
|
||||
area?: string;
|
||||
perColumn: number;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { project, ...query } = params;
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/board', {
|
||||
params: { path: { project }, query },
|
||||
}),
|
||||
)
|
||||
).map((column) => ({
|
||||
status: column.status,
|
||||
total: column.total,
|
||||
issues: column.issues.map(issueLine),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project_stats',
|
||||
description:
|
||||
'Tracking numbers: total and open tickets, by status, type, priority, area and open tickets per person.',
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
annotations: READ,
|
||||
})
|
||||
stats({ project }: { project: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/stats', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project_analytics',
|
||||
description:
|
||||
'Statistics over the last days: created vs resolved per week, cumulative flow, cycle and lead time (average and p85), throughput per person and QA pass/fail rate.',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
days: z.number().int().min(7).max(365).default(90),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
analytics(
|
||||
{ project, days }: { project: string; days: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/analytics', {
|
||||
params: { path: { project }, query: { days } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project_activity',
|
||||
description: 'Latest activity of a project (newest first).',
|
||||
paramsSchema: z.object({ project: projectRef, limit: limit(200, 30) }),
|
||||
annotations: READ,
|
||||
})
|
||||
activity(
|
||||
{ project, limit }: { project: string; limit: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/activity', {
|
||||
params: { path: { project }, query: { limit } },
|
||||
}),
|
||||
)
|
||||
).map((a) => ({
|
||||
at: a.occurredAt,
|
||||
who: a.actor.name,
|
||||
summary: a.summary,
|
||||
issue: a.issueKey,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
151
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
151
apps/mcp/src/app/tools/releases.resolver.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||
import { z } from 'zod';
|
||||
import { TaskStreamApi } from '../api/taskstream-api';
|
||||
import { limit, projectRef, releaseId } from './params';
|
||||
import { issueLine, releaseLine } from './summaries';
|
||||
import { toolResult } from './tool-result';
|
||||
|
||||
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||
|
||||
@Resolver()
|
||||
export class ReleasesResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_releases',
|
||||
description: 'Releases of a project (planned, released and archived).',
|
||||
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}/releases', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
)
|
||||
).map(releaseLine),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'upcoming_releases',
|
||||
description:
|
||||
"Planned releases of all the user's projects, soonest target date first.",
|
||||
paramsSchema: z.object({ limit: limit(50, 10) }),
|
||||
annotations: READ,
|
||||
})
|
||||
upcoming({ limit }: { limit: number }, ctx: McpContext) {
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api
|
||||
.as(ctx)
|
||||
.GET('/api/me/releases', { params: { query: { limit } } }),
|
||||
)
|
||||
).map((release) => ({
|
||||
...releaseLine(release),
|
||||
projectId: release.projectId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_release',
|
||||
description:
|
||||
'A release with its progress (finished out of total), its tickets and, optionally, the day-by-day burnup.',
|
||||
paramsSchema: z.object({
|
||||
releaseId,
|
||||
withBurnup: z.boolean().default(false),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
get(params: { releaseId: string; withBurnup: boolean }, ctx: McpContext) {
|
||||
return toolResult(async () => {
|
||||
const client = this.api.as(ctx);
|
||||
const path = { releaseId: params.releaseId };
|
||||
const [release, progress, issues] = await Promise.all([
|
||||
this.api.data(
|
||||
client.GET('/api/releases/{releaseId}', { params: { path } }),
|
||||
),
|
||||
this.api.data(
|
||||
client.GET('/api/releases/{releaseId}/progress', {
|
||||
params: { path },
|
||||
}),
|
||||
),
|
||||
this.api.data(
|
||||
client.GET('/api/releases/{releaseId}/issues', {
|
||||
params: { path, query: { limit: 200 } },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
const burnup = params.withBurnup
|
||||
? await this.api.data(
|
||||
client.GET('/api/releases/{releaseId}/burnup', {
|
||||
params: { path },
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...releaseLine(release),
|
||||
description: release.description,
|
||||
progress,
|
||||
issues: issues.items.map(issueLine),
|
||||
burnup,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_release',
|
||||
description: 'Plan a release in a project (owners and admins).',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
name: z.string().min(1).max(60).describe('e.g. v1.4.0'),
|
||||
description: z.string().max(5000).optional(),
|
||||
targetDate: z.string().optional().describe('ISO date'),
|
||||
}),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
})
|
||||
create(
|
||||
params: {
|
||||
project: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
targetDate?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { project, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/projects/{project}/releases', {
|
||||
params: { path: { project } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'publish_release',
|
||||
description:
|
||||
'Ship a planned release. Fails while it has unfinished tickets unless force is true.',
|
||||
paramsSchema: z.object({ releaseId, force: z.boolean().default(false) }),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
})
|
||||
publish(
|
||||
{ releaseId, force }: { releaseId: string; force: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/releases/{releaseId}/publish', {
|
||||
params: { path: { releaseId } },
|
||||
body: { force },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
63
apps/mcp/src/app/tools/summaries.ts
Normal file
63
apps/mcp/src/app/tools/summaries.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Schemas } from '../api/taskstream-api';
|
||||
|
||||
/*
|
||||
* Compact views for listings: what the model needs to reason, without ids
|
||||
* and nested objects it rarely uses (full detail through the get_* tools).
|
||||
*/
|
||||
|
||||
type Issue = Schemas['IssueDto'];
|
||||
type Project = Schemas['ProjectDto'];
|
||||
type Release = Schemas['ReleaseDto'];
|
||||
|
||||
export const issueLine = (issue: Issue) => ({
|
||||
key: issue.key,
|
||||
title: issue.title,
|
||||
type: issue.type,
|
||||
status: issue.status,
|
||||
statusCategory: issue.statusCategory,
|
||||
priority: issue.priority,
|
||||
area: issue.area,
|
||||
assignee: issue.assignee?.name ?? null,
|
||||
release: issue.release?.name ?? null,
|
||||
labels: issue.labels,
|
||||
updatedAt: issue.updatedAt,
|
||||
});
|
||||
|
||||
export const projectLine = (project: Project) => ({
|
||||
key: project.key,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
isPublic: project.isPublic,
|
||||
owner: project.owner.name,
|
||||
members: project.members.length,
|
||||
updatedAt: project.updatedAt,
|
||||
});
|
||||
|
||||
/** Statuses, types and areas by id: what create/update/move tools accept. */
|
||||
export const projectSettings = (project: Project) => ({
|
||||
statuses: project.settings.statuses.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
category: s.category,
|
||||
requiresReason: s.requiresReason,
|
||||
archived: s.archived,
|
||||
})),
|
||||
issueTypes: project.settings.issueTypes.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
archived: t.archived,
|
||||
})),
|
||||
areas: project.settings.areas.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
archived: a.archived,
|
||||
})),
|
||||
});
|
||||
|
||||
export const releaseLine = (release: Release) => ({
|
||||
id: release.id,
|
||||
name: release.name,
|
||||
status: release.status,
|
||||
targetDate: release.targetDate,
|
||||
releasedAt: release.releasedAt,
|
||||
});
|
||||
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
34
apps/mcp/src/app/tools/tool-result.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/server';
|
||||
import { ApiError } from '../api/taskstream-api';
|
||||
|
||||
/**
|
||||
* Runs a tool and answers with its result as JSON text, or, if the API
|
||||
* refuses it, with an error result the model can read and act on (instead of
|
||||
* a protocol error that aborts the call).
|
||||
*/
|
||||
export async function toolResult(
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<CallToolResult> {
|
||||
try {
|
||||
const result = await run();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
typeof result === 'string'
|
||||
? result
|
||||
: JSON.stringify(result ?? { ok: true }, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiError)) throw error;
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: 'text', text: `Error ${error.status}: ${error.message}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
49
apps/mcp/src/app/tools/users.resolver.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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';
|
||||
|
||||
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
||||
|
||||
@Resolver()
|
||||
export class UsersResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
@Tool({
|
||||
name: 'whoami',
|
||||
description:
|
||||
'The user this MCP acts as (from the Pireon token the client sends).',
|
||||
annotations: READ,
|
||||
})
|
||||
whoami(ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(this.api.as(ctx).GET('/api/users/me')),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'search_users',
|
||||
description:
|
||||
'Find Pireon users by name or email (to assign tickets: use their id).',
|
||||
paramsSchema: z.object({
|
||||
search: z.string().min(2),
|
||||
limit: limit(50, 10),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
search(
|
||||
{ search, limit }: { search: string; limit: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api
|
||||
.as(ctx)
|
||||
.GET('/api/users', { params: { query: { search, limit } } }),
|
||||
)
|
||||
).map((u) => ({ id: u.id, name: u.name, email: u.email })),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user