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