fix(mcplocal): make the paginated-result contract usable by any MCP client
Some checks failed
CI/CD / lint (pull_request) Successful in 1m13s
CI/CD / test (pull_request) Successful in 1m25s
CI/CD / typecheck (pull_request) Successful in 3m2s
CI/CD / smoke (pull_request) Failing after 2m5s
CI/CD / build (pull_request) Successful in 5m1s
CI/CD / publish (pull_request) Has been skipped

UniFi tools were unusable from non-Claude agents. A tool result over 2000
chars is replaced with a table of contents and re-read by calling the tool
again with _resultId/_section, but those params were never declared on the
tool's inputSchema — and unifi-network and my-grafana ship
`additionalProperties: false`, so for a client that validates arguments the
drill-down call was illegal and the data unreachable. The stub's own wording
made it worse: "Use section parameter" names a param that does not exist;
sending it fell through to the upstream, re-paginated, and minted a fresh
_resultId. An unbounded loop.

UniFi took the blame because it is the one server whose results always trip
the threshold: get_devices was 11,718 chars, get_clients 136,696 across 19
pages with a 92-char page-1. Grafana and Gitea return compact results.

- content-pipeline gains onToolsList, declaring _resultId/_section on every
  tool it can paginate (gate tools excluded — they are intercepted before the
  pipeline runs). additionalProperties stays false: a property listed in
  `properties` is already legal under it, so declaring is enough and the
  upstream keeps its typo protection.
- createDefaultPlugin wired only the gate's onToolsList, so the pipeline's
  would have been dropped on the floor. Both now chain, gate first.
- _resultId without _section re-shows the table of contents instead of
  forwarding an unknown argument to a strict upstream.
- The stub names the tool, the live _resultId and a real section id.
- Nested MCP envelopes are collapsed. A server fronting another MCP server
  returns the inner result wrapped in its own content/structuredContent pair,
  so the payload arrives two or three times over. A layer is peeled only when
  it carries nothing the inner value lacks, which keeps it lossless. Live
  against the sre project: get_devices 11,718 -> 5,210 chars and no longer
  paginates at all; get_clients 136,696 -> 62,358 across 8 pages instead of 19.
- deploy/mcplocal.service shipped MCPLOCAL_MCPD_URL=http://10.0.0.194:3100,
  which is dead — every working machine had a hand-written drop-in. Points at
  the k8s ingress now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JNXFvxanvM6uiFcb4Mp3xU
This commit is contained in:
Michal
2026-08-14 23:27:36 +01:00
parent 8c359902c7
commit 03350856ea
12 changed files with 710 additions and 24 deletions

View File

