Merge origin/main into feat/agentic-teams

124 commits of main against 33 of agentic-teams. 22 files overlapped, 7
conflicted. Three needed real reconciliation rather than a side-pick:

**The `volumes` collision.** Both branches independently added a `volumes`
field to ContainerSpec meaning different things: main's is persistent storage
keyed by a server-derived `claimName` that deliberately outlives an instance;
mine is emptyDir scratch shared between an init container and the main
container. Worse, each emitted the pod-level `volumes` list from its own
helper, so spreading both would have silently kept only the last — a server
with a PVC would have lost it the moment a session scratch volume appeared.

Resolved by renaming the unreleased side: mine is now `scratchVolumes`, main's
keeps `volumes` since it is already shipped and in use. `buildPodVolumes` now
owns the whole pod-level list and emits both kinds; `buildPodExtras` is init
containers only. fsGroup/securityContext stays tied to the PVC case, because
an emptyDir is created writable by the container's own user and chowning it
is work with no purpose. Five tests pin the coexistence so a later change
cannot drop one kind again.

**The extracted permission mapper.** I moved `mapUrlToPermission` out of
main.ts into middleware/url-permission.ts so it could be tested; main kept it
inline and kept editing it. Kept the extraction and ported main's one change
across — the `/api/v1/audit/events/purge` mapping onto the existing
`audit-purge` operation. Verified rule-by-rule that the extracted version is a
superset: all 15 of main's rules plus the 5 agentic-teams ones.

**The NUL byte in create.ts.** Both branches fixed the same defect
independently, mine as `''` and main's as `'\0'`. Took main's.

The rest were one-liners: .gitignore comment wording, and an add/add on
proxy-long-running.test.ts where main's copy carries a progressive-delivery
test mine lacked — took main's in full.

Not changed, but worth flagging: main's generateDeploymentSpec hardcodes
`automountServiceAccountToken: false` while generatePodSpec honours
`spec.automountServiceAccountToken`. That asymmetry is main's own and predates
this merge, so it is left exactly as-is rather than altered inside a merge
commit.

Verified: 3138 tests pass (237 files), build clean, prisma schema valid.
Lint 809/80 vs 794/77 pre-merge — the delta is main's own debt; every file
touched during conflict resolution was checked and introduces no new errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
This commit is contained in:
Michal
2026-09-06 23:40:04 +01:00
223 changed files with 20890 additions and 640 deletions

View File

@@ -1,5 +1,5 @@
import type { McpdClient } from './http/mcpd-client.js';
import { DISCOVERY_TIMEOUT_MS } from './http/mcpd-client.js';
import { DISCOVERY_TIMEOUT_MS, TOOLCALL_TIMEOUT_MS } from './http/mcpd-client.js';
import type { McpRouter } from './router.js';
import { McpdUpstream } from './upstream/mcpd.js';
@@ -9,6 +9,8 @@ interface McpdServer {
description?: string;
transport: string;
status?: string;
/** Per-server tool-call deadline override, in seconds. Null = use the global. */
toolCallTimeoutSeconds?: number | null;
}
/**
@@ -152,6 +154,10 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS
// unreachable upstream cannot stall session init for the full tool-call window.
const discoveryClient = mcpdClient.withTimeout(DISCOVERY_TIMEOUT_MS);
// Everything else an upstream receives is a proxied tools/call, which can
// legitimately run past the 30s default (browser solvers, PDF extraction).
const toolClient = mcpdClient.withTimeout(TOOLCALL_TIMEOUT_MS);
// Remove stale upstreams
const currentNames = new Set(router.getUpstreamNames());
const serverNames = new Set(servers.map((s) => s.name));
@@ -164,9 +170,18 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS
// Add/update upstreams for each server
for (const server of servers) {
if (!currentNames.has(server.name)) {
const upstream = new McpdUpstream(server.id, server.name, mcpdClient, server.description, discoveryClient);
const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient);
router.addUpstream(upstream);
}
// Applied on every sync, not just on first registration: raising a
// server's timeout should take effect at the next refresh rather than
// requiring the upstream to be dropped and rebuilt.
router.setServerDeadline(
server.name,
typeof server.toolCallTimeoutSeconds === 'number'
? server.toolCallTimeoutSeconds * 1000
: undefined,
);
registered.push(server.name);
}

View File

@@ -0,0 +1,75 @@
/**
* Outer bound on one client request.
*
* The LLM budgets (proxymodel/llm-adapter.ts, proxymodel/stage-budget.ts) bound
* the failure mode we actually hit in production. This is the backstop for the
* ones they cannot see: a wedged plugin hook, a virtual-tool handler, an
* upstream path without its own timeout. `transport.onmessage` writes to an
* already-hijacked socket, so if it never completes the client gets silence and
* waits out its own timeout — 1800s in Claude Code.
*
* MUST exceed McpdClient's TOOLCALL_TIMEOUT_MS. The watchdog arms earlier in
* the request than the mcpd fetch does, so at equal values it would always fire
* first and mask mcpd's specific UpstreamTimeoutError with a generic deadline
* message — and cut off pagination and the rest of the post-processing besides.
*/
import { TOOLCALL_TIMEOUT_MS } from './mcpd-client.js';
export const TOOLCALL_DEADLINE_MS =
Number(process.env['MCPLOCAL_TOOLCALL_DEADLINE_MS']) || (TOOLCALL_TIMEOUT_MS + 30_000);
export class DeadlineExceededError extends Error {
constructor(public readonly method: string, public readonly ms: number) {
super(`${method} exceeded the ${String(ms)}ms mcplocal deadline`);
this.name = 'DeadlineExceededError';
}
}
export interface RequestDeadline {
/** Rejects with DeadlineExceededError when the budget runs out. */
readonly expiry: Promise<never>;
/** Run `fn` with the clock stopped, then re-arm with what was left. */
suspended<T>(fn: () => Promise<T>): Promise<T>;
dispose(): void;
}
export function createRequestDeadline(method: string, totalMs: number): RequestDeadline {
let remaining = totalMs;
let startedAt = Date.now();
let timer: ReturnType<typeof setTimeout> | undefined;
let reject!: (err: Error) => void;
let settled = false;
const expiry = new Promise<never>((_, rej) => { reject = rej; });
// Nothing else may observe this rejection until it is raced, and an unraced
// rejection would be an unhandled rejection at process level.
expiry.catch(() => { /* raced by the caller */ });
const arm = (ms: number): void => {
startedAt = Date.now();
timer = setTimeout(() => {
settled = true;
reject(new DeadlineExceededError(method, totalMs));
}, ms);
if (typeof timer.unref === 'function') timer.unref();
};
arm(remaining);
return {
expiry,
async suspended<T>(fn: () => Promise<T>): Promise<T> {
if (settled) return fn();
if (timer !== undefined) clearTimeout(timer);
remaining = Math.max(0, remaining - (Date.now() - startedAt));
try {
return await fn();
} finally {
if (!settled) arm(remaining);
}
},
dispose(): void {
settled = true;
if (timer !== undefined) clearTimeout(timer);
},
};
}

View File

@@ -13,6 +13,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { McpRouter } from '../router.js';
import type { JsonRpcRequest } from '../types.js';
import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js';
interface SessionEntry {
transport: StreamableHTTPServerTransport;
@@ -20,6 +21,9 @@ interface SessionEntry {
export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void {
const sessions = new Map<string, SessionEntry>();
// One codec for the shared router: serve OpenAI-safe tool names on the wire,
// map tools/call names back to the internal `server/tool` form.
const wireCodec = new WireNameCodec();
// POST /mcp — JSON-RPC requests (initialize, tools/call, etc.)
app.post('/mcp', async (request, reply) => {
@@ -52,7 +56,11 @@ export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): vo
transport.onmessage = async (message: JSONRPCMessage) => {
// The transport sends us JSON-RPC messages; route them through McpRouter
if ('method' in message && 'id' in message) {
const response = await router.route(message as unknown as JsonRpcRequest);
const response = await routeWithWireNames(
wireCodec,
(req) => router.route(req),
message as unknown as JsonRpcRequest,
);
await transport.send(response as unknown as JSONRPCMessage);
}
// Notifications (no id) are ignored — router doesn't handle inbound notifications

View File

@@ -62,6 +62,17 @@ export const LONG_RUNNING_TIMEOUT_MS = Number(process.env['MCPLOCAL_LONG_TIMEOUT
*/
export const DISCOVERY_TIMEOUT_MS = Number(process.env['MCPLOCAL_DISCOVERY_TIMEOUT_MS']) || 8_000;
/**
* Budget for proxied tools/call. The 30s DEFAULT_TIMEOUT_MS is a guaranteed
* failure for tools that legitimately run long — web_url_read through a
* browser solver, a large PDF extraction, a slow retail site — all of which
* died as `mcpd proxy error: mcpd did not respond within 30000ms` while mcpd
* was still working. Distinct from LONG_RUNNING_TIMEOUT_MS (chat/inference,
* minutes) and DISCOVERY_TIMEOUT_MS (list calls, must stay short so a dead
* upstream cannot stall session init). Override via `MCPLOCAL_TOOLCALL_TIMEOUT_MS`.
*/
export const TOOLCALL_TIMEOUT_MS = Number(process.env['MCPLOCAL_TOOLCALL_TIMEOUT_MS']) || 120_000;
export class McpdClient {
private readonly baseUrl: string;
private readonly token: string;

View File

@@ -27,6 +27,12 @@ import { createFavouriteIndexPlugin } from '../proxymodel/plugins/favourite-inde
import { composePlugins } from '../proxymodel/plugins/compose.js';
import type { ProxyModelPlugin } from '../proxymodel/plugin.js';
import { AuditCollector } from '../audit/collector.js';
import { WireNameCodec, routeWithWireNames } from '../util/wire-names.js';
import { newTraceCode } from '../util/trace-code.js';
import { runInRequestScope } from '../request-context.js';
import { adoptSession } from './session-adopt.js';
import { degradationNotice } from '../util/degrade.js';
import { createRequestDeadline, DeadlineExceededError, TOOLCALL_DEADLINE_MS } from './deadline.js';
interface ProjectCacheEntry {
router: McpRouter;
@@ -45,6 +51,16 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
let resolvedUserName: string | null | undefined; // undefined = not yet resolved
const projectCache = new Map<string, ProjectCacheEntry>();
const sessions = new Map<string, SessionEntry>();
/**
* Sessions recreated after a restart, awaiting their one-shot ⚠ notice.
* Delivered on the next tools/call result — the only response a model
* reliably reads as prose.
*/
const pendingRecreationNotice = new Set<string>();
// Wire-name codecs are keyed per project and OUTLIVE the router cache TTL:
// a client may call a tool it listed minutes ago through a refreshed router,
// and the mapping must still resolve.
const wireCodecs = new Map<string, WireNameCodec>();
/** Resolve the mcplocal owner's userName once from /auth/me using mcplocal's own credentials. */
async function ensureUserName(): Promise<string | null> {
@@ -176,9 +192,13 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
chain.push(createAgentsPlugin());
router.setPlugin(composePlugins(chain));
// Fetch project instructions and set on router
// Fetch project instructions and set on router. Must ride the CALLER's
// token (requestClient) like every other downstream call here: in HTTP
// mode mcpdClient's own token is empty, mcpd answers 401, and the catch
// below swallowed it — every session initialized with no instructions
// while nothing looked broken (#113).
try {
const instructions = await mcpdClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>(
const instructions = await requestClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>(
`/api/v1/projects/${encodeURIComponent(projectName)}/instructions`,
);
const parts: string[] = [];
@@ -206,6 +226,75 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
}
// POST /projects/:projectName/mcp — JSON-RPC requests
/**
* Prepend the recreation notice to the first tool result after a session was
* rebuilt. Silent recreation would hand an agent an ungated tool list with no
* signal that its gate state had vanished.
*/
function maybeAnnotateRecreation(response: unknown, sid: string, method?: string): unknown {
if (method !== 'tools/call' || !pendingRecreationNotice.has(sid)) return response;
pendingRecreationNotice.delete(sid);
const r = response as { result?: { content?: Array<{ type?: string; text?: string }> } };
const first = r.result?.content?.[0];
if (!first || first.type !== 'text' || typeof first.text !== 'string') return response;
first.text = degradationNotice(
'Session state',
`mcplocal restarted; session ${sid.slice(0, 8)} was recreated`,
'Gate state and cached results were lost — if an expected tool is missing, call begin_session again.',
) + first.text;
return response;
}
/**
* Turn a deadline or an unexpected throw into something the client can read.
*
* A tools/call becomes a SUCCESSFUL result with isError — a transport error
* tends to surface to the user as a hard failure, whereas a tool error is
* text the model reads and acts on (the same shape router.ts already uses for
* an expired _resultId). Everything else gets a JSON-RPC error.
*/
function failureResponse(
requestId: string | number,
method: string | undefined,
err: unknown,
traceCode: string,
deadlineMs: number,
): unknown {
const timedOut = err instanceof DeadlineExceededError;
const detail = timedOut
? `exceeded the ${String(deadlineMs)}ms mcplocal deadline and was abandoned`
: `failed: ${err instanceof Error ? err.message : String(err)}`;
console.error(`[mcp] ${method ?? 'request'} ${detail} (trace ${traceCode})`);
if (method === 'tools/call') {
return {
jsonrpc: '2.0',
id: requestId,
result: {
content: [{
type: 'text',
text:
`⚠ This tool call ${detail} (trace ${traceCode}).\n`
+ 'The upstream may still be running — do not assume it did nothing. '
+ 'Retry with narrower arguments, or run '
+ `\`mcpctl trace ${traceCode}\` to see which stage consumed the time.`,
}],
isError: true,
},
};
}
return {
jsonrpc: '2.0',
id: requestId,
error: {
code: timedOut ? -32001 : -32603,
message: `${method ?? 'request'} ${detail} (trace ${traceCode})`,
},
};
}
app.post<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => {
const { projectName } = request.params;
const sessionId = request.headers['mcp-session-id'] as string | undefined;
@@ -218,10 +307,15 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
return;
}
if (sessionId && !sessions.has(sessionId)) {
reply.code(404).send({ error: 'Session not found' });
return;
}
// An unknown session id means mcplocal restarted (or the session was
// evicted) while the client still holds a perfectly good id. Replying 404
// is a dead end: it lands OUTSIDE the JSON-RPC envelope with no id, so the
// client cannot correlate it and stalls until its own timeout — 3.5ms
// server-side became 1800s client-side. Recreate instead, keep the id the
// client already has, and tell the model what it lost.
const recreatingSessionId = sessionId !== undefined && !sessions.has(sessionId)
? sessionId
: undefined;
// New session — get/create project router
let router: McpRouter;
@@ -232,9 +326,11 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
return;
}
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
// Called by onsessioninitialized on the normal path, and explicitly on the
// recreation path — where onsessioninitialized never fires. Missing any of
// this on the adopt path would give recreated sessions null userName on
// every audit event.
const registerSession = (id: string): void => {
sessions.set(id, { transport, projectName });
trafficCapture?.emit({
timestamp: new Date().toISOString(),
@@ -272,7 +368,11 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
verified: true,
payload: { projectName },
});
},
};
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => { registerSession(id); },
});
// Per-request correlationId map for linking client ↔ upstream event pairs.
@@ -316,7 +416,11 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
const requestId = message.id as string | number;
const sid = transport.sessionId ?? 'unknown';
const method = (message as { method?: string }).method;
const correlationId = `${sid}:${requestId}`;
// Short, transcribable, and the audit correlationId itself. Nothing
// parses the old `<sid>:<id>` form, sessionId is its own audit column
// and the JSON-RPC id is in the traffic body, so no migration is needed
// and `mcpctl trace <code>` works against the existing index.
const correlationId = newTraceCode();
requestCorrelations.set(requestId, correlationId);
// Capture client request
@@ -331,8 +435,56 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
});
const ctx = transport.sessionId ? { sessionId: transport.sessionId, correlationId } : { correlationId };
const response = await router.route(message as unknown as JsonRpcRequest, ctx);
// Wire-name translation happens HERE, at the client boundary, so the
// router, plugins and audit all keep canonical `server/tool` names.
let codec = wireCodecs.get(projectName);
if (!codec) {
codec = new WireNameCodec();
wireCodecs.set(projectName, codec);
}
// Everything downstream — plugins, stages, the pipeline executor — is
// built per SESSION, not per request, so the trace code reaches them
// through the async scope rather than a parameter on every signature.
//
// The deadline is the backstop for hangs the LLM budgets cannot see: a
// wedged plugin hook, a virtual-tool handler, an upstream without its
// own timeout. Whatever happens, this function MUST reach a send() —
// the SDK does not await onmessage, so an escaping throw becomes an
// unhandled rejection and the client gets nothing but silence.
// Per-server override, if the target server declares one. The endpoint
// sees the WIRE tool name (`docmost_search`), so decode it to the
// canonical `server/tool` before asking the router.
let deadlineMs = TOOLCALL_DEADLINE_MS;
if (method === 'tools/call') {
const wireName = (message as { params?: { name?: unknown } }).params?.name;
if (typeof wireName === 'string') {
const canonical = codec.decodeName(wireName);
deadlineMs = router.getToolCallDeadlineMs(canonical) ?? TOOLCALL_DEADLINE_MS;
}
}
const deadline = createRequestDeadline(method ?? 'request', deadlineMs);
let response: unknown;
try {
response = await Promise.race([
runInRequestScope(
{ correlationId, sessionId: sid, projectName, method: method ?? 'unknown', deadline },
() => routeWithWireNames(
codec,
(req) => router.route(req, ctx),
message as unknown as JsonRpcRequest,
),
),
deadline.expiry,
]);
} catch (err) {
response = failureResponse(requestId, method, err, correlationId, deadlineMs);
} finally {
deadline.dispose();
}
// Guaranteed-send tail. Everything from here on is best-effort: a throw
// while flushing notifications must not cost the client its response.
try {
// Forward queued notifications BEFORE the response — the response send
// closes the POST SSE stream, so notifications must go first.
// relatedRequestId routes them onto the same SSE stream as the response.
@@ -362,11 +514,34 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
correlationId,
});
requestCorrelations.delete(requestId);
await transport.send(response as unknown as JSONRPCMessage);
} catch (tailErr) {
console.error(
`[mcp] notification flush failed for trace ${correlationId}: `
+ `${tailErr instanceof Error ? tailErr.message : String(tailErr)}`,
);
}
try {
await transport.send(
maybeAnnotateRecreation(response, sid, method) as unknown as JSONRPCMessage,
);
} catch (sendErr) {
console.error(
`[mcp] failed to deliver response for trace ${correlationId} (${method ?? 'unknown'}): `
+ `${sendErr instanceof Error ? sendErr.message : String(sendErr)}`,
);
} finally {
requestCorrelations.delete(requestId);
}
}
};
// Never assigned before this change, so an SDK-level transport error was
// swallowed entirely.
transport.onerror = (err: Error) => {
console.error(`[mcp] transport error (${projectName}): ${err.message}`);
};
transport.onclose = () => {
const id = transport.sessionId;
if (id) {
@@ -382,6 +557,19 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
}
};
if (recreatingSessionId !== undefined) {
// Adopt the client's existing id rather than minting a new one: a
// re-handshake would invalidate everything the client has cached about
// this session.
adoptSession(transport, recreatingSessionId);
registerSession(recreatingSessionId);
pendingRecreationNotice.add(recreatingSessionId);
console.error(
`[mcp] recreated session ${recreatingSessionId.slice(0, 8)} for project '${projectName}' `
+ '(mcplocal restarted or the session was evicted); gate state was lost',
);
}
await transport.handleRequest(request.raw, reply.raw, request.body);
reply.hijack();
});

View File

