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:
214
apps/mcp/src/app/mcp.spec.ts
Normal file
214
apps/mcp/src/app/mcp.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { createServer, IncomingMessage, Server } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
/** What the fake API received. */
|
||||
interface Received {
|
||||
method: string;
|
||||
url: string;
|
||||
authorization?: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const project = {
|
||||
id: 'p1',
|
||||
key: 'WEB',
|
||||
name: 'Web',
|
||||
description: null,
|
||||
isPublic: false,
|
||||
owner: { id: '101', name: 'Ana' },
|
||||
members: [{ user: { id: '101', name: 'Ana' }, role: 'OWNER' }],
|
||||
settings: {
|
||||
statuses: [
|
||||
{
|
||||
id: 'TODO',
|
||||
name: 'Por hacer',
|
||||
category: 'TODO',
|
||||
color: 'slate',
|
||||
requiresReason: false,
|
||||
archived: false,
|
||||
},
|
||||
],
|
||||
transitions: [],
|
||||
issueTypes: [
|
||||
{ id: 'BUG', name: 'Bug', icon: 'bug', color: 'red', archived: false },
|
||||
],
|
||||
areas: [],
|
||||
},
|
||||
createdAt: '2026-10-01T00:00:00Z',
|
||||
updatedAt: '2026-10-02T00:00:00Z',
|
||||
version: 3,
|
||||
};
|
||||
|
||||
/** A stand-in for the task-stream API: answers a few routes and records every call. */
|
||||
function fakeApi(received: Received[]): Server {
|
||||
return createServer(async (req: IncomingMessage, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
const text = Buffer.concat(chunks).toString();
|
||||
received.push({
|
||||
method: req.method ?? '',
|
||||
url: req.url ?? '',
|
||||
authorization: req.headers.authorization,
|
||||
body: text ? JSON.parse(text) : undefined,
|
||||
});
|
||||
const reply = (status: number, body: unknown) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (req.url === '/api/projects/WEB') return reply(200, project);
|
||||
if (req.url === '/api/projects/SECRET')
|
||||
return reply(401, { statusCode: 401, message: 'Missing bearer token' });
|
||||
if (req.url === '/api/issues/WEB-1/status')
|
||||
return reply(200, { id: 'i1', key: 'WEB-1', version: 4 });
|
||||
if (req.url === '/api/issues/WEB-1/status-denied')
|
||||
return reply(422, {
|
||||
statusCode: 422,
|
||||
code: 'BUSINESS_RULE',
|
||||
message: 'Cannot move',
|
||||
});
|
||||
return reply(404, { statusCode: 404, message: 'Not found' });
|
||||
});
|
||||
}
|
||||
|
||||
describe('TaskStream MCP', () => {
|
||||
let app: INestApplication;
|
||||
let api: Server;
|
||||
let url: string;
|
||||
const received: Received[] = [];
|
||||
|
||||
/** One JSON-RPC call over Streamable HTTP (answers come as an SSE event). */
|
||||
async function rpc(
|
||||
method: string,
|
||||
params: object,
|
||||
token?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${url}/mcp`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream',
|
||||
'mcp-protocol-version': '2025-06-18',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text.startsWith('{')
|
||||
? text
|
||||
: (text.split('\n').find((line) => line.startsWith('data:')) ?? '').slice(
|
||||
5,
|
||||
);
|
||||
return JSON.parse(data).result;
|
||||
}
|
||||
|
||||
const call = (name: string, args: object, token?: string) =>
|
||||
rpc('tools/call', { name, arguments: args }, token) as Promise<{
|
||||
isError?: boolean;
|
||||
content: { type: string; text: string }[];
|
||||
}>;
|
||||
|
||||
beforeAll(async () => {
|
||||
api = fakeApi(received);
|
||||
await new Promise<void>((resolve) => api.listen(0, resolve));
|
||||
const apiUrl = `http://127.0.0.1:${(api.address() as AddressInfo).port}`;
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(TASKSTREAM_API_URL)
|
||||
.useValue(apiUrl)
|
||||
.compile();
|
||||
app = moduleRef.createNestApplication({ logger: false });
|
||||
await app.listen(0);
|
||||
url = (await app.getUrl()).replace('[::1]', '127.0.0.1');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
await new Promise((resolve) => api?.close(resolve));
|
||||
});
|
||||
|
||||
beforeEach(() => (received.length = 0));
|
||||
|
||||
it('lists the tools, read-only ones marked as such', async () => {
|
||||
const { tools } = (await rpc('tools/list', {})) as {
|
||||
tools: { name: string; annotations?: { readOnlyHint?: boolean } }[];
|
||||
};
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'list_my_projects',
|
||||
'get_project',
|
||||
'get_board',
|
||||
'search_issues',
|
||||
'get_issue',
|
||||
'create_issue',
|
||||
'change_issue_status',
|
||||
'get_release',
|
||||
'whoami',
|
||||
]),
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it("calls the API with the caller's own token and compacts the answer", async () => {
|
||||
const result = await call(
|
||||
'get_project',
|
||||
{ project: 'WEB' },
|
||||
'pireon-token',
|
||||
);
|
||||
|
||||
expect(received).toEqual([
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
url: '/api/projects/WEB',
|
||||
authorization: 'Bearer pireon-token',
|
||||
}),
|
||||
]);
|
||||
expect(JSON.parse(result.content[0].text)).toMatchObject({
|
||||
key: 'WEB',
|
||||
owner: 'Ana',
|
||||
members: [{ id: '101', name: 'Ana', role: 'OWNER' }],
|
||||
statuses: [{ id: 'TODO', category: 'TODO' }],
|
||||
issueTypes: [{ id: 'BUG', name: 'Bug' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('sends no credentials when the client has none (public projects only)', async () => {
|
||||
await call('get_project', { project: 'WEB' });
|
||||
expect(received[0].authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('turns API refusals into tool errors the model can read', async () => {
|
||||
const result = await call('get_project', { project: 'SECRET' });
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toMatch(/Error 401: Missing bearer token/);
|
||||
expect(result.content[0].text).toMatch(/Authorization: Bearer/);
|
||||
});
|
||||
|
||||
it('writes through the REST API', async () => {
|
||||
const result = await call(
|
||||
'change_issue_status',
|
||||
{ issue: 'WEB-1', status: 'QA_FAILED', reason: 'Falla en Safari' },
|
||||
'pireon-token',
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(received[0]).toMatchObject({
|
||||
method: 'PUT',
|
||||
url: '/api/issues/WEB-1/status',
|
||||
body: { status: 'QA_FAILED', reason: 'Falla en Safari' },
|
||||
});
|
||||
});
|
||||
|
||||
it('validates the arguments before calling the API', async () => {
|
||||
const result = await call('get_project_analytics', {
|
||||
project: 'WEB',
|
||||
days: 2,
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user