P1.5.7 — router_* MCP Tools Audit
1. Scope
P1.5.7 registers the four δ Model Router MCP tools on the production surface:
router_score— wrapsscoreIntent(prompt, context)router_call— wrapsrouteRequest(prompt, options)router_fallback— inspects + resets the circuit breakerrouter_stats— wrapsgetRouterStats()
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
inputSchemato be az.ZodObjectinstance. - Optional
outputSchema(also Zod object — passes its.shapeto 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_PARAMSenvelope,isError: true. - Handler errors return
HANDLER_ERRORenvelope 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_WINNERconstant whencontext.candidatesSnapshotis undefined or empty - rule_version_hash: Available via
computeScoringRuleVersionHash()re-exported byscoring.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:
FallbackChainExhaustedErrorwithattempts: ReadonlyArray<FallbackAttempt>andcause; eachattempt = {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
modelsmap (sparse projection)
3.6. ModelId — src/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. ScoreContext — src/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 enumModelIdwould be tighter; the prompt saysz.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 (onlyCOLIBRI_*) - No
apiKey/completionFn/completionFnRegistry/scoringFn/fetchFn/logger/delayFn/nowFnin MCP-facing input schemas - No new MCP tool beyond the 4 named (
router_score,router_call,router_fallback,router_stats) - No router-internal
thought_recordemission (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?)wrapsrouteRequest; output is theRouteResultshape includingcostUsd,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_PARAMSwith Zod issues indetails.issues. - AC7:
FallbackChainExhaustedErrorfromrouter_callsurfaces as MCPHANDLER_ERROR; the handler propagates the error via re-throw so the middleware Stage-4 catch wraps it. (Noattemptsarray in MCPdataper currentHANDLER_ERRORenvelope shape —error.messagecarries the model list. This is mode-consistent with howconsensus_votereportsALREADY_VOTED: <round_id>.) - AC8:
npm run build && npm run lint && npm testall 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.