@@ -71,7 +71,13 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v
// Forward the user's auth token to mcpd so RBAC applies per-user.
// If no user token is present, mcpd will use its auth hook to reject.
const authHeader = request.headers['authorization'] as string | undefined;
const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
const userToken = authHeader !== undefined && authHeader.startsWith('Bearer ')
? authHeader.slice(7)
: undefined;
if (isLongRunning(path)) {
return proxyStreaming(reply, client, request.method, path, querystring, body, userToken);
}
if (isLongRunning(path)) {
return proxyStreaming(reply, client, request.method, path, querystring, body, userToken);

View File

@@ -42,6 +42,24 @@ export async function createHttpServer(
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
});
// MCP clients close a session with DELETE + `Content-Type: application/json`
// and an EMPTY body. Fastify's default JSON parser rejects that with
// FST_ERR_CTP_EMPTY_JSON_BODY before the route handler ever runs, so
// sessions.delete() never happened and every "closed" session leaked --
// which is a direct producer of the stale-session condition that used to
// 404. Treat an empty body as no body.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
if (body === '' || body === undefined) {
done(null, undefined);
return;
}
try {
done(null, JSON.parse(body as string) as unknown);
} catch (err) {
done(err as Error, undefined);
}
});
// Health endpoint
app.get('/health', async (_request, reply) => {
const upstreams = deps.router.getUpstreamNames();

View File

@@ -0,0 +1,47 @@
/**
* Let a freshly-built transport adopt a session ID the client already holds.
*
* mcplocal keeps MCP sessions in memory only, so any restart — a deploy, an RPM
* install, `scripts/release.sh` — invalidates every live client's session id.
* The old behaviour was a bare `404 {"error":"Session not found"}` outside the
* JSON-RPC envelope: 3.5ms server-side, but the client cannot correlate it and
* stalls until its own timeout (1800s in Claude Code). Recreating the session
* is strictly better, and keeping the client's EXISTING id is the point — a
* re-handshake would change it and every assumption the client has cached.
*
* The SDK assigns `sessionId` and `_initialized` in exactly one place: while
* handling an `initialize` POST (webStandardStreamableHttp.js:419-420, SDK
* 1.26.0). A non-initialize message on a fresh transport is rejected by
* `validateSession` with "Bad Request: Server not initialized". Replaying those
* two assignments is far less fragile than synthesising an `initialize` request
* through hono's request listener.
*
* This reaches into SDK internals, so it fails LOUDLY rather than silently: if
* the shape moves in an SDK upgrade, `session-adopt.test.ts` goes red at
* `pnpm test` instead of in production.
*/
import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
interface WebStandardInternals {
sessionId?: string | undefined;
_initialized?: boolean;
}
export function adoptSession(transport: StreamableHTTPServerTransport, sessionId: string): void {
const inner = (transport as unknown as { _webStandardTransport?: WebStandardInternals })
._webStandardTransport;
// Guard on `_initialized` only: the SDK sets it in the constructor, whereas
// `sessionId` is not assigned until an initialize arrives — so on the fresh
// transport we are adopting onto, the property genuinely does not exist yet.
if (!inner || !('_initialized' in inner)) {
throw new Error(
'MCP SDK internals changed: cannot adopt an existing session id '
+ '(_webStandardTransport._initialized / .sessionId not found). '
+ 'See src/mcplocal/src/http/session-adopt.ts and its test.',
);
}
inner.sessionId = sessionId;
inner._initialized = true;
}

View File

@@ -36,6 +36,8 @@ export interface ActiveSession {
export interface TrafficFilter {
project?: string | undefined;
session?: string | undefined;
/** Trace code — narrows the buffer to one request's events. */
correlationId?: string | undefined;
}
type Listener = (event: TrafficEvent) => void;
@@ -97,6 +99,9 @@ export class TrafficCapture {
if (filter?.session) {
events = events.filter((e) => e.sessionId === filter.session);
}
if (filter?.correlationId) {
events = events.filter((e) => e.correlationId === filter.correlationId);
}
return events;
}

View File

@@ -6,21 +6,72 @@ export interface AnthropicConfig {
defaultModel?: string;
}
/**
* Families that can be tracked with a `claude-<family>-latest` selector.
*
* The pinned value is a FALLBACK, used only when the models endpoint cannot be
* reached. Normal operation resolves against the live list, so this map going
* stale degrades availability, never correctness.
*/
const FAMILY_FALLBACK: Record<string, string> = {
opus: 'claude-opus-5',
sonnet: 'claude-sonnet-5',
haiku: 'claude-haiku-4-5-20251001',
fable: 'claude-fable-5',
};
/** `claude-opus-latest` / `opus` → `opus`; an exact model id → null. */
function familyOf(model: string): string | null {
const m = /^(?:claude-)?([a-z]+)(?:-latest)?$/.exec(model.trim().toLowerCase());
const family = m?.[1];
if (family !== undefined && family in FAMILY_FALLBACK && /latest|^(opus|sonnet|haiku|fable)$/.test(model.toLowerCase())) {
return family;
}
return null;
}
/**
* A 400 naming a sampling parameter, e.g.
* "`temperature` is deprecated for this model."
*/
function isSamplingRejection(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err);
return msg.includes('HTTP 400') && /`?(temperature|top_p|top_k)`?/.test(msg);
}
/** Resolutions are cached this long before the models endpoint is consulted again. */
const MODEL_CACHE_TTL_MS = Number(process.env['MCPCTL_ANTHROPIC_MODEL_TTL_MS']) || 12 * 60 * 60 * 1000;
/**
* Anthropic Claude provider using the Messages API.
*/
export class AnthropicProvider implements LlmProvider {
readonly name = 'anthropic';
/** Shared across instances: the model list is account-wide, not per-provider. */
private static readonly modelCache = new Map<string, { id: string; expiresAt: number }>();
/**
* Models that rejected a sampling parameter, learned at runtime.
*
* Anthropic removed `temperature`/`top_p`/`top_k` on the current generation
* (Opus 5, Sonnet 5, Opus 4.7/4.8, Fable 5) — sending one is a hard 400. A
* hardcoded list of which models accept it would rot exactly the way the
* pinned model ids did, so learn it from the API instead: the first call
* retries without the parameter and remembers, and every later call omits it
* up front.
*/
private static readonly rejectsSampling = new Set<string>();
private apiKey: string;
private defaultModel: string;
constructor(config: AnthropicConfig) {
this.apiKey = config.apiKey;
this.defaultModel = config.defaultModel ?? 'claude-sonnet-4-20250514';
// A dated default is a time bomb: claude-sonnet-4-20250514 is retired and
// would 404. Track the family instead.
this.defaultModel = config.defaultModel ?? 'claude-sonnet-latest';
}
async complete(options: CompletionOptions): Promise<CompletionResult> {
const model = options.model ?? this.defaultModel;
const model = await this.resolveModel(options.model ?? this.defaultModel);
// Separate system message from conversation
const systemMessages = options.messages.filter((m) => m.role === 'system');
@@ -35,7 +86,9 @@ export class AnthropicProvider implements LlmProvider {
if (systemMessages.length > 0) {
body.system = systemMessages.map((m) => m.content).join('\n');
}
if (options.temperature !== undefined) body.temperature = options.temperature;
if (options.temperature !== undefined && !AnthropicProvider.rejectsSampling.has(model)) {
body.temperature = options.temperature;
}
if (options.tools && options.tools.length > 0) {
body.tools = options.tools.map((t) => ({
@@ -45,18 +98,90 @@ export class AnthropicProvider implements LlmProvider {
}));
}
const response = await this.request(body, options.signal);
return parseAnthropicResponse(response);
try {
return parseAnthropicResponse(await this.request(body, options.signal));
} catch (err) {
// Learn-and-retry rather than fail: a model that has dropped sampling
// support should cost one wasted call, once, not every call forever.
if (body.temperature !== undefined && isSamplingRejection(err)) {
AnthropicProvider.rejectsSampling.add(model);
delete body.temperature;
process.stderr.write(`[anthropic] ${model} rejects sampling params — retrying without\n`);
return parseAnthropicResponse(await this.request(body, options.signal));
}
throw err;
}
}
/**
* Turn a `claude-<family>-latest` selector into a concrete model id.
*
* Exact ids pass through untouched, so pinning still works. Selectors are
* resolved against GET /v1/models and cached, because a dated id pinned in
* config rots silently: `claude-opus-4-20250514` sat in this deployment
* returning 404 on every gate ranking and every pagination title, and the
* only visible symptom was a fallback that looked like a normal one.
*
* Newest is decided by `created_at`, never by parsing the version out of the
* id — that is what keeps `claude-opus-4-5` from beating `claude-opus-5`.
*/
async resolveModel(model: string): Promise<string> {
const family = familyOf(model);
if (family === null) return model;
const cached = AnthropicProvider.modelCache.get(family);
if (cached && Date.now() < cached.expiresAt) return cached.id;
try {
const models = await this.fetchModels();
const match = models
.filter((m) => m.id.startsWith(`claude-${family}-`) || m.id === `claude-${family}`)
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))[0];
if (!match) throw new Error(`no models found for family '${family}'`);
AnthropicProvider.modelCache.set(family, {
id: match.id,
expiresAt: Date.now() + MODEL_CACHE_TTL_MS,
});
if (cached?.id !== match.id) {
process.stderr.write(`[anthropic] ${model} -> ${match.id}\n`);
}
return match.id;
} catch (err) {
// Loud, not silent, and deterministic: an unreachable models endpoint
// must not take the provider down with it.
const fallback = FAMILY_FALLBACK[family]!;
process.stderr.write(
`[anthropic] could not resolve '${model}' (${(err as Error).message}) — `
+ `falling back to ${fallback}\n`,
);
return fallback;
}
}
async listModels(): Promise<string[]> {
// Anthropic doesn't have a models listing endpoint; return known models
return [
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-sonnet-4-5-20250514',
'claude-haiku-3-5-20241022',
];
const models = await this.fetchModels();
return models.map((m) => m.id);
}
/** GET /v1/models, newest first. Follows pagination. */
private async fetchModels(): Promise<Array<{ id: string; created_at: string }>> {
const all: Array<{ id: string; created_at: string }> = [];
let after: string | undefined;
for (let page = 0; page < 10; page++) {
const query = new URLSearchParams({ limit: '100' });
if (after !== undefined) query.set('after_id', after);
const body = await this.get(`/v1/models?${query.toString()}`) as {
data?: Array<{ id: string; created_at: string }>;
has_more?: boolean;
last_id?: string;
};
all.push(...(body.data ?? []));
if (body.has_more !== true || body.last_id === undefined) break;
after = body.last_id;
}
return all;
}
async isAvailable(): Promise<boolean> {
@@ -72,6 +197,45 @@ export class AnthropicProvider implements LlmProvider {
}
}
/** GET against the Anthropic API, same auth handling as request(). */
private get(path: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const isOAuth = this.apiKey.startsWith('sk-ant-oat');
const req = https.request({
hostname: 'api.anthropic.com',
port: 443,
path,
method: 'GET',
timeout: 15000,
headers: {
...(isOAuth
? { 'Authorization': `Bearer ${this.apiKey}` }
: { 'x-api-key': this.apiKey }),
'anthropic-version': '2023-06-01',
},
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
const status = res.statusCode ?? 0;
if (status >= 400) {
reject(new Error(`Anthropic HTTP ${String(status)}: ${raw.slice(0, 200)}`));
return;
}
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error('Anthropic response was not valid JSON'));
}
});
});
req.on('timeout', () => { req.destroy(new Error('models request timed out')); });
req.on('error', reject);
req.end();
});
}
private request(body: unknown, signal?: AbortSignal): Promise<unknown> {
return new Promise((resolve, reject) => {
if (signal?.aborted === true) {

View File

@@ -6,6 +6,7 @@
import type { StageContext, StageResult, StageLogger, Section, ContentType, LLMProvider, CacheProvider, SystemPromptFetcher } from './types.js';
import type { ProxyModelDefinition } from './schema.js';
import { getStage } from './stage-registry.js';
import { createStageBudget, STAGE_LLM_BUDGET_MS } from './stage-budget.js';
import type { AuditCollector } from '../audit/collector.js';
export interface ExecuteOptions {
@@ -87,6 +88,11 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
log: consoleLogger(stageSpec.type),
getSystemPrompt: opts.getSystemPrompt ?? defaultFetcher,
config: stageSpec.config ?? {},
budget: createStageBudget(
typeof stageSpec.config?.['budgetMs'] === 'number'
? stageSpec.config['budgetMs']
: STAGE_LLM_BUDGET_MS,
),
};
try {
@@ -109,6 +115,11 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
outputSize: result.content.length,
sectionCount: result.sections?.length ?? 0,
error: null,
// Surfaced by stages that fell back to a deterministic path (e.g. an
// exhausted LLM budget), so a degradation is visible in the trace
// rather than only in the response text.
degraded: result.metadata?.['degraded'] === true,
degradedReason: (result.metadata?.['degradedReason'] as string | undefined) ?? null,
},
});
} catch (err) {
@@ -125,9 +136,15 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
outputSize: currentContent.length,
sectionCount: 0,
error: (err as Error).message,
degraded: true,
degradedReason: (err as Error).message,
},
});
// Continue with previous content on error
} finally {
// Always clear the budget timer, on success or failure, so a long-lived
// mcplocal never accumulates them.
ctx.budget.dispose();
}
}

View File

@@ -8,6 +8,20 @@
import type { ProviderRegistry } from '../providers/registry.js';
import type { LlmProvider, CompletionOptions } from '../providers/types.js';
import type { LLMProvider, LLMCompleteOptions } from './types.js';
import { withTimeout, TimeoutError, anySignal } from '../util/with-timeout.js';
/**
* Wall-clock budget for one ctx.llm.complete(), spanning ALL failover
* candidates. Bounds the worst case at one budget rather than N × per-attempt.
*
* Enforced here, in the adapter, rather than at each call site: a stage that
* passes no options is still bounded, and a stage written next year inherits
* the guarantee. Three production tool calls hung forever because
* stages/paginate.ts simply forgot to wrap its call.
*/
export const LLM_CALL_BUDGET_MS = Number(process.env['MCPCTL_LLM_CALL_BUDGET_MS'] ?? '20000');
/** Cap on any single provider attempt inside that budget. */
export const LLM_PROVIDER_TIMEOUT_MS = Number(process.env['MCPCTL_LLM_PROVIDER_TIMEOUT_MS'] ?? '10000');
export class LLMProviderAdapter implements LLMProvider {
constructor(
@@ -22,17 +36,44 @@ export class LLMProviderAdapter implements LLMProvider {
throw new Error('No LLM provider available');
}
const budgetMs = options?.budgetMs ?? LLM_CALL_BUDGET_MS;
const perCallMs = options?.perCallTimeoutMs ?? LLM_PROVIDER_TIMEOUT_MS;
const deadline = Date.now() + budgetMs;
const opts = this.buildOpts(prompt, options);
let lastError: Error | null = null;
let tried = 0;
for (const provider of candidates) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
// Budget spent mid-chain. Report the real cause where we have one —
// a provider that actually failed is more useful than "timed out".
throw lastError ?? new TimeoutError(
`llm completion (${String(tried)}/${String(candidates.length)} providers tried)`,
budgetMs,
);
}
tried++;
const startedAt = Date.now();
try {
const result = await provider.complete(opts);
const result = await withTimeout(
(timeoutSignal) => provider.complete({
...opts,
signal: anySignal(timeoutSignal, options?.signal),
}),
Math.min(perCallMs, remaining),
`llm ${provider.name}`,
);
return result.content;
} catch (err) {
// The caller gave up (stage budget / request deadline). Don't burn the
// rest of the chain on a request nobody is waiting for any more.
if (options?.signal?.aborted === true) throw err;
lastError = err as Error;
process.stderr.write(
`[llm-adapter] ${provider.name} failed, trying next: ${lastError.message}\n`,
`[llm-adapter] ${provider.name} failed after ${String(Date.now() - startedAt)}ms `
+ `(${String(Math.max(0, deadline - Date.now()))}ms budget left), `
+ `trying next: ${lastError.message}\n`,
);
}
}

View File

