import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import { readFileSync } from 'node:fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { connect, fakeFetch, textOf, GOOD_SUBMIT } from './helpers.js';
import { startHttp } from '../src/cli.js';
const BASE = 'https://reg.test/registry';
const NAMES = ['lax_get_job', 'lax_get_offer', 'lax_list_jobs', 'lax_search_agents', 'lax_submit'];
test('A1 tools/list returns the 5 tools, each with a description and an input schema', async () => {
const { client, close } = await connect({ baseUrl: BASE, fetch: fakeFetch([]).fn });
const { tools } = await client.listTools();
assert.deepEqual(tools.map((t) => t.name).sort(), NAMES);
for (const t of tools) {
assert.ok(t.description && t.description.length > 20, `${t.name} description`);
assert.equal(t.inputSchema.type, 'object');
}
const submit = tools.find((t) => t.name === 'lax_submit')!;
assert.deepEqual([...(submit.inputSchema.required ?? [])].sort(), ['agent_did', 'builder', 'contact', 'job_id', 'payout_address', 'repo_or_patch_url']);
await close();
});
test('A2 each tool calls its documented route and returns the registry JSON as text', async () => {
const f = fakeFetch((s) => ({ status: s.method === 'POST' ? 201 : 200, body: { echo: s.url } }));
const { client, close } = await connect({ baseUrl: BASE + '/', fetch: f.fn });
const cases: [string, Record<string, unknown>, string, string][] = [
['lax_search_agents', { query: 'video agent' }, 'GET', `${BASE}/search?query=video%20agent`],
['lax_search_agents', {}, 'GET', `${BASE}/search`],
['lax_get_offer', { id: 'KGN-0096' }, 'GET', `${BASE}/offers/KGN-0096`],
['lax_list_jobs', { track: 'coding-agents', limit: 5, offset: 10 }, 'GET', `${BASE}/jobs?track=coding-agents&limit=5&offset=10`],
['lax_list_jobs', {}, 'GET', `${BASE}/jobs`],
['lax_get_job', { id: 'a14478ee-1' }, 'GET', `${BASE}/jobs/a14478ee-1`],
['lax_submit', GOOD_SUBMIT, 'POST', `${BASE}/jobs/${GOOD_SUBMIT.job_id}/submissions`],
];
for (const [name, args, method, url] of cases) {
const r = await client.callTool({ name, arguments: args });
assert.ok(!r.isError, `${name} not an error`);
const last = f.seen.at(-1)!;
assert.equal(last.method, method, name);
assert.equal(last.url, url, name);
assert.deepEqual(JSON.parse(textOf(r)), { echo: url });
}
const { job_id, ...rest } = GOOD_SUBMIT;
assert.deepEqual(f.seen.at(-1)!.body, rest, 'submission body is the documented fields without job_id');
assert.equal(f.seen.at(-1)!.headers['content-type'], 'application/json');
await close();
});
test('A3 4xx, 5xx and network errors come back as isError with status and message; the next call still works', async () => {
const f = fakeFetch([
{ status: 404, body: { error: 'not found' } },
{ status: 409, body: { error: 'the job is not taking submissions' } },
{ status: 503, body: 'upstream down' },
new TypeError('fetch failed: ECONNREFUSED'),
{ status: 200, body: { jobs: [], count: 0 } },
]);
const { client, close } = await connect({ baseUrl: BASE, fetch: f.fn });
let r = await client.callTool({ name: 'lax_get_job', arguments: { id: 'missing' } });
assert.equal(r.isError, true);
assert.match(textOf(r), /HTTP 404: not found/);
r = await client.callTool({ name: 'lax_submit', arguments: GOOD_SUBMIT });
assert.equal(r.isError, true);
assert.match(textOf(r), /HTTP 409: the job is not taking submissions/);
r = await client.callTool({ name: 'lax_get_offer', arguments: { id: 'x1' } });
assert.equal(r.isError, true);
assert.match(textOf(r), /HTTP 503: upstream down/);
r = await client.callTool({ name: 'lax_list_jobs', arguments: {} });
assert.equal(r.isError, true);
assert.match(textOf(r), /network.*ECONNREFUSED/);
r = await client.callTool({ name: 'lax_list_jobs', arguments: {} });
assert.ok(!r.isError);
assert.deepEqual(JSON.parse(textOf(r)), { jobs: [], count: 0 });
await close();
});
test('A3 a hung registry times out as isError instead of hanging the server', async () => {
const hang = ((_u: unknown, init?: RequestInit) => new Promise((_res, rej) => init?.signal?.addEventListener('abort', () => rej(new Error('aborted'))))) as typeof fetch;
const { client, close } = await connect({ baseUrl: BASE, fetch: hang, timeoutMs: 50 });
const r = await client.callTool({ name: 'lax_get_job', arguments: { id: 'j1' } });
assert.equal(r.isError, true);
assert.match(textOf(r), /no answer within 50 ms/);
await close();
});
test('A4 invalid input is refused without any request', async () => {
const f = fakeFetch([]);
const { client, close } = await connect({ baseUrl: BASE, fetch: f.fn });
const bad: [string, Record<string, unknown>][] = [
['lax_get_job', {}],
['lax_get_job', { id: '' }],
['lax_get_offer', {}],
['lax_get_offer', { id: '../jobs' }],
['lax_search_agents', { query: 'x'.repeat(101) }],
['lax_list_jobs', { track: 'nope' }],
['lax_list_jobs', { limit: 0 }],
['lax_list_jobs', { limit: 101 }],
['lax_list_jobs', { offset: -1 }],
['lax_submit', { ...GOOD_SUBMIT, job_id: undefined }],
['lax_submit', { ...GOOD_SUBMIT, payout_address: '0x1234' }],
['lax_submit', { ...GOOD_SUBMIT, payout_address: '0x' + 'zz'.repeat(20) }],
['lax_submit', { ...GOOD_SUBMIT, repo_or_patch_url: 'http://github.com/x/y' }],
['lax_submit', { ...GOOD_SUBMIT, repo_or_patch_url: 'not a link' }],
['lax_submit', { ...GOOD_SUBMIT, agent_did: 'agent-1' }],
['lax_submit', { ...GOOD_SUBMIT, builder: 'x' }],
['lax_submit', { ...GOOD_SUBMIT, contact: undefined }],
];
for (const [name, args] of bad) {
const r = await client.callTool({ name, arguments: args });
assert.equal(r.isError, true, `${name} ${JSON.stringify(args).slice(0, 80)} refused`);
}
assert.equal(f.seen.length, 0, 'no request was made');
await close();
});
function fakeRegistry(): Promise<{ srv: Server; url: string }> {
const srv = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
const status = req.method === 'POST' ? 201 : 200;
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ path: req.url, received: req.method === 'POST' }));
});
});
return new Promise((r) => srv.listen(0, () => r({ srv, url: `http://127.0.0.1:${(srv.address() as { port: number }).port}/registry` })));
}
test('A5 logs carry no contact, payout address or headers, and only go through the logger', async () => {
const lines: string[] = [];
const f = fakeFetch((s) => ({ status: s.method === 'POST' ? 201 : 500, body: { error: 'boom' } }));
const { client, close } = await connect({ baseUrl: BASE, fetch: f.fn, log: (l) => lines.push(l) });
await client.callTool({ name: 'lax_submit', arguments: GOOD_SUBMIT });
await client.callTool({ name: 'lax_get_job', arguments: { id: 'j1' } });
assert.equal(lines.length, 2);
const all = lines.join('\n');
for (const secret of [GOOD_SUBMIT.contact, GOOD_SUBMIT.payout_address, 'content-type', 'authorization', GOOD_SUBMIT.notes])
assert.ok(!all.toLowerCase().includes(secret.toLowerCase()), `log must not contain ${secret}`);
await close();
});
test('A5+A6 stdio bin: stdout carries only JSON-RPC frames, stderr carries no secrets', async () => {
const { srv, url } = await fakeRegistry();
const child = spawn(process.execPath, ['--import', 'tsx', 'src/cli.ts', '--base-url', url], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
let err = '';
child.stdout.on('data', (c) => (out += c));
child.stderr.on('data', (c) => (err += c));
const send = (m: unknown) => child.stdin.write(JSON.stringify(m) + '\n');
const waitFor = async (id: number) => {
for (let i = 0; i < 200; i++) {
const hit = out.split('\n').filter(Boolean).map((l) => JSON.parse(l)).find((m) => m.id === id);
if (hit) return hit;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error(`no reply ${id}; stderr: ${err}`);
};
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 't', version: '0' } } });
await waitFor(1);
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
assert.equal((await waitFor(2)).result.tools.length, 5);
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'lax_submit', arguments: GOOD_SUBMIT } });
const r3 = await waitFor(3);
assert.ok(!r3.result.isError, JSON.stringify(r3));
assert.match(r3.result.content[0].text, /submissions/);
child.kill();
srv.close();
for (const line of out.split('\n').filter(Boolean)) assert.equal(JSON.parse(line).jsonrpc, '2.0', 'stdout line is a JSON-RPC frame');
assert.match(err, /lax_submit 201/);
assert.ok(!err.includes(GOOD_SUBMIT.contact) && !err.toLowerCase().includes(GOOD_SUBMIT.payout_address));
});
test('A6 Streamable HTTP mode answers tools/list and tools/call', async () => {
const { srv: reg, url } = await fakeRegistry();
const http = await startHttp(0, url);
const port = (http.address() as { port: number }).port;
const client = new Client({ name: 'http-test', version: '0' });
await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)));
assert.equal((await client.listTools()).tools.length, 5);
const r = await client.callTool({ name: 'lax_get_job', arguments: { id: 'abc' } });
assert.deepEqual(JSON.parse(textOf(r)), { path: '/registry/jobs/abc', received: false });
const health = await fetch(`http://127.0.0.1:${port}/healthz`);
assert.equal(health.status, 200);
await client.close();
http.close();
reg.close();
});
test('A6 package exposes a lax-mcp bin with a shebang (npm publish itself is a release step)', () => {
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
assert.equal(pkg.bin['lax-mcp'], 'dist/cli.js');
assert.ok(readFileSync('src/cli.ts', 'utf8').startsWith('#!/usr/bin/env node'));
assert.ok(pkg.files.includes('dist'));
});
test('A7 README has the Claude Desktop config and an example for every tool', () => {
const readme = readFileSync('README.md', 'utf8');
assert.match(readme, /"mcpServers"/);
for (const n of NAMES) assert.ok(readme.includes(`### ${n}`), `README section for ${n}`);
});
agentgate-source: code mirror on Cloudflare Pages. All repos