import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { once } from 'node:events'; import { AgentNotConfiguredError, callAgent, } from './agentClient.js'; const DUMMY_SECRET = 'test-secret-not-production'; const DUMMY_TOOL = 'get_health'; const GENERIC_ERROR_MESSAGE = 'Agent request failed.'; const MAX_RESPONSE_SIZE = 1024 * 1024; const ENV_KEYS = [ 'OPAX_MCP_URL', 'MCP_SECRET', 'OPAX_MCP_ALLOWED_ORIGIN', ]; const originalEnv = Object.fromEntries( ENV_KEYS.map((key) => [key, process.env[key]]), ); const originalFetch = globalThis.fetch; function restoreEnvironment() { for (const key of ENV_KEYS) { if (originalEnv[key] === undefined) { delete process.env[key]; } else { process.env[key] = originalEnv[key]; } } } function clearTestEnvironment() { for (const key of ENV_KEYS) { delete process.env[key]; } } function configureLocalMock(url) { process.env.OPAX_MCP_URL = url; process.env.MCP_SECRET = DUMMY_SECRET; delete process.env.OPAX_MCP_ALLOWED_ORIGIN; } async function expectGenericFailure(action) { await assert.rejects(action, { message: GENERIC_ERROR_MESSAGE, }); } async function expectNoFetch(action) { let fetchCalled = false; globalThis.fetch = async () => { fetchCalled = true; throw new Error('fetch must not be called'); }; try { await action(); assert.equal(fetchCalled, false); } finally { globalThis.fetch = originalFetch; } } function sendJson(response, statusCode, body) { response.writeHead(statusCode, { 'Content-Type': 'application/json', }); response.end(JSON.stringify(body)); } describe('callAgent', { concurrency: false }, () => { beforeEach(() => { clearTestEnvironment(); globalThis.fetch = originalFetch; }); afterEach(() => { globalThis.fetch = originalFetch; restoreEnvironment(); }); after(() => { globalThis.fetch = originalFetch; restoreEnvironment(); }); describe('configuration and local validation', () => { it('rejects a missing MCP URL with AgentNotConfiguredError', async () => { process.env.MCP_SECRET = DUMMY_SECRET; await assert.rejects( () => callAgent(DUMMY_TOOL, {}), AgentNotConfiguredError, ); }); it('rejects a missing MCP secret with AgentNotConfiguredError', async () => { process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; await assert.rejects( () => callAgent(DUMMY_TOOL, {}), AgentNotConfiguredError, ); }); it('rejects whitespace-only MCP configuration', async () => { process.env.OPAX_MCP_URL = ' '; process.env.MCP_SECRET = DUMMY_SECRET; await assert.rejects( () => callAgent(DUMMY_TOOL, {}), AgentNotConfiguredError, ); process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; process.env.MCP_SECRET = ' '; await assert.rejects( () => callAgent(DUMMY_TOOL, {}), AgentNotConfiguredError, ); }); it('rejects invalid and unsafe URLs before fetch', async () => { const unsafeUrls = [ 'not a URL', 'ftp://127.0.0.1:12345', 'http://example.com', 'http://user:password@127.0.0.1:12345', 'http://127.0.0.1:12345#fragment', ]; for (const url of unsafeUrls) { process.env.OPAX_MCP_URL = url; process.env.MCP_SECRET = DUMMY_SECRET; await expectNoFetch(() => expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), ); } }); it('rejects HTTPS without an allowed origin before fetch', async () => { process.env.OPAX_MCP_URL = 'https://example.invalid'; process.env.MCP_SECRET = DUMMY_SECRET; await expectNoFetch(() => expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), ); }); it('rejects HTTPS with a mismatched origin before fetch', async () => { process.env.OPAX_MCP_URL = 'https://example.invalid'; process.env.MCP_SECRET = DUMMY_SECRET; process.env.OPAX_MCP_ALLOWED_ORIGIN = 'https://allowed.invalid'; await expectNoFetch(() => expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), ); }); it('rejects non-allowlisted and mutating tools before fetch', async () => { process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; process.env.MCP_SECRET = DUMMY_SECRET; for (const tool of ['unknown_tool', 'commit_and_push_files']) { await expectNoFetch(() => expectGenericFailure(() => callAgent(tool, {})), ); } }); it('rejects circular arguments before fetch', async () => { process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; process.env.MCP_SECRET = DUMMY_SECRET; const circular = {}; circular.self = circular; await expectNoFetch(() => expectGenericFailure(() => callAgent(DUMMY_TOOL, circular)), ); }); it('rejects an oversized request before fetch', async () => { process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; process.env.MCP_SECRET = DUMMY_SECRET; const largeArguments = { data: 'a'.repeat(17000), }; await expectNoFetch(() => expectGenericFailure(() => callAgent(DUMMY_TOOL, largeArguments)), ); }); }); describe('local MCP mock', () => { let server; let serverUrl; let handler; before(async () => { server = createServer((request, response) => { Promise.resolve(handler?.(request, response)) .catch(() => { if (!response.headersSent) { response.writeHead(500); } response.end(); }); }); server.listen(0, '127.0.0.1'); await once(server, 'listening'); const address = server.address(); serverUrl = `http://127.0.0.1:${address.port}`; }); beforeEach(() => { configureLocalMock(serverUrl); handler = (_request, response) => { response.writeHead(500); response.end(); }; }); after(async () => { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); }); it('returns parsed inner JSON from a valid JSON-RPC response', async () => { const expected = { ok: true, status: 'healthy', }; handler = (_request, response) => { sendJson(response, 200, { jsonrpc: '2.0', id: 'mock-response-id', result: { content: [{ type: 'text', text: JSON.stringify(expected), }], }, }); }; const result = await callAgent(DUMMY_TOOL, { check: true, }); assert.deepEqual(result, expected); }); it('sends the required JSON-RPC request contract', async () => { const argumentsValue = { scope: 'local-test', }; let received; handler = async (request, response) => { let body = ''; for await (const chunk of request) { body += chunk; } received = { method: request.method, contentType: request.headers['content-type'], authorization: request.headers.authorization, body: JSON.parse(body), }; sendJson(response, 200, { jsonrpc: '2.0', id: received.body.id, result: { content: [{ type: 'text', text: '{}', }], }, }); }; await callAgent(DUMMY_TOOL, argumentsValue); assert.equal(received.method, 'POST'); assert.equal(received.contentType, 'application/json'); assert.equal( received.authorization, `Bearer ${DUMMY_SECRET}`, ); assert.equal(received.body.jsonrpc, '2.0'); assert.equal(received.body.method, 'tools/call'); assert.equal(typeof received.body.id, 'string'); assert.ok(received.body.id.length > 0); assert.equal(received.body.params.name, DUMMY_TOOL); assert.deepEqual( received.body.params.arguments, argumentsValue, ); }); it('normalizes an HTTP error', async () => { handler = (_request, response) => { response.writeHead(500); response.end(); }; await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); }); it('normalizes malformed outer JSON', async () => { handler = (_request, response) => { response.writeHead(200, { 'Content-Type': 'application/json', }); response.end('not-json'); }; await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); }); it('normalizes a JSON-RPC error', async () => { handler = (_request, response) => { sendJson(response, 200, { jsonrpc: '2.0', id: 'mock-response-id', error: { code: -32000, message: 'mock failure', }, }); }; await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); }); it('normalizes invalid content responses', async () => { const invalidResponses = [ { result: { content: [], }, }, { result: { content: [{ type: 'image', text: '{}', }], }, }, { result: { content: [{ type: 'text', text: 'not-json', }], }, }, ]; for (const invalidResponse of invalidResponses) { handler = (_request, response) => { sendJson(response, 200, { jsonrpc: '2.0', id: 'mock-response-id', ...invalidResponse, }); }; await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); } }); it('normalizes a response body larger than 1 MiB', async () => { handler = (_request, response) => { response.writeHead(200, { 'Content-Type': 'text/plain', }); response.end('a'.repeat(MAX_RESPONSE_SIZE + 1)); }; await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); }); }); });