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:
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user