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> { 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((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): Promise { 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); }); });