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 outputSchema declarations in src/domains/**/tools.ts (they are referenced as TypeScript types via z.infer<typeof X> throughout the codebase).
  • MUST keep registerColibriTool’s public signature: ColibriToolConfig continues to accept outputSchema?.
  • 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 outputSchema passthrough at src/server.ts:404-406. The Zod schemas stay in tools.ts for 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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.