diff --git a/src/mcplocal/tests/smoke/audit.test.ts b/src/mcplocal/tests/smoke/audit.test.ts index f675137..9c8c2a8 100644 --- a/src/mcplocal/tests/smoke/audit.test.ts +++ b/src/mcplocal/tests/smoke/audit.test.ts @@ -155,7 +155,7 @@ describe('Smoke: Audit events', () => { try { await chat.initialize(); const tools = await chat.listTools(); - chat.check('Gated (only begin_session)', tools.length, (v) => v >= 1); + chat.check('Gated tools/list responds', tools.length, (v) => v >= 1); // Trigger gate decision await chat.callTool('begin_session', { description: 'audit smoke test' }); diff --git a/src/mcplocal/tests/smoke/backup-and-servers.test.ts b/src/mcplocal/tests/smoke/backup-and-servers.test.ts index f15f7f4..d715e45 100644 --- a/src/mcplocal/tests/smoke/backup-and-servers.test.ts +++ b/src/mcplocal/tests/smoke/backup-and-servers.test.ts @@ -167,11 +167,21 @@ describe('Smoke: Backup completeness', () => { } }); - it('backup servers have command field for docker-image STDIO servers', () => { + it('backup preserves the command of docker-image STDIO servers', async () => { if (!available) return; - const dockerStdio = bundle.servers.filter((s) => s.dockerImage && s.transport === 'STDIO'); - for (const s of dockerStdio) { - expect(s.command, `${s.name} (dockerImage STDIO) should have command`).toBeTruthy(); + // `command` is optional for dockerImage servers (the image entrypoint is + // used when it is empty — calcmcp, dav-michal, jmap-michal all run that + // way), so requiring it universally asserted a false invariant. What the + // backup MUST do is carry the command through for servers that have one. + const live = (await mcpdRequest('GET', '/api/v1/servers')).data; + const withCommand = live.filter( + (s) => s.dockerImage && s.transport === 'STDIO' && Array.isArray(s.command) && s.command.length > 0, + ); + expect(withCommand.length, 'no live dockerImage STDIO server has a command — fidelity unverifiable').toBeGreaterThan(0); + for (const s of withCommand) { + const backed = bundle.servers.find((b) => b.name === s.name); + expect(backed, `${s.name} missing from backup`).toBeTruthy(); + expect(backed!.command, `${s.name}: backup dropped its command`).toEqual(s.command); } }); diff --git a/src/mcplocal/tests/smoke/proxy-pipeline.test.ts b/src/mcplocal/tests/smoke/proxy-pipeline.test.ts index e339d17..4882970 100644 --- a/src/mcplocal/tests/smoke/proxy-pipeline.test.ts +++ b/src/mcplocal/tests/smoke/proxy-pipeline.test.ts @@ -108,23 +108,38 @@ describe('Smoke: ProxyModel pipeline', () => { expect(true).toBe(true); }); - it('gated session: tools/list returns only begin_session', async () => { + it('gated session: tools/list returns begin_session plus only gate-exempt tools', async () => { if (!serverResponding) return; + // Since the ungated-global system project (PR #137), servers attached to + // it ride every project PRE-gate: a fresh gated session sees their tools + // alongside begin_session. Anything else leaking through is a gate bug. + let exemptServers: string[] = []; + try { + const raw = await mcpctl('get project ungated-global -o json'); + const parsed = JSON.parse(raw) as Array<{ servers?: string[] }>; + exemptServers = parsed[0]?.servers ?? []; + } catch { + // Project deleted by an admin (it is deletable) — nothing is exempt. + } + const session = new SmokeMcpSession(PROJECT_NAME); const chat = new ChatReporter(session); - chat.section('Gating: fresh session sees only begin_session'); + chat.section('Gating: fresh session sees begin_session + gate-exempt tools only'); try { await chat.initialize(); await chat.sendNotification('notifications/initialized'); const tools = await chat.listTools(); + const names = tools.map((t) => t.name); + const isExempt = (n: string): boolean => exemptServers.some((s) => n.startsWith(`${s}_`)); + const leaked = names.filter((n) => n !== 'begin_session' && !isExempt(n)); - chat.check('Exactly 1 tool', tools.length, (v) => v === 1); - chat.check('Tool is begin_session', tools[0]?.name ?? '', (v) => v === 'begin_session'); - chat.check('Has inputSchema', !!tools[0]?.inputSchema, (v) => v === true); + chat.check('begin_session present', names.includes('begin_session'), (v) => v === true); + chat.check('No gated tools leaked', leaked.length, (v) => v === 0); + chat.check('Has inputSchema', !!tools.find((t) => t.name === 'begin_session')?.inputSchema, (v) => v === true); - expect(tools).toHaveLength(1); - expect(tools[0]!.name).toBe('begin_session'); + expect(names).toContain('begin_session'); + expect(leaked, 'tools visible before begin_session that are not gate-exempt').toEqual([]); } finally { await chat.close(); } diff --git a/src/mcplocal/tests/smoke/tool-drilldown.test.ts b/src/mcplocal/tests/smoke/tool-drilldown.test.ts index 8ccbf7b..811803f 100644 --- a/src/mcplocal/tests/smoke/tool-drilldown.test.ts +++ b/src/mcplocal/tests/smoke/tool-drilldown.test.ts @@ -30,7 +30,10 @@ interface Tool { /** Tools served by the gate plugin — intercepted before the pipeline, so exempt. */ -const GATE_TOOLS = new Set(['begin_session', 'read_prompts', 'propose_prompt', 'propose_skill']); +// mcplocal-virtual tools: dispatched by the router before the pipeline, so +// they never produce paginatable results and rightly skip the drill-down +// contract. get_current_time is the ambient-context clock. +const GATE_TOOLS = new Set(['begin_session', 'read_prompts', 'propose_prompt', 'propose_skill', 'get_current_time']); describe('Smoke: tool drill-down contract', () => { let available = false;