110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, unknown>; 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();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|