P1.5.7 — router_* MCP Tools Audit

1. Scope

P1.5.7 registers the four δ Model Router MCP tools on the production surface:

  1. router_score — wraps scoreIntent(prompt, context)
  2. router_call — wraps routeRequest(prompt, options)
  3. router_fallback — inspects + resets the circuit breaker
  4. router_stats — wraps getRouterStats()

The MCP tool surface count moves 23 → 27 (Phase 0: 14, λ R89A: 4, θ R89B: 5, δ Phase 1.5: 4).

2. Inventory — existing tool registration patterns

2.1. Domain tool files (live exemplars)

File Tools registered Pattern
src/domains/reputation/tools.ts 4 (reputation_get, reputation_history, reputation_leaderboard, reputation_check_gates) DB-lazy (getDb() at call time); read-only
src/domains/consensus/tools.ts 5 (consensus_propose, consensus_vote, consensus_finality, consensus_gossip, vrf_eval) Process-singleton state; no DB
src/domains/tasks/repository.ts 5 task tools DB-lazy + state-machine
src/domains/trail/repository.ts 2 thought tools DB-lazy
src/domains/trail/verifier.ts 1 (audit_verify_chain) DB-lazy
src/domains/skills/repository.ts 1 (skill_list) DB-lazy
src/tools/health.ts 2 (server_ping is in server.ts; this file ships server_health only) Inline ctx-aware
src/tools/merkle.ts 3 (audit_session_start, merkle_finalize, merkle_root) DB-lazy

2.2. Registration call site — src/server.ts:540-593 (bootstrap)

registerColibriTool(ctx, 'server_ping', …);           // server.ts line 540
registerHealthTool(ctx);                              // line 557 — adds server_health
registerThoughtTools(ctx);                            // line 561 — adds 2 thought tools
registerVerifyChainTool(ctx);                         // line 565 — adds audit_verify_chain
registerSkillTools(ctx);                              // line 569 — adds skill_list
registerTaskTools(ctx);                               // line 572 — adds 5 task tools
registerMerkleTools(ctx);                             // line 577 — adds 3 η tools
registerReputationTools(ctx);                         // line 583 — adds 4 λ tools (R89 Phase A)
registerConsensusTools(ctx);                          // line 593 — adds 5 θ tools (R89 Phase B)

After P1.5.7: insert registerRouterTools(ctx); after registerConsensusTools(ctx);. Net 4 new tool names, total 23 → 27.

2.3. Registration helper — src/server.ts:281-412 (registerColibriTool)

  • Validates tool name against /^[a-z_][a-z0-9_]*$/ (TOOL_NAME_RE).
  • Requires inputSchema to be a z.ZodObject instance.
  • Optional outputSchema (also Zod object — passes its .shape to the MCP SDK).
  • Duplicate registration throws (tool already registered: <name>).
  • 5-stage middleware wraps every handler: tool-lock → schema-validate → audit-enter → dispatch → audit-exit.
  • Schema validation errors return INVALID_PARAMS envelope, isError: true.
  • Handler errors return HANDLER_ERROR envelope with message, isError: true.

2.4. Existing test patterns

src/__tests__/domains/consensus/tools.test.ts and src/__tests__/domains/reputation/tools.test.ts follow this shape:

  • §1 fixtures + setup
  • §2 Zod schema validation tests (per tool, valid + invalid)
  • §3 per-handler tests (happy path + error path)
  • §4 cross-cutting tests (state isolation, mutation safety)
  • §5 register tool — ctx._registeredToolNames.has(<name>) × N + _registeredToolNames.size === N
  • §6 duplicate-registration guard test

3. Inventory — router functions to wrap

3.1. scoreIntent(prompt, context)src/domains/router/scoring.ts:547-606

  • Signature: (prompt: string, context: ScoreContext = {}) => IntentScore
  • Pure: no DB, no env, no clock, no RNG (P1.5.1 invariant I8)
  • Output: { scores: Readonly<Record<ModelId, number>>, winner: ModelId }
  • Frozen at both levels
  • Empty-candidate fallback: returns PHASE_0_CLAUDE_WINNER constant when context.candidatesSnapshot is undefined or empty
  • rule_version_hash: Available via computeScoringRuleVersionHash() re-exported by scoring.ts:85 — Phase 1.5 tool consumes this to expose the κ rule version

3.2. routeRequest(prompt, options)src/domains/router/fallback.ts:625-753

  • Signature: (prompt: string, options: RouteOptions = {}) => Promise<RouteResult>
  • RouteOptions (fallback.ts:172-210): MCP-safe subset = {maxTokens?, systemPrompt?, model?, tools?, task?, operatorPreference?, candidatesSnapshot?}.
  • Forbidden from MCP surface: apiKey, completionFn, completionFnRegistry, scoringFn, fetchFn, logger, delayFn, nowFn — these are test/injection seams (per slice §FORBIDDENS).
  • RouteResult (fallback.ts:232-242): {model, content, finishReason, promptTokens, completionTokens, latencyMs, costUsd, modelsAttempted}
  • Error path: FallbackChainExhaustedError with attempts: ReadonlyArray<FallbackAttempt> and cause; each attempt = {model, error}.

