Files
task-stream/apps/mcp/src/app/mcp.spec.ts
Josep Piguillem e6ac02d328 feat(mcp): sprints, epics, backlog, trash, checklists and release notes
Thirty new tools (54 in all) for what the API gained since the MCP:
create a project and read its public showcase; sprints (list, progress
and burndown, plan, change, start, close with carry-over, delete, move a
ticket in or out); epics and the backlog (list epics with progress, set
a ticket's parent, rank); bulk edit of up to 100 tickets by key, saying
which refused; the trash; checklists; following a ticket, removing a
relation, editing and deleting comments; editing and archiving releases,
drafting and saving their notes; notifications.

Searching filters by epic, sprint and dates and sorts by backlog order;
get_issue shows parent, checklist, commits and followers; create and
update take parent and start date.

API: `unreadOnly` and `dryRun` were documented as objects in the
OpenAPI; they are booleans now. Chapter 9 lists every tool; Word and PDF
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:17:10 +02:00

376 lines
12 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import {
createServer,
IncomingMessage,
request as httpRequest,
Server,
} from 'node:http';
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;
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',
});
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' });
});
}
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',
'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 () => {
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);
});
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<string, string>): Promise<number> {
const { port } = new URL(url);
return new Promise((resolve, reject) => {
const req = httpRequest(
{
host: '127.0.0.1',
port,
path: '/mcp',
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': '2025-06-18',
...headers,
},
},
(res) => {
res.resume();
resolve(res.statusCode ?? 0);
},
);
req.on('error', reject);
req.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }));
});
}
it('only answers on its own domain (DNS rebinding protection)', async () => {
// Default allowlist: localhost only (MCP_ALLOWED_HOSTS sets the real domain).
expect(await rawPost({ host: `localhost:${new URL(url).port}` })).toBe(200);
expect(await rawPost({ host: 'evil.example' })).toBe(403);
expect(
await rawPost({
host: `localhost:${new URL(url).port}`,
origin: 'https://evil.example',
}),
).toBe(403);
expect(
await rawPost({
host: `localhost:${new URL(url).port}`,
origin: 'http://localhost:6274',
}),
).toBe(200);
expect((await fetch(`${url}/health`)).status).toBe(200);
});
});