@@ -0,0 +1,339 @@
/**
* content-pipeline drill-down contract tests.
*
* Regression cover for the defect that made UniFi tools unusable from
* non-Claude MCP clients: a large tool result was replaced with a stub that
* told the caller to pass _resultId/_section, but those parameters were never
* advertised on the tool's inputSchema. Upstreams that declare
* `additionalProperties: false` (unifi-network, my-grafana) therefore made the
* drill-down call invalid for any client that validates against the schema,
* and the stub's own wording ("Use section parameter") pointed at a parameter
* that does not exist — sending it re-paginated and minted a fresh _resultId,
* an unbounded loop.
*
* Driven through a real McpRouter with the default plugin (gate +
* content-pipeline), mirroring project-mcp-endpoint wiring.
*/
import { describe, it, expect, vi } from 'vitest';
import { McpRouter } from '../src/router.js';
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
import type { McpdClient } from '../src/http/mcpd-client.js';
import { createDefaultPlugin } from '../src/proxymodel/plugins/default.js';
import { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js';
import { MemoryCache } from '../src/proxymodel/cache.js';
/** Mirrors unifi-network: strict schema, and a payload big enough to paginate. */
const STRICT_SCHEMA = {
type: 'object',
properties: {
targetId: { type: 'string' },
site: { type: 'string' },
},
additionalProperties: false,
$schema: 'http://json-schema.org/draft-07/schema#',
};
const BIG_PAYLOAD = 'x'.repeat(20_000);
/**
* The shape unifi-network actually returns: the inner MCP result is wrapped in
* the server's own content/structuredContent pair, so the payload arrives
* twice — once escaped inside `content`, once parsed in `structuredContent`.
*/
function wrapLikeUnifi(tool: string, inner: unknown): string {
const innerEnvelope = { tool, targetId: 'home', result: inner };
return JSON.stringify(
{
tool,
targetId: 'home',
content: [{ type: 'text', text: JSON.stringify(innerEnvelope, null, 2) }],
structuredContent: innerEnvelope,
},
null,
2,
);
}
/** Inner payload stays under the 8000-char page size; wrapped, it does not. */
const DEVICE_ROWS = Array.from({ length: 30 }, (_, i) => ({
mac: `0c:ea:14:38:af:${i.toString(16).padStart(2, '0')}`,
name: `Switch ${i} — office floor plan position ${i}`,
model: 'USPM16',
ip: `192.168.1.${i + 10}`,
version: '7.4.1.16850',
}));
const PAYLOADS: Record<string, string> = {
get_devices: BIG_PAYLOAD,
get_clients: wrapLikeUnifi('get_clients', { data: DEVICE_ROWS }),
get_alarms: wrapLikeUnifi('get_alarms', { data: [] }),
};
interface Upstream {
conn: UpstreamConnection;
calls: Array<Record<string, unknown>>;
}
function mockUpstream(name: string, payloads: Record<string, string> = PAYLOADS): Upstream {
const calls: Array<Record<string, unknown>> = [];
const conn = {
name,
isAlive: vi.fn(() => true),
close: vi.fn(async () => {}),
onNotification: vi.fn(),
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
if (req.method === 'tools/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: {
tools: Object.keys(payloads).map((n) => ({
name: n,
description: `Retrieve ${n}`,
inputSchema: STRICT_SCHEMA,
})),
},
};
}
if (req.method === 'tools/call') {
const params = (req.params ?? {}) as Record<string, unknown>;
calls.push((params['arguments'] as Record<string, unknown>) ?? {});
const tool = String(params['name'] ?? '').split('/').pop() ?? '';
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: payloads[tool] ?? '' }] } };
}
if (req.method === 'resources/list') return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
if (req.method === 'prompts/list') return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } };
return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } };
}),
} as unknown as UpstreamConnection;
return { conn, calls };
}
function mockMcpdClient(): McpdClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
forward: vi.fn(async () => ({ status: 200, body: {} })),
withHeaders: vi.fn(function (this: McpdClient) { return this; }),
} as unknown as McpdClient;
}
function setup(opts: { gated?: boolean; payloads?: Record<string, string> } = {}) {
const router = new McpRouter();
router.setPromptConfig(mockMcpdClient(), 'test-project');
router.setPlugin(createDefaultPlugin({ gated: opts.gated ?? false, providerRegistry: null }));
router.setProxyModel(
'default',
{ complete: async () => '', available: () => false } as unknown as LLMProviderAdapter,
new MemoryCache(),
);
const upstream = mockUpstream('unifi-network', opts.payloads ?? PAYLOADS);
router.addUpstream(upstream.conn);
return { router, upstream };
}
async function listTools(router: McpRouter, sessionId = 's1') {
await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId });
const res = await router.route({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, { sessionId });
return (res.result as { tools: Array<{ name: string; inputSchema?: unknown }> }).tools;
}
function textOf(res: JsonRpcResponse): string {
return (res.result as { content: Array<{ text: string }> }).content[0]!.text;
}
async function callNamed(
router: McpRouter,
tool: string,
args: Record<string, unknown>,
id: number,
sessionId = 's1',
): Promise<JsonRpcResponse> {
return router.route(
{ jsonrpc: '2.0', id, method: 'tools/call', params: { name: `unifi-network/${tool}`, arguments: args } },
{ sessionId },
);
}
async function callTool(
router: McpRouter,
args: Record<string, unknown>,
id: number,
sessionId = 's1',
): Promise<JsonRpcResponse> {
return router.route(
{ jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'unifi-network/get_devices', arguments: args } },
{ sessionId },
);
}
describe('content-pipeline drill-down contract', () => {
it('advertises _resultId/_section on a strict-schema upstream tool', async () => {
const { router } = setup();
const tool = (await listTools(router)).find((t) => t.name.endsWith('get_devices'));
expect(tool).toBeDefined();
const schema = tool!.inputSchema as Record<string, unknown>;
const props = schema['properties'] as Record<string, unknown>;
expect(props['_resultId']).toMatchObject({ type: 'string' });
expect(props['_section']).toMatchObject({ type: 'string' });
// The upstream's own params survive untouched.
expect(props['targetId']).toMatchObject({ type: 'string' });
// Declaring the params in `properties` is what makes them legal; the
// upstream's strictness is preserved rather than loosened.
expect(schema['additionalProperties']).toBe(false);
});
it('leaves gate tools alone (they never reach the pipeline)', async () => {
const { router } = setup({ gated: true });
const tools = await listTools(router);
const beginSession = tools.find((t) => t.name === 'begin_session');
expect(beginSession).toBeDefined();
const props = (beginSession!.inputSchema as Record<string, unknown>)['properties'] as Record<string, unknown>;
expect(props['_resultId']).toBeUndefined();
expect(props['_section']).toBeUndefined();
});
it('stubs a large result with an instruction naming the real parameters', async () => {
const { router } = setup();
await listTools(router);
const text = textOf(await callTool(router, {}, 3));
expect(text).toContain('_resultId=');
expect(text).toContain('_section=');
// The old wording pointed at a parameter that does not exist.
expect(text).not.toContain('Use section parameter');
});
it('serves the page from cache without a second upstream call', async () => {
const { router, upstream } = setup();
await listTools(router);
const stub = textOf(await callTool(router, {}, 3));
const resultId = /_resultId="([^"]+)"/.exec(stub)?.[1];
expect(resultId).toBeTruthy();
const callsAfterFirst = upstream.calls.length;
const page = textOf(await callTool(router, { _resultId: resultId!, _section: 'page-1' }, 4));
expect(page).toContain('x'.repeat(100));
// Drill-down is answered locally, so the strict upstream never sees the
// params it would reject.
expect(upstream.calls.length).toBe(callsAfterFirst);
});
it('re-shows the table of contents when _resultId arrives without _section', async () => {
const { router, upstream } = setup();
await listTools(router);
const stub = textOf(await callTool(router, {}, 3));
const resultId = /_resultId="([^"]+)"/.exec(stub)?.[1];
const callsAfterFirst = upstream.calls.length;
const res = textOf(await callTool(router, { _resultId: resultId! }, 4));
expect(res).toContain('page-1');
expect(res).toContain('_section=');
expect(upstream.calls.length).toBe(callsAfterFirst);
});
it('reports an expired or unknown _resultId instead of forwarding it', async () => {
const { router, upstream } = setup();
await listTools(router);
const callsBefore = upstream.calls.length;
const res = textOf(await callTool(router, { _resultId: 'pm-nope', _section: 'page-1' }, 3));
expect(res).toContain('Cached result not found');
expect(upstream.calls.length).toBe(callsBefore);
});
});
describe('nested MCP envelope collapse', () => {
it('collapses a doubly-wrapped result to its inner payload', async () => {
const { router } = setup();
await listTools(router);
const text = textOf(await callNamed(router, 'get_alarms', {}, 3));
const parsed = JSON.parse(text) as Record<string, unknown>;
// The wrapper's content/structuredContent pair is gone; the payload it
// carried twice now appears once.
expect(parsed['content']).toBeUndefined();
expect(parsed['structuredContent']).toBeUndefined();
expect(parsed['tool']).toBe('get_alarms');
expect(parsed['targetId']).toBe('home');
expect(parsed['result']).toEqual({ data: [] });
});
it('drops a formerly-paginated result below the pagination threshold', async () => {
const { router } = setup();
await listTools(router);
const wrapped = PAYLOADS['get_clients']!;
expect(wrapped.length).toBeGreaterThan(8000); // would paginate as-is
const text = textOf(await callNamed(router, 'get_clients', {}, 3));
// Delivered whole, not as a table of contents.
expect(text).not.toContain('Content split into');
expect(text).not.toContain('_resultId=');
expect(text.length).toBeLessThan(wrapped.length);
const parsed = JSON.parse(text) as { result: { data: unknown[] } };
expect(parsed.result.data).toHaveLength(30);
});
it('leaves a wrapper alone when structuredContent disagrees with the text', async () => {
const divergent = JSON.stringify({
tool: 'get_devices',
content: [{ type: 'text', text: JSON.stringify({ tool: 'get_devices', result: { data: [1] } }) }],
structuredContent: { tool: 'get_devices', result: { data: [1, 2, 3] } },
});
const { router } = setup({ payloads: { get_devices: divergent } });
await listTools(router);
const text = textOf(await callTool(router, {}, 3));
// Collapsing here would silently drop rows, so the envelope survives.
expect(text).toBe(divergent);
});
it('leaves a wrapper alone when it carries a key the payload lacks', async () => {
const extraKey = JSON.stringify({
tool: 'get_devices',
warning: 'partial results — controller unreachable',
content: [{ type: 'text', text: JSON.stringify({ tool: 'get_devices', result: { data: [] } }) }],
});
const { router } = setup({ payloads: { get_devices: extraKey } });
await listTools(router);
expect(textOf(await callTool(router, {}, 3))).toBe(extraKey);
});
it('leaves non-text content parts intact', async () => {
const withImage = JSON.stringify({
tool: 'get_devices',
content: [
{ type: 'text', text: '{"tool":"get_devices"}' },
{ type: 'image', data: 'iVBORw0KGgo=', mimeType: 'image/png' },
],
});
const { router } = setup({ payloads: { get_devices: withImage } });
await listTools(router);
expect(textOf(await callTool(router, {}, 3))).toBe(withImage);
});
it('leaves plain non-JSON results untouched', async () => {
const { router } = setup({ payloads: { get_devices: 'plain text, no envelope' } });
await listTools(router);
expect(textOf(await callTool(router, {}, 3))).toBe('plain text, no envelope');
});
});