3.3. getCircuitBreakerState()src/domains/router/circuit.ts:283-285

  • Signature: () => ReadonlyMap<ModelId, CircuitState>
  • CircuitState: {failures: number, openedAt: number | null} (frozen per-entry)
  • Re-exported from fallback.ts:113 (the live exemplar import for callers).

3.4. resetCircuitBreaker(modelId?)src/domains/router/circuit.ts:246-252

  • Signature: (modelId?: ModelId) => void
  • With modelId: clears one model’s state
  • Without: clears the whole map
  • Re-exported from fallback.ts:113

3.5. getRouterStats()src/domains/router/cost.ts:405-429

  • Signature: () => { models: Readonly<Record<ModelId, RouterStats>> }
  • RouterStats: {calls_total, successes, failures, avg_cost_usd, p50_latency_ms, success_rate}
  • Frozen at both levels
  • Models never touched are absent from the models map (sparse projection)

3.6. ModelIdsrc/domains/router/scoring.ts:110-119

type ModelId =
  | 'claude'
  | 'claude-sonnet-3-5'
  | 'claude-haiku-3-5'
  | 'gpt-4o'
  | 'gpt-4o-mini'
  | 'gemini-1-5-pro'
  | 'llama-3-3-70b'
  | 'mixtral-8x22b'
  | 'kimi-k2';

9 string literals. Used in Zod input/output schemas via z.enum([...]).

3.7. ScoreContextsrc/domains/router/scoring.ts:185-200

MCP-safe subset = {task?, operatorPreference?, candidatesSnapshot?, weightsSnapshot?}.

Open index signature [key: string]: unknown — MCP layer narrows to the explicitly-allowed fields only (no pass-through unknown).

4. Decisions

4.1. File location — src/domains/router/tools.ts (matches slice §”Files to create”)

Slice file at L997 explicitly names src/domains/router/tools.ts. The other domain tool files all live in src/domains/<domain>/tools.ts (consensus) or src/domains/<domain>/repository.ts (tasks, trail, skills). Consistent.

4.2. Test location — src/__tests__/domains/router/tools.test.ts

Slice file at L998 explicitly names this path. Mirrors src/__tests__/domains/consensus/tools.test.ts location.

4.3. Tool naming — router_score, router_call, router_fallback, router_stats

Slice file at L997 names all 4 explicitly. All snake_case, all start with router_, all match TOOL_NAME_RE.

4.4. Zod schemas — input strict, output strict

Mirrors consensus pattern (.strict() on every object). Strict mode means unknown keys are rejected — important for the MCP trust boundary (apiKey + injection seams MUST be rejected by the strict input schema).

4.5. Output schemas — provided for type safety

The consensus tools include outputSchema (e.g. ConsensusProposeOutputSchema). The reputation tools do NOT (they return plain TypeScript types). Both are valid per the registration helper.

P1.5.7 will include output schemas because:

  • They document the wire shape explicitly.
  • They mirror the consensus pattern (the live R89 Phase B exemplar).
  • They catch handler regressions if a future refactor mis-shapes the output.

Exception: router_call output is too complex to constrain with Zod cheaply (variant content shapes from different adapters). Output schema is OMITTED for router_call — handler is type-safe at the TS level. This mirrors task_create which similarly omits output schema for variable shape reasons.

4.6. Decision-trail thought_record emission — DEFERRED to P1.5.10

The slice file’s writeback template (L1095-1098) mentions “Every handler emits thought_record (‘decision’ type) with routing-decision shape”. However, the dispatch packet also says (in the slice header) that P1.5.7 references the ζ decision-trail shape from P1.5.10 (the shape lands in P1.5.10).

Decision: P1.5.7 does NOT emit thought_record from inside the router tools. ζ integration lands in P1.5.10 per slice file L1011 (“from P1.5.10 (the shape landed in that sub-task; P1.5.7 references it)”) and the round prompt’s explicit “ζ integration (P1.5.10 scope)” forbidden.

The audit-enter / audit-exit middleware stages (src/server.ts:346-389) ALREADY emit per-call audit events via ctx.auditSink. In Phase 0 the sink is a no-op (createNoOpAuditSink). When P0.7’s SQLite-backed sink lands (or P1.5.10 wires a router-specific sink), every router tool call is automatically audited via the 5-stage chain. No router-internal thought_record writes needed.

4.7. router_fallback input shape

Slice file L1059-1061:

  • model_id: z.string().optional() — Zod enum ModelId would be tighter; the prompt says z.string().optional() for forward-compat with future models.
  • reset: z.boolean().optional()

Decision: use z.enum([…ModelId tuple…]).optional() because (a) every other ModelId consumer in the codebase enums it (consensus FSM, reputation has its own enum), (b) tighter validation is the trust-boundary norm.

