Files

557 lines
26 KiB
TypeScript
Raw Permalink Normal View History

/**
* @fileoverview `codeman mcp` (src/mcp-server.ts): the dual-era MCP protocol core, the
* seven tools against a recording fake transport, and one end-to-end run of the real
* CLI over stdio against a local fake Codeman.
*
* Pinned invariants: no guessed URL (unconfigured = tools answer how to configure, list
* still works); prompting/interrupting/deleting only sessions this server started (or
* the calling session's own children, or with --allow-any-session), never itself;
* control bytes refused before any request; tool failures are `isError` results;
* cancellation aborts the HTTP call and sends nothing for that id.
*
* The protocol and tool tests need no port. The stdio test binds a raw http server on
* port 0 and reads `address().port`.
*/
import { describe, it, expect } from 'vitest';
import http from 'node:http';
import { spawn } from 'node:child_process';
import type { AddressInfo } from 'node:net';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
buildToolDefinitions,
createMcpServer,
createToolRunner,
DEFAULT_WAIT_MS,
MAX_OUTPUT_CHARS,
resolveMcpConfig,
RPC_ERROR,
type McpConfig,
} from '../src/mcp-server.js';
import type { ApiRequest, ApiResponse, RequestOptions } from '../src/cli-agent.js';
const MODERN_META = {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientCapabilities': {},
};
const SELF = 'aaaaaaaa-0000-0000-0000-000000000000';
const MINE = 'bbbbbbbb-1111-1111-1111-111111111111';
const USERS = 'cccccccc-2222-2222-2222-222222222222';
const CHILD = 'dddddddd-3333-3333-3333-333333333333';
function config(overrides: Partial<McpConfig> = {}): McpConfig {
return { apiUrl: 'http://127.0.0.1:9', allowAnySession: false, version: '0.0.0-test', ...overrides };
}
function ok(data: unknown): ApiResponse {
return { status: 200, json: { success: true, data }, text: JSON.stringify({ success: true, data }) };
}
interface FakeCall extends RequestOptions {
url: string;
}
/** A fake Codeman: a session list plus per-route answers; records every call. */
function fakeCodeman(
rows: Array<Record<string, unknown>> = [
{ id: SELF, name: 'lead', mode: 'claude', status: 'busy' },
{ id: USERS, name: 'w1-myapp', mode: 'claude', status: 'idle' },
{ id: CHILD, name: 'child', mode: 'codex', status: 'idle', parentSessionId: SELF },
],
answers: Record<string, (call: FakeCall) => ApiResponse | Promise<ApiResponse>> = {}
): { request: ApiRequest; calls: FakeCall[] } {
const calls: FakeCall[] = [];
const request: ApiRequest = async (ctx, options) => {
const call = { ...options, url: ctx.apiUrl };
calls.push(call);
const key = `${options.method} ${options.path}`;
for (const [pattern, answer] of Object.entries(answers)) {
if (key === pattern || (pattern.endsWith('*') && key.startsWith(pattern.slice(0, -1)))) return answer(call);
}
if (key === 'GET /api/v1/sessions') return ok(rows);
if (key === 'POST /api/v1/quick-start') {
rows.push({ id: MINE, name: 'worker', mode: String(options.body?.mode ?? 'claude'), status: 'idle' });
return ok({
sessionId: MINE,
caseName: options.body?.caseName,
casePath: `/cases/${String(options.body?.caseName)}`,
});
}
if (key.endsWith('/wait-output')) return ok({ wait: { matched: true, match: options.query?.match } });
if (key.endsWith('/input'))
return ok({ delivered: true, wait: { signal: 'stop', until: ['stop', 'idle', 'exit'] } });
if (options.method === 'DELETE') return ok({});
return { status: 404, json: { success: false, error: 'not found', errorCode: 'NOT_FOUND' }, text: '' };
};
return { request, calls };
}
function runner(cfg: McpConfig, fake = fakeCodeman()) {
return {
...fake,
tools: createToolRunner({ config: cfg, request: fake.request, started: new Set(), clientId: 'c', now: () => 1 }),
};
}
/** Drive the protocol core; returns every message it wrote. */
function server(cfg: McpConfig = config(), fake = fakeCodeman()) {
const out: Array<Record<string, unknown>> = [];
const srv = createMcpServer({
config: cfg,
write: (m) => out.push(m),
request: fake.request,
modes: ['claude', 'codex', 'shell'],
});
const send = async (message: unknown) => srv.handleLine(JSON.stringify(message));
return { srv, out, send, ...fake };
}
describe('protocol: both eras', () => {
it('answers a legacy initialize with the requested revision, or its newest legacy one', async () => {
const { out, send } = server();
await send({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {} },
});
await send({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2030-01-01' } });
const [first, second] = out as Array<{ result: Record<string, unknown> }>;
expect(first.result.protocolVersion).toBe('2025-06-18');
expect(first.result.serverInfo).toMatchObject({ name: 'codeman' });
expect(first.result.capabilities).toEqual({ tools: { listChanged: false } });
expect(first.result.resultType).toBeUndefined();
expect(second.result.protocolVersion).toBe('2025-11-25');
});
it('serves server/discover with resultType, supported versions and serverInfo in _meta', async () => {
const { out, send } = server();
await send({ jsonrpc: '2.0', id: 'd', method: 'server/discover', params: { _meta: MODERN_META } });
const result = (out[0] as { result: Record<string, unknown> }).result;
expect(result.resultType).toBe('complete');
expect(result.supportedVersions).toEqual(['2026-07-28']);
expect((result._meta as Record<string, unknown>)['io.modelcontextprotocol/serverInfo']).toMatchObject({
name: 'codeman',
});
});
it('rejects an unsupported modern version with -32022 listing what it supports', async () => {
const { out, send } = server();
await send({
jsonrpc: '2.0',
id: 3,
method: 'tools/list',
params: { _meta: { ...MODERN_META, 'io.modelcontextprotocol/protocolVersion': '1999-01-01' } },
});
expect(out[0]).toMatchObject({
id: 3,
error: {
code: RPC_ERROR.unsupportedProtocolVersion,
data: { supported: ['2026-07-28'], requested: '1999-01-01' },
},
});
});
it('rejects a modern request without clientCapabilities as invalid params', async () => {
const { out, send } = server();
await send({
jsonrpc: '2.0',
id: 4,
method: 'tools/list',
params: { _meta: { 'io.modelcontextprotocol/protocolVersion': '2026-07-28' } },
});
expect(out[0]).toMatchObject({ id: 4, error: { code: RPC_ERROR.invalidParams } });
});
it('lists the same seven tools in a fixed order, decorated for the era', async () => {
const { out, send } = server();
await send({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
await send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: { _meta: MODERN_META } });
const legacy = (out[0] as { result: { tools: Array<{ name: string }>; resultType?: string } }).result;
const modern = (out[1] as { result: { tools: Array<{ name: string }>; resultType?: string } }).result;
const names = [
'list_sessions',
'start_session',
'send_prompt',
'wait_for_session',
'read_output',
'interrupt_session',
'delete_session',
];
expect(legacy.tools.map((t) => t.name)).toEqual(names);
expect(modern.tools.map((t) => t.name)).toEqual(names);
expect(legacy.resultType).toBeUndefined();
expect(modern.resultType).toBe('complete');
});
it('puts the caching hints the modern revision requires on tools/list and server/discover', async () => {
// Claude Code 2.1.296 validates these and drops every tool when they are missing.
const { out, send } = server();
await send({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: MODERN_META } });
await send({ jsonrpc: '2.0', id: 2, method: 'server/discover', params: { _meta: MODERN_META } });
for (const message of out as Array<{ result: { ttlMs?: unknown; cacheScope?: unknown } }>) {
expect(typeof message.result.ttlMs).toBe('number');
expect(message.result.ttlMs as number).toBeGreaterThanOrEqual(0);
expect(['public', 'private']).toContain(message.result.cacheScope);
}
});
it('answers protocol faults with JSON-RPC errors and notifications with nothing', async () => {
const { out, srv, send } = server();
await srv.handleLine('{not json');
await send([{ jsonrpc: '2.0', id: 1, method: 'ping' }]);
await send({ jsonrpc: '2.0', id: 2, method: 'resources/list' });
await send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'rm_rf', arguments: {} } });
await send({ jsonrpc: '2.0', method: 'notifications/initialized' });
await send({ jsonrpc: '2.0', id: 4, method: 'ping' });
expect(out.map((m) => (m as { error?: { code: number } }).error?.code ?? 'ok')).toEqual([
RPC_ERROR.parse,
RPC_ERROR.invalidRequest,
RPC_ERROR.methodNotFound,
RPC_ERROR.invalidParams,
'ok',
]);
expect(out[0]).toMatchObject({ id: null });
});
it('a cancelled call aborts its HTTP request and is never answered', async () => {
let seen: AbortSignal | undefined;
const fake = fakeCodeman(undefined, {
'GET /api/v1/sessions/*': (call) =>
new Promise((_resolve, reject) => {
seen = call.signal;
call.signal?.addEventListener('abort', () => reject(new Error('aborted')));
}),
});
const { srv, out, send } = server(config(), fake);
const pending = send({
jsonrpc: '2.0',
id: 7,
method: 'tools/call',
params: { name: 'wait_for_session', arguments: { session_id: USERS, until: ['stop'] } },
});
await new Promise((r) => setTimeout(r, 20));
expect(srv.inflightCount()).toBe(1);
await send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 7 } });
await pending;
expect(seen?.aborted).toBe(true);
expect(out).toEqual([]);
expect(srv.inflightCount()).toBe(0);
});
it('a tool failure is an isError result, not a protocol error', async () => {
const { out, send } = server(config({ apiUrl: undefined }));
await send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } });
const result = (out[0] as { result: { isError: boolean; content: Array<{ text: string }> } }).result;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('CODEMAN_API_URL');
});
});
describe('tool definitions', () => {
it('every tool has an object input schema and honest annotations', () => {
for (const tool of buildToolDefinitions(['claude'])) {
expect(tool.inputSchema.type, tool.name).toBe('object');
expect(tool.inputSchema.additionalProperties, tool.name).toBe(false);
expect(tool.description.length, tool.name).toBeGreaterThan(80);
expect(tool.annotations.openWorldHint).toBe(false);
}
const byName = Object.fromEntries(buildToolDefinitions(['claude']).map((t) => [t.name, t]));
for (const readOnly of ['list_sessions', 'wait_for_session', 'read_output']) {
expect(byName[readOnly].annotations.readOnlyHint, readOnly).toBe(true);
}
expect(byName.delete_session.annotations.destructiveHint).toBe(true);
});
it('start_session offers the run modes it is given, defaulting to claude', () => {
const start = buildToolDefinitions(['claude', 'codex']).find((t) => t.name === 'start_session')!;
const mode = (start.inputSchema.properties as Record<string, { enum?: string[]; default?: string }>).mode;
expect(mode.enum).toEqual(['claude', 'codex']);
expect(mode.default).toBe('claude');
});
});
describe('configuration never guesses a server', () => {
it('takes --url over CODEMAN_API_URL, and neither means unconfigured', () => {
const env = { CODEMAN_API_URL: 'https://127.0.0.1:3000/' };
expect(resolveMcpConfig({ version: 'x' }, env, () => ({})).apiUrl).toBe('https://127.0.0.1:3000');
expect(resolveMcpConfig({ url: 'http://10.0.0.2:5000', version: 'x' }, env, () => ({})).apiUrl).toBe(
'http://10.0.0.2:5000'
);
expect(resolveMcpConfig({ version: 'x' }, {}, () => ({})).apiUrl).toBeUndefined();
});
it('refuses a URL that is not http(s)', () => {
const cfg = resolveMcpConfig({ url: 'file:///etc/passwd', version: 'x' }, {}, () => ({}));
expect(cfg.apiUrl).toBeUndefined();
expect(cfg.urlError).toContain('not an http(s) URL');
});
it('knows its own session only inside a Codeman pane', () => {
expect(resolveMcpConfig({ version: 'x' }, { CODEMAN_MUX: '1', CODEMAN_SESSION_ID: SELF }, () => ({})).selfId).toBe(
SELF
);
expect(resolveMcpConfig({ version: 'x' }, { CODEMAN_SESSION_ID: SELF }, () => ({})).selfId).toBeUndefined();
});
it('an unconfigured tool call makes no request at all', async () => {
const { tools, calls } = runner(config({ apiUrl: undefined }));
const result = await tools.run('start_session', { case: 'x' });
expect(result.isError).toBe(true);
expect(calls).toEqual([]);
});
});
describe('who may be prompted, interrupted and deleted', () => {
it('refuses a session this server did not start, with how to allow it, and sends nothing', async () => {
const { tools, calls } = runner(config());
for (const [tool, args] of [
['send_prompt', { session_id: USERS, text: 'hi' }],
['interrupt_session', { session_id: USERS }],
['delete_session', { session_id: USERS }],
] as const) {
const result = await tools.run(tool, args);
expect(result.isError, tool).toBe(true);
expect(result.text).toContain('--allow-any-session');
}
expect(calls.filter((c) => c.method !== 'GET')).toEqual([]);
});
it('a session it started is fair game, and leaves the allowlist once deleted', async () => {
const { tools, calls } = runner(config());
const started = await tools.run('start_session', { case: 'review-1', mode: 'codex' });
expect(started.structured?.session_id).toBe(MINE);
expect((await tools.run('send_prompt', { session_id: MINE, text: 'go', wait: false })).isError).toBe(false);
expect((await tools.run('delete_session', { session_id: MINE.slice(0, 8) })).isError).toBe(false);
expect(calls.some((c) => c.method === 'DELETE' && c.path === `/api/v1/sessions/${MINE}`)).toBe(true);
expect((await tools.run('send_prompt', { session_id: MINE, text: 'again' })).isError).toBe(true);
});
it('inside Codeman, the calling session can drive its own children', async () => {
const { tools } = runner(config({ selfId: SELF }));
expect((await tools.run('send_prompt', { session_id: CHILD, text: 'go', wait: false })).isError).toBe(false);
expect((await tools.run('send_prompt', { session_id: USERS, text: 'go', wait: false })).isError).toBe(true);
});
it('--allow-any-session opens every session except its own', async () => {
const { tools } = runner(config({ selfId: SELF, allowAnySession: true }));
expect((await tools.run('send_prompt', { session_id: USERS, text: 'go', wait: false })).isError).toBe(false);
const self = await tools.run('delete_session', { session_id: SELF });
expect(self.isError).toBe(true);
expect(self.text).toContain('the session this server runs in');
});
it('list_sessions marks the caller and what it may control', async () => {
const { tools } = runner(config({ selfId: SELF }));
const sessions = (await tools.run('list_sessions', {})).structured?.sessions as Array<Record<string, unknown>>;
expect(sessions.map((s) => [s.short_id, s.you, s.controllable])).toEqual([
['aaaaaaaa', true, false],
['cccccccc', false, false],
['dddddddd', false, true],
]);
});
});
describe('the tools', () => {
it('start_session posts quick-start with lineage and the scratch label, then waits for the ready mark', async () => {
const { tools, calls } = runner(config({ selfId: SELF }));
const result = await tools.run('start_session', { case: 'review-1', mode: 'claude', name: 'rev' });
const post = calls.find((c) => c.path === '/api/v1/quick-start')!;
expect(post.body).toEqual({ caseName: 'review-1', mode: 'claude', parentSessionId: SELF, sessionName: 'rev' });
expect(post.headers).toEqual({ 'X-Codeman-Agent-Origin': 'codeman-mcp' });
const ready = calls.find((c) => c.path.endsWith('/wait-output'))!;
expect(ready.query?.timeout).toBe(DEFAULT_WAIT_MS);
expect(result.structured).toMatchObject({ session_id: MINE, ready: true });
});
it('start_session still returns the session id when only the readiness call fails', async () => {
const busy = fakeCodeman(undefined, {
'GET /api/v1/sessions/*': () => ({
status: 429,
json: { success: false, error: 'too many waiters', errorCode: 'WAIT_CAPACITY' },
text: '',
}),
});
const result = await runner(config(), busy).tools.run('start_session', { case: 'review-2', mode: 'claude' });
expect(result.isError).toBeFalsy();
expect(result.structured).toMatchObject({ session_id: MINE, ready: null });
expect(String(result.structured?.note)).toContain('readiness check failed');
});
it('send_prompt refuses a newline or control byte before any request', async () => {
const { tools, calls } = runner(config());
for (const text of ['line one\nline two', 'esc \u001b[A', 'tab\there']) {
expect((await tools.run('send_prompt', { session_id: USERS, text })).isError).toBe(true);
}
expect(calls).toEqual([]);
});
it('send_prompt says when "finished" is definite and when it is a quiet-screen guess', async () => {
const quiet = fakeCodeman(undefined, {
'POST /api/v1/sessions/*': () => ok({ delivered: true, wait: { signal: 'idle', until: ['idle', 'exit'] } }),
});
const { tools } = runner(config({ allowAnySession: true }), quiet);
const guessed = await tools.run('send_prompt', { session_id: USERS, text: 'go' });
expect(guessed.structured).toMatchObject({ outcome: 'finished', signal: 'idle' });
expect(String(guessed.structured?.note)).toContain('not definite');
const definite = runner(config({ allowAnySession: true }));
const finished = await definite.tools.run('send_prompt', { session_id: USERS, text: 'go' });
expect(String(finished.structured?.note)).toContain('finished its turn');
const input = definite.calls.find((c) => c.path.endsWith('/input'))!;
expect(input.body).toMatchObject({ input: 'go\r', useMux: true, wait: true, waitTimeout: DEFAULT_WAIT_MS });
});
it('send_prompt reports a still-working timeout and a dead pane distinctly', async () => {
const slow = fakeCodeman(undefined, {
'POST /api/v1/sessions/*': () => ok({ delivered: true, wait: { signal: null, timedOut: true } }),
});
expect(
(
await runner(config({ allowAnySession: true }), slow).tools.run('send_prompt', {
session_id: USERS,
text: 'go',
})
).structured
).toMatchObject({ outcome: 'still_working' });
const dead = fakeCodeman(undefined, { 'POST /api/v1/sessions/*': () => ok({ delivered: false }) });
const result = await runner(config({ allowAnySession: true }), dead).tools.run('send_prompt', {
session_id: USERS,
text: 'go',
});
expect(result.isError).toBe(true);
expect(result.structured).toMatchObject({ outcome: 'not_delivered' });
});
it('input seq stays strictly increasing within one millisecond, across send_prompt and interrupt_session', async () => {
// The runner's clock is frozen at 1: the server drops a repeated seq while still answering 200.
const { tools, calls } = runner(config({ allowAnySession: true }));
await tools.run('send_prompt', { session_id: USERS, text: 'one', wait: false });
await tools.run('send_prompt', { session_id: USERS, text: 'two', wait: false });
await tools.run('interrupt_session', { session_id: USERS });
const seqs = calls.filter((c) => c.path.endsWith('/input')).map((c) => c.body?.seq);
expect(seqs).toEqual([1, 2, 3]);
});
it('wait_for_session needs exactly one of until and match, and passes a marker literally', async () => {
const { tools, calls } = runner(config());
expect((await tools.run('wait_for_session', { session_id: USERS })).isError).toBe(true);
expect((await tools.run('wait_for_session', { session_id: USERS, until: ['stop'], match: 'X' })).isError).toBe(
true
);
const matched = await tools.run('wait_for_session', { session_id: USERS, match: 'DONE_7f3a' });
expect(matched.structured).toMatchObject({ outcome: 'matched' });
expect(calls.at(-1)?.query).toMatchObject({ match: 'DONE_7f3a', from: 'buffer', timeout: DEFAULT_WAIT_MS });
});
it('read_output strips the terminal of escape codes and keeps its tail under the cap, as text only', async () => {
const huge = `\u001b[31mred\u001b[0m${'x'.repeat(MAX_OUTPUT_CHARS + 500)}END`;
const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ terminalBuffer: huge }) });
const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS, source: 'terminal' });
const [note, body] = [
result.text.slice(0, result.text.indexOf('\n')),
result.text.slice(result.text.indexOf('\n') + 1),
];
expect(note).toBe(`[showing the last ${MAX_OUTPUT_CHARS} of ${MAX_OUTPUT_CHARS + 506} characters]`);
expect(body.length).toBe(MAX_OUTPUT_CHARS);
expect(body.endsWith('END')).toBe(true);
expect(result.text).not.toContain('\u001b');
// No structuredContent: a client that prefers it would show the model metadata, not
// the output (measured with Claude Code, a terminal read came back "empty").
expect(result.structured).toBeUndefined();
});
it('read_output tidies a pane capture: LF line ends, blank runs collapsed, no trailing blanks', async () => {
const pane = 'one\r\ntwo \r\n\r\n\r\n\r\nthree\r\n\r\n\r\n';
const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ terminalBuffer: pane }) });
const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS, source: 'terminal' });
expect(result.text).toBe('one\ntwo\n\nthree');
});
it('read_output says what to try when there is no answer yet', async () => {
const fake = fakeCodeman(undefined, { 'GET /api/v1/sessions/*': () => ok({ text: '' }) });
const result = await runner(config(), fake).tools.run('read_output', { session_id: USERS });
expect(result.text).toContain('source terminal');
});
it('refuses a session id shorter than 8 characters and an ambiguous prefix', async () => {
const twins = [{ id: 'eeeeeeee-1111-1111-1111-111111111111' }, { id: 'eeeeeeee-2222-2222-2222-222222222222' }];
const { tools } = runner(config(), fakeCodeman(twins));
expect((await tools.run('read_output', { session_id: 'eeee' })).text).toContain('shorter than 8');
expect((await tools.run('read_output', { session_id: 'eeeeeeee' })).text).toContain('ambiguous');
});
});
describe('codeman mcp over real stdio', () => {
it('handshakes and lists sessions from a live (fake) Codeman', async () => {
const api = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.url === '/api/v1/sessions') {
res.end(JSON.stringify({ success: true, data: [{ id: USERS, name: 'w1', mode: 'claude', status: 'idle' }] }));
} else {
res.statusCode = 404;
res.end(JSON.stringify({ success: false, error: 'nope' }));
}
});
await new Promise<void>((resolve) => api.listen(0, '127.0.0.1', resolve));
const port = (api.address() as AddressInfo).port;
try {
const env = { ...process.env, CODEMAN_API_URL: `http://127.0.0.1:${port}` };
delete env.CODEMAN_MUX;
delete env.CODEMAN_SESSION_ID;
const child = spawn('npx', ['tsx', 'src/index.ts', 'mcp'], { cwd: process.cwd(), env });
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString()));
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString()));
const lines = () => stdout.split('\n').filter(Boolean);
child.stdin.write(
`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {} } })}\n`
);
child.stdin.write(
`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } })}\n`
);
const deadline = Date.now() + 25_000;
while (lines().length < 2 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
child.stdin.end();
const exitCode = await new Promise<number | null>((resolve) => child.on('exit', resolve));
const messages = lines().map((line) => JSON.parse(line) as Record<string, unknown>);
expect(messages.map((m) => m.id)).toEqual([1, 2]);
const listed = (messages[1].result as { structuredContent: { sessions: Array<{ id: string }> } })
.structuredContent;
expect(listed.sessions.map((s) => s.id)).toEqual([USERS]);
expect(stderr).toContain(`driving http://127.0.0.1:${port}`);
expect(exitCode).toBe(0);
} finally {
await new Promise<void>((resolve) => api.close(() => resolve()));
}
}, 40_000);
});
describe('the MCP registry manifest (config/mcp-registry/server.json)', () => {
const manifest = JSON.parse(readFileSync(join(process.cwd(), 'config', 'mcp-registry', 'server.json'), 'utf-8'));
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf-8'));
it("carries the name package.json's mcpName declares (the registry's ownership check)", () => {
expect(manifest.name).toBe(pkg.mcpName);
expect(manifest.description.length).toBeLessThanOrEqual(100);
});
it('runs this npm package as `<bin> mcp` over stdio, at the package version (sync-plugin.mjs keeps it)', () => {
expect(manifest.version).toBe(pkg.version);
const [npm] = manifest.packages;
expect(npm).toMatchObject({
registryType: 'npm',
identifier: pkg.name,
version: pkg.version,
transport: { type: 'stdio' },
packageArguments: [{ type: 'positional', value: 'mcp' }],
});
const env = Object.fromEntries(npm.environmentVariables.map((v: { name: string }) => [v.name, v]));
expect(env.CODEMAN_API_URL.isRequired).toBe(true);
expect(env.CODEMAN_PASSWORD.isSecret).toBe(true);
});
});