View File

@@ -261,9 +261,11 @@ describe('Prompt section drill-down', () => {
expect(result.sections).toBeDefined();
expect(result.sections!.length).toBeGreaterThanOrEqual(3);
// TOC should list sections
// TOC should list sections. The stage must NOT name a navigation
// parameter: the caller appends the authoritative _resultId/_section
// instruction, and a bare `section` hint here sends models into a loop.
expect(result.content).toContain('sections');
expect(result.content).toContain('Use section parameter');
expect(result.content).not.toContain('Use section parameter');
// Original was ~16K, TOC should be much shorter
expect(result.content.length).toBeLessThan(largePrompt.length);

View File

@@ -4,7 +4,7 @@
* Verifies that large prompts served via prompts/get are section-split
* and that subsequent calls with _resultId + _section return cached sections.
*
* Requires: mcplocal running on localhost:3200, mcpd on 10.0.0.194:3100
* Requires: mcplocal running on localhost:3200, mcpd at https://mcpctl.ad.itaz.eu
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SmokeMcpSession, isMcplocalRunning } from './mcp-client.js';

View File

@@ -6,7 +6,7 @@
*
* Prerequisites:
* - mcplocal running on localhost:3200
* - mcpd running on 10.0.0.194:3100
* - mcpd reachable at https://mcpctl.ad.itaz.eu
* - smoke-aws-docs server deployed (runtime: python)
*
* The test suite uses the fixture at fixtures/smoke-data.yaml which

View File

@@ -0,0 +1,109 @@
/**
* 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();
}
});
});