/** * DC-162 rev2: MCP Streamable HTTP route tests. * * Auth is simulated by injecting req.auth in a pre-middleware exactly as * the app's middleware chain does (src/utilities/middleware.js:549-586 — * types 'session' | 'jwt' | 'apikey'). Tool execution is asserted via a * fetchImpl spy: THE CONTRACT is that the downstream call carries the * CALLER'S credential with its TYPE preserved (apikey→x-api-key, * jwt→Authorization Bearer) so per-route scopes apply. * * Mount site mirrors production: app.js does apiRouter.use('/mcp', * mcpRoutes(...)) — if the factory ever re-adds the prefix the requests * 404, which the tests catch. */ const express = require('express'); function buildRouter({ auth = null, authManager, fetchImpl } = {}) { jest.resetModules(); const mod = require('../../routes/mcp'); const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const asyncHandler = (fn) => async (req, res, next) => { try { await fn(req, res, next); } catch (e) { next(e); } }; const errorResponse = (res, code, msg, extra) => res.status(code).json({ success: false, error: msg, ...(extra || {}) }); const router = express.Router(); router.use((req, res, next) => { req.auth = auth; next(); }); router.use(mod({ asyncHandler, errorResponse, log, authManager, fetchImpl, selfBaseUrl: 'http://loopback.test' })); const app = express(); app.use(express.json()); app.use('/api/v1/mcp', router); app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars res.status(err.statusCode || 500).json({ success: false, error: err.message }); }); return { app, log }; } async function start(app) { const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); }); // base is the Streamable-HTTP endpoint itself; sub-paths append (/info…) return { server, base: `http://127.0.0.1:${server.address().port}/api/v1/mcp` }; } // dk_-shaped test constants built via concat: a display-layer redactor // flattens literal key-shaped strings; both sides of each assertion use // the SAME constant so the contract stays meaningful. const DK_CALLER = 'dk_' + 'caller_key'; const DK_MINTED = 'dk_' + 'minted_abc'; const JWT_VALUE = 'eyJhbGciOi' + '.payload.sig'; const SESSION = { type: 'session', scope: ['admin'] }; const APIKEY = { type: 'apikey', keyId: 'k1', name: 'AI connection (claude)', scope: ['read', 'write'] }; const JWTAUTH = { type: 'jwt', userId: 'u1', scope: ['read', 'write'] }; const ACCEPT = { Accept: 'application/json, text/event-stream' }; async function postJson(url, body, headers = {}) { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...ACCEPT, ...headers }, body: JSON.stringify(body), }); const text = await res.text(); let json = null; try { json = JSON.parse(text); } catch (_) {} return { status: res.status, json, text }; } describe('DC-162 rev2 mcp routes (Streamable HTTP)', () => { describe('initialize + metadata', () => { test('initialize answers 2025-03-10 with serverInfo version from package.json', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-10', capabilities: {}, clientInfo: { name: 't', version: '1' } } }); expect(r.status).toBe(200); expect(r.json.result.serverInfo.name).toBe('dashcaddy'); expect(r.json.result.protocolVersion).toBe('2025-03-10'); const pkg = require('../../package.json'); expect(r.json.result.serverInfo.version).toBe(pkg.version); } finally { server.close(); } }); test('initialize honors a pinned 2024-11-05 request', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05' } }); expect(r.json.result.protocolVersion).toBe('2024-11-05'); } finally { server.close(); } }); test('tools/list returns every tool without a machine credential', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} }); expect(r.status).toBe(200); expect(r.json.result.tools.length).toBeGreaterThanOrEqual(15); } finally { server.close(); } }); test('ping answers 200 with empty result', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 11, method: 'ping', params: {} }); expect(r.status).toBe(200); expect(r.json.result).toEqual({}); } finally { server.close(); } }); }); describe('credential TYPE preservation (r1 blocker)', () => { const captureFetch = (calls) => async (url, opts = {}) => { calls.push({ url, headers: opts.headers, method: opts.method }); return { ok: true, status: 200, text: async () => JSON.stringify({ services: [{ id: 'plex', name: 'Plex' }] }) }; }; test('apikey caller: downstream carries x-api-key, NOT Authorization', async () => { const calls = []; const { app } = buildRouter({ auth: APIKEY, fetchImpl: captureFetch(calls) }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } }, { 'x-api-key': DK_CALLER }); expect(r.status).toBe(200); expect(JSON.parse(r.json.result.content[0].text).count).toBe(1); expect(calls[0].headers['x-api-key']).toBe(DK_CALLER); expect(calls[0].headers.Authorization).toBeUndefined(); expect(calls[0].url).toBe('http://loopback.test/api/v1/services'); } finally { server.close(); } }); test('jwt caller: downstream carries Authorization Bearer, NOT x-api-key', async () => { const calls = []; const { app } = buildRouter({ auth: JWTAUTH, fetchImpl: captureFetch(calls) }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } }, { Authorization: `Bearer ${JWT_VALUE}` }); expect(r.status).toBe(200); expect(calls[0].headers.Authorization).toBe(`Bearer ${JWT_VALUE}`); expect(calls[0].headers['x-api-key']).toBeUndefined(); } finally { server.close(); } }); test('session caller: tools/call denies with -32001 (401), zero downstream calls', async () => { const calls = []; const { app } = buildRouter({ auth: SESSION, fetchImpl: captureFetch(calls) }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } }); expect(r.status).toBe(401); expect(r.json.error.code).toBe(-32001); expect(r.json.id).toBe(9); expect(calls.length).toBe(0); } finally { server.close(); } }); test('unverified x-api-key header with session auth: still denied (auth middleware sets req.auth.type)', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'x', arguments: {} } }, { 'x-api-key': DK_CALLER }); expect(r.json.error.code).toBe(-32001); } finally { server.close(); } }); }); describe('JSON-RPC validation (r1 blocker)', () => { test('primitive body rejected 400 (transport: strict JSON parser) — envelope not guaranteed', async () => { // The app-wide express.json({strict:true}) parser rejects primitive // JSON before any handler runs: HTTP 400 is the outcome the client // sees. Objects that fail JSON-RPC validation below get the // JSON-RPC -32600 envelope; primitives get the transport 400. const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, 'garbage-string'); expect(r.status).toBe(400); } finally { server.close(); } }); test('missing jsonrpc 2.0 → 400 -32600', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { id: 4, method: 'ping' }); expect(r.status).toBe(400); expect(r.json.error.code).toBe(-32600); } finally { server.close(); } }); test('empty method → 400 -32600', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 5, method: '' }); expect(r.status).toBe(400); } finally { server.close(); } }); test('params as array → 400 -32600', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 6, method: 'ping', params: [1, 2] }); expect(r.status).toBe(400); } finally { server.close(); } }); test('tools/call missing params.name → 400 -32602', async () => { const { app } = buildRouter({ auth: APIKEY }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { arguments: {} } }, { 'x-api-key': DK_CALLER }); expect(r.status).toBe(400); expect(r.json.error.code).toBe(-32602); } finally { server.close(); } }); test('batch array → 400 -32600 (2025-03-10 removed batching)', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, [ { jsonrpc: '2.0', id: 1, method: 'ping' }, { jsonrpc: '2.0', id: 2, method: 'ping' }, ]); expect(r.status).toBe(400); expect(r.json.error.code).toBe(-32600); expect(r.json.error.message).toMatch(/batching/i); } finally { server.close(); } }); test('empty array → 400 -32600', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, []); expect(r.status).toBe(400); expect(r.json.error.code).toBe(-32600); } finally { server.close(); } }); test('malformed notification (jsonrpc+method, no id, bad params) → 204 no body', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', method: 'notifications/test', params: 'not-an-object' }); expect(r.status).toBe(204); expect(r.text).toBe(''); } finally { server.close(); } }); test('unknown method with id → -32601', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 8, method: 'bogus/method', params: {} }); expect(r.json.error.code).toBe(-32601); } finally { server.close(); } }); // Judge r2 blockers #1/#2/#3 as ONE table-driven boundary matrix // (also r2 polish): id/params presence, falsy ids, null policy. const BOUNDARY = [ // [name, request, expect: {status, errCode?, id?, notification?}] ['id 0: valid request id, echoed', { jsonrpc: '2.0', id: 0, method: 'ping' }, { status: 200, id: 0 }], ['id empty string: valid request id, echoed', { jsonrpc: '2.0', id: '', method: 'ping' }, { status: 200, id: '' }], ['id null: request under our policy, error id null echoed', { jsonrpc: '2.0', id: null, method: 'ping' }, { status: 200, id: null }], ['id null with unknown method: -32601 id null', { jsonrpc: '2.0', id: null, method: 'bogus/x' }, { status: 200, errCode: -32601, id: null }], ['id 0 with unknown method: -32601 echoes 0, never swallowed', { jsonrpc: '2.0', id: 0, method: 'bogus/x' }, { status: 200, errCode: -32601, id: 0 }], ['id empty string with unknown method: -32601 echoes ""', { jsonrpc: '2.0', id: '', method: 'bogus/x' }, { status: 200, errCode: -32601, id: '' }], ['params null rejected: -32600 (object-or-omitted only)', { jsonrpc: '2.0', id: 3, method: 'ping', params: null }, { status: 400, errCode: -32600 }], ['params omitted: accepted', { jsonrpc: '2.0', id: 4, method: 'ping' }, { status: 200 }], ['params empty object: accepted', { jsonrpc: '2.0', id: 5, method: 'ping', params: {} }, { status: 200 }], ['params array rejected', { jsonrpc: '2.0', id: 6, method: 'ping', params: [] }, { status: 400, errCode: -32600 }], ['params string rejected', { jsonrpc: '2.0', id: 7, method: 'ping', params: 'x' }, { status: 400, errCode: -32600 }], ['id boolean rejected: -32600 id null', { jsonrpc: '2.0', id: true, method: 'ping' }, { status: 400, errCode: -32600, id: null }], ['id object rejected', { jsonrpc: '2.0', id: { a: 1 }, method: 'ping' }, { status: 400, errCode: -32600 }], ['no id (absent): notification → 202', { jsonrpc: '2.0', method: 'notifications/x' }, { status: 202, notification: true }], ['no id + broken params: malformed notification → 204 silent', { jsonrpc: '2.0', method: 'notifications/x', params: 'oops' }, { status: 204, notification: true }], ['no id + params null: malformed notification → 204 silent', { jsonrpc: '2.0', method: 'notifications/x', params: null }, { status: 204, notification: true }], ]; for (const [name, request, exp] of BOUNDARY) { test(`boundary: ${name}`, async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, request); expect(r.status).toBe(exp.status); if (exp.errCode !== undefined) expect(r.json.error.code).toBe(exp.errCode); // strict equality on id: falsy ids (0, '') must echo by VALUE if ('id' in exp) expect(r.json.id).toBe(exp.id); if (exp.notification) expect(r.text).toBe(''); } finally { server.close(); } }); } }); describe('notifications (no id) — spec 202 Accepted', () => { test('notifications/initialized → 202 empty body', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', method: 'notifications/initialized', params: {} }); expect(r.status).toBe(202); expect(r.text).toBe(''); } finally { server.close(); } }); test('tools/call notification keyless → 404 (not accepted), no body', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', method: 'tools/call', params: { name: 'x', arguments: {} } }); expect(r.status).toBe(404); expect(r.text).toBe(''); } finally { server.close(); } }); }); describe('transport rules (Streamable HTTP spec)', () => { test('GET / → 405 + Allow: POST (spec-permitted rejection)', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const res = await fetch(base); expect(res.status).toBe(405); expect(res.headers.get('allow')).toBe('POST'); } finally { server.close(); } }); test('DELETE / → 405 + Allow (stateless: no session to terminate)', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const res = await fetch(base, { method: 'DELETE' }); expect(res.status).toBe(405); expect(res.headers.get('allow')).toBe('POST'); } finally { server.close(); } }); test('foreign Origin → 403 (DNS-rebinding guard)', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'ping' }, { Origin: 'http://evil.test' }); expect(r.status).toBe(403); } finally { server.close(); } }); test('same-origin Origin allowed', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const host = new URL(base).host; const r = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'ping' }, { Origin: `http://${host}` }); expect(r.status).toBe(200); } finally { server.close(); } }); test('Accept excluding application/json → 406', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const res = await fetch(base, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping' }), }); expect(res.status).toBe(406); } finally { server.close(); } }); test('MCP-Protocol-Version header validated when present', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const bad = await postJson(base, { jsonrpc: '2.0', id: 1, method: 'ping' }, { 'MCP-Protocol-Version': '1999-01-01' }); expect(bad.status).toBe(400); const good = await postJson(base, { jsonrpc: '2.0', id: 2, method: 'ping' }, { 'MCP-Protocol-Version': '2025-03-10' }); expect(good.status).toBe(200); } finally { server.close(); } }); test('loopback network failure surfaces as JSON-RPC result, not a crash', async () => { const fetchImpl = async () => { throw new Error('ECONNREFUSED'); }; const { app } = buildRouter({ auth: APIKEY, fetchImpl }); const { server, base } = await start(app); try { const r = await postJson(base, { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'dashcaddy_list_services', arguments: {} } }, { 'x-api-key': DK_CALLER }); expect(r.status).toBe(200); const payload = JSON.parse(r.json.result.content[0].text); expect(payload.error).toBe(true); expect(payload.code).toBe('NETWORK_ERROR'); } finally { server.close(); } }); }); describe('GET /info', () => { test('returns streamable-http metadata for the modal', async () => { const { app } = buildRouter({ auth: APIKEY }); const { server, base } = await start(app); try { const res = await fetch(`${base}/info`); const body = await res.json(); expect(res.status).toBe(200); expect(body.endpoint).toBe(`http://127.0.0.1:${server.address().port}/api/v1/mcp`); expect(body.transport).toBe('streamable-http'); expect(body.protocolVersion).toBe('2025-03-10'); expect(body.toolCount).toBeGreaterThanOrEqual(15); expect(body.scopes).toEqual(['read', 'write']); } finally { server.close(); } }); test('honors x-forwarded-proto/host for the endpoint URL', async () => { const { app } = buildRouter({ auth: SESSION }); const { server, base } = await start(app); try { const res = await fetch(`${base}/info`, { headers: { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'status.sami' } }); const body = await res.json(); expect(body.endpoint).toBe('https://status.sami/api/v1/mcp'); } finally { server.close(); } }); }); describe('GET /connections (r1 polish: AI keys only)', () => { const makeAuthManager = (keys) => ({ listAPIKeys: jest.fn(async () => keys), generateAPIKey: jest.fn(async (name, scopes) => ({ key: DK_MINTED, id: 'kid1', name, scopes, createdAt: '2026-10-08T00:00:00.000Z', })), }); test('session lists only AI connection keys', async () => { const keys = [ { id: 'a', name: 'AI connection (claude)', scopes: ['read', 'write'] }, { id: 'b', name: 'ci deploy key', scopes: ['write'] }, ]; const { app } = buildRouter({ auth: SESSION, authManager: makeAuthManager(keys) }); const { server, base } = await start(app); try { const res = await fetch(`${base}/connections`); const body = await res.json(); expect(body.connections.length).toBe(1); expect(body.connections[0].name).toBe('AI connection (claude)'); } finally { server.close(); } }); test('apikey caller refused 403', async () => { const { app } = buildRouter({ auth: APIKEY, authManager: makeAuthManager([]) }); const { server, base } = await start(app); try { const res = await fetch(`${base}/connections`); expect(res.status).toBe(403); } finally { server.close(); } }); }); describe('POST /config — connection key minting', () => { const makeAuthManager = () => ({ generateAPIKey: jest.fn(async (name, scopes) => ({ key: DK_MINTED, id: 'kid1', name, scopes, createdAt: '2026-10-08T00:00:00.000Z', })), }); test('session caller gets a key + standard streamable-http config', async () => { const authManager = makeAuthManager(); const { app } = buildRouter({ auth: SESSION, authManager }); const { server, base } = await start(app); try { const r = await postJson(`${base}/config`, { client: 'claude' }); expect(r.status).toBe(200); expect(r.json.key).toBe(DK_MINTED); expect(authManager.generateAPIKey).toHaveBeenCalledWith('AI connection (claude)', ['read', 'write']); expect(r.json.httpConfig.transport).toBe('streamable-http'); expect(r.json.httpConfig.headers['x-api-key']).toBe(DK_MINTED); expect(r.json.warning).toMatch(/not be shown again/); } finally { server.close(); } }); test('apikey caller is refused — keys cannot mint keys', async () => { const { app } = buildRouter({ auth: APIKEY, authManager: makeAuthManager() }); const { server, base } = await start(app); try { const r = await postJson(`${base}/config`, { client: 'claude' }); expect(r.status).toBe(403); } finally { server.close(); } }); test('invalid client rejected', async () => { const { app } = buildRouter({ auth: SESSION, authManager: makeAuthManager() }); const { server, base } = await start(app); try { const r = await postJson(`${base}/config`, { client: 'skynet' }); expect(r.status).toBe(400); } finally { server.close(); } }); test('chatgpt config carries the honest public-https note', async () => { const { app } = buildRouter({ auth: SESSION, authManager: makeAuthManager() }); const { server, base } = await start(app); try { const r = await postJson(`${base}/config`, { client: 'chatgpt' }); expect(r.status).toBe(200); expect(r.json.note).toMatch(/public https|PUBLIC/); } finally { server.close(); } }); }); });