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>
376 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|