/** * Smoke tests: tool drill-down contract. * * A large tool result is replaced with a table of contents and re-read by * calling the same tool with _resultId + _section. Those two parameters must * appear in the tool's advertised inputSchema, because upstreams such as * unifi-network and my-grafana declare `additionalProperties: false` — a * client validating against the schema cannot send an undeclared parameter, * which used to make every paginated UniFi result unreadable. * * Requires: mcplocal running (localhost:3200) with a project whose servers are * reachable. Set SMOKE_PROJECT to target a specific project. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { SmokeMcpSession, isMcplocalRunning } from './mcp-client.js'; const PROJECT_NAME = process.env['SMOKE_PROJECT'] ?? 'smoke-data'; /** HTTP-mode mcplocal authenticates every request with an McpToken. */ const TOKEN = process.env['SMOKE_MCPTOKEN']; interface Tool { name: string; inputSchema?: { type?: string; properties?: Record; additionalProperties?: unknown }; } /** 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']); describe('Smoke: tool drill-down contract', () => { let available = false; let session: SmokeMcpSession; let tools: Tool[] = []; beforeAll(async () => { available = await isMcplocalRunning(); if (!available) return; session = new SmokeMcpSession(PROJECT_NAME, TOKEN); await session.initialize(); await session.sendNotification('notifications/initialized'); // Open the gate if the project is gated, so the real catalog is visible. const gated = await session.send('tools/list') as { tools: Tool[] }; if (gated.tools.some((t) => t.name === 'begin_session')) { await session.send('tools/call', { name: 'begin_session', arguments: { description: 'Verify the paginated tool-result drill-down contract' }, }, 180_000); } tools = ((await session.send('tools/list')) as { tools: Tool[] }).tools; }, 240_000); afterAll(async () => { if (session) await session.close(); }); it('every upstream tool advertises _resultId and _section', async () => { if (!available) return; const upstreamTools = tools.filter((t) => !GATE_TOOLS.has(t.name)); if (upstreamTools.length === 0) { console.log(` No upstream tools in project "${PROJECT_NAME}" — skipping`); return; } const missing = upstreamTools.filter((t) => { const props = t.inputSchema?.properties; return props === undefined || props['_resultId'] === undefined || props['_section'] === undefined; }); if (missing.length > 0) { console.log(` Missing drill-down params: ${missing.map((t) => t.name).join(', ')}`); } expect(missing).toEqual([]); console.log(` ${upstreamTools.length} tools carry the drill-down contract`); }); it('strict upstream schemas stay strict (params are declared, not permitted)', async () => { if (!available) return; // Declaring the params in `properties` is what makes them legal under // `additionalProperties: false`; loosening it instead would drop the // upstream's own typo protection. const strict = tools.filter( (t) => !GATE_TOOLS.has(t.name) && t.inputSchema?.additionalProperties === false, ); if (strict.length === 0) { console.log(' No strict-schema tools in this project — skipping'); return; } for (const t of strict) { expect(t.inputSchema!.properties!['_resultId']).toBeDefined(); expect(t.inputSchema!.properties!['_section']).toBeDefined(); } console.log(` ${strict.length} strict-schema tools keep additionalProperties: false`); }); it('gate tools do not advertise drill-down params', async () => { if (!available) return; for (const t of tools.filter((x) => GATE_TOOLS.has(x.name))) { const props = t.inputSchema?.properties ?? {}; expect(props['_resultId']).toBeUndefined(); expect(props['_section']).toBeUndefined(); } }); });