Audit — R93 B1 outputSchema SDK Passthrough Envelope Mismatch
Round: R93 debug-sweep (fix #1 of 6)
Branch: feature/r93-b1-output-schema-envelope
Base SHA: fbc8808a (R92 close)
Source authority: Live MCP audit at fbc8808a (debug session 2026-05-13)
β task: 6b2da36a-8f1d-4a90-95c1-ac549b3fd60e
§1. Goal
Eliminate the MCP -32602 output-validation errors that affect 8 of the 27 shipped Phase 0+ tools. Tools that declare an outputSchema are unusable through the standard MCP wire today; the SDK rejects the response before it reaches the caller. The fix must preserve the existing α envelope contract ({ok, data} on success, {ok, error} on failure) and must not regress any of the 3492 currently-green tests across 79 suites.
§2. Current state (read-only)
§2.1. The α middleware envelope
src/server.ts:282-413 defines registerColibriTool. The wrappedHandler (lines 299-394) executes the 5-stage chain (tool-lock → schema-validate → audit-enter → dispatch → audit-exit). On success (line 357-361):
const envelope = { ok: true as const, data: result };
return {
content: [{ type: 'text', text: JSON.stringify(envelope) }],
structuredContent: envelope,
};
On failure (line 362-375), the envelope is {ok: false, error: {code: 'HANDLER_ERROR', message}}. On Zod input failure (line 309-336), it is {ok: false, error: {code: 'INVALID_PARAMS', message, details: {issues}}}. Every success path produces a structuredContent of shape {ok, data}.
§2.2. The SDK registration call
Lines 396-412:
ctx.server.registerTool(
name,
{
title: toolConfig.title ?? name,
...(toolConfig.description !== undefined
? { description: toolConfig.description }
: {}),
inputSchema: toolConfig.inputSchema.shape,
...(toolConfig.outputSchema !== undefined
? { outputSchema: toolConfig.outputSchema.shape }
: {}),
},
wrappedHandler as never,
);
When toolConfig.outputSchema is defined, its .shape (the raw field definitions of the Zod object) is passed to the MCP SDK as the tool’s declared outputSchema. The SDK uses this to validate the structuredContent field of every response — at server-emit time, before the response leaves the process.
§2.3. The conflict
The outputSchema callers pass describes the unwrapped handler result (e.g. router {models, …}, consensus {round_id, level, evidence?}, vrf {output_hex, proof_hex}). The α middleware emits {ok, data: <unwrapped result>}. The SDK validates the envelope against the unwrapped schema → top-level required fields (models, winner, scores, output_hex, …) are seen as undefined → SDK throws MCP error -32602: Output validation error.
§3. Affected tool surface
Grep for outputSchema in src/domains/** (verified at fbc8808a):
| File | Tool | outputSchema |
|---|---|---|
src/domains/router/tools.ts:442 |
router_score |
RouterScoreOutputSchema (lines 211-217) |
src/domains/router/tools.ts:468 |
router_fallback |
RouterFallbackOutputSchema (lines 234-238) |
src/domains/router/tools.ts:481 |
router_stats |
RouterStatsOutputSchema (lines 258-262) |
src/domains/consensus/tools.ts:560 |
consensus_propose |
ConsensusProposeOutputSchema (lines 254-259) |
src/domains/consensus/tools.ts:573 |
consensus_vote |
ConsensusVoteOutputSchema (lines 261-266) |
src/domains/consensus/tools.ts:586 |
consensus_finality |
ConsensusFinalityOutputSchema (lines 268-274) |
src/domains/consensus/tools.ts:599 |
consensus_gossip |
ConsensusGossipOutputSchema (lines 276-281) |
src/domains/consensus/tools.ts:612 |
vrf_eval |
VrfEvalOutputSchema (lines 283-288) |
router_call is the only outputSchema-free tool in src/domains/router/tools.ts — intentionally omitted per the comment at line 264-267 (“router_call’s output schema is omitted: RouteResult.content is a variable shape from different upstream adapters…”).
The 19 other shipped tools (system × 2, β × 5, ε × 1, ζ × 3, η × 3, λ × 4, plus router_call) do not declare outputSchema and therefore evade the bug.
§4. Live reproduction (debug session 2026-05-13)
mcp__colibri__router_stats {}
→ MCP error -32602: Output validation error: path:["models"], received:"undefined"
mcp__colibri__router_score {"prompt": "haiku"}
→ MCP error -32602: paths:["scores","winner","rule_version_hash"]
mcp__colibri__router_fallback {}
→ MCP error -32602: path:["circuitState"]
mcp__colibri__consensus_gossip {"peer_id": "phase0-test"}
→ MCP error -32602: paths:["events_sent","events_received"]
mcp__colibri__vrf_eval {seed_hex, input_hex, priv_key_hex}
→ MCP error -32602: paths:["output_hex","proof_hex"]
The unwrapped handler returns are demonstrably correct (e.g. cost.ts:405-429 getRouterStats() always returns {models: <object>}). The error is at the SDK validation layer, not in any domain code.
§5. Test-coverage gap
src/__tests__/server.test.ts:441-457 is the only test that touches outputSchema:
it('accepts an optional description and outputSchema', () => {
const ctx = freshCtx();
expect(() =>
registerColibriTool(ctx, 'full_tool', {
title: 'Full Tool',
description: 'a tool with everything',
inputSchema: z.object({ x: z.string() }),
outputSchema: z.object({ y: z.number() }),
}, async ({ x }) => ({ y: x.length })),
).not.toThrow();
});
The test asserts registration acceptance only. It does not invoke the tool through a transport, so the SDK’s structuredContent validator is never exercised. 3492 tests across 79 suites pass, none of them cover this code path.
The e2e harness makeLinkedPair at src/__tests__/server.test.ts:115-177 (used by the server_ping e2e at line 465 and the bootstrap e2es at lines 1261-1351) is the right vehicle for a regression test — it gives a real Client + real InMemoryTransport pair.
§6. Domain-tool tests probe the wrong layer
src/domains/router/__tests__/tools.test.ts and src/domains/consensus/__tests__/tools.test.ts (assumed present given the 3492-test baseline) exercise the handler functions directly (routerStats(input), consensusGossip(input), …), bypassing registerColibriTool and therefore the SDK validation path. Direct-handler tests cannot catch this class of bug; only transport-pair tests can.
§7. Constraints on the fix
- MUST NOT change the α envelope shape (existing
{ok, data}clients depend on it). - MUST NOT change the per-handler return shape (8 handlers in router + consensus + vrf).
- MUST NOT change the Zod
outputSchemadeclarations insrc/domains/**/tools.ts(they are referenced as TypeScript types viaz.infer<typeof X>throughout the codebase). - MUST keep
registerColibriTool’s public signature:ColibriToolConfigcontinues to acceptoutputSchema?. - MUST land a regression test that exercises the SDK validation path so this class of bug cannot return undetected.
§8. Path forward
Two options were considered:
- A. Drop the
outputSchemapassthrough atsrc/server.ts:404-406. The Zod schemas stay intools.tsfor type inference. No runtime validation of handler output. - B. Keep the schemas but validate handler output internally (before envelope wrap) instead of via the SDK. Add a new error code
OUTPUT_VALIDATION_FAILED.
Option A is selected for minimal surface change and zero new failure modes. Option B can be revisited as a separate slice if/when output-validation enforcement becomes desirable. The schemas’ TypeScript value (compile-time type inference + documentation) is unaffected by either choice.
Proceeding to contract.