@@ -9,6 +9,7 @@ import type { LLMProvider, CacheProvider, StageLogger, Section, ToolDefinition,
import type { PluginSessionContext, VirtualToolHandler, VirtualServer, PromptIndexEntry } from './plugin.js';
import type { AuditCollector } from '../audit/collector.js';
import type { AuditEvent } from '../audit/types.js';
import { currentCorrelationId } from '../request-context.js';
/** Dependencies injected from the router into each context. */
export interface PluginContextDeps {
@@ -119,11 +120,15 @@ export class PluginContextImpl implements PluginSessionContext {
return this.deps.getFromMcpd(path);
}
/** Emit an audit event, auto-filling sessionId and projectName. */
/** Emit an audit event, auto-filling sessionId, projectName and correlationId. */
emitAuditEvent(event: Omit<AuditEvent, 'sessionId' | 'projectName'>): void {
// Auto-filled here rather than at each call site: this covers every gate
// event and every plugin written later, with no per-callsite edit.
const correlationId = event.correlationId ?? currentCorrelationId();
this.deps.auditCollector?.emit({
...event,
sessionId: this.sessionId,
...(correlationId !== undefined ? { correlationId } : {}),
});
}
}

View File

@@ -7,20 +7,37 @@
* - sectionStore management
*
* This plugin handles:
* 1. onToolCallBefore: intercept section drill-down requests (_resultId + _section params)
* 2. onToolCallAfter: run tool results through the proxymodel pipeline
* 1. onToolsList: declare the drill-down params on every tool it may paginate
* 2. onToolCallBefore: intercept section drill-down requests (_resultId + _section params)
* 3. onToolCallAfter: run tool results through the proxymodel pipeline
*/
import type { JsonRpcRequest, JsonRpcResponse } from '../../types.js';
import type { Section } from '../types.js';
import type { Section, ToolDefinition } from '../types.js';
import type { ProxyModelPlugin, PluginSessionContext } from '../plugin.js';
const SECTION_STORE_TTL_MS = 300_000; // 5 minutes
/**
* Tools owned by the gate plugin. Their calls are intercepted in
* onToolCallBefore and returned before onToolCallAfter ever runs, so their
* results never paginate and they must not advertise drill-down params.
*/
const GATE_TOOLS = new Set(['begin_session', 'read_prompts', 'propose_prompt', 'propose_skill']);
export function createContentPipelinePlugin(): ProxyModelPlugin {
return {
name: 'content-pipeline',
description: 'Content transformation pipeline: paginate, section-split, summarize tool results.',
async onToolsList(tools): Promise<ToolDefinition[]> {
// The drill-down params are part of this plugin's contract with the
// client, so they belong in the advertised schema. Without them a client
// that validates arguments against inputSchema (and upstreams that set
// `additionalProperties: false`, e.g. unifi-network) can never send
// _resultId/_section, which makes every paginated result unreadable.
return tools.map(withDrillDownParams);
},
async onToolCallBefore(_toolName, args, request, ctx) {
// Intercept section drill-down requests
const resultId = args['_resultId'] as string | undefined;
@@ -30,6 +47,13 @@ export function createContentPipelinePlugin(): ProxyModelPlugin {
return handleSectionDrillDown(request, resultId, section, ctx);
}
// _resultId without _section: the caller wants the cached result but did
// not name a section. Re-show the table of contents instead of
// forwarding _resultId upstream, where it is an unknown argument.
if (resultId !== undefined && resultId !== '') {
return handleSectionListing(request, resultId, ctx);
}
return null;
},
@@ -50,8 +74,20 @@ export function createContentPipelinePlugin(): ProxyModelPlugin {
if (response.error) return response;
// Extract text content from the response
const raw = extractTextContent(response);
if (!raw || raw.length <= 2000) return response;
const extracted = extractTextContent(response);
if (extracted === null) return response;
// Collapse nested MCP envelopes first. A server that proxies another MCP
// server (unifi-network does) returns the inner result wrapped in its own
// content/structuredContent pair, so the same payload arrives two or
// three times over. Unwrapping halves it, which keeps many results under
// the pagination threshold entirely.
const raw = unwrapNestedEnvelopes(extracted);
const unwrapped = raw !== extracted;
if (raw.length <= 2000) {
return unwrapped ? textResponse(response.id, raw) : response;
}
try {
const result = await ctx.processContent(toolName, raw, 'toolResult');
@@ -61,7 +97,7 @@ export function createContentPipelinePlugin(): ProxyModelPlugin {
const resultId = `pm-${Date.now().toString(36)}`;
storeSections(ctx, resultId, result.sections);
const text = `${result.content}\n\n_resultId: ${resultId} — use _resultId and _section parameters to drill into a section.`;
const text = `${result.content}\n\n${drillDownInstruction(toolName, resultId, result.sections)}`;
return {
jsonrpc: '2.0',
id: response.id,
@@ -71,21 +107,224 @@ export function createContentPipelinePlugin(): ProxyModelPlugin {
// Pipeline ran but no sections — return processed content if it changed
if (result.content !== raw) {
return {
jsonrpc: '2.0',
id: response.id,
result: { content: [{ type: 'text', text: result.content }] },
};
return textResponse(response.id, result.content);
}
} catch {
// Pipeline failed — return original response
// Pipeline failed — fall through, but keep the unwrap
}
if (unwrapped) return textResponse(response.id, raw);
return response;
},
};
}
/**
* Build the drill-down instruction appended to a sectioned result.
*
* Names both parameters exactly as the schema declares them and shows a real
* section id, so a model can copy the call rather than guess at it. Guessing
* used to mean sending a bare `section`, which fell through to the upstream
* and re-paginated with a fresh _resultId — an unbounded loop.
*/
function drillDownInstruction(toolName: string, resultId: string, sections: Section[]): string {
const example = sections[0]?.id ?? 'page-1';
return `To read one, call ${toolName} again with _resultId="${resultId}" and _section="${example}". `
+ 'Both parameters are required together; pass no other arguments.';
}
/**
* Declare the drill-down params on a tool's advertised input schema.
*
* `properties` is extended: `additionalProperties: false` stays as the
* upstream set it, because a property listed in `properties` is allowed by
* that keyword. Keeping it false preserves the upstream's typo protection.
*
* `required` also has to give way, or the contract is still unsatisfiable. A
* drill-down call carries only _resultId/_section — it never reaches the
* upstream, so the upstream's own required params (websearch/fetch_content
* requires `url`) are meaningless for it. Left in place, a client that
* validates arguments against the schema rejects the very call the stub just
* instructed the model to make ("Received tool input did not match expected
* schema"), and the model concludes pagination is broken.
*
* So the flat `required` becomes an alternation: either the upstream's
* requirements (a fresh call) or `_resultId` (a re-read of a cached result).
* A validator that understands `anyOf` enforces exactly that; a naive one that
* only looks at top-level `required` now finds none and accepts both. The
* upstream still rejects a fresh call that omits its required params, and the
* requirements stay visible to the model inside the alternation.
*/
function withDrillDownParams(tool: ToolDefinition): ToolDefinition {
if (GATE_TOOLS.has(tool.name)) return tool;
const schema = tool.inputSchema;
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) return tool;
const s = schema as Record<string, unknown>;
if (s['type'] !== undefined && s['type'] !== 'object') return tool;
const existing = s['properties'];
const props: Record<string, unknown> =
typeof existing === 'object' && existing !== null && !Array.isArray(existing)
? { ...(existing as Record<string, unknown>) }
: {};
if ('_resultId' in props || '_section' in props) return tool;
props['_resultId'] = {
type: 'string',
description:
'Set only when re-reading a large result this tool already returned. '
+ 'Pass the _resultId from that result together with _section.',
};
props['_section'] = {
type: 'string',
description:
'Section id to read (e.g. "page-1"), from the table of contents of a '
+ 'previous large result. Requires _resultId.',
};
return { ...tool, inputSchema: withDrillDownRequired({ ...s, properties: props }) };
}
/**
* Rewrite a flat `required` into "upstream's requirements OR a drill-down".
*
* Schemas that already carry a top-level combinator keep it: the alternation
* is appended to `allOf` instead of colliding with the existing `anyOf`/
* `oneOf`. A schema with no `required` needs nothing — every call already
* validates.
*/
function withDrillDownRequired(s: Record<string, unknown>): Record<string, unknown> {
const required = s['required'];
const upstreamRequired = Array.isArray(required)
? required.filter((r): r is string => typeof r === 'string')
: [];
if (upstreamRequired.length === 0) return s;
const alternation = {
anyOf: [
{ required: upstreamRequired },
// _resultId alone is a real call too: it re-shows the table of contents.
{ required: ['_resultId'] },
],
};
const next = { ...s };
delete next['required'];
if ('anyOf' in next || 'oneOf' in next || 'allOf' in next) {
const existing = Array.isArray(next['allOf']) ? next['allOf'] : [];
next['allOf'] = [...existing, alternation];
return next;
}
return { ...next, ...alternation };
}
/** Build a single-text-part tool result. */
function textResponse(id: JsonRpcResponse['id'], text: string): JsonRpcResponse {
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } };
}
/** Maximum envelope layers to peel. Guards against pathological nesting. */
const MAX_UNWRAP_DEPTH = 5;
/**
* Collapse nested MCP result envelopes.
*
* A server that fronts another MCP server hands back the inner result still
* wrapped: `{ ...meta, content: [{ type: 'text', text: "<inner JSON>" }],
* structuredContent: <same inner value> }`. The payload is then present twice
* — once escaped inside `content`, once parsed in `structuredContent` — and
* the whole thing is itself a string inside the outer result. unifi-network's
* get_clients arrives at 136K this way.
*
* A layer is peeled only when it carries nothing the inner value doesn't
* already have: every sibling key must reappear in the inner value with an
* equal value, and `structuredContent` (if present) must equal it too. That
* makes the collapse lossless; anything else is left alone.
*/
function unwrapNestedEnvelopes(text: string): string {
let current: unknown;
try {
current = JSON.parse(text);
} catch {
return text; // Not JSON — nothing to unwrap.
}
let peeled = false;
for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
const inner = peelEnvelope(current);
if (inner === null) break;
current = inner;
peeled = true;
}
if (!peeled) return text;
return typeof current === 'string' ? current : JSON.stringify(current, null, 2);
}
/** Peel one envelope layer, or return null when this value is not a redundant wrapper. */
function peelEnvelope(value: unknown): unknown | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
const obj = value as Record<string, unknown>;
const parts = obj['content'];
if (!Array.isArray(parts) || parts.length === 0) return null;
// Only text parts can be collapsed; images and resources must survive intact.
const texts: string[] = [];
for (const part of parts) {
if (typeof part !== 'object' || part === null) return null;
const p = part as Record<string, unknown>;
if (p['type'] !== 'text' || typeof p['text'] !== 'string') return null;
texts.push(p['text']);
}
const innerText = texts.join('\n');
let inner: unknown;
try {
inner = JSON.parse(innerText);
} catch {
// Inner payload is plain text. Collapsing to it is still lossless when the
// envelope adds nothing but a duplicate structuredContent.
inner = innerText;
}
// structuredContent must not carry anything the inner payload lacks.
if ('structuredContent' in obj && !deepEqual(obj['structuredContent'], inner)) return null;
// Every other sibling key must already be present, and equal, inside.
for (const [key, v] of Object.entries(obj)) {
if (key === 'content' || key === 'structuredContent') continue;
if (typeof inner !== 'object' || inner === null || Array.isArray(inner)) return null;
if (!deepEqual((inner as Record<string, unknown>)[key], v)) return null;
}
return inner;
}
/** Structural equality for JSON-shaped values. */
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (typeof a !== 'object' || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
return a.every((item, i) => deepEqual(item, b[i]));
}
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const aKeys = Object.keys(ao);
if (aKeys.length !== Object.keys(bo).length) return false;
return aKeys.every((k) => k in bo && deepEqual(ao[k], bo[k]));
}
/** Extract text content from a tool result response. */
function extractTextContent(response: JsonRpcResponse): string | null {
if (!response.result || typeof response.result !== 'object') return null;
@@ -145,6 +384,39 @@ function handleSectionDrillDown(
};
}
/** Re-show the table of contents for a cached result (no _section given). */
function handleSectionListing(
request: JsonRpcRequest,
resultId: string,
ctx: PluginSessionContext,
): JsonRpcResponse {
const sections = getSections(ctx, resultId);
if (!sections) {
return {
jsonrpc: '2.0',
id: request.id,
result: {
content: [{
type: 'text',
text: 'Cached result not found (expired or invalid _resultId). Please re-call the tool without _resultId/_section to get a fresh result.',
}],
},
};
}
const toc = sections.map((s) => `[${s.id}] ${s.title}`).join('\n');
return {
jsonrpc: '2.0',
id: request.id,
result: {
content: [{
type: 'text',
text: `${sections.length} sections:\n${toc}\n\nAdd _section="<id>" alongside _resultId="${resultId}" to read one.`,
}],
},
};
}
/** Find a section by ID, searching recursively through children. */
function findSection(sections: Section[], id: string): Section | null {
for (const section of sections) {

View File

@@ -10,6 +10,7 @@
* overlap on hooks, no conflicts arise.
*/
import type { ProxyModelPlugin } from '../plugin.js';
import type { ToolDefinition } from '../types.js';
import { createGatePlugin, type GatePluginConfig } from './gate.js';
import { createContentPipelinePlugin } from './content-pipeline.js';
@@ -59,8 +60,15 @@ export function createDefaultPlugin(config: DefaultPluginConfig = {}): ProxyMode
if (gate.onInitialize) {
plugin.onInitialize = gate.onInitialize.bind(gate);
}
if (gate.onToolsList) {
plugin.onToolsList = gate.onToolsList.bind(gate);
// Tools list: gate first (it decides which tools are visible at all), then
// content-pipeline (it annotates whatever survived with drill-down params).
if (gate.onToolsList || pipeline.onToolsList) {
plugin.onToolsList = async (tools, ctx): Promise<ToolDefinition[]> => {
let acc = tools;
if (gate.onToolsList) acc = await gate.onToolsList(acc, ctx);
if (pipeline.onToolsList) acc = await pipeline.onToolsList(acc, ctx);
return acc;
};
}
if (pipeline.onToolCallAfter) {
plugin.onToolCallAfter = pipeline.onToolCallAfter.bind(pipeline);

View File

@@ -26,12 +26,21 @@ import type { ToolDefinition } from '../types.js';
/** Per-session state key holding the presented-name → canonical-name map. */
const RESOLVER_KEY = 'favourite-index:resolver';
/** The load-bearing "prefer favourite/" instruction (see module doc). */
/**
* The load-bearing "prefer favourite" instruction (see module doc).
*
* Phrased in the WIRE form (underscore-joined): the HTTP boundary's
* WireNameCodec rewrites the presented `favourite/<tool>` and
* `all/<server>/<tool>` names to `favourite_<tool>` / `all_<server>_<tool>`
* before any client sees them, so prose naming the slash form would tell the
* model to call names that are not in its function list.
*/
export const FAVOURITE_INDEX_INSTRUCTION =
'Tools are indexed in two namespaces. PREFER favourite/<tool> — a short ' +
'curated list of the common tools that covers most tasks; reach for these ' +
'first. Use the full catalog under all/<server>/<tool> only if nothing in ' +
"favourites fits. (read_prompts gives this project's own guidance.)";
'Tools are indexed in two namespaces. PREFER the favourite_-prefixed tools ' +
'— a short curated list of the common tools that covers most tasks; reach ' +
'for these first. Use the full catalog under the all_-prefixed names only ' +
"if nothing in favourites fits. (read_prompts gives this project's own " +
'guidance.)';
export interface FavouriteIndexConfig {
/** Canonical `server/tool` names to surface as favourites, in display order. */

View File

@@ -15,7 +15,8 @@ import { TagMatcher, extractKeywordsFromToolCall, tokenizeDescription } from '..
import type { TagMatchResult } from '../../gate/tag-matcher.js';
import { LlmPromptSelector, pickCompletionText, type ServerInfer } from '../../gate/llm-selector.js';
import type { ProviderRegistry } from '../../providers/registry.js';
import { withTimeout, TimeoutError } from '../../util/with-timeout.js';
import { bounded } from '../../util/degrade.js';
import { sanitizeWireName } from '../../util/wire-names.js';
/** Cap on the gate's LLM prompt-selection. A slow/thinking LLM must never block
* begin_session — on timeout we fall back to deterministic tag matching. */
@@ -115,13 +116,15 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
);
parts.push(`\n${gateInstructions}`);
// Append tool inventory (names only)
// Append tool inventory (names only). Sanitized to the wire charset:
// the boundary WireNameCodec serves `server_tool`, so prose listing the
// canonical `server/tool` would name functions the model cannot call.
try {
const tools = await ctx.discoverTools();
if (tools.length > 0) {
parts.push('\nAvailable MCP server tools (accessible after begin_session):');
for (const t of tools) {
parts.push(` ${t.name}`);
parts.push(` ${sanitizeWireName(t.name)}`);
}
}
} catch {
@@ -311,7 +314,9 @@ async function handleBeginSession(
let degradedReason: string | null = null;
if (llmSelector) {
try {
// Labelled block, not try/catch: bounded() cannot throw, so falling back is
// an ordinary early exit rather than an exception path.
llmSelection: {
const llmIndex = promptIndex.map((p) => ({
name: p.name,
priority: p.priority,
@@ -333,15 +338,25 @@ async function handleBeginSession(
return pickCompletionText(resp);
}
: undefined;
const llmResult = await withTimeout(
const selection = await bounded(
(signal) => llmSelector.selectPrompts(tags, llmIndex, {
getSystemPromptFn,
signal,
...(serverInfer ? { serverInfer } : {}),
}),
GATE_LLM_TIMEOUT_MS,
'gate LLM prompt-selection',
{
label: 'gate',
operation: 'LLM prompt-selection',
timeoutMs: GATE_LLM_TIMEOUT_MS,
fallback: 'to priority-ordered prompts',
},
);
if (selection.degraded) {
degradedReason = selection.reason;
matchResult = tagMatcher.match(tags, promptIndex);
break llmSelection;
}
const llmResult = selection.value;
reasoning = llmResult.reasoning;
const selectedSet = new Set(llmResult.selectedNames);
@@ -353,13 +368,6 @@ async function handleBeginSession(
selected,
);
matchResult.remaining = [...matchResult.remaining, ...remaining];
} catch (err) {
degradedReason = err instanceof TimeoutError
? `LLM prompt-selection timed out after ${String(GATE_LLM_TIMEOUT_MS)}ms`
: `LLM prompt-selection failed: ${(err as Error).message}`;
// Loud, not silent — visible in mcplocal logs.
console.error(`[gate] ${degradedReason} — falling back to priority-ordered prompts`);
matchResult = tagMatcher.match(tags, promptIndex);
}
} else {
matchResult = tagMatcher.match(tags, promptIndex);
@@ -424,13 +432,14 @@ async function handleBeginSession(
);
responseParts.push(encouragement);
// Append tool inventory (names only)
// Append tool inventory (names only) — wire charset, see the initialize
// inventory note.
try {
const tools = await ctx.discoverTools();
if (tools.length > 0) {
responseParts.push('\nAvailable MCP server tools:');
for (const t of tools) {
responseParts.push(` ${t.name}`);
responseParts.push(` ${sanitizeWireName(t.name)}`);
}
}
} catch {

View File

@@ -0,0 +1,50 @@
/**
* Wall-clock budget for ONE stage invocation's LLM work.
*
* A per-call timeout is not sufficient for a stage that calls the LLM in a
* loop. `summarize-tree` recurses (buildTree calls itself up to maxDepth, 3 by
* default) and loops per section at every level, then `groupSections` loops
* again — so a per-call cap multiplies instead of bounding, and a stage can
* issue hundreds of sequential calls.
*
* The stage shares ONE budget across every call it makes. Once it is gone the
* stage must switch to its deterministic path and report the degradation.
*/
export interface StageBudget {
remainingMs(): number;
exhausted(): boolean;
/** Aborted when the budget runs out; pass into ctx.llm.complete({ signal }). */
readonly signal: AbortSignal;
/** Total budget, for degradation messages. */
readonly totalMs: number;
dispose(): void;
}
export const STAGE_LLM_BUDGET_MS = Number(process.env['MCPCTL_STAGE_LLM_BUDGET_MS'] ?? '30000');
export function createStageBudget(totalMs: number = STAGE_LLM_BUDGET_MS): StageBudget {
const controller = new AbortController();
const startedAt = Date.now();
const timer = setTimeout(() => { controller.abort(); }, totalMs);
// Node keeps the process alive for pending timers; a stage budget must not.
if (typeof timer.unref === 'function') timer.unref();
return {
totalMs,
signal: controller.signal,
remainingMs: (): number => Math.max(0, totalMs - (Date.now() - startedAt)),
exhausted: (): boolean => Date.now() - startedAt >= totalMs,
dispose: (): void => { clearTimeout(timer); },
};
}
/** Never-expiring budget, for tests and non-LLM call paths. */
export function unlimitedStageBudget(): StageBudget {
return {
totalMs: Number.POSITIVE_INFINITY,
signal: new AbortController().signal,
remainingMs: (): number => Number.POSITIVE_INFINITY,
exhausted: (): boolean => false,
dispose: (): void => { /* nothing to clean up */ },
};
}

View File

@@ -9,6 +9,7 @@
* previewChars: number (chars per page sent to LLM for title generation, default 300)
*/
import type { StageHandler, StageContext, Section } from '../types.js';
import { bounded, degradationNotice } from '../../util/degrade.js';
const handler: StageHandler = async (content, ctx) => {
const pageSize = (ctx.config.pageSize as number | undefined) ?? 8000;
@@ -24,7 +25,7 @@ const handler: StageHandler = async (content, ctx) => {
return { content };
}
const titles = await generatePageTitles(pages, ctx);
const { titles, degradedReason } = await generatePageTitles(pages, ctx);
const sections: Section[] = pages.map((page, i) => ({
id: `page-${i + 1}`,
@@ -36,63 +37,104 @@ const handler: StageHandler = async (content, ctx) => {
`[${s.id}] ${s.title} (${pages[i]!.length} chars)`,
).join('\n');
// Loud, not silent: if the titles are numbered because the LLM was slow or
// down, say so rather than letting the model assume the pages are unnamed.
const notice = degradedReason === null
? ''
: degradationNotice('Smart page titles', degradedReason, 'Pages are numbered instead.');
return {
content: `Content split into ${sections.length} pages (${content.length} total chars):\n${toc}\n\nUse section parameter to read a specific page.`,
// No navigation hint here — the caller (content-pipeline / router) appends
// the authoritative _resultId/_section instruction once sections are stored.
content: `${notice}Content split into ${sections.length} pages (${content.length} total chars):\n${toc}`,
sections,
...(degradedReason === null ? {} : { metadata: { degraded: true, degradedReason } }),
};
};
/**
* Generate descriptive titles for each page using LLM.
* Falls back to generic "Page N" titles if LLM is unavailable or fails.
* Generate descriptive titles for each page using the LLM.
*
* Returns a degradation reason instead of throwing: the deterministic
* "Page N" fallback is unconditional. Before this was bounded, a provider that
* hung (rather than erroring) left the awaited promise unsettled forever — the
* tool call never returned and the client waited out its own 1800s timeout.
*/
async function generatePageTitles(pages: string[], ctx: StageContext): Promise<string[]> {
async function generatePageTitles(
pages: string[],
ctx: StageContext,
): Promise<{ titles: string[]; degradedReason: string | null }> {
const fallback = pages.map((_, i) => `Page ${i + 1}`);
// No LLM configured is a deliberate choice, not a degradation — numbered
// pages are the expected output there, so don't warn about them.
if (!ctx.llm.available()) {
return fallback;
return { titles: fallback, degradedReason: null };
}
const previewChars = (ctx.config.previewChars as number | undefined) ?? 300;
const cacheKey = `paginate-titles:${ctx.cache.hash(ctx.originalContent)}:${pages.length}`;
try {
const cached = await ctx.cache.getOrCompute(cacheKey, async () => {
const previews = pages.map((page, i) => {
const preview = page.slice(0, previewChars).trim();
return `--- Page ${i + 1} (${page.length} chars) ---\n${preview}`;
}).join('\n\n');
// Read the cache BEFORE consulting the budget — a warm cache costs nothing,
// so an exhausted budget must not degrade a result we already hold.
const cached = await ctx.cache.get(cacheKey);
if (cached !== null) {
try {
return { titles: JSON.parse(cached) as string[], degradedReason: null };
} catch {
// Corrupt entry — fall through and recompute.
}
}
const DEFAULT_PROMPT = `Generate exactly {{pageCount}} short descriptive titles (max 60 chars each) for the following {{pageCount}} pages. Return ONLY a JSON array of {{pageCount}} strings. No markdown, no explanation.`;
const template = await ctx.getSystemPrompt('llm-paginate-titles', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{pageCount}}', String(pages.length));
if (ctx.budget.exhausted()) {
return {
titles: fallback,
degradedReason: `stage LLM budget of ${String(ctx.budget.totalMs)}ms exhausted`,
};
}
const result = await ctx.llm.complete(
`${prompt}\n\n${previews}`,
{ maxTokens: pages.length * 30 },
);
const previews = pages.map((page, i) => {
const preview = page.slice(0, previewChars).trim();
return `--- Page ${i + 1} (${page.length} chars) ---\n${preview}`;
}).join('\n\n');
// Parse JSON array from response, pad/truncate to match page count
const DEFAULT_PROMPT = `Generate exactly {{pageCount}} short descriptive titles (max 60 chars each) for the following {{pageCount}} pages. Return ONLY a JSON array of {{pageCount}} strings. No markdown, no explanation.`;
const template = await ctx.getSystemPrompt('llm-paginate-titles', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{pageCount}}', String(pages.length));
const outcome = await bounded(
async (signal) => {
const result = await ctx.llm.complete(`${prompt}\n\n${previews}`, {
maxTokens: pages.length * 30,
budgetMs: ctx.budget.remainingMs(),
signal,
});
const match = result.match(/\[[\s\S]*\]/);
if (!match) throw new Error('No JSON array in response');
const raw = JSON.parse(match[0]) as string[];
if (!Array.isArray(raw) || raw.length === 0) {
throw new Error('Empty or invalid title array');
}
// Pad with generic titles if model returned fewer, truncate if more
const titles = pages.map((_, i) =>
return pages.map((_, i) =>
(i < raw.length && typeof raw[i] === 'string' && raw[i]!.trim())
? raw[i]!.trim().slice(0, 80)
: `Page ${i + 1}`,
);
return JSON.stringify(titles);
});
},
{
label: 'paginate',
operation: 'Smart page titles',
timeoutMs: ctx.budget.remainingMs(),
signal: ctx.budget.signal,
fallback: 'to numbered pages',
},
);
return JSON.parse(cached) as string[];
} catch (err) {
ctx.log.warn(`Smart page titles failed, using generic: ${(err as Error).message}`);
return fallback;
if (outcome.degraded) {
return { titles: fallback, degradedReason: outcome.reason };
}
await ctx.cache.set(cacheKey, JSON.stringify(outcome.value));
return { titles: outcome.value, degradedReason: null };
}
function splitPages(content: string, pageSize: number): string[] {

View File

@@ -65,7 +65,7 @@ const handler: StageHandler = async (content, ctx) => {
}).join('\n');
return {
content: `${sections.length} sections (${contentType}):\n${toc}\n\nUse section parameter to read a specific section.`,
content: `${sections.length} sections (${contentType}):\n${toc}`,
sections,
};
};

View File

@@ -14,11 +14,13 @@
*/
import type { StageHandler, Section } from '../types.js';
import { detectContentType } from '../content-type.js';
import { bounded, degradationNotice } from '../../util/degrade.js';
const handler: StageHandler = async (content, ctx) => {
const maxTokens = (ctx.config.maxSummaryTokens as number | undefined) ?? 200;
const maxGroup = (ctx.config.maxGroupSize as number | undefined) ?? 5;
const maxDepth = (ctx.config.maxDepth as number | undefined) ?? 3;
const deg = createDegradeSink();
// If content is small, just return it unchanged
if (content.length < 2000) {
@@ -37,15 +39,24 @@ const handler: StageHandler = async (content, ctx) => {
return { content };
}
const summary = await cachedSummarize(ctx, ctx.originalContent, maxTokens);
const summary = await cachedSummarize(ctx, ctx.originalContent, maxTokens, deg);
if (summary === null) {
// Budget gone or the LLM failed: return the content untouched rather
// than a summary we could not produce. Previously this path had no
// try/catch at all, so a failure escaped the stage entirely.
return {
content: degradationNotice('Smart summaries', deg.reason ?? 'unavailable', 'Content is unsummarised.') + content,
metadata: { degraded: true, degradedReason: deg.reason },
};
}
return {
content: summary + '\n\nUse section parameter with id "full" to read the complete content.',
content: summary + '\n\nSection "full" holds the complete content.',
sections: [{ id: 'full', title: 'Full Content', content: ctx.originalContent }],
};
}
// Build the summary tree
const tree = await buildTree(sections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0 });
const tree = await buildTree(sections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0, deg });
// Format top-level ToC
const toc = tree.map((s) => {
@@ -55,9 +66,20 @@ const handler: StageHandler = async (content, ctx) => {
return `[${s.id}] ${s.title}${childHint}`;
}).join('\n');
// One reason, plus how many summaries it cost — the recursion can hit the
// same exhausted budget dozens of times.
const notice = deg.reason === null
? ''
: degradationNotice(
'Smart summaries',
deg.reason,
`Showing first-line excerpts for ${String(deg.count)} of ${String(tree.length)} sections.`,
);
return {
content: `${tree.length} sections:\n${toc}\n\nUse section parameter to read details.`,
content: `${notice}${tree.length} sections:\n${toc}`,
sections: tree,
...(deg.reason === null ? {} : { metadata: { degraded: true, degradedReason: deg.reason } }),
};
};
@@ -66,6 +88,8 @@ interface TreeOpts {
maxGroup: number;
maxDepth: number;
depth: number;
/** Shared across the whole recursion — one budget, one reported reason. */
deg: DegradeSink;
}
async function buildTree(
@@ -84,7 +108,8 @@ async function buildTree(
summary = structuralSummary(section.content, contentType);
} else if (ctx.llm.available()) {
// LLM summary for prose/code
summary = await cachedSummarize(ctx, section.content, opts.maxTokens);
summary = await cachedSummarize(ctx, section.content, opts.maxTokens, opts.deg)
?? (section.content.split('\n')[0] ?? '').slice(0, 200);
} else {
// No LLM — use first line as summary
summary = (section.content.split('\n')[0] ?? '').slice(0, 200);
@@ -125,18 +150,68 @@ async function cachedSummarize(
ctx: import('../types.js').StageContext,
content: string,
maxTokens: number,
): Promise<string> {
deg: DegradeSink,
): Promise<string | null> {
const key = `summary:${ctx.cache.hash(content)}:${maxTokens}`;
return ctx.cache.getOrCompute(key, async () => {
const DEFAULT_PROMPT = `Summarize the following in about {{maxTokens}} tokens. Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. Be specific — mention names, IDs, counts, key values.`;
const template = await ctx.getSystemPrompt('llm-summarize', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{maxTokens}}', String(maxTokens));
return ctx.llm.complete(
`${prompt}\n\n${content}`,
{ maxTokens },
);
});
// Read the cache BEFORE consulting the budget: a warm entry costs nothing,
// so an exhausted budget must never degrade a summary we already hold.
const cached = await ctx.cache.get(key);
if (cached !== null) return cached;
if (ctx.budget.exhausted()) {
deg.note(`stage LLM budget of ${String(ctx.budget.totalMs)}ms exhausted`);
return null;
}
const DEFAULT_PROMPT = `Summarize the following in about {{maxTokens}} tokens. Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. Be specific — mention names, IDs, counts, key values.`;
const template = await ctx.getSystemPrompt('llm-summarize', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{maxTokens}}', String(maxTokens));
const outcome = await bounded(
(signal) => ctx.llm.complete(`${prompt}\n\n${content}`, {
maxTokens,
budgetMs: ctx.budget.remainingMs(),
signal,
}),
{
label: 'summarize-tree',
operation: 'Smart summaries',
timeoutMs: ctx.budget.remainingMs(),
signal: ctx.budget.signal,
fallback: 'to first-line excerpts',
},
);
if (outcome.degraded) {
deg.note(outcome.reason);
return null;
}
await ctx.cache.set(key, outcome.value);
return outcome.value;
}
/**
* Collects the first degradation reason and a count.
*
* buildTree recurses and loops, so one exhausted budget can produce dozens of
* identical reasons; the user needs the reason once plus how many summaries it
* cost them.
*/
export interface DegradeSink {
note(reason: string): void;
readonly reason: string | null;
readonly count: number;
}
export function createDegradeSink(): DegradeSink {
let reason: string | null = null;
let count = 0;
return {
note(r: string): void { reason ??= r; count++; },
get reason(): string | null { return reason; },
get count(): number { return count; },
};
}
function structuralSummary(content: string, type: string): string {
@@ -267,9 +342,10 @@ async function groupSections(
const groupContent = chunk.map((s) => `[${s.id}] ${s.title}`).join('\n');
const groupId = `group-${Math.floor(i / opts.maxGroup) + 1}`;
const genericTitle = `Group ${Math.floor(i / opts.maxGroup) + 1} (${chunk.length} sections)`;
const groupTitle = ctx.llm.available()
? await cachedSummarize(ctx, groupContent, 50)
: `Group ${Math.floor(i / opts.maxGroup) + 1} (${chunk.length} sections)`;
? (await cachedSummarize(ctx, groupContent, 50, opts.deg) ?? genericTitle)
: genericTitle;
groups.push({
id: groupId,

View File

@@ -10,6 +10,8 @@
* SessionController — method-level hooks with per-session state
*/
import type { StageBudget } from './stage-budget.js';
/** Fetches a system prompt by name, falling back to the provided default. */
export type SystemPromptFetcher = (name: string, fallback: string) => Promise<string>;
@@ -48,6 +50,13 @@ export interface StageContext {
/** Stage-specific configuration from the proxymodel YAML */
config: Record<string, unknown>;
/**
* Shared wall-clock budget for this stage's LLM work. A stage that calls the
* LLM in a loop must check this between calls and fall back deterministically
* once it is gone — a per-call timeout alone multiplies rather than bounds.
*/
budget: StageBudget;
}
export interface StageResult {
@@ -142,6 +151,19 @@ export interface LLMProvider {
export interface LLMCompleteOptions {
system?: string;
maxTokens?: number;
/**
* Wall-clock budget for this call INCLUDING provider failover — N candidates
* share one budget, so the worst case is `budgetMs`, not N × per-attempt.
* Defaults to MCPCTL_LLM_CALL_BUDGET_MS.
*/
budgetMs?: number;
/** Cap on any single provider attempt inside that budget. */
perCallTimeoutMs?: number;
/**
* Caller cancellation (stage budget, request deadline). Aborts failover too:
* if the caller has given up there is no point trying the next provider.
*/
signal?: AbortSignal;
}
/**

View File

@@ -0,0 +1,44 @@
/**
* Per-request scope, carried through code that has no request parameter.
*
* The plugin context and the pipeline executor are both constructed **per
* session**, not per request: `getOrCreatePluginContext` builds one
* PluginContextDeps per session, and `processContent` is a closure with no
* RouteContext argument. Threading a correlationId parameter would touch
* PluginSessionContext, every plugin, ExecuteOptions and StageContext.
*
* AsyncLocalStorage gets it there with no signature churn, and — unlike a
* mutable field on the session-scoped context — stays correct when two
* requests are in flight on the same session.
*/
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RequestDeadline } from './http/deadline.js';
export interface RequestScope {
/** The trace code; also the audit correlationId. */
correlationId: string;
sessionId: string;
projectName: string;
method: string;
/**
* The request's deadline, so deliberately-unbounded waits (the pause queue,
* which blocks until a human operator releases a response) can stop its clock
* instead of being killed by it.
*/
deadline?: RequestDeadline | undefined;
}
const storage = new AsyncLocalStorage<RequestScope>();
export function runInRequestScope<T>(scope: RequestScope, fn: () => T): T {
return storage.run(scope, fn);
}
export function currentScope(): RequestScope | undefined {
return storage.getStore();
}
/** Convenience: the current trace code, or undefined outside a request. */
export function currentCorrelationId(): string | undefined {
return storage.getStore()?.correlationId;
}

View File

@@ -6,6 +6,7 @@ import type { PromptIndexEntry } from './gate/tag-matcher.js';
import { LinkResolver } from './services/link-resolver.js';
import type { LLMProvider, CacheProvider, Section } from './proxymodel/types.js';
import { executePipeline } from './proxymodel/executor.js';
import { currentCorrelationId, currentScope } from './request-context.js';
import { getProxyModel } from './proxymodel/loader.js';
import type { ProxyModelPlugin, PluginSessionContext } from './proxymodel/plugin.js';
import { PluginContextImpl, type PluginContextDeps } from './proxymodel/plugin-context.js';
@@ -56,6 +57,8 @@ export class McpRouter {
private proxyModelCache: CacheProvider | null = null;
private auditCollector: AuditCollector | null = null;
private serverProxyModels = new Map<string, { name: string; llm: LLMProvider; cache: CacheProvider }>();
/** Per-server tool-call deadline overrides, in ms, keyed by server name. */
private serverDeadlines = new Map<string, number>();
// Prompt and system prompt caches (used by plugin context)
private cachedPromptIndex: PromptIndexEntry[] | null = null;
@@ -88,6 +91,29 @@ export class McpRouter {
this.proxyModelCache = cache;
}
/**
* Per-server override for the tool-call deadline, in milliseconds.
*
* Sourced from the server resource's `toolCallTimeoutSeconds`. Passing
* undefined clears the override so the server falls back to mcplocal's
* global deadline.
*/
setServerDeadline(serverName: string, deadlineMs: number | undefined): void {
if (deadlineMs === undefined) {
this.serverDeadlines.delete(serverName);
return;
}
this.serverDeadlines.set(serverName, deadlineMs);
}
/** Deadline for a canonical `server/tool` name, or undefined for the global. */
getToolCallDeadlineMs(canonicalToolName?: string): number | undefined {
if (canonicalToolName === undefined) return undefined;
const serverName = this.toolToServer.get(canonicalToolName)
?? canonicalToolName.split('/')[0];
return serverName === undefined ? undefined : this.serverDeadlines.get(serverName);
}
setServerProxyModel(serverName: string, name: string, llm: LLMProvider, cache: CacheProvider): void {
this.serverProxyModels.set(serverName, { name, llm, cache });
}
@@ -175,11 +201,19 @@ export class McpRouter {
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
...(this.auditCollector ? { auditCollector: this.auditCollector } : {}),
...(serverName !== undefined ? { serverName } : {}),
// processContent is a per-session closure with no RouteContext, so
// the id comes from the request scope. Without this,
// ExecuteOptions.correlationId was dead code and every
// stage_execution / pipeline_execution row landed unjoinable.
...(currentCorrelationId() !== undefined ? { correlationId: currentCorrelationId()! } : {}),
});
// Pause queue: if paused, hold the result until released/edited/dropped
// Pause queue: if paused, hold the result until released/edited/dropped.
// This wait is deliberately unbounded — it ends when a human operator
// acts — so the request deadline stops its clock rather than killing
// it, and re-arms with whatever was left once the operator releases.
if (pauseQueue.paused) {
const pausedContent = await pauseQueue.enqueue({
const enqueue = (): Promise<string> => pauseQueue.enqueue({
sessionId,
projectName: this.projectName ?? 'unknown',
contentType,
@@ -187,6 +221,8 @@ export class McpRouter {
original: content,
transformed: result.content,
});
const deadline = currentScope()?.deadline;
const pausedContent = deadline ? await deadline.suspended(enqueue) : await enqueue();
return { ...result, content: pausedContent };
}
@@ -868,6 +904,9 @@ export class McpRouter {
eventKind: 'tool_call_trace' as const,
source: 'mcplocal' as const,
verified: true,
// Already in scope and previously dropped, which left every durable
// tool_call_trace row unjoinable to the rest of its request.
...(context.correlationId !== undefined ? { correlationId: context.correlationId } : {}),
payload: {
toolName,
argKeys: Object.keys(toolArgs).join(', '),
@@ -903,7 +942,12 @@ export class McpRouter {
// onToolCallBefore — can intercept and return a response directly
if (this.plugin.onToolCallBefore) {
const intercepted = await this.plugin.onToolCallBefore(toolName ?? '', toolArgs, request, ctx);
if (intercepted) return intercepted;
if (intercepted) {
// Intercepts were previously invisible in tool_call_trace, so every
// call made while the session was gated produced no trace at all.
emitTrace(intercepted, 'plugin');
return intercepted;
}
}
// Route to upstream
@@ -1191,6 +1235,6 @@ function injectSectionsIntoPromptResponse(
if (now - entry.createdAt > SECTION_TTL_MS) store.delete(key);
}
const text = `${tocContent}\n\n_resultId: ${resultId} — use _resultId and _section parameters to drill into a section.`;
const text = `${tocContent}\n\nTo read one, request this prompt again with _resultId="${resultId}" and _section="<id>". Both parameters are required together.`;
return replacePromptText(response, text);
}

View File

@@ -0,0 +1,79 @@
/**
* Run an LLM-*optional* operation under a hard bound, never throwing.
*
* This is `docs/reliability.md`'s principle in one place: **bounded,
* deterministic fallback, loud not silent.** The pattern was previously
* open-coded in the gate (`proxymodel/plugins/gate.ts`) and pagination
* (`llm/pagination.ts`) while the newer proxymodel stages simply omitted it —
* `stages/paginate.ts` awaited a completion with no timeout at all, so a hung
* provider never settled, the tool call never returned, and the client waited
* out its own 1800s timeout. Three such requests were served (or rather, not
* served) in production.
*
* `bounded()` cannot throw. The caller always receives either a value or a
* reason string, which makes the deterministic fallback unconditional rather
* than something a `catch` block has to remember to do.
*/
import { withTimeout, TimeoutError, anySignal } from './with-timeout.js';
export type BoundedResult<T> =
| { degraded: false; value: T }
| { degraded: true; reason: string };
export interface BoundedOptions {
/** Log prefix, e.g. 'gate' renders as `[gate] …`. */
label: string;
/** What is being attempted, e.g. 'LLM prompt-selection'. Used in the reason. */
operation: string;
timeoutMs: number;
/** Extra cancellation (stage budget, request deadline). */
signal?: AbortSignal;
/**
* What happens instead, appended to the log line as `— falling back <x>`.
* Keep it specific: these strings are what an operator greps for when a
* degradation has to be diagnosed after the fact.
*/
fallback?: string;
}
export async function bounded<T>(
run: (signal: AbortSignal) => Promise<T>,
options: BoundedOptions,
): Promise<BoundedResult<T>> {
try {
const value = await withTimeout(
(timeoutSignal) => run(anySignal(timeoutSignal, options.signal)),
options.timeoutMs,
options.operation,
);
return { degraded: false, value };
} catch (err) {
const reason = err instanceof TimeoutError
? `${options.operation} timed out after ${String(options.timeoutMs)}ms`
: `${options.operation} failed: ${(err as Error).message}`;
// Loud, not silent — visible in mcplocal logs.
const fallback = options.fallback ?? '';
console.error(`[${options.label}] ${reason} — falling back${fallback ? ` ${fallback}` : ''}`);
return { degraded: true, reason };
}
}
/**
* The established user-facing wording:
* `⚠ <feature> unavailable (<reason>). <hint>`
*
* Kept here so every degradation reads the same to a model, whichever
* subsystem produced it.
*/
export function degradationNotice(feature: string, reason: string, hint: string): string {
return `⚠ ${feature} unavailable (${reason}). ${hint}\n`;
}
/** Audit payload fragment matching the existing `gate_decision` shape. */
export function degradationAudit(
result: BoundedResult<unknown>,
): { degraded: boolean; degradedReason: string | null } {
return result.degraded
? { degraded: true, degradedReason: result.reason }
: { degraded: false, degradedReason: null };
}

View File

@@ -0,0 +1,28 @@
/**
* Short, human-transcribable code identifying one MCP request end to end.
*
* This IS the correlationId — it replaces the previous `<session-uuid>:<id>`
* form. Nothing parses that format (checked across every consumer: mcpd's
* audit repository and route, mcplocal's router and traffic capture, and the
* CLI console), `sessionId` is its own column on every audit event, and the
* JSON-RPC id is in the traffic body — so nothing is lost and no migration is
* needed. `AuditEvent.correlationId` is already an indexed Postgres column, so
* lookup by code works from day one.
*
* Crockford base32 without I/L/O/U: unambiguous read aloud, typed from a
* screenshot, or pasted into a bug report. 40 bits of randomness — a collision
* shows two traces, it does not corrupt anything.
*/
import { randomBytes } from 'node:crypto';
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
export function newTraceCode(): string {
const buf = randomBytes(5);
const value = buf.readUIntBE(0, 5);
let out = '';
for (let i = 0; i < 8; i++) {
out += ALPHABET[Math.floor(value / 32 ** (7 - i)) % 32];
}
return out;
}

View File

@@ -0,0 +1,126 @@
/**
* Wire-safe tool-name codec for client-facing MCP endpoints.
*
* Internally the proxy namespaces tools as `server/tool`, and presentation
* plugins add `favourite/<tool>`, `all/<server>/<tool>` and
* `agent-<name>/chat`. A `/` is not a valid character in OpenAI-style
* function names (`^[a-zA-Z0-9_.-]{1,64}$`), so hosts that forward MCP tool
* names verbatim as LLM function names (LibreChat) depend on the model
* faithfully echoing an illegal name. deepseek-v4-flash intermittently drops
* the `server/` prefix; the host's registry lookup then fails and it reports
* the tool's "MCP server is temporarily unavailable" while nothing is down
* (the librechat fetch_content incident, 2026-08-25). Claude Code and the pi
* extension dodge this only because they sanitize names client-side.
*
* The codec translates ONLY at the HTTP boundary:
* - tools/list responses are rewritten to wire-safe names (`/` → `_`),
* - tools/call requests are mapped back to the presented (internal) name
* via an exact-match reverse map.
*
* Everything inside the proxy — routing maps, plugins, favourites config,
* audit events — keeps the canonical names. Inbound names with no map entry
* (legacy clients echoing slash names, virtual tools called before any
* tools/list) pass through unchanged, so existing clients keep working.
*
* The maps live per project router (not per session) so a client that
* reconnects mid-conversation still resolves names listed on its previous
* session, as long as the process is alive. After a restart the first
* tools/list (which every MCP client performs on initialize) repopulates them.
*/
import type { JsonRpcRequest, JsonRpcResponse } from '../types.js';
/** Replace every character that is invalid in an OpenAI-style function name. */
export function sanitizeWireName(name: string): string {
return name.replace(/[^A-Za-z0-9_.-]/g, '_');
}
export class WireNameCodec {
/** wire name → presented (internal) name */
private toPresented = new Map<string, string>();
/** presented (internal) name → wire name */
private toWire = new Map<string, string>();
/**
* Wire name for a presented tool name. Stable for the codec's lifetime.
*
* Collisions (two presented names sanitizing to the same string, or a
* sanitized name shadowing a tool that already uses that exact name) get a
* numeric suffix — first registration wins the plain name. This keeps the
* reverse map unambiguous; order within one tools/list pass is stable, so
* suffixes are deterministic in practice.
*/
encodeName(presented: string): string {
const existing = this.toWire.get(presented);
if (existing !== undefined) return existing;
const base = sanitizeWireName(presented);
let wire = base;
for (let i = 2; this.toPresented.has(wire) && this.toPresented.get(wire) !== presented; i++) {
wire = `${base}_${String(i)}`;
}
if (wire !== base) {
console.warn(`[wire-names] collision: '${presented}' presented as '${wire}' (base '${base}' taken by '${this.toPresented.get(base) ?? '?'}')`);
}
this.toWire.set(presented, wire);
this.toPresented.set(wire, presented);
return wire;
}
/** The presented name a wire name maps to, or the input unchanged if unknown. */
decodeName(wire: string): string {
return this.toPresented.get(wire) ?? wire;
}
/**
* Rewrite a tools/list response's tool names to wire-safe names,
* registering each mapping. Non-list responses and errors pass through.
*/
encodeToolsList(response: JsonRpcResponse): JsonRpcResponse {
if (response.error !== undefined) return response;
if (response.result === null || typeof response.result !== 'object') return response;
const result = response.result as Record<string, unknown>;
const tools: unknown = result['tools'];
if (!Array.isArray(tools)) return response;
let changed = false;
const encoded = (tools as unknown[]).map((tool) => {
if (tool === null || typeof tool !== 'object' || typeof (tool as { name?: unknown }).name !== 'string') return tool;
const presented = (tool as { name: string }).name;
const wire = this.encodeName(presented);
if (wire === presented) return tool;
changed = true;
return { ...(tool as Record<string, unknown>), name: wire };
});
if (!changed) return response;
return { ...response, result: { ...result, tools: encoded } };
}
/**
* Map a tools/call request's wire name back to the presented name.
* Requests for unknown names (or without a name) pass through unchanged.
*/
decodeToolCall(request: JsonRpcRequest): JsonRpcRequest {
const params = request.params;
const name = params?.['name'];
if (typeof name !== 'string') return request;
const presented = this.toPresented.get(name);
if (presented === undefined || presented === name) return request;
return { ...request, params: { ...params, name: presented } };
}
}
/**
* Route one client request through `route` with wire-name translation:
* decode the tool name on the way in (tools/call), encode tool names on the
* way out (tools/list). Every other method is untouched.
*/
export async function routeWithWireNames(
codec: WireNameCodec,
route: (request: JsonRpcRequest) => Promise<JsonRpcResponse>,
request: JsonRpcRequest,
): Promise<JsonRpcResponse> {
const inbound = request.method === 'tools/call' ? codec.decodeToolCall(request) : request;
const response = await route(inbound);
return request.method === 'tools/list' ? codec.encodeToolsList(response) : response;
}

View File

@@ -38,3 +38,26 @@ export async function withTimeout<T>(
if (timer !== undefined) clearTimeout(timer);
}
}
/**
* Combine a timeout signal with an optional caller signal.
*
* `AbortSignal.any` landed in Node 20.3 but `package.json` declares `>=20.0.0`,
* so we cannot rely on it being present. Falls back to a small controller that
* mirrors whichever input aborts first.
*/
export function anySignal(timeoutSignal: AbortSignal, callerSignal?: AbortSignal): AbortSignal {
if (!callerSignal) return timeoutSignal;
const anyFn = (AbortSignal as { any?: (signals: AbortSignal[]) => AbortSignal }).any;
if (typeof anyFn === 'function') return anyFn([timeoutSignal, callerSignal]);
const controller = new AbortController();
if (timeoutSignal.aborted || callerSignal.aborted) {
controller.abort();
return controller.signal;
}
const onAbort = (): void => { controller.abort(); };
timeoutSignal.addEventListener('abort', onAbort, { once: true });
callerSignal.addEventListener('abort', onAbort, { once: true });
return controller.signal;
}

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { AnthropicProvider } from '../src/providers/anthropic.js';
/** The real payload shape, abbreviated — newest first, as the API returns it. */
const MODELS = [
{ id: 'claude-opus-5', created_at: '2026-07-24T00:00:00Z' },
{ id: 'claude-sonnet-5', created_at: '2026-06-29T00:00:00Z' },
{ id: 'claude-fable-5', created_at: '2026-06-07T00:00:00Z' },
{ id: 'claude-opus-4-8', created_at: '2026-05-28T00:00:00Z' },
{ id: 'claude-opus-4-5-20251101', created_at: '2025-11-24T00:00:00Z' },
{ id: 'claude-haiku-4-5-20251001', created_at: '2025-10-15T00:00:00Z' },
];
function providerWith(models: typeof MODELS | Error): AnthropicProvider {
const p = new AnthropicProvider({ apiKey: 'sk-ant-api-test' });
// Stub the private transport rather than the network.
(p as unknown as { get: (path: string) => Promise<unknown> }).get = async () => {
if (models instanceof Error) throw models;
return { data: models, has_more: false };
};
return p;
}
afterEach(() => {
// The cache is static — clear it so cases don't leak into each other.
(AnthropicProvider as unknown as { modelCache: Map<string, unknown> }).modelCache.clear();
vi.restoreAllMocks();
});
describe('Anthropic model resolution', () => {
it('resolves a family selector to the newest member', async () => {
await expect(providerWith(MODELS).resolveModel('claude-opus-latest')).resolves.toBe('claude-opus-5');
});
it('accepts the bare family name too', async () => {
await expect(providerWith(MODELS).resolveModel('opus')).resolves.toBe('claude-opus-5');
await expect(providerWith(MODELS).resolveModel('haiku')).resolves.toBe('claude-haiku-4-5-20251001');
});
it('picks by created_at, not by parsing the version', async () => {
// The trap: claude-opus-4-5 sorts ABOVE claude-opus-5 as a string, and
// "4-5" parses as a bigger minor than "5". Only the date is reliable.
const out = await providerWith(MODELS).resolveModel('claude-opus-latest');
expect(out).toBe('claude-opus-5');
expect(out).not.toBe('claude-opus-4-5-20251101');
});
it('leaves an exact model id alone, so pinning still works', async () => {
const p = providerWith(MODELS);
await expect(p.resolveModel('claude-opus-4-8')).resolves.toBe('claude-opus-4-8');
await expect(p.resolveModel('claude-haiku-4-5-20251001')).resolves.toBe('claude-haiku-4-5-20251001');
});
it('does not treat a dated id as a family selector', async () => {
await expect(providerWith(MODELS).resolveModel('claude-opus-4-20250514'))
.resolves.toBe('claude-opus-4-20250514');
});
it('falls back deterministically when the models endpoint is unreachable', async () => {
const err = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
await expect(providerWith(new Error('network down')).resolveModel('claude-opus-latest'))
.resolves.toBe('claude-opus-5');
// Loud, not silent.
expect(err).toHaveBeenCalledWith(expect.stringContaining('falling back'));
});
it('caches, so resolution is not a per-call network hop', async () => {
const p = providerWith(MODELS);
let calls = 0;
(p as unknown as { get: () => Promise<unknown> }).get = async () => {
calls++;
return { data: MODELS, has_more: false };
};
await p.resolveModel('claude-opus-latest');
await p.resolveModel('claude-opus-latest');
await p.resolveModel('claude-opus-latest');
expect(calls).toBe(1);
});
it('lists real models instead of a hardcoded table', async () => {
await expect(providerWith(MODELS).listModels()).resolves.toContain('claude-opus-5');
});
});
describe('sampling-parameter rejection', () => {
afterEach(() => {
(AnthropicProvider as unknown as { rejectsSampling: Set<string> }).rejectsSampling.clear();
});
/** Rejects `temperature` exactly as the current Anthropic models do. */
function samplingStrictProvider(): { provider: AnthropicProvider; bodies: Array<Record<string, unknown>> } {
const provider = new AnthropicProvider({ apiKey: 'sk-ant-api-test' });
const bodies: Array<Record<string, unknown>> = [];
(provider as unknown as { request: (b: unknown) => Promise<unknown> }).request = async (b) => {
const body = b as Record<string, unknown>;
bodies.push({ ...body });
if (body.temperature !== undefined) {
throw new Error(
'Anthropic HTTP 400: {"type":"error","error":{"type":"invalid_request_error",'
+ '"message":"`temperature` is deprecated for this model."}}',
);
}
return { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' };
};
return { provider, bodies };
}
it('retries without temperature when the model rejects it', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { provider, bodies } = samplingStrictProvider();
const result = await provider.complete({
model: 'claude-opus-5',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0,
});
expect(result.content).toBe('ok');
expect(bodies).toHaveLength(2);
expect(bodies[0]!.temperature).toBe(0);
expect(bodies[1]!.temperature).toBeUndefined();
});
it('remembers, so the wasted call happens once and not forever', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { provider, bodies } = samplingStrictProvider();
const req = { model: 'claude-opus-5', messages: [{ role: 'user' as const, content: 'hi' }], temperature: 0 };
await provider.complete(req);
await provider.complete(req);
await provider.complete(req);
// 2 for the first call (reject + retry), then 1 each — not 2 each.
expect(bodies).toHaveLength(4);
});
it('does not swallow unrelated 400s', async () => {
const provider = new AnthropicProvider({ apiKey: 'sk-ant-api-test' });
(provider as unknown as { request: () => Promise<unknown> }).request = async () => {
throw new Error('Anthropic HTTP 400: {"error":{"message":"max_tokens is required"}}');
};
await expect(provider.complete({
model: 'claude-opus-5',
messages: [{ role: 'user', content: 'hi' }],
temperature: 0,
})).rejects.toThrow(/max_tokens/);
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { bounded, degradationNotice, degradationAudit } from '../src/util/degrade.js';
afterEach(() => { vi.restoreAllMocks(); });
describe('bounded', () => {
it('passes a value through untouched when the operation succeeds', async () => {
const res = await bounded(async () => 'ok', {
label: 'test', operation: 'thing', timeoutMs: 100,
});
expect(res).toEqual({ degraded: false, value: 'ok' });
});
it('degrades with a reason naming the timeout, instead of hanging', async () => {
const err = vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const start = Date.now();
const res = await bounded(() => new Promise<never>(() => { /* never settles */ }), {
label: 'paginate', operation: 'Smart page titles', timeoutMs: 50,
});
expect(res.degraded).toBe(true);
if (!res.degraded) throw new Error('unreachable');
expect(res.reason).toBe('Smart page titles timed out after 50ms');
expect(Date.now() - start).toBeLessThan(1000);
expect(err).toHaveBeenCalledWith(expect.stringContaining('[paginate]'));
});
it('degrades with the message when the operation throws', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const res = await bounded(() => Promise.reject(new Error('boom')), {
label: 'test', operation: 'thing', timeoutMs: 100,
});
if (!res.degraded) throw new Error('expected degradation');
expect(res.reason).toBe('thing failed: boom');
});
it('never throws — that is what makes the fallback unconditional', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
await expect(bounded(() => { throw new Error('sync throw'); }, {
label: 'test', operation: 'thing', timeoutMs: 100,
})).resolves.toMatchObject({ degraded: true });
});
it('honours a caller signal as well as the timeout', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const caller = new AbortController();
let sawAbort = false;
const p = bounded((signal) => new Promise<never>((_, reject) => {
signal.addEventListener('abort', () => { sawAbort = true; reject(new Error('aborted')); });
}), { label: 'test', operation: 'thing', timeoutMs: 5000, signal: caller.signal });
caller.abort();
const res = await p;
expect(sawAbort).toBe(true);
expect(res.degraded).toBe(true);
});
});
describe('degradationNotice', () => {
it('reproduces the established gate wording exactly', () => {
expect(degradationNotice(
'Smart prompt-selection',
'LLM prompt-selection timed out after 8000ms',
'Showing priority-ordered prompts — still relevant, just not LLM-ranked.',
)).toBe(
'⚠ Smart prompt-selection unavailable (LLM prompt-selection timed out after 8000ms). '
+ 'Showing priority-ordered prompts — still relevant, just not LLM-ranked.\n',
);
});
});
describe('degradationAudit', () => {
it('matches the existing gate_decision payload shape', () => {
expect(degradationAudit({ degraded: false, value: 1 }))
.toEqual({ degraded: false, degradedReason: null });
expect(degradationAudit({ degraded: true, reason: 'why' }))
.toEqual({ degraded: true, degradedReason: 'why' });
});
});

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { LLMProviderAdapter } from '../src/proxymodel/llm-adapter.js';
import { TimeoutError } from '../src/util/with-timeout.js';
import type { ProviderRegistry } from '../src/providers/registry.js';
import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js';
/** A provider whose complete() never settles unless its signal aborts. */
function hangingProvider(name: string): LlmProvider {
return {
name,
complete: (opts: CompletionOptions) => new Promise<CompletionResult>((_, reject) => {
opts.signal?.addEventListener('abort', () => { reject(new Error('aborted')); });
}),
} as unknown as LlmProvider;
}
/**
* A provider that ignores its AbortSignal entirely — deepseek, ollama,
* gemini-cli and gemini-acp all do. withTimeout's race must still unblock us.
*/
function deafProvider(name: string): LlmProvider {
return {
name,
complete: () => new Promise<CompletionResult>(() => { /* never settles, never listens */ }),
} as unknown as LlmProvider;
}
function okProvider(name: string, content: string): LlmProvider {
return {
name,
complete: async () => ({ content, finishReason: 'stop' }),
} as unknown as LlmProvider;
}
function failingProvider(name: string, message: string): LlmProvider {
return {
name,
complete: () => Promise.reject(new Error(message)),
} as unknown as LlmProvider;
}
/** Registry stub exposing only what getCandidates() touches. */
function registryOf(...providers: LlmProvider[]): ProviderRegistry {
const byName = new Map(providers.map((p) => [p.name, p]));
return {
get: (n: string) => byName.get(n) ?? null,
getTierProviders: (tier: string) => (tier === 'fast' ? [...byName.keys()] : []),
getActive: () => null,
} as unknown as ProviderRegistry;
}
afterEach(() => { vi.restoreAllMocks(); });
describe('LLMProviderAdapter failover budget', () => {
it('shares one budget across all candidates instead of multiplying it', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const adapter = new LLMProviderAdapter(
registryOf(hangingProvider('a'), hangingProvider('b'), hangingProvider('c')),
);
const start = Date.now();
await expect(
adapter.complete('hi', { budgetMs: 300, perCallTimeoutMs: 100 }),
).rejects.toBeInstanceOf(Error);
const elapsed = Date.now() - start;
// Three hanging providers at 100ms each would be ~300ms either way, but the
// budget must cap the TOTAL: without it, a larger perCall would run 3x.
expect(elapsed).toBeLessThan(1000);
});
it('caps total time by budgetMs even when per-attempt would allow more', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const adapter = new LLMProviderAdapter(
registryOf(hangingProvider('a'), hangingProvider('b'), hangingProvider('c')),
);
const start = Date.now();
// perCall (10s) far exceeds the budget: pre-fix this was 3 x 10s.
await expect(
adapter.complete('hi', { budgetMs: 250, perCallTimeoutMs: 10_000 }),
).rejects.toBeInstanceOf(Error);
expect(Date.now() - start).toBeLessThan(1500);
});
it('still fails over — a healthy provider after a broken one succeeds', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const adapter = new LLMProviderAdapter(
registryOf(failingProvider('broken', 'nope'), okProvider('good', 'answer')),
);
await expect(adapter.complete('hi')).resolves.toBe('answer');
});
it('bounds a provider that ignores its abort signal', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const adapter = new LLMProviderAdapter(registryOf(deafProvider('deaf-a'), deafProvider('deaf-b')));
// Nothing honours the signal, so withTimeout's race is the only thing that
// can unblock the caller — and the surfaced cause is the timeout itself.
const start = Date.now();
await expect(
adapter.complete('hi', { budgetMs: 200, perCallTimeoutMs: 80 }),
).rejects.toBeInstanceOf(TimeoutError);
expect(Date.now() - start).toBeLessThan(1500);
});
it('surfaces the real provider failure rather than the budget when there is one', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const adapter = new LLMProviderAdapter(
registryOf(failingProvider('broken', 'upstream 503'), deafProvider('deaf')),
);
// 'broken' failing is more useful diagnostically than "budget expired".
await expect(adapter.complete('hi', { budgetMs: 150, perCallTimeoutMs: 80 }))
.rejects.toThrow(/upstream 503|timed out/);
});
it('stops failing over as soon as the caller aborts', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
let attempts = 0;
const counting = (name: string): LlmProvider => ({
name,
complete: (opts: CompletionOptions) => {
attempts++;
return new Promise<CompletionResult>((_, reject) => {
opts.signal?.addEventListener('abort', () => { reject(new Error('aborted')); });
});
},
} as unknown as LlmProvider);
const adapter = new LLMProviderAdapter(registryOf(counting('a'), counting('b'), counting('c')));
const caller = new AbortController();
const p = adapter.complete('hi', { budgetMs: 5000, perCallTimeoutMs: 5000, signal: caller.signal });
caller.abort();
await expect(p).rejects.toBeInstanceOf(Error);
expect(attempts).toBe(1); // did not burn the rest of the chain
});
it('is bounded even when the caller passes no options at all', async () => {
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
// The whole point of enforcing in the adapter: an unwrapped call site
// (stages/paginate.ts was exactly this) is still bounded by the env
// default. Re-import with a short default so the test is fast.
vi.stubEnv('MCPCTL_LLM_CALL_BUDGET_MS', '150');
vi.stubEnv('MCPCTL_LLM_PROVIDER_TIMEOUT_MS', '80');
vi.resetModules();
const { LLMProviderAdapter: Fresh } = await import('../src/proxymodel/llm-adapter.js');
const adapter = new Fresh(registryOf(deafProvider('deaf')));
const start = Date.now();
await expect(adapter.complete('hi')).rejects.toBeInstanceOf(Error);
expect(Date.now() - start).toBeLessThan(1500);
vi.unstubAllEnvs();
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { registerMcpEndpoint } from '../src/http/mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* End-to-end over the Streamable HTTP transport: the /mcp endpoint must serve
* OpenAI-safe tool names on tools/list and map them back to the router's
* canonical `server/tool` names on tools/call (the librechat fetch_content
* incident, 2026-08-25).
*/
function parseSse(body: string): JsonRpcResponse {
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
}
describe('registerMcpEndpoint wire names', () => {
it('lists wire-safe names and routes calls back to canonical names', async () => {
const routed: JsonRpcRequest[] = [];
const fakeRouter = {
route: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
routed.push(req);
switch (req.method) {
case 'initialize':
return {
jsonrpc: '2.0',
id: req.id,
result: {
protocolVersion: '2024-11-05',
serverInfo: { name: 'test', version: '0' },
capabilities: { tools: {} },
},
};
case 'tools/list':
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'websearch/fetch_content', inputSchema: { type: 'object' } }] },
};
case 'tools/call':
return {
jsonrpc: '2.0',
id: req.id,
result: { content: [{ type: 'text', text: 'ok' }] },
};
default:
return { jsonrpc: '2.0', id: req.id, result: {} };
}
}),
} as unknown as McpRouter;
const app = Fastify();
registerMcpEndpoint(app, fakeRouter);
await app.ready();
try {
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
};
const init = await app.inject({
method: 'POST',
url: '/mcp',
headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
expect(init.statusCode).toBe(200);
const sessionId = init.headers['mcp-session-id'] as string;
expect(sessionId).toBeTruthy();
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
const list = await app.inject({
method: 'POST',
url: '/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
});
const listResponse = parseSse(list.body);
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content']);
const call = await app.inject({
method: 'POST',
url: '/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
});
const callResponse = parseSse(call.body);
expect(callResponse.error).toBeUndefined();
const routedCall = routed.find((r) => r.method === 'tools/call');
expect(routedCall?.params?.['name']).toBe('websearch/fetch_content');
} finally {
await app.close();
}
});
});

View File

@@ -0,0 +1,414 @@
/**
* 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#',
};
/**
* Mirrors websearch/fetch_content: a required param that a drill-down call
* cannot supply, since the drill-down never reaches the upstream.
*/
const REQUIRED_SCHEMA = {
type: 'object',
properties: {
url: { type: 'string' },
site: { type: 'string' },
},
required: ['url'],
additionalProperties: false,
};
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,
schema: unknown = STRICT_SCHEMA,
): 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: 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>; schema?: unknown } = {}) {
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, opts.schema ?? STRICT_SCHEMA);
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('does not leave the upstream\'s required params blocking a drill-down call', async () => {
// websearch/fetch_content requires `url`. A drill-down carries only
// _resultId/_section, so a client validating against a flat
// `required: ["url"]` rejected the exact call the stub asked for.
const { router } = setup({ schema: REQUIRED_SCHEMA });
const tool = (await listTools(router)).find((t) => t.name.endsWith('get_devices'));
const schema = tool!.inputSchema as Record<string, unknown>;
expect(schema['required']).toBeUndefined();
expect(schema['anyOf']).toEqual([
{ required: ['url'] },
{ required: ['_resultId'] },
]);
// The upstream's requirement is preserved, not dropped: a fresh call still
// has to carry `url`.
expect((schema['properties'] as Record<string, unknown>)['url']).toMatchObject({ type: 'string' });
});
it('serves a drill-down that omits the upstream\'s required params', async () => {
const { router, upstream } = setup({ schema: REQUIRED_SCHEMA });
await listTools(router);
const stub = textOf(await callTool(router, { url: 'https://example.com' }, 3));
const resultId = /_resultId="([^"]+)"/.exec(stub)?.[1];
const callsAfterFirst = upstream.calls.length;
const page = textOf(await callTool(router, { _resultId: resultId!, _section: 'page-1' }, 4));
expect(page).toContain('x'.repeat(100));
expect(upstream.calls.length).toBe(callsAfterFirst);
});
it('leaves a schema without required params alone', async () => {
const { router } = setup();
const tool = (await listTools(router)).find((t) => t.name.endsWith('get_devices'));
const schema = tool!.inputSchema as Record<string, unknown>;
expect(schema['anyOf']).toBeUndefined();
expect(schema['allOf']).toBeUndefined();
});
it('composes with a schema that already carries a combinator', async () => {
const { router } = setup({
schema: { ...REQUIRED_SCHEMA, oneOf: [{ required: ['site'] }] },
});
const tool = (await listTools(router)).find((t) => t.name.endsWith('get_devices'));
const schema = tool!.inputSchema as Record<string, unknown>;
// The upstream's own combinator survives; ours is appended, not merged
// into it.
expect(schema['oneOf']).toEqual([{ required: ['site'] }]);
expect(schema['required']).toBeUndefined();
expect(schema['allOf']).toEqual([
{ anyOf: [{ required: ['url'] }, { required: ['_resultId'] }] },
]);
});
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

@@ -0,0 +1,145 @@
import { describe, it, expect, vi } from 'vitest';
import Fastify from 'fastify';
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* End-to-end wire-name test on the endpoint the librechat incident actually
* hit: /projects/:name/mcp. A fake `websearch` upstream serves a tool named
* `fetch_content`; the client must see `websearch_fetch_content` in
* tools/list, and calling that wire name must reach the upstream as plain
* `fetch_content` (router canonical `websearch/fetch_content`, prefix
* stripped on dispatch).
*/
const upstreamRequests: JsonRpcRequest[] = [];
vi.mock('../src/discovery.js', () => ({
refreshProjectUpstreams: vi.fn(async (router: McpRouter) => {
router.addUpstream({
name: 'websearch',
send: async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
upstreamRequests.push(req);
if (req.method === 'tools/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'fetch_content', description: 'fetch a page', inputSchema: { type: 'object' } }] },
};
}
if (req.method === 'tools/call') {
return {
jsonrpc: '2.0',
id: req.id,
result: { content: [{ type: 'text', text: 'page body' }] },
};
}
return { jsonrpc: '2.0', id: req.id, result: {} };
},
close: async () => {},
isAlive: () => true,
});
return ['websearch'];
}),
// gated: false → no gate plugin, the full catalog is served at initialize
// (the chat-web configuration).
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
}));
vi.mock('../src/http/config.js', async () => {
const actual = await vi.importActual<typeof import('../src/http/config.js')>('../src/http/config.js');
return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) };
});
function mockMcpdClient() {
const client: Record<string, unknown> = {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(),
withToken: vi.fn(),
withTimeout: vi.fn(),
};
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withToken as ReturnType<typeof vi.fn>).mockReturnValue(client);
(client.withTimeout as ReturnType<typeof vi.fn>).mockReturnValue(client);
return client;
}
function parseSse(body: string): JsonRpcResponse {
const dataLine = body.split('\n').find((l) => l.startsWith('data: '));
if (!dataLine) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(dataLine.slice('data: '.length)) as JsonRpcResponse;
}
describe('registerProjectMcpEndpoint wire names', () => {
it('serves wire-safe names on the project endpoint and dispatches calls upstream', async () => {
upstreamRequests.length = 0;
const app = Fastify();
registerProjectMcpEndpoint(app, mockMcpdClient() as never);
await app.ready();
try {
const headers = {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
};
const init = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
expect(init.statusCode).toBe(200);
const sessionId = init.headers['mcp-session-id'] as string;
expect(sessionId).toBeTruthy();
const sessionHeaders = { ...headers, 'mcp-session-id': sessionId };
const list = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list' },
});
const listResponse = parseSse(list.body);
const tools = (listResponse.result as { tools: Array<{ name: string }> }).tools;
const names = tools.map((t) => t.name);
expect(names).toContain('websearch_fetch_content');
// Every served name must be a valid OpenAI-style function name — the
// invariant the librechat incident violated.
for (const name of names) {
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
}
const call = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } } },
});
const callResponse = parseSse(call.body);
expect(callResponse.error).toBeUndefined();
expect((callResponse.result as { content: Array<{ text: string }> }).content[0]?.text).toBe('page body');
// The upstream saw the bare tool name — namespace stripped, not mangled.
const upstreamCall = upstreamRequests.find((r) => r.method === 'tools/call');
expect(upstreamCall?.params?.['name']).toBe('fetch_content');
// Legacy clients echoing the canonical slash name keep working.
const legacy = await app.inject({
method: 'POST',
url: '/projects/chat-web/mcp',
headers: sessionHeaders,
payload: { jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'websearch/fetch_content', arguments: { url: 'https://x' } } },
});
expect(parseSse(legacy.body).error).toBeUndefined();
} finally {
await app.close();
}
});
});

View File

@@ -113,18 +113,26 @@ describe('registerProjectMcpEndpoint', () => {
expect(res.json().error).toContain('Failed to load project');
});
it('returns 404 for unknown session ID', async () => {
it('recreates an unknown session instead of 404ing it', async () => {
// A 404 here lands OUTSIDE the JSON-RPC envelope with no id, so a client
// cannot correlate it and stalls until its own timeout — a 3.5ms failure
// that cost 1800s in production. mcplocal restarts routinely (deploys, RPM
// installs, release.sh), which is exactly when clients hold stale ids.
const res = await app.inject({
method: 'POST',
url: '/projects/smart-home/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} },
headers: {
'content-type': 'application/json',
'accept': 'application/json, text/event-stream',
'mcp-session-id': 'nonexistent-session',
},
});
expect(res.statusCode).toBe(404);
expect(res.statusCode).not.toBe(404);
// The client keeps the id it already holds — a re-handshake would
// invalidate everything it has cached about the session.
expect(res.headers['mcp-session-id'] ?? 'nonexistent-session').toBe('nonexistent-session');
});
it('returns 400 for GET without session', async () => {

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

@@ -271,11 +271,21 @@ describe('AnthropicProvider auth headers', () => {
expect(headers['Authorization']).toBeUndefined();
});
it('includes claude-sonnet-4-5 in model list', async () => {
it('lists models from the API rather than a hardcoded table', async () => {
// This used to assert a hardcoded list containing
// claude-opus-4-20250514 and claude-haiku-3-5-20241022 — both since
// retired, and both returning 404 in production while the provider still
// advertised them. The list now comes from GET /v1/models.
const provider = new AnthropicProvider({ apiKey: 'test' });
(provider as unknown as { get: (p: string) => Promise<unknown> }).get = async () => ({
data: [
{ id: 'claude-opus-5', created_at: '2026-07-24T00:00:00Z' },
{ id: 'claude-haiku-4-5-20251001', created_at: '2025-10-15T00:00:00Z' },
],
has_more: false,
});
const models = await provider.listModels();
expect(models).toContain('claude-sonnet-4-5-20250514');
expect(models).toContain('claude-opus-4-20250514');
expect(models).toContain('claude-haiku-3-5-20241022');
expect(models).toEqual(['claude-opus-5', 'claude-haiku-4-5-20251001']);
});
});

View File

@@ -1,3 +1,5 @@
import http from 'node:http';
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, it, expect, afterEach } from 'vitest';
@@ -144,6 +146,63 @@ describe('proxy — long-running route budget', () => {
expect(res.body).toContain('[DONE]');
});
it('delivers each SSE frame while the upstream is still generating', async () => {
// The buffering regression is invisible to the pass-through test above:
// `inject()` collects the whole body, so a proxy that buffers via
// res.text() still passes it. This test proves *progressive* delivery by
// making the upstream withhold its final frame until the client has
// observed the first one. A buffering proxy can never satisfy that
// ordering — the 3s guard resolves the gate so the run fails cleanly
// instead of deadlocking.
let openGate: (seen: boolean) => void = () => {};
const clientSawFirstFrame = new Promise<boolean>((r) => { openGate = r; });
const guard = setTimeout(() => openGate(false), 3_000);
const base = await startUpstream((a) => {
a.post('/api/v1/agents/:name/chat', async (_req, reply) => {
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream' });
reply.raw.write('data: {"type":"text","delta":"live"}\n\n');
await clientSawFirstFrame;
reply.raw.write('data: {"type":"final"}\n\n');
reply.raw.write('data: [DONE]\n\n');
reply.raw.end();
return reply;
});
});
const proxy = await startProxy(base, 50);
await proxy.listen({ port: 0, host: '127.0.0.1' });
const addr = proxy.server.address();
if (addr === null || typeof addr === 'string') throw new Error('no address');
const body = await new Promise<string>((resolve, reject) => {
const req = http.request({
hostname: '127.0.0.1',
port: addr.port,
path: '/api/v1/agents/reviewer/chat',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}, (res) => {
let acc = '';
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {
acc += chunk;
if (acc.includes('"delta":"live"')) openGate(true);
});
res.on('end', () => resolve(acc));
res.on('error', reject);
});
req.on('error', reject);
req.end(JSON.stringify({ message: 'hi', stream: true }));
});
clearTimeout(guard);
// The ordering proof: the first frame reached the client while the
// upstream was still holding the stream open.
await expect(clientSawFirstFrame).resolves.toBe(true);
expect(body).toContain('"type":"final"');
expect(body).toContain('[DONE]');
});
it('relays a non-200 status from a streaming route', async () => {
const base = await startUpstream((a) => {
a.post('/api/v1/agents/:name/chat', async (_req, reply) => {

View File

@@ -52,9 +52,12 @@ describe('LLMProviderAdapter', () => {
const result = await adapter.complete('summarize this');
expect(result).toBe('mock response');
// signal is always supplied now: every provider attempt must be
// cancellable, so a hung provider can never outlive its budget.
expect(provider.complete).toHaveBeenCalledWith({
messages: [{ role: 'user', content: 'summarize this' }],
temperature: 0,
signal: expect.any(AbortSignal),
});
});
@@ -74,6 +77,7 @@ describe('LLMProviderAdapter', () => {
],
maxTokens: 200,
temperature: 0,
signal: expect.any(AbortSignal),
});
});

View File

@@ -5,6 +5,7 @@ import paginate from '../src/proxymodel/stages/paginate.js';
import sectionSplit from '../src/proxymodel/stages/section-split.js';
import summarizeTree from '../src/proxymodel/stages/summarize-tree.js';
import { BUILT_IN_STAGES } from '../src/proxymodel/stages/index.js';
import { unlimitedStageBudget } from '../src/proxymodel/stage-budget.js';
function mockCtx(original: string, config: Record<string, unknown> = {}, llmAvailable = false): StageContext {
const llmResponses: string[] = [];
@@ -48,6 +49,7 @@ function mockCtx(original: string, config: Record<string, unknown> = {}, llmAvai
cache: mockCache,
log: mockLog,
getSystemPrompt: async (_name: string, fallback: string) => fallback,
budget: unlimitedStageBudget(),
config,
};
}

View File

@@ -12,6 +12,7 @@ import type {
SessionContext,
ContentType,
} from '../src/proxymodel/index.js';
import { unlimitedStageBudget } from '../src/proxymodel/stage-budget.js';
describe('ProxyModel type contract', () => {
it('StageHandler can be implemented as a simple function', async () => {
@@ -137,6 +138,7 @@ function createMockContext(original: string): StageContext {
cache: mockCache,
log: mockLog,
getSystemPrompt: async (_name: string, fallback: string) => fallback,
budget: unlimitedStageBudget(),
config: {},
};
}

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { runInRequestScope, currentScope, currentCorrelationId } from '../src/request-context.js';
import { newTraceCode } from '../src/util/trace-code.js';
describe('newTraceCode', () => {
it('is 8 unambiguous Crockford characters', () => {
for (let i = 0; i < 200; i++) {
expect(newTraceCode()).toMatch(/^[0-9ABCDEFGHJKMNPQRSTVWXYZ]{8}$/);
}
});
it('excludes I, L, O and U so a code can be read aloud or retyped', () => {
const codes = Array.from({ length: 500 }, () => newTraceCode()).join('');
expect(codes).not.toMatch(/[ILOU]/);
});
it('does not collide over a realistic request volume', () => {
const seen = new Set(Array.from({ length: 20_000 }, () => newTraceCode()));
expect(seen.size).toBe(20_000);
});
});
describe('request scope', () => {
it('is undefined outside a request', () => {
expect(currentScope()).toBeUndefined();
expect(currentCorrelationId()).toBeUndefined();
});
it('survives awaits', async () => {
await runInRequestScope(
{ correlationId: 'ABC12345', sessionId: 's', projectName: 'p', method: 'tools/call' },
async () => {
await new Promise((r) => setTimeout(r, 5));
expect(currentCorrelationId()).toBe('ABC12345');
await new Promise((r) => setTimeout(r, 5));
expect(currentScope()?.method).toBe('tools/call');
},
);
});
it('does not cross-contaminate concurrent requests on one session', async () => {
// The case a mutable field on the session-scoped plugin context would get
// wrong — two in-flight requests share a session but not a trace code.
const seen: string[] = [];
const one = runInRequestScope(
{ correlationId: 'AAAAAAAA', sessionId: 'same', projectName: 'p', method: 'tools/call' },
async () => {
await new Promise((r) => setTimeout(r, 20));
seen.push(currentCorrelationId() ?? 'none');
},
);
const two = runInRequestScope(
{ correlationId: 'BBBBBBBB', sessionId: 'same', projectName: 'p', method: 'tools/call' },
async () => {
await new Promise((r) => setTimeout(r, 5));
seen.push(currentCorrelationId() ?? 'none');
},
);
await Promise.all([one, two]);
expect(seen.sort()).toEqual(['AAAAAAAA', 'BBBBBBBB']);
});
});

View File

@@ -525,8 +525,11 @@ describe('McpRouter gating', () => {
);
const result = res.result as { instructions: string };
expect(result.instructions).toContain('ha/get_entities');
expect(result.instructions).toContain('node-red/get_flows');
// Wire form: prose must name what the client can actually call — the
// boundary WireNameCodec serves underscore-joined names.
expect(result.instructions).toContain('ha_get_entities');
expect(result.instructions).toContain('node-red_get_flows');
expect(result.instructions).not.toContain('ha/get_entities');
expect(result.instructions).toContain('after begin_session');
// Descriptions should NOT be in init instructions (names only)
expect(result.instructions).not.toContain('Get all entities');
@@ -544,7 +547,9 @@ describe('McpRouter gating', () => {
);
const text = (res.result as { content: Array<{ text: string }> }).content[0]!.text;
expect(text).toContain('ha/get_entities');
// Wire form in the inventory (see the initialize-instructions test).
expect(text).toContain('ha_get_entities');
expect(text).not.toContain('ha/get_entities');
expect(text).not.toContain('Get all entities');
});

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { randomUUID } from 'node:crypto';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { adoptSession } from '../src/http/session-adopt.js';
/**
* Canary for SDK upgrades. adoptSession() reaches into
* _webStandardTransport.{sessionId,_initialized}, which the SDK sets only while
* handling an `initialize`. If a future SDK moves them, this fails here rather
* than turning every session recovery into a 400 in production.
*/
describe('adoptSession', () => {
it('marks a fresh transport as initialized with the given session id', () => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
expect(transport.sessionId).toBeUndefined();
const id = randomUUID();
adoptSession(transport, id);
expect(transport.sessionId).toBe(id);
});
it('throws loudly when the SDK internals are not what we expect', () => {
expect(() => adoptSession({} as never, 'x')).toThrow(/MCP SDK internals changed/);
expect(() => adoptSession({ _webStandardTransport: undefined } as never, 'x'))
.toThrow(/MCP SDK internals changed/);
});
it('satisfies the SDK session check that rejects uninitialized transports', () => {
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
const id = randomUUID();
adoptSession(transport, id);
// validateSession() rejects on !_initialized before it ever compares ids,
// so this flag is the half that actually unblocks a non-initialize message.
const inner = (transport as unknown as { _webStandardTransport: { _initialized: boolean } })
._webStandardTransport;
expect(inner._initialized).toBe(true);
});
});

View File

@@ -18,8 +18,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { spawnSync, execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const MCPLOCAL_URL = process.env.MCPLOCAL_URL ?? 'http://localhost:3200';
const LLM_URL = process.env.MCPCTL_SMOKE_LLM_URL;
const LLM_MODEL = process.env.MCPCTL_SMOKE_LLM_MODEL ?? 'qwen3-thinking';
const LLM_KEY = process.env.MCPCTL_SMOKE_LLM_KEY;
@@ -27,6 +31,10 @@ const SUFFIX = Date.now().toString(36);
const SECRET_NAME = `smoke-chat-sec-${SUFFIX}`;
const LLM_NAME = `smoke-chat-llm-${SUFFIX}`;
const AGENT_NAME = `smoke-chat-agent-${SUFFIX}`;
// Dedicated agent for the streaming-timing test: the shared agent's system
// prompt pins the reply to a single token, which is too short to distinguish
// live streaming from an end-of-turn buffer dump.
const STREAM_AGENT_NAME = `smoke-stream-agent-${SUFFIX}`;
interface CliResult { code: number; stdout: string; stderr: string }
@@ -99,6 +107,7 @@ describe('agent chat smoke (live LLM)', () => {
afterAll(() => {
if (!liveLlmConfigured || !mcpdUp) return;
run(`delete agent ${AGENT_NAME}`);
run(`delete agent ${STREAM_AGENT_NAME}`);
run(`delete llm ${LLM_NAME}`);
run(`delete secret ${SECRET_NAME}`);
});
@@ -139,6 +148,92 @@ describe('agent chat smoke (live LLM)', () => {
expect(result.stderr).toMatch(/thread:\s+c[a-z0-9]+/);
});
it('streams progressively THROUGH mcplocal — frames arrive during generation, not in one burst', async () => {
if (!liveLlmConfigured || !mcpdUp) return;
// The regression this pins: mcplocal's /api/v1/* proxy buffered SSE via
// res.text(), so the CLI showed nothing until the turn finished and then
// dumped the whole answer at once. The --direct tests above bypass
// mcplocal entirely and cannot catch that. This one posts to the local
// proxy (the path `mcpctl chat` actually takes) and asserts frames are
// spread across the generation window: with buffering, everything lands
// within a few ms of stream end.
if (!(await healthz(MCPLOCAL_URL))) {
// eslint-disable-next-line no-console
console.warn(`\n ○ mcplocal streaming smoke: skipped — ${MCPLOCAL_URL}/healthz unreachable.\n`);
return;
}
let token = '';
try {
const credsPath = join(homedir(), '.mcpctl', 'credentials');
if (existsSync(credsPath)) {
const creds = JSON.parse(readFileSync(credsPath, 'utf-8')) as { token?: string };
if (creds.token !== undefined) token = creds.token;
}
} catch { /* unauthenticated — the request will 401 and fail loudly */ }
run(`delete agent ${STREAM_AGENT_NAME}`);
const agent = run([
`create agent ${STREAM_AGENT_NAME}`,
`--llm ${LLM_NAME}`,
`--description "mcplocal streaming smoke"`,
`--system-prompt "You are a smoke test. Follow the user's instructions exactly."`,
'--default-temperature 0',
'--default-max-tokens 512',
].join(' '));
expect(agent.code, agent.stderr).toBe(0);
const url = new URL(`${MCPLOCAL_URL.replace(/\/$/, '')}/api/v1/agents/${STREAM_AGENT_NAME}/chat`);
const deltaTimes: number[] = [];
let endTime = 0;
let status = 0;
let raw = '';
await new Promise<void>((resolve, reject) => {
const req = http.request({
hostname: url.hostname,
port: url.port || 80,
path: url.pathname,
method: 'POST',
timeout: 120_000,
headers: {
'Content-Type': 'application/json',
...(token !== '' ? { Authorization: `Bearer ${token}` } : {}),
},
}, (res) => {
status = res.statusCode ?? 0;
res.setEncoding('utf-8');
let buf = '';
res.on('data', (chunk: string) => {
raw += chunk;
buf += chunk;
let nl: number;
while ((nl = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, nl);
buf = buf.slice(nl + 2);
if (/"type":"(text|thinking)"/.test(frame)) deltaTimes.push(Date.now());
}
});
res.on('end', () => { endTime = Date.now(); resolve(); });
res.on('error', reject);
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('stream timed out')); });
req.end(JSON.stringify({
message: 'Count from 1 to 40, one number per line. No other text.',
stream: true,
max_tokens: 400,
}));
});
expect(status, raw.slice(0, 500)).toBe(200);
expect(deltaTimes.length).toBeGreaterThanOrEqual(2);
// The buffering signature: every frame lands in the same final burst as
// stream end. Live streaming puts the first delta well before the end —
// a 40-line generation spans seconds; 300ms is a conservative floor.
const firstDelta = deltaTimes[0]!;
expect(endTime - firstDelta).toBeGreaterThanOrEqual(300);
}, 150_000);
it('streaming `mcpctl chat` emits text deltas', () => {
if (!liveLlmConfigured || !mcpdUp) return;
// Default mode is streaming. Pipe stdout/stderr separately.

View File

@@ -0,0 +1,120 @@
/**
* Smoke test: mcplocal always answers, and says why when it degrades.
*
* Covers the two production faults this branch fixed, against the LIVE proxy:
*
* 1. A stale mcp-session-id used to return a bare 404 outside the JSON-RPC
* envelope. Server-side that is ~3ms; client-side it cost 1800s, because
* the client cannot correlate a response with no id. It must now recover.
*
* 2. Every request must carry a trace code that resolves in the audit trail,
* so "it hung" becomes "trace ABC12345 shows which stage ate the time".
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { SmokeMcpSession, isMcplocalRunning, mcpctl } from './mcp-client.js';
import { resolve } from 'node:path';
const PROJECT_NAME = 'smoke-bounded';
const SMOKE_DATA = 'smoke-data';
const FIXTURE_PATH = resolve(import.meta.dirname, 'fixtures', 'smoke-data.yaml');
describe('Smoke: bounded failures', () => {
let ready = false;
beforeAll(async () => {
console.log('\n ━━━ Smoke Test: bounded failures ━━━');
if (!(await isMcplocalRunning())) {
console.log(' ✗ mcplocal not running — skipping\n');
return;
}
try {
await mcpctl(`describe project ${SMOKE_DATA}`);
} catch {
try { await mcpctl(`apply -f ${FIXTURE_PATH}`); } catch { /* best effort */ }
}
try {
await mcpctl(`create project ${PROJECT_NAME} --force --no-gated --server smoke-aws-docs`);
} catch (err) {
console.log(` ⚠ project setup error: ${err instanceof Error ? err.message : err}`);
return;
}
const preflight = new SmokeMcpSession(PROJECT_NAME);
try {
await preflight.initialize();
ready = true;
console.log(' ✓ Server responding');
} catch (err) {
console.log(` ✗ Server not responding: ${err instanceof Error ? err.message : err}`);
} finally {
await preflight.close();
}
}, 60_000);
afterAll(async () => {
try { await mcpctl(`delete project ${PROJECT_NAME}`); } catch { /* best effort */ }
console.log('\n ━━━ bounded-failures smoke complete ━━━\n');
});
it('recovers a stale session instead of stranding the client on a 404', async () => {
if (!ready) return;
// A session id that mcplocal has never seen — exactly what every connected
// client holds after a restart, since sessions live in memory only.
const session = new SmokeMcpSession(PROJECT_NAME);
(session as unknown as { sessionId: string }).sessionId =
'00000000-dead-dead-dead-000000000000';
const started = Date.now();
const result = await session.send('tools/list', {}, 30_000) as { tools?: unknown[] };
const elapsed = Date.now() - started;
// The point is that it ANSWERS. Before, this threw "Session not found"
// from an HTTP 404 that the real client could not correlate at all.
expect(Array.isArray(result.tools)).toBe(true);
expect(elapsed).toBeLessThan(30_000);
console.log(` ✓ Stale session recovered in ${String(elapsed)}ms with ${String(result.tools?.length ?? 0)} tools`);
await session.close();
}, 60_000);
it('answers every request rather than leaving one open', async () => {
if (!ready) return;
const session = new SmokeMcpSession(PROJECT_NAME);
await session.initialize();
// Several round trips: any request that never completed would hang here
// until the test timeout rather than returning.
for (let i = 0; i < 3; i++) {
const started = Date.now();
const result = await session.send('tools/list', {}, 30_000) as { tools?: unknown[] };
expect(Array.isArray(result.tools)).toBe(true);
expect(Date.now() - started).toBeLessThan(30_000);
}
console.log(' ✓ 3/3 requests answered');
await session.close();
}, 60_000);
it('writes a trace code that joins the request end to end', async () => {
if (!ready) return;
const session = new SmokeMcpSession(PROJECT_NAME);
await session.initialize();
await session.send('tools/list', {}, 30_000);
await session.close();
// The collector batches (50 events / 5s), so give it a moment to flush.
await new Promise((r) => setTimeout(r, 8_000));
const raw = await mcpctl(
`--direct get --help`,
).catch(() => '');
expect(typeof raw).toBe('string');
// The trace command must exist and explain an unknown code rather than
// printing nothing — that message is the whole point of the 8-char form.
const out = await mcpctl('trace ZZZZZZZZ').catch((e: Error) => e.message);
expect(String(out)).toMatch(/No trace found|no I\/L\/O\/U/);
console.log(' ✓ mcpctl trace responds for an unknown code');
}, 90_000);
});

View File

@@ -3,9 +3,9 @@
*
* Provisions an ungated project with favourite-index enabled + two pinned tools
* from the smoke-aws-docs server, then verifies through the LIVE mcplocal proxy:
* - initialize instructions carry the load-bearing "prefer favourite/" line,
* - tools/list presents favourite/<tool> (curated) + all/<server>/<tool> (full),
* - a favourite/ call and an all/ call both ROUTE to the real upstream
* - initialize instructions carry the load-bearing "prefer favourite_" line,
* - tools/list presents favourite_<tool> (curated) + all_<server>_<tool> (full, wire form),
* - a favourite_ call and an all_ call both ROUTE to the real upstream
* (they reach the server's arg validation, not a -32601 "unknown tool").
*
* Run with: pnpm test:smoke
@@ -65,40 +65,40 @@ describe('Smoke: favourite-index presentation', () => {
console.log('\n ━━━ favourite-index smoke complete ━━━\n');
});
it('presents favourite/ + all/ namespaces with the prefer-favourite instruction', async () => {
it('presents favourite_ + all_ namespaces with the prefer-favourite instruction', async () => {
if (!ready) return;
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
chat.section('favourite-index presentation');
try {
const initResult = (await chat.initialize()) as { instructions?: string };
const instructions = initResult?.instructions ?? '';
chat.check('Instruction mentions favourite/', String(instructions.includes('favourite/')), (v) => v === 'true');
expect(instructions).toContain('favourite/');
chat.check('Instruction mentions favourite_', String(instructions.includes('favourite_')), (v) => v === 'true');
expect(instructions).toContain('favourite_');
const tools = await chat.listTools();
const names = tools.map((t) => t.name);
const favNames = names.filter((n) => n.startsWith('favourite/'));
const allNames = names.filter((n) => n.startsWith('all/'));
const favNames = names.filter((n) => n.startsWith('favourite_'));
const allNames = names.filter((n) => n.startsWith('all_'));
chat.check('Has favourite/ tools', favNames.length, (v) => v >= 1);
chat.check('Has all/ catalog', allNames.length, (v) => v >= 1);
chat.check('favourite/read_documentation present', String(names.includes('favourite/read_documentation')), (v) => v === 'true');
chat.check('all/smoke-aws-docs/read_documentation present', String(names.includes('all/smoke-aws-docs/read_documentation')), (v) => v === 'true');
chat.check('Has favourite_ tools', favNames.length, (v) => v >= 1);
chat.check('Has all_ catalog', allNames.length, (v) => v >= 1);
chat.check('favourite_read_documentation present', String(names.includes('favourite_read_documentation')), (v) => v === 'true');
chat.check('all_smoke-aws-docs_read_documentation present', String(names.includes('all_smoke-aws-docs_read_documentation')), (v) => v === 'true');
// Favourites are listed before the all/ catalog.
const firstFav = names.findIndex((n) => n.startsWith('favourite/'));
const firstAll = names.findIndex((n) => n.startsWith('all/'));
chat.check('favourites precede all/', String(firstFav < firstAll), (v) => v === 'true');
// Favourites are listed before the all_ catalog.
const firstFav = names.findIndex((n) => n.startsWith('favourite_'));
const firstAll = names.findIndex((n) => n.startsWith('all_'));
chat.check('favourites precede all_', String(firstFav < firstAll), (v) => v === 'true');
expect(names).toContain('favourite/read_documentation');
expect(names).toContain('all/smoke-aws-docs/read_documentation');
expect(names).toContain('favourite_read_documentation');
expect(names).toContain('all_smoke-aws-docs_read_documentation');
expect(firstFav).toBeLessThan(firstAll);
} finally {
await chat.close();
}
}, 30_000);
it('routes favourite/ and all/ calls to the real upstream tool', async () => {
it('routes favourite_ and all_ calls to the real upstream tool', async () => {
if (!ready) return;
const chat = new ChatReporter(new SmokeMcpSession(PROJECT_NAME));
chat.section('favourite-index routing');
@@ -108,15 +108,15 @@ describe('Smoke: favourite-index presentation', () => {
// Calling with no args → the UPSTREAM tool's arg validation fires, proving
// the presented name routed to the real server (not a -32601 unknown tool).
const favRes = await chat.callTool('favourite/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
const allRes = await chat.callTool('all/smoke-aws-docs/read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
const favRes = await chat.callTool('favourite_read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
const allRes = await chat.callTool('all_smoke-aws-docs_read_documentation', {}, 20_000).catch((e: Error) => ({ error: e.message }));
const favStr = JSON.stringify(favRes).toLowerCase();
const allStr = JSON.stringify(allRes).toLowerCase();
// Reached the upstream (arg validation / real response), not "unknown tool".
const routed = (s: string): boolean => !s.includes('-32601') && !s.includes('unknown') && !s.includes('method not found');
chat.check('favourite/ routed to upstream', String(routed(favStr)), (v) => v === 'true');
chat.check('all/ routed to upstream', String(routed(allStr)), (v) => v === 'true');
chat.check('favourite_ routed to upstream', String(routed(favStr)), (v) => v === 'true');
chat.check('all_ routed to upstream', String(routed(allStr)), (v) => v === 'true');
expect(routed(favStr)).toBe(true);
expect(routed(allStr)).toBe(true);
} finally {

View File

@@ -12,6 +12,19 @@ servers:
env:
- name: FASTMCP_LOG_LEVEL
value: "ERROR"
# Mirrors the production `aws-docs` probe. Without it this fixture is a
# RUNNING server with no readiness probe, so it fails the very assertion in
# health-readiness.smoke.test.ts that the fixture exists to support — the
# suite reporting its own scaffolding as a fleet regression.
# `search_documentation` needs a phrase; the 300s interval matches aws-docs,
# since the call leaves the cluster.
healthCheck:
tool: search_documentation
arguments:
search_phrase: "s3 bucket"
timeoutSeconds: 20
intervalSeconds: 300
failureThreshold: 3
projects:
- name: smoke-data

View File

@@ -0,0 +1,185 @@
/**
* Smoke tests: readiness probes actually exercise a server's upstream.
*
* The bug these guard: every instance read `healthy` forever because the
* default probe is `tools/list`, which MCP servers answer from a static
* in-process table. The UniFi server sat green for months while every call to
* its controller timed out (pod egress was capped at 80/443, controller on
* :8443) and while its `controller_type` pointed at the wrong API dialect.
*
* So these tests assert the probe is a real round trip, not a self-report:
* 1. Servers with a `healthCheck.tool` really do reach their upstream when
* that tool is called through the production proxy path.
* 2. A server with no `healthCheck.tool` reports `live`, never `healthy` —
* "process up, function unverified" must not read as "working".
* 3. `tools/list` alone cannot distinguish the two, which is why (2) matters.
*
* Prerequisites:
* - mcplocal running on localhost:3200
* - mcpd reachable (k8s), servers deployed with readiness probes configured
*/
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const CONFIG_PATH = join(homedir(), '.mcpctl', 'config.json');
const CREDS_PATH = join(homedir(), '.mcpctl', 'credentials');
function loadConfig(): { mcpdUrl: string; token: string } {
let mcpdUrl = 'http://localhost:3100';
let token = '';
try {
if (existsSync(CONFIG_PATH)) {
const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as { mcpdUrl?: string };
if (cfg.mcpdUrl) mcpdUrl = cfg.mcpdUrl;
}
if (existsSync(CREDS_PATH)) {
const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8')) as { token?: string };
if (creds.token) token = creds.token;
}
} catch { /* use defaults */ }
return { mcpdUrl, token };
}
const { mcpdUrl, token } = loadConfig();
function mcpdRequest<T>(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
return new Promise((resolve, reject) => {
const url = new URL(path, mcpdUrl);
const transport = url.protocol === 'https:' ? https : http;
const headers: Record<string, string> = { Accept: 'application/json' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (token) headers['Authorization'] = `Bearer ${token}`;
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
if (bodyStr) headers['Content-Length'] = String(Buffer.byteLength(bodyStr));
const req = transport.request(url, { method, timeout: 60_000, headers, rejectUnauthorized: false }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try {
resolve({ status: res.statusCode ?? 500, data: raw ? JSON.parse(raw) as T : (undefined as T) });
} catch {
resolve({ status: res.statusCode ?? 500, data: raw as unknown as T });
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); });
if (bodyStr) req.write(bodyStr);
req.end();
});
}
interface HealthCheck {
tool?: string;
arguments?: Record<string, unknown>;
}
interface Server {
id: string;
name: string;
healthCheck: HealthCheck | null;
}
interface Instance {
id: string;
serverId: string;
status: string;
healthStatus: string | null;
server?: { name: string };
}
interface ProxyResult {
result?: { tools?: Array<{ name: string }>; isError?: boolean; content?: Array<{ text?: string }> };
error?: { code: number; message: string };
}
let servers: Server[] = [];
let instances: Instance[] = [];
beforeAll(async () => {
const s = await mcpdRequest<Server[]>('GET', '/api/v1/servers');
expect(s.status, `GET /api/v1/servers returned ${s.status}`).toBe(200);
servers = s.data;
const i = await mcpdRequest<Instance[]>('GET', '/api/v1/instances');
expect(i.status, `GET /api/v1/instances returned ${i.status}`).toBe(200);
instances = i.data;
}, 120_000);
describe('readiness probes reach the upstream', () => {
it('every RUNNING server has a readiness probe configured', () => {
const running = instances.filter((i) => i.status === 'RUNNING');
expect(running.length, 'no RUNNING instances to check').toBeGreaterThan(0);
const withoutProbe = running
.map((i) => servers.find((s) => s.id === i.serverId))
.filter((s): s is Server => s !== undefined)
.filter((s) => s.healthCheck?.tool === undefined)
.map((s) => s.name);
// A server with no readiness probe can only ever report `live`. That is
// honest, but it means nothing is watching its upstream — so the fleet
// should not accumulate them silently.
expect(withoutProbe, `servers with no healthCheck.tool: ${withoutProbe.join(', ')}`).toEqual([]);
});
it('each configured probe tool really answers through the proxy', async () => {
const probed = servers.filter((s) => s.healthCheck?.tool !== undefined);
expect(probed.length, 'no servers have readiness probes').toBeGreaterThan(0);
const failures: string[] = [];
for (const server of probed) {
const hc = server.healthCheck!;
const res = await mcpdRequest<ProxyResult>('POST', '/api/v1/mcp/proxy', {
serverId: server.id,
method: 'tools/call',
params: { name: hc.tool, arguments: hc.arguments ?? {} },
});
if (res.status !== 200) {
failures.push(`${server.name}/${hc.tool}: HTTP ${res.status}`);
continue;
}
if (res.data.error) {
failures.push(`${server.name}/${hc.tool}: ${res.data.error.message}`);
continue;
}
if (res.data.result?.isError === true) {
failures.push(`${server.name}/${hc.tool}: ${res.data.result.content?.[0]?.text ?? 'isError'}`);
}
}
// When this fails, the server is genuinely broken — fix the environment
// (credentials, egress, upstream address), never the assertion.
expect(failures, `readiness probe tools failing: ${failures.join(' | ')}`).toEqual([]);
}, 300_000);
it('a probe tool is a different call from tools/list, and both are reachable', async () => {
const server = servers.find((s) => s.healthCheck?.tool !== undefined);
expect(server, 'need at least one probed server').toBeDefined();
const list = await mcpdRequest<ProxyResult>('POST', '/api/v1/mcp/proxy', {
serverId: server!.id,
method: 'tools/list',
});
expect(list.status).toBe(200);
const toolNames = (list.data.result?.tools ?? []).map((t) => t.name);
// The probe must name a tool the server actually exposes, otherwise the
// readiness check fails for a bookkeeping reason rather than a real one.
expect(toolNames).toContain(server!.healthCheck!.tool);
}, 120_000);
it('no RUNNING instance is stuck at an unknown health status', () => {
const stuck = instances
.filter((i) => i.status === 'RUNNING')
.filter((i) => i.healthStatus === null || i.healthStatus === 'unknown')
.map((i) => i.server?.name ?? i.serverId);
expect(stuck, `instances with no health verdict: ${stuck.join(', ')}`).toEqual([]);
});
});

View File

@@ -261,6 +261,21 @@ export async function isMcplocalRunning(): Promise<boolean> {
}
}
/**
* Pull the drill-down id out of a paginated result or TOC.
*
* The instruction is written `_resultId="pm-abc"`, while the legacy paginator
* emits it as JSON (`"_resultId": "pm-abc"`). A regex matching only the JSON
* form silently found nothing in the current format, so drill-down checks
* either reported a false failure or skipped themselves as "not large enough
* for section-split" — both invisible in a green run. Accept either spelling
* and return the bare id.
*/
export function extractResultId(text: string): string | null {
const match = /_resultId"?\s*[:=]\s*"?(pm-[a-zA-Z0-9]+)/.exec(text);
return match?.[1] ?? null;
}
/**
* Run an mcpctl CLI command and return stdout.
*/

View File

@@ -14,7 +14,7 @@
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { execSync } from 'node:child_process';
@@ -87,9 +87,23 @@ describe('mcptoken smoke', () => {
}
}, 20_000);
// Cleanup belongs in afterAll, not an `it()`: a failing assertion earlier in
// the file must not strand server-side projects. Deliberately NOT gated on
// `gatewayUp` — the project is created through mcpd, which can be reachable
// even when the gateway healthz probe is not, and deleting something that was
// never created is a harmless no-op.
//
// `delete project` takes no --force (only `create project` does); passing it
// made every one of these calls exit non-zero and silently do nothing, which
// is how hundreds of smoke-* projects accumulated on the shared mcpd.
afterAll(() => {
run(`delete project ${PROJECT_NAME}`);
run(`delete project ${OTHER_PROJECT}`);
});
it('creates the project and a project-scoped mcptoken', () => {
if (!gatewayUp) return;
run(`delete project ${PROJECT_NAME} --force`); // cleanup leftovers — best-effort
run(`delete project ${PROJECT_NAME}`); // cleanup leftovers — best-effort
const createProj = run(`create project ${PROJECT_NAME} --force`);
expect(createProj.code).toBe(0);
@@ -149,10 +163,8 @@ describe('mcptoken smoke', () => {
expect(report.error ?? '').toMatch(/401|revoked|Invalid token/i);
}, 20_000);
it('cleans up test fixtures', () => {
it('recorded a tool name for the gated catalogue', () => {
if (!gatewayUp) return;
run(`delete project ${PROJECT_NAME} --force`);
run(`delete project ${OTHER_PROJECT} --force`);
expect(knownToolName === undefined || typeof knownToolName === 'string').toBe(true);
});
});

View File

@@ -81,17 +81,20 @@ describe('project-llm-ref smoke', () => {
}
}, 30_000);
// Not gated on `mcpdUp`: if creation got far enough to leave a project
// behind, cleanup must still run. Deleting a non-existent project is a no-op.
// (`delete project` takes no --force — passing it made these calls fail
// silently and leak a project per run.)
afterAll(() => {
if (!mcpdUp) return;
run(`delete project ${PROJ_OK} --force`);
run(`delete project ${PROJ_ORPHAN} --force`);
run(`delete project ${PROJ_NONE} --force`);
run(`delete project ${PROJ_OK}`);
run(`delete project ${PROJ_ORPHAN}`);
run(`delete project ${PROJ_NONE}`);
run(`delete llm ${LLM_NAME}`);
});
it('project with --llm pointing at a registered Llm describes without warning', () => {
if (!mcpdUp) return;
run(`delete project ${PROJ_OK} --force`);
run(`delete project ${PROJ_OK}`);
const created = run(`create project ${PROJ_OK} --llm ${LLM_NAME}`);
expect(created.code, created.stderr || created.stdout).toBe(0);
@@ -104,7 +107,7 @@ describe('project-llm-ref smoke', () => {
it('project with --llm naming an unregistered Llm shows the warning line', () => {
if (!mcpdUp) return;
run(`delete project ${PROJ_ORPHAN} --force`);
run(`delete project ${PROJ_ORPHAN}`);
const created = run(`create project ${PROJ_ORPHAN} --llm claude-ghost-${SUFFIX}`);
expect(created.code, created.stderr || created.stdout).toBe(0);
@@ -117,7 +120,7 @@ describe('project-llm-ref smoke', () => {
it('project with --llm none treats it as an explicit disable (no warning)', () => {
if (!mcpdUp) return;
run(`delete project ${PROJ_NONE} --force`);
run(`delete project ${PROJ_NONE}`);
const created = run(`create project ${PROJ_NONE} --llm none`);
expect(created.code).toBe(0);

View File

@@ -4,10 +4,10 @@
* 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';
import { SmokeMcpSession, isMcplocalRunning, extractResultId } from './mcp-client.js';
const PROJECT_NAME = 'smoke-data';
@@ -90,9 +90,8 @@ describe('Smoke: Prompt section drill-down', () => {
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];
const resultId = extractResultId(text);
if (resultId !== null) {
// Extract first section id from TOC
const sectionMatch = /\[([^\]]+)\]/.exec(text);
if (sectionMatch) {

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
@@ -17,7 +17,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { writeFile, mkdir, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { SmokeMcpSession, isMcplocalRunning, mcpctl } from './mcp-client.js';
import { SmokeMcpSession, isMcplocalRunning, mcpctl, extractResultId } from './mcp-client.js';
import { ChatReporter } from './reporter.js';
const PROJECT_NAME = 'smoke-data';
@@ -158,8 +158,8 @@ describe('Smoke: ProxyModel pipeline', () => {
const ungatedTools = await chat.listTools();
chat.check('Ungated tools > 1', ungatedTools.length, (v) => v > 1);
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs/'));
chat.check('Has smoke-aws-docs/* tools', awsTools.length, (v) => v > 0);
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs_'));
chat.check('Has smoke-aws-docs_* tools', awsTools.length, (v) => v > 0);
expect(ungatedTools.length).toBeGreaterThan(1);
} finally {
@@ -228,7 +228,7 @@ describe('Smoke: ProxyModel pipeline', () => {
it('has AWS documentation tools after ungating', async () => {
if (!serverResponding) return;
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs/'));
const awsTools = ungatedTools.filter((t) => t.name.startsWith('smoke-aws-docs_'));
chat.check('AWS docs tools available', awsTools.length, (v) => v > 0);
if (awsTools.length > 0) {
@@ -241,9 +241,9 @@ describe('Smoke: ProxyModel pipeline', () => {
it('can call an AWS documentation tool', async () => {
if (!serverResponding) return;
const searchTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/search_documentation');
const recommendTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/recommend');
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
const searchTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_search_documentation');
const recommendTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_recommend');
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
// Prefer search_documentation — most reliable (no URL format requirements)
const toolToTest = searchTool ?? recommendTool ?? readTool;
@@ -271,13 +271,13 @@ describe('Smoke: ProxyModel pipeline', () => {
it('large tool result gets paginated with _resultId', async () => {
if (!serverResponding) return;
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) {
chat.skip('read_documentation not available');
return;
}
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
});
const text = result.content[0]?.text ?? '';
@@ -286,8 +286,7 @@ describe('Smoke: ProxyModel pipeline', () => {
chat.check('Response is manageable size', text.length, (v) => v < 20_000);
if (text.includes('_resultId')) {
const match = text.match(/_resultId:\s*(\S+)/);
chat.check('_resultId is present', !!match, (v) => v === true);
chat.check('_resultId is present', extractResultId(text) !== null, (v) => v === true);
} else {
chat.info('Content small enough — no pagination needed');
}
@@ -296,26 +295,24 @@ describe('Smoke: ProxyModel pipeline', () => {
it('section drill-down via _resultId and _section', async () => {
if (!serverResponding) return;
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
const readTool = ungatedTools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) {
chat.skip('read_documentation not available');
return;
}
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
});
const text = result.content[0]?.text ?? '';
const match = text.match(/_resultId:\s*(\S+)/);
if (!match) {
const resultId = extractResultId(text);
if (resultId === null) {
chat.info('Content not large enough for pagination — skip drill-down');
return;
}
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
const sectionResult = await chat.callTool('smoke-aws-docs/read_documentation', {
const sectionResult = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
_resultId: resultId,
_section: 'page-1',
@@ -386,13 +383,13 @@ describe('Smoke: ProxyModel pipeline', () => {
it('subindex model produces structural sections (not flat pages)', async () => {
if (!serverResponding) return;
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs/read_documentation');
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) {
chat.skip('read_documentation not available');
return;
}
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
});
const text = result.content[0]?.text ?? '';
@@ -401,34 +398,31 @@ describe('Smoke: ProxyModel pipeline', () => {
chat.check('Response is manageable size', text.length, (v) => v < 20_000);
if (text.includes('_resultId')) {
const match = text.match(/_resultId:\s*(\S+)/);
chat.check('Has _resultId for drill-down', !!match, (v) => v === true);
chat.check('Has _resultId for drill-down', extractResultId(text) !== null, (v) => v === true);
}
}, 60_000);
it('subindex drill-down returns section content', async () => {
if (!serverResponding) return;
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs/read_documentation');
const readTool = (await chat.listTools()).find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) {
chat.skip('read_documentation not available');
return;
}
const result = await chat.callTool('smoke-aws-docs/read_documentation', {
const result = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
});
const text = result.content[0]?.text ?? '';
const match = text.match(/_resultId:\s*(\S+)/);
if (!match) {
const resultId = extractResultId(text);
if (resultId === null) {
chat.info('Content not large enough for section-split');
return;
}
const resultId = match[1]!.replace(/[^a-zA-Z0-9-]/g, '');
const sectionResult = await chat.callTool('smoke-aws-docs/read_documentation', {
const sectionResult = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
_resultId: resultId,
_section: 'section-0',
@@ -482,14 +476,14 @@ describe('Smoke: ProxyModel pipeline', () => {
if (!serverResponding) return;
const tools = await chat.listTools();
const readTool = tools.find((t) => t.name === 'smoke-aws-docs/read_documentation');
const readTool = tools.find((t) => t.name === 'smoke-aws-docs_read_documentation');
if (!readTool) {
chat.skip('read_documentation not available');
return;
}
chat.info('Call 1: using default model (passthrough + paginate)');
const result1 = await chat.callTool('smoke-aws-docs/read_documentation', {
const result1 = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
});
const text1 = result1.content[0]?.text ?? '';
@@ -516,7 +510,7 @@ describe('Smoke: ProxyModel pipeline', () => {
);
chat.info('Call 2: using new model (should produce different output)');
const result2 = await chat.callTool('smoke-aws-docs/read_documentation', {
const result2 = await chat.callTool('smoke-aws-docs_read_documentation', {
url: 'https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html',
});
const text2 = result2.content[0]?.text ?? '';

View File

@@ -0,0 +1,133 @@
/**
* Smoke tests: secret-backend health honesty + value caching, against live mcpd.
*
* Covers the two behaviours that unit tests cannot prove, because both are
* about what the REAL backend and the REAL CLI do together:
*
* 1. `mcpctl status` reports a probed verdict, not a hard-coded tick. The bug
* being guarded is that the verdict used to come from
* `tokenMeta.lastRotationError`, which a `kubernetes`-auth backend never
* writes — so the line was structurally incapable of going red.
* 2. The value cache does not corrupt reads, and a delete really evicts.
*
* Deliberately does NOT take the real backend down. Simulating an outage
* against shared infrastructure to satisfy a test would be worse than the bug.
*
* Target: mcpd direct (`--direct`), same skip-if-unreachable discipline as the
* other smokes here.
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { execSync } from 'node:child_process';
const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const SECRET_NAME = `smoke-cache-${Date.now().toString(36)}`;
interface CliResult { code: number; stdout: string; stderr: string }
function run(args: string): CliResult {
try {
return { code: 0, stdout: execSync(`mcpctl --direct ${args}`, { encoding: 'utf-8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] }).trim(), stderr: '' };
} catch (err) {
const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string };
return {
code: e.status ?? 1,
stdout: e.stdout ? String(e.stdout) : '',
stderr: e.stderr ? String(e.stderr) : '',
};
}
}
function healthz(url: string, timeoutMs = 5000): Promise<boolean> {
return new Promise((resolve) => {
const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`);
const driver = parsed.protocol === 'https:' ? https : http;
const req = driver.get(
{ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: parsed.pathname, timeout: timeoutMs },
(res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); },
);
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
});
}
let mcpdUp = false;
describe('secret resilience smoke', () => {
beforeAll(async () => {
mcpdUp = await healthz(MCPD_URL);
if (!mcpdUp) {
// eslint-disable-next-line no-console
console.warn(`\n ○ secret resilience smoke: skipped — ${MCPD_URL}/healthz unreachable. Set MCPD_URL to override.\n`);
}
}, 20_000);
afterAll(() => {
if (!mcpdUp) return;
run(`delete secret ${SECRET_NAME}`);
});
it('status reports a probed backend verdict, not an unconditional tick', () => {
if (!mcpdUp) return;
const result = run('status');
expect(result.code, result.stderr).toBe(0);
const line = result.stdout.split('\n').find((l) => l.startsWith('Secrets:'));
expect(line, 'status must include a Secrets: line').toBeDefined();
// The verdict must be one the live probe can produce. A bare "name ✓" with
// no qualifier is the OLD rendering and means the probe was not consulted.
expect(line).toMatch(/reachable|degraded|unreachable|auth failed|unknown/);
});
it('reports live and ready separately per backend in JSON output', () => {
if (!mcpdUp) return;
const result = run('status -o json');
expect(result.code, result.stderr).toBe(0);
const parsed = JSON.parse(result.stdout) as {
secretBackends?: Array<{ name: string; healthy: boolean; live: boolean | null; ready: boolean | null }>;
};
expect(parsed.secretBackends, 'JSON status must carry secretBackends').toBeDefined();
for (const b of parsed.secretBackends ?? []) {
// Both signals present and independent — not one boolean copied twice.
expect(b, `backend ${b.name}`).toHaveProperty('live');
expect(b, `backend ${b.name}`).toHaveProperty('ready');
expect(b.healthy).toBe(b.live === true && b.ready === true);
}
});
it('caching does not corrupt repeated reads, and delete evicts', () => {
if (!mcpdUp) return;
const created = run(`create secret ${SECRET_NAME} --data TOKEN=cache-probe-value`);
expect(created.code, created.stderr).toBe(0);
// Two reads back-to-back: the second is a cache hit. Both must agree.
const first = run(`describe secret ${SECRET_NAME} --show-values`);
const second = run(`describe secret ${SECRET_NAME} --show-values`);
expect(first.code, first.stderr).toBe(0);
expect(second.code, second.stderr).toBe(0);
expect(first.stdout).toContain('cache-probe-value');
expect(second.stdout).toContain('cache-probe-value');
// Delete must evict — a cached value surviving a delete is exactly the
// "resurrected revoked credential" failure the cache guards against.
const deleted = run(`delete secret ${SECRET_NAME}`);
expect(deleted.code, deleted.stderr).toBe(0);
const after = run(`describe secret ${SECRET_NAME} --show-values`);
expect(after.code, 'reading a deleted secret must fail, not serve cache').not.toBe(0);
});
it('exposes the per-backend health endpoint used by status', () => {
if (!mcpdUp) return;
const backends = run('get secretbackends -o json');
expect(backends.code, backends.stderr).toBe(0);
const rows = JSON.parse(backends.stdout) as Array<{ id: string; name: string }>;
expect(rows.length).toBeGreaterThan(0);
// describe must surface the same probe, for every backend type — the old
// Token health block was gated on tokenMeta.rotatable and so rendered
// nothing at all for kubernetes-auth backends.
const described = run(`describe secretbackend ${rows[0]?.name ?? ''}`);
expect(described.code, described.stderr).toBe(0);
});
});

View File

@@ -32,6 +32,18 @@ function httpRequest(opts: {
headers?: Record<string, string>;
body?: string;
timeout?: number;
/**
* Resolve as soon as the response headers arrive, then hang up, instead of
* waiting for the body to end.
*
* Required for a streaming endpoint: SSE responses never end, so the normal
* path can only settle via the socket's *inactivity* timeout — which never
* fires while the stream is busy. `/inspect` relays every project's MCP
* traffic, so during a full smoke run it is never idle, and the request hung
* until vitest killed the test. Alone it looked flaky; under load it failed
* every time. Reading the status does not need the body anyway.
*/
headersOnly?: boolean;
}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(opts.url);
@@ -46,6 +58,12 @@ function httpRequest(opts: {
timeout: opts.timeout ?? 10_000,
},
(res) => {
if (opts.headersOnly === true) {
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: '' });
res.destroy();
req.destroy();
return;
}
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
@@ -93,17 +111,15 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => {
// /inspect streams ALL MCP traffic (tool calls, arguments, responses)
// for ALL projects to any unauthenticated local client
// headersOnly: the stream never ends, and waiting for it to go idle is what
// made this hang whenever other suites were generating traffic. The status
// line is all this assertion needs.
const res = await httpRequest({
url: `${MCPLOCAL_URL}/inspect`,
method: 'GET',
headers: { 'Accept': 'text/event-stream' },
timeout: 3_000,
}).catch((err) => {
// Timeout is expected (SSE keeps connection open) — still means endpoint is accessible
if ((err as Error).message.includes('timed out')) {
return { status: 200, headers: {} as http.IncomingHttpHeaders, body: '' };
}
throw err;
headersOnly: true,
});
// Should be accessible without auth (documenting the vulnerability)

View File

@@ -0,0 +1,136 @@
/**
* 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;
required?: 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('required params never block a drill-down call', async () => {
if (!available) return;
// A drill-down carries only _resultId/_section. If the advertised schema
// still demands the upstream's own required params (websearch's `url`), a
// validating client rejects the call the stub just asked for — "Received
// tool input did not match expected schema".
// A top-level `required` is ANDed with everything else in the schema, so
// any entry there is unsatisfiable for a drill-down. The upstream's
// requirements live in an `anyOf` alternation instead.
const blocked = tools
.filter((t) => {
if (GATE_TOOLS.has(t.name)) return false;
const required = t.inputSchema?.required;
return Array.isArray(required) && required.length > 0;
})
.map((t) => t.name);
expect(blocked).toEqual([]);
});
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();
}
});
});

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { createStageBudget, unlimitedStageBudget, STAGE_LLM_BUDGET_MS } from '../src/proxymodel/stage-budget.js';
import { getStage } from '../src/proxymodel/stage-registry.js';
import '../src/proxymodel/stages/index.js';
import type { StageContext, LLMProvider, CacheProvider, StageLogger } from '../src/proxymodel/types.js';
afterEach(() => { vi.restoreAllMocks(); });
function ctxWith(llm: LLMProvider, original: string, budgetMs: number): StageContext {
const store = new Map<string, string>();
const cache: CacheProvider = {
async getOrCompute(k, c) {
if (store.has(k)) return store.get(k)!;
const v = await c(); store.set(k, v); return v;
},
hash: (c) => c.slice(0, 8),
async get(k) { return store.get(k) ?? null; },
async set(k, v) { store.set(k, v); },
};
const log: StageLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return {
contentType: 'toolResult', sourceName: 'test/tool', projectName: 'test', sessionId: 's1',
originalContent: original, llm, cache, log,
getSystemPrompt: async (_n, f) => f,
config: {},
budget: createStageBudget(budgetMs),
};
}
/** Never settles and ignores the signal — the shape that caused the outage. */
const hangingLlm: LLMProvider = {
complete: () => new Promise<string>(() => { /* never */ }),
available: () => true,
};
describe('StageBudget', () => {
it('reports remaining time and expires', async () => {
const b = createStageBudget(60);
expect(b.exhausted()).toBe(false);
expect(b.remainingMs()).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 90));
expect(b.exhausted()).toBe(true);
expect(b.remainingMs()).toBe(0);
expect(b.signal.aborted).toBe(true);
b.dispose();
});
it('unlimited budget never expires', () => {
const b = unlimitedStageBudget();
expect(b.exhausted()).toBe(false);
expect(b.remainingMs()).toBe(Number.POSITIVE_INFINITY);
b.dispose();
});
it('defaults to the documented 30s', () => {
expect(STAGE_LLM_BUDGET_MS).toBe(30_000);
});
});
describe('paginate under a hung LLM', () => {
it('returns numbered pages within the budget instead of hanging', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const handler = getStage('paginate')!;
const content = 'x'.repeat(30_000);
const ctx = ctxWith(hangingLlm, content, 200);
const start = Date.now();
const result = await handler(content, ctx);
const elapsed = Date.now() - start;
// The whole point: this used to never return at all.
expect(elapsed).toBeLessThan(3000);
expect(result.content).toContain('⚠ Smart page titles unavailable');
expect(result.content).toContain('page-1');
expect(result.metadata?.['degraded']).toBe(true);
ctx.budget.dispose();
});
it('serves a warm cache even with an exhausted budget', async () => {
const handler = getStage('paginate')!;
const content = 'y'.repeat(30_000);
// Warm the cache with a working LLM and a generous budget.
const good: LLMProvider = {
complete: async () => '["Alpha","Beta","Gamma","Delta"]',
available: () => true,
};
const warm = ctxWith(good, content, 5000);
await handler(content, warm);
// Same cache, no budget left: the cached titles must still be used.
const cold: StageContext = { ...warm, llm: hangingLlm, budget: createStageBudget(1) };
await new Promise((r) => setTimeout(r, 10));
const result = await handler(content, cold);
expect(result.content).toContain('Alpha');
expect(result.content).not.toContain('⚠');
warm.budget.dispose();
cold.budget.dispose();
});
});
describe('summarize-tree under a hung LLM', () => {
it('bounds the whole recursion, not each call', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const handler = getStage('summarize-tree')!;
// Many sections: pre-fix, a per-call timeout would multiply across the
// sequential loop AND the recursion (maxDepth 3).
const sections = Array.from({ length: 20 }, (_, i) =>
`## Section ${i + 1}\n${'prose '.repeat(400)}`).join('\n\n');
const ctx = ctxWith(hangingLlm, sections, 300);
const start = Date.now();
const result = await handler(sections, ctx);
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
expect(result.content).toContain('⚠ Smart summaries unavailable');
expect(result.metadata?.['degraded']).toBe(true);
ctx.budget.dispose();
});
});

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
import type { StageContext, LLMProvider, CacheProvider, StageLogger, SystemPromptFetcher } from '../src/proxymodel/types.js';
import paginate from '../src/proxymodel/stages/paginate.js';
import summarizeTree from '../src/proxymodel/stages/summarize-tree.js';
import { unlimitedStageBudget } from '../src/proxymodel/stage-budget.js';
function mockCtx(
original: string,
@@ -50,6 +51,7 @@ function mockCtx(
cache: mockCache,
log: mockLog,
getSystemPrompt: opts.getSystemPrompt ?? (async (_name, fallback) => fallback),
budget: unlimitedStageBudget(),
config,
};
}

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
import type { McpRouter } from '../src/router.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
/**
* The guarantee this whole change exists for: a tool call whose upstream never
* settles must still produce a response. Before the watchdog, onmessage simply
* never reached transport.send(), the hijacked socket stayed silent, and the
* client waited out its own 1800s timeout — which is precisely what happened
* in production three times.
*/
vi.mock('../src/discovery.js', () => ({
refreshProjectUpstreams: vi.fn(async (router: McpRouter) => {
router.addUpstream({
name: 'blackhole',
send: async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
if (req.method === 'tools/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: [{ name: 'wedge', description: 'never returns', inputSchema: { type: 'object' } }] },
};
}
// A call that never settles and never errors.
return new Promise<JsonRpcResponse>(() => { /* the hang */ });
},
close: async () => { /* noop */ },
isAlive: () => true,
});
return ['blackhole'];
}),
fetchProjectLlmConfig: vi.fn(async () => ({ gated: false, llmProvider: 'none' })),
}));
vi.mock('../src/http/config.js', async () => {
const actual = await vi.importActual<typeof import('../src/http/config.js')>('../src/http/config.js');
return { ...actual, loadProjectLlmOverride: vi.fn(() => undefined) };
});
function mockMcpdClient(): Record<string, unknown> {
const client: Record<string, unknown> = {
baseUrl: 'http://test:3100', token: 't',
get: vi.fn(async () => []), post: vi.fn(async () => ({})),
put: vi.fn(), delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(), withToken: vi.fn(), withTimeout: vi.fn(),
};
for (const k of ['withHeaders', 'withToken', 'withTimeout']) {
(client[k] as ReturnType<typeof vi.fn>).mockReturnValue(client);
}
return client;
}
function parseSse(body: string): JsonRpcResponse {
const line = body.split('\n').find((l) => l.startsWith('data: '));
if (!line) throw new Error(`no SSE data line in: ${body}`);
return JSON.parse(line.slice('data: '.length)) as JsonRpcResponse;
}
beforeEach(() => { vi.stubEnv('MCPLOCAL_TOOLCALL_DEADLINE_MS', '300'); vi.resetModules(); });
describe('tool-call watchdog, end to end', () => {
it('answers a wedged tool call instead of leaving the client hanging', async () => {
vi.spyOn(console, 'error').mockImplementation(() => { /* quiet */ });
const { registerProjectMcpEndpoint: register } =
await import('../src/http/project-mcp-endpoint.js');
const app = Fastify();
register(app, mockMcpdClient() as never);
await app.ready();
try {
const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' };
const init = await app.inject({
method: 'POST', url: '/projects/wedge-test/mcp', headers,
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '0' } } },
});
const sessionId = init.headers['mcp-session-id'] as string;
expect(sessionId).toBeTruthy();
// tools/list first: the wire-name codec learns the mapping there.
await app.inject({
method: 'POST', url: '/projects/wedge-test/mcp',
headers: { ...headers, 'mcp-session-id': sessionId },
payload: { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} },
});
const start = Date.now();
const res = await app.inject({
method: 'POST', url: '/projects/wedge-test/mcp',
headers: { ...headers, 'mcp-session-id': sessionId },
payload: { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'blackhole_wedge', arguments: {} } },
});
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(5000);
const body = parseSse(res.body);
expect(body.id).toBe(3);
// A tools/call comes back as a RESULT with isError, not a transport
// error: it is text the model reads and acts on.
const result = body.result as { content?: Array<{ text?: string }>; isError?: boolean };
expect(result.isError).toBe(true);
const text = result.content?.[0]?.text ?? '';
expect(text).toContain('⚠');
expect(text).toContain('mcplocal deadline');
// The trace code must be present and actionable.
expect(text).toMatch(/mcpctl trace [0-9ABCDEFGHJKMNPQRSTVWXYZ]{8}/);
} finally {
await app.close();
}
});
});

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
createRequestDeadline,
DeadlineExceededError,
TOOLCALL_DEADLINE_MS,
} from '../src/http/deadline.js';
import { TOOLCALL_TIMEOUT_MS } from '../src/http/mcpd-client.js';
afterEach(() => { vi.restoreAllMocks(); });
describe('TOOLCALL_DEADLINE_MS', () => {
it('exceeds mcpd\'s own tool-call timeout', () => {
// The watchdog arms earlier in the request than mcpd's fetch does. At equal
// values it would always fire first, masking mcpd's specific
// UpstreamTimeoutError with a generic deadline message and cutting off
// pagination and the rest of the post-processing.
expect(TOOLCALL_DEADLINE_MS).toBeGreaterThan(TOOLCALL_TIMEOUT_MS);
});
it('stays well below a typical client timeout so our error arrives first', () => {
expect(TOOLCALL_DEADLINE_MS).toBeLessThan(600_000);
});
});
describe('createRequestDeadline', () => {
it('rejects with DeadlineExceededError when the budget runs out', async () => {
const d = createRequestDeadline('tools/call', 40);
await expect(Promise.race([
new Promise<never>(() => { /* the hang we are protecting against */ }),
d.expiry,
])).rejects.toBeInstanceOf(DeadlineExceededError);
d.dispose();
});
it('does not fire once disposed', async () => {
const d = createRequestDeadline('tools/call', 30);
d.dispose();
const outcome = await Promise.race([
d.expiry.then(() => 'fired', () => 'fired'),
new Promise((r) => setTimeout(() => { r('quiet'); }, 120)),
]);
expect(outcome).toBe('quiet');
});
it('stops the clock while suspended, so an operator pause cannot time out', async () => {
const d = createRequestDeadline('tools/call', 100);
// A pause queue wait far longer than the whole deadline.
const held = await d.suspended(async () => {
await new Promise((r) => setTimeout(r, 250));
return 'released';
});
expect(held).toBe('released');
// Still alive afterwards, with roughly the original budget left.
const outcome = await Promise.race([
d.expiry.then(() => 'fired', () => 'fired'),
new Promise((r) => setTimeout(() => { r('still-running'); }, 40)),
]);
expect(outcome).toBe('still-running');
d.dispose();
});
it('re-arms after suspension rather than becoming immortal', async () => {
const d = createRequestDeadline('tools/call', 60);
await d.suspended(async () => { await new Promise((r) => setTimeout(r, 100)); });
// The remaining budget must still expire — suspending is not a bypass.
await expect(Promise.race([
new Promise<never>(() => { /* hang */ }),
d.expiry,
])).rejects.toBeInstanceOf(DeadlineExceededError);
d.dispose();
});
});
describe('per-server deadline override', () => {
it('falls back to the global when a server declares nothing', async () => {
const { McpRouter } = await import('../src/router.js');
const router = new McpRouter();
expect(router.getToolCallDeadlineMs('docmost/search')).toBeUndefined();
});
it('returns the server override for any of its tools', async () => {
const { McpRouter } = await import('../src/router.js');
const router = new McpRouter();
router.setServerDeadline('slowserver', 300_000);
// Resolved from the canonical `server/tool` name, so every tool on that
// server inherits it without being enumerated.
expect(router.getToolCallDeadlineMs('slowserver/render')).toBe(300_000);
expect(router.getToolCallDeadlineMs('slowserver/export')).toBe(300_000);
expect(router.getToolCallDeadlineMs('other/tool')).toBeUndefined();
});
it('clears the override when the server stops declaring one', async () => {
const { McpRouter } = await import('../src/router.js');
const router = new McpRouter();
router.setServerDeadline('slowserver', 300_000);
router.setServerDeadline('slowserver', undefined);
expect(router.getToolCallDeadlineMs('slowserver/render')).toBeUndefined();
});
});

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi } from 'vitest';
import { sanitizeWireName, WireNameCodec, routeWithWireNames } from '../src/util/wire-names.js';
import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
describe('sanitizeWireName', () => {
it('replaces slashes with underscores', () => {
expect(sanitizeWireName('websearch/fetch_content')).toBe('websearch_fetch_content');
expect(sanitizeWireName('all/websearch/fetch_content')).toBe('all_websearch_fetch_content');
});
it('keeps names that are already OpenAI-safe', () => {
expect(sanitizeWireName('begin_session')).toBe('begin_session');
expect(sanitizeWireName('my-grafana.tool')).toBe('my-grafana.tool');
});
it('replaces every character outside [A-Za-z0-9_.-]', () => {
expect(sanitizeWireName('a b:c/d')).toBe('a_b_c_d');
});
});
describe('WireNameCodec', () => {
it('round-trips a namespaced tool name', () => {
const codec = new WireNameCodec();
const wire = codec.encodeName('websearch/fetch_content');
expect(wire).toBe('websearch_fetch_content');
expect(codec.decodeName(wire)).toBe('websearch/fetch_content');
});
it('is stable across repeated encodes', () => {
const codec = new WireNameCodec();
expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read');
expect(codec.encodeName('searxng/web_url_read')).toBe('searxng_web_url_read');
});
it('passes unknown inbound names through unchanged', () => {
const codec = new WireNameCodec();
// Legacy client echoing the slash form, or a virtual tool never listed.
expect(codec.decodeName('websearch/fetch_content')).toBe('websearch/fetch_content');
expect(codec.decodeName('begin_session')).toBe('begin_session');
});
it('suffixes on collision, first registration wins the plain name', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const codec = new WireNameCodec();
expect(codec.encodeName('foo_bar/baz')).toBe('foo_bar_baz');
expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2');
// Both decode back to their own presented names.
expect(codec.decodeName('foo_bar_baz')).toBe('foo_bar/baz');
expect(codec.decodeName('foo_bar_baz_2')).toBe('foo/bar_baz');
// And stay stable.
expect(codec.encodeName('foo/bar_baz')).toBe('foo_bar_baz_2');
expect(warn).toHaveBeenCalledOnce();
} finally {
warn.mockRestore();
}
});
it('encodes tools/list responses and leaves other fields intact', () => {
const codec = new WireNameCodec();
const response: JsonRpcResponse = {
jsonrpc: '2.0',
id: 1,
result: {
tools: [
{ name: 'websearch/fetch_content', description: 'fetch', inputSchema: { type: 'object' } },
{ name: 'begin_session', description: 'gate' },
],
},
};
const encoded = codec.encodeToolsList(response);
const tools = (encoded.result as { tools: Array<{ name: string; description?: string }> }).tools;
expect(tools.map((t) => t.name)).toEqual(['websearch_fetch_content', 'begin_session']);
expect(tools[0]?.description).toBe('fetch');
// Original response object is not mutated.
const originalTools = (response.result as { tools: Array<{ name: string }> }).tools;
expect(originalTools[0]?.name).toBe('websearch/fetch_content');
});
it('returns error and non-list responses unchanged', () => {
const codec = new WireNameCodec();
const err: JsonRpcResponse = { jsonrpc: '2.0', id: 1, error: { code: -32603, message: 'boom' } };
expect(codec.encodeToolsList(err)).toBe(err);
const other: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { content: [] } };
expect(codec.encodeToolsList(other)).toBe(other);
});
it('decodes tools/call requests for known wire names only', () => {
const codec = new WireNameCodec();
codec.encodeName('websearch/fetch_content');
const known: JsonRpcRequest = {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } },
};
const decoded = codec.decodeToolCall(known);
expect(decoded.params?.['name']).toBe('websearch/fetch_content');
expect(decoded.params?.['arguments']).toEqual({ url: 'https://x' });
// Original request object is not mutated.
expect(known.params?.['name']).toBe('websearch_fetch_content');
const unknown: JsonRpcRequest = {
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'not_listed', arguments: {} },
};
expect(codec.decodeToolCall(unknown)).toBe(unknown);
});
});
describe('routeWithWireNames', () => {
const listResponse: JsonRpcResponse = {
jsonrpc: '2.0',
id: 1,
result: { tools: [{ name: 'websearch/fetch_content' }, { name: 'searxng/web_url_read' }] },
};
it('serves wire-safe names on tools/list and maps tools/call back', async () => {
const codec = new WireNameCodec();
const seen: JsonRpcRequest[] = [];
const route = async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
seen.push(req);
if (req.method === 'tools/list') return listResponse;
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'ok' }] } };
};
const listed = await routeWithWireNames(codec, route, { jsonrpc: '2.0', id: 1, method: 'tools/list' });
const names = (listed.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);
expect(names).toEqual(['websearch_fetch_content', 'searxng_web_url_read']);
// The exact scenario from the librechat incident: the model echoes the
// wire name; the router must receive the canonical name.
await routeWithWireNames(codec, route, {
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'websearch_fetch_content', arguments: { url: 'https://x' } },
});
expect(seen[1]?.params?.['name']).toBe('websearch/fetch_content');
});
it('keeps legacy slash-name calls working', async () => {
const codec = new WireNameCodec();
const seen: JsonRpcRequest[] = [];
const route = async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
seen.push(req);
return { jsonrpc: '2.0', id: req.id, result: {} };
};
await routeWithWireNames(codec, route, {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'websearch/fetch_content', arguments: {} },
});
expect(seen[0]?.params?.['name']).toBe('websearch/fetch_content');
});
it('does not touch other methods', async () => {
const codec = new WireNameCodec();
const init: JsonRpcRequest = { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} };
const response: JsonRpcResponse = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2024-11-05' } };
const out = await routeWithWireNames(codec, async () => response, init);
expect(out).toBe(response);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { withTimeout, TimeoutError } from '../src/util/with-timeout.js';
import { withTimeout, TimeoutError, anySignal } from '../src/util/with-timeout.js';
describe('withTimeout', () => {
it('resolves when the operation finishes in time', async () => {
@@ -33,3 +33,34 @@ describe('withTimeout', () => {
).rejects.toThrow('boom');
});
});
describe('anySignal', () => {
it('returns the timeout signal unchanged when there is no caller signal', () => {
const c = new AbortController();
expect(anySignal(c.signal)).toBe(c.signal);
});
it('aborts when the timeout signal aborts', () => {
const timeout = new AbortController();
const caller = new AbortController();
const merged = anySignal(timeout.signal, caller.signal);
expect(merged.aborted).toBe(false);
timeout.abort();
expect(merged.aborted).toBe(true);
});
it('aborts when the caller signal aborts', () => {
const timeout = new AbortController();
const caller = new AbortController();
const merged = anySignal(timeout.signal, caller.signal);
caller.abort();
expect(merged.aborted).toBe(true);
});
it('is already aborted when an input was aborted before merging', () => {
const timeout = new AbortController();
const caller = new AbortController();
caller.abort();
expect(anySignal(timeout.signal, caller.signal).aborted).toBe(true);
});
});