Files
mcpctl/src/mcplocal/tests/smoke/prompt-drilldown.test.ts
Michal 03350856ea
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
fix(mcplocal): make the paginated-result contract usable by any MCP client
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
2026-08-14 23:27:36 +01:00

123 lines
4.5 KiB
TypeScript

/**
* Smoke tests: Prompt section drill-down.
*
* 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 at https://mcpctl.ad.itaz.eu
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SmokeMcpSession, isMcplocalRunning } from './mcp-client.js';
const PROJECT_NAME = 'smoke-data';
describe('Smoke: Prompt section drill-down', () => {
let available = false;
let session: SmokeMcpSession;
beforeAll(async () => {
available = await isMcplocalRunning();
if (!available) return;
session = new SmokeMcpSession(PROJECT_NAME);
await session.initialize();
await session.sendNotification('notifications/initialized');
});
afterAll(async () => {
if (session) await session.close();
});
it('prompts/list returns available prompts', async () => {
if (!available) return;
const result = await session.send('prompts/list') as { prompts: Array<{ name: string; description?: string }> };
expect(result.prompts).toBeDefined();
expect(Array.isArray(result.prompts)).toBe(true);
// Should have at least mcpctl-managed prompts
const mcpctlPrompts = result.prompts.filter((p) => p.name.startsWith('mcpctl/'));
console.log(` Found ${result.prompts.length} prompts (${mcpctlPrompts.length} mcpctl-managed)`);
});
it('prompts/get returns prompt content', async () => {
if (!available) return;
const listResult = await session.send('prompts/list') as { prompts: Array<{ name: string }> };
if (listResult.prompts.length === 0) {
console.log(' No prompts available — skipping');
return;
}
const promptName = listResult.prompts[0].name;
const getResult = await session.send('prompts/get', { name: promptName }) as {
messages?: Array<{ role: string; content: unknown }>;
};
expect(getResult.messages).toBeDefined();
expect(getResult.messages!.length).toBeGreaterThan(0);
console.log(` prompts/get "${promptName}": ${getResult.messages!.length} message(s)`);
});
it('large prompt response includes section TOC with _resultId', async () => {
if (!available) return;
// Find a mcpctl-managed prompt (these tend to be large system prompts)
const listResult = await session.send('prompts/list') as { prompts: Array<{ name: string }> };
const mcpctlPrompts = listResult.prompts.filter((p) => p.name.startsWith('mcpctl/'));
if (mcpctlPrompts.length === 0) {
console.log(' No mcpctl prompts — skipping section drill-down test');
return;
}
// Try each prompt to find one large enough to be section-split
let foundSections = false;
for (const prompt of mcpctlPrompts) {
const getResult = await session.send('prompts/get', { name: prompt.name }) as {
messages?: Array<{ role: string; content: unknown }>;
};
if (!getResult.messages || getResult.messages.length === 0) continue;
const msg = getResult.messages[0];
const text = typeof msg.content === 'string'
? msg.content
: (msg.content as { text?: string }).text ?? '';
if (text.includes('_resultId')) {
foundSections = true;
console.log(` "${prompt.name}": section-split TOC detected (${text.length} chars)`);
console.log(` TOC preview: ${text.slice(0, 200)}...`);
// Extract _resultId
const match = /_resultId:\s*(pm-[a-z0-9]+)/.exec(text);
if (match) {
const resultId = match[1];
// Extract first section id from TOC
const sectionMatch = /\[([^\]]+)\]/.exec(text);
if (sectionMatch) {
const sectionId = sectionMatch[1];
console.log(` Drilling into section "${sectionId}" with resultId "${resultId}"...`);
// Drill down
const drillResult = await session.send('prompts/get', {
name: prompt.name,
arguments: { _resultId: resultId, _section: sectionId },
}) as { content?: Array<{ text: string }> };
expect(drillResult).toBeDefined();
console.log(` Drill-down returned: ${JSON.stringify(drillResult).length} chars`);
}
}
break;
} else {
console.log(` "${prompt.name}": ${text.length} chars (not section-split — likely too small)`);
}
}
if (!foundSections) {
console.log(' No prompts large enough for section-split — test inconclusive but not failing');
}
});
});