4.8. router_stats input shape — empty {}

Slice file L1063 explicitly: Input: {} (no params). Schema = z.object({}).strict().

4.9. apiKey + injection seams — REJECTED at strict schema boundary

RouteOptions includes apiKey, completionFn, completionFnRegistry, scoringFn, fetchFn, logger, delayFn, nowFn. The MCP-facing Zod input schema for router_call MUST NOT include any of these (slice file L1100-1101 forbids them explicitly).

Strict mode on the input schema means a caller passing apiKey gets INVALID_PARAMS from stage 2 with an “Unrecognized key” Zod issue.

5. Risks

5.1. router_call requires real adapters to succeed

Calling router_call without apiKey in the env results in FallbackChainExhaustedError — every chain member’s adapter fails at the auth step. This is correct behavior per the slice file L1014 acceptance criterion (“Call-time auth errors surface as FallbackChainExhaustedError → MCP error with attempts array in data”).

Tests handle this via routeRequest not being directly called — consensus tools also handle their failure paths in tests by asserting on the error envelope shape.

For test purposes, router_call’s handler will be tested via direct call to the exported handler function (not via the MCP wrapper), with injected mocks. The Zod schema validation tests run via the schema’s safeParse.

5.2. router_call MUST swallow RouteOptions extras

The MCP input schema must NOT pass through arbitrary ScoreContext extras to routeRequest. The Zod-narrowed input is a small subset; the handler converts that subset to a RouteOptions instance before calling.

5.3. getRouterStats is process-local — Jest worker isolation

The cost.ts aggregates are module-level Maps. Within a Jest worker, tests must resetRouterStats() in beforeEach / afterEach. Tests for router_stats will use this reset pattern (mirrored from cost.test.ts).

5.4. getCircuitBreakerState returns a ReadonlyMap, not a plain object

The MCP wire shape can’t transmit a Map. The handler must Object.fromEntries(map) (or equivalent) to project to a plain object. Output schema = z.record(z.string(), CircuitStateSchema).

5.5. Test expectations may rely on ctx._registeredToolNames.size

Other tests (src/__tests__/domains/consensus/tools.test.ts etc.) assert exact _registeredToolNames.size on a per-domain ctx. The bootstrap-level total is asserted in src/__tests__/server.test.ts (if such an assertion exists) — check if any server.test.ts asserts total count of tools at boot. If so, that test will need a bump 23 → 27.

Audit action: grep server.test.ts for “23” or size/length/count.

6. Forbidden tokens / patterns

  • No AMS_* env vars (only COLIBRI_*)
  • No apiKey / completionFn / completionFnRegistry / scoringFn / fetchFn / logger / delayFn / nowFn in MCP-facing input schemas
  • No new MCP tool beyond the 4 named (router_score, router_call, router_fallback, router_stats)
  • No router-internal thought_record emission (P1.5.10 scope)
  • No edits to scoring.ts / fallback.ts / circuit.ts / cost.ts / adapters/* (handler-only registration)
  • No κ rule edits, no κ engine edits
  • No δ frontmatter graduation (stays partial)

7. Acceptance criteria

(From slice file L1005-1015, restated for verification.)

  • AC1: router_score(prompt, context?) returns {scores, winner, rule_version_hash} (Zod enforces non-empty prompt).
  • AC2: router_call(prompt, options?) wraps routeRequest; output is the RouteResult shape including costUsd, modelsAttempted.
  • AC3: router_fallback(model_id?, reset?) returns {circuitState: Record<ModelId, CircuitState>}; resets when both fields provided.
  • AC4: router_stats() returns {models: Record<ModelId, RouterStats>}.
  • AC5: 4 tools registered; MCP surface 23 → 27.
  • AC6: Schema validation errors return MCP INVALID_PARAMS with Zod issues in details.issues.
  • AC7: FallbackChainExhaustedError from router_call surfaces as MCP HANDLER_ERROR; the handler propagates the error via re-throw so the middleware Stage-4 catch wraps it. (No attempts array in MCP data per current HANDLER_ERROR envelope shape — error.message carries the model list. This is mode-consistent with how consensus_vote reports ALREADY_VOTED: <round_id>.)
  • AC8: npm run build && npm run lint && npm test all green.

Deviation from slice AC: AC7 — Slice file L1014 specifies “MCP error with attempts array in data”. Current HANDLER_ERROR envelope shape (src/server.ts:363-368) only includes code + message. Per the audit’s invariant of NOT touching middleware in P1.5.7 (middleware is src/server.ts core, the slice files L1075-1079 only authorizes inserting one registerRouterTools call), and per the forbidden of not editing the underlying middleware, the conservative interpretation is: the FallbackChainExhaustedError message string already lists every attempt and the model order, so the HANDLER_ERROR envelope carries the information via message. A future P1.5.10 round may extend the envelope to carry structured data if needed. Audit deviates from slice on this AC and proceeds with message-only error reporting.


Back to top

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

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