P1.5.7 — router_* MCP Tools Contract
1. Scope
This contract specifies the behavioral surface of the four δ Model Router MCP tools registered by src/domains/router/tools.ts. The tools wrap the production router functions (no stubs); registration grows the MCP surface 23 → 27.
2. Tool: router_score
2.1. Input schema (Zod, strict)
RouterScoreInputSchema = z.object({
prompt: z.string().min(1),
context: z.object({
task: z.object({
domain: z.string().optional(),
tokens: z.number().int().nonnegative().optional(),
deadline_ms: z.number().int().nonnegative().optional(),
skill: z.array(z.string()).optional(),
}).strict().optional(),
operatorPreference: z.record(z.string(), z.number().min(0).max(1)).optional(),
}).strict().optional(),
}).strict();
Restricted from MCP input (vs internal ScoreContext):
candidatesSnapshot— sourced from DB in production; not caller-supplied.weightsSnapshot— sourced from κ rule pack in production.toolCount,complexity— legacy Phase 0 keys ignored.
2.2. Output schema (Zod, strict)
RouterScoreOutputSchema = z.object({
scores: z.record(z.string(), z.number().min(0).max(1)),
winner: ModelIdSchema,
rule_version_hash: z.string().regex(/^[0-9a-f]{64}$/),
}).strict();
2.3. Behavior
- Strict Zod parse on input →
INVALID_PARAMSon bad input. - Call
scoreIntent(prompt, context ?? {}). - Call
computeScoringRuleVersionHash()forrule_version_hash. - Return
{scores, winner, rule_version_hash}.
Phase 0 / empty-candidate fallback (per scoring.ts:551-555): with no candidatesSnapshot from DB, scoreIntent returns the frozen constant {scores: {claude: 1.0, ...}, winner: 'claude'}. P1.5.7 does NOT read DB candidates — that’s P1.5.10’s role. The MCP surface returns the fallback constant for now, which matches the slice file’s “constant-returns-claude” Phase 0 posture.
2.4. Determinism
Pure — same (prompt, context) → same output. No clock, no RNG.
3. Tool: router_call
3.1. Input schema (Zod, strict)
RouterCallInputSchema = z.object({
prompt: z.string().min(1),
options: z.object({
maxTokens: z.number().int().positive().optional(),
systemPrompt: z.string().optional(),
model: z.string().optional(), // upstream version-string hint, not ModelId
task: TaskShapeSchema.optional(),
operatorPreference: z.record(z.string(), z.number().min(0).max(1)).optional(),
}).strict().optional(),
}).strict();
Restricted from MCP input (per slice file L1100-1101):
apiKey— secrets only from env (COLIBRI_*_API_KEY).completionFn,completionFnRegistry,scoringFn,fetchFn,logger,delayFn,nowFn— test injection seams only.tools— Anthropic tool-shape pass-through is too complex to validate at the MCP boundary without re-vendoring the schema; deferred to future round.candidatesSnapshot,weightsSnapshot— sourced internally.
3.2. Output (no Zod schema — variable shape)
interface RouterCallOutput {
model: ModelId;
content: string;
finishReason: string;
promptTokens: number;
completionTokens: number;
latencyMs: number;
costUsd: number;
modelsAttempted: ReadonlyArray<ModelId>;
}
Matches RouteResult from fallback.ts:232-242 byte-for-byte. No additional projection.
3.3. Behavior
- Strict Zod parse →
INVALID_PARAMSon bad input. - Project the validated
optionsto aRouteOptionsinstance (TypeScript narrowing — the validated shape is already a subset). - Call
await routeRequest(prompt, options). - Return
RouteResultdirectly.
3.4. Error path
FallbackChainExhaustedError from routeRequest propagates upward. The middleware Stage 4 catch (src/server.ts:361-374) wraps it as HANDLER_ERROR envelope:
{
"ok": false,
"error": {
"code": "HANDLER_ERROR",
"message": "δ fallback chain exhausted after N attempts: [...] <last-error-msg>"
}
}
The error message already lists every model attempted and the cause — preserved by FallbackChainExhaustedError.constructor (fallback.ts:312-323).
3.5. Auth failure path (call-time secrets)
In production with no COLIBRI_ANTHROPIC_API_KEY, each adapter call throws AnthropicConfigError. The chain walk records each as a FallbackAttempt, then throws FallbackChainExhaustedError after all members. The MCP response is HANDLER_ERROR (per §3.4) — never INVALID_PARAMS.
This matches the slice file’s call-time-validation Design Invariant 5.
4. Tool: router_fallback
4.1. Input schema (Zod, strict)
ModelIdSchema = z.enum([
'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',
]);
RouterFallbackInputSchema = z.object({
model_id: ModelIdSchema.optional(),
reset: z.boolean().optional(),
}).strict();
4.2. Output schema (Zod, strict)
CircuitStateSchema = z.object({
failures: z.number().int().nonnegative(),
openedAt: z.number().nullable(),
}).strict();
RouterFallbackOutputSchema = z.object({
circuitState: z.record(z.string(), CircuitStateSchema),
}).strict();
4.3. Behavior
- Strict Zod parse →
INVALID_PARAMSon bad input. - Reset branch (when
reset === true):- If
model_idis provided:resetCircuitBreaker(model_id). - If
model_idis omitted:resetCircuitBreaker()(clears all).
- If
- Call
getCircuitBreakerState()→ReadonlyMap<ModelId, CircuitState>. - Project to plain object:
Object.fromEntries(map)→Record<ModelId, CircuitState>. - Return
{circuitState: <projected>}.
4.4. Idempotence
Calling router_fallback({reset: true, model_id: 'gpt-4o'}) twice clears once and is a no-op the second time. Calling with {reset: false} is a pure read.
5. Tool: router_stats
5.1. Input schema (Zod, strict)
RouterStatsInputSchema = z.object({}).strict();
No params. Strict mode rejects any extra key.
5.2. Output schema (Zod, strict)
RouterStatsRowSchema = z.object({
calls_total: z.number().int().nonnegative(),
successes: z.number().int().nonnegative(),
failures: z.number().int().nonnegative(),
avg_cost_usd: z.number().nonnegative(),
p50_latency_ms: z.number().nonnegative(),
success_rate: z.number().min(0).max(1),
}).strict();
RouterStatsOutputSchema = z.object({
models: z.record(z.string(), RouterStatsRowSchema),
}).strict();
5.3. Behavior
- Strict Zod parse on
{}→INVALID_PARAMSon extra keys. - Call
getRouterStats()directly. - Return the result as-is —
getRouterStats()already returns the exact{models: Record<ModelId, RouterStats>}shape.
5.4. Sparse models
Models that have never been touched are absent from models (matches cost.ts:405-429). Callers must tolerate missing keys.
6. Cross-cutting invariants
6.1. INVARIANT — Tool count after registration
registerRouterTools(ctx) adds exactly 4 names to ctx._registeredToolNames:
router_scorerouter_callrouter_fallbackrouter_stats
After this call, ctx._registeredToolNames.size increases by 4.
6.2. INVARIANT — No DB access
None of the 4 router tools opens the DB. The router functions they wrap are either:
- Pure (
scoreIntent) - In-memory only (
getCircuitBreakerState,resetCircuitBreaker,getRouterStats) - Network-only via adapters (
routeRequest— calls upstream HTTP, NOT SQLite)
Tests do NOT need to initDb() to exercise the router tools.
6.3. INVARIANT — Strict schema rejects forbidden keys
For every tool, the input schema is .strict(). A caller passing apiKey, completionFn, fetchFn, logger, delayFn, nowFn, or any other unrecognized key gets INVALID_PARAMS with the Zod issue "Unrecognized key: \"<keyname>\"".
6.4. INVARIANT — Output frozen depth
All 4 handlers return frozen-deep objects:
router_score: passes throughscoreIntent’s frozen output, wraps withrule_version_hash.router_call: passes throughrouteRequest’sObject.freeze‘d output.router_fallback:Object.freeze(Object.freeze({circuitState})).router_stats: passes throughgetRouterStats’s already-frozen output.
6.5. INVARIANT — Duplicate registration throws
registerRouterTools(ctx) called twice on the same ctx throws Error: tool already registered: router_score on the second call (via registerColibriTool’s duplicate-registration guard at src/server.ts:293-296).
6.6. INVARIANT — No router function modified
P1.5.7 edits ONLY:
src/domains/router/tools.ts(new file)src/__tests__/domains/router/tools.test.ts(new file)src/server.ts(insert oneregisterRouterTools(ctx);line)src/domains/router/index.ts(add one re-export line —export * from './tools.js';)- 5 chain docs
Specifically NOT modified: scoring.ts, fallback.ts, circuit.ts, cost.ts, adapters/*.ts, scoring-weights.ts.
7. Error mapping
| Failure | MCP envelope | Source |
|---|---|---|
| Bad input (missing prompt, wrong type, extra key) | INVALID_PARAMS with details.issues |
Middleware Stage 2 (src/server.ts:307-336) |
Handler throws (e.g. FallbackChainExhaustedError, RouterTimeoutError) |
HANDLER_ERROR with error.message |
Middleware Stage 4 (src/server.ts:361-374) |
| Tool already registered | Error at registration time (NOT a runtime envelope) | registerColibriTool (src/server.ts:293-296) |
8. Forward compatibility
8.1. P1.5.10 ζ Decision Trail recording
When P1.5.10 lands, the audit-sink (currently no-op) will write thought_record entries for every router tool call. No router-tools edits required — the middleware’s audit-enter/audit-exit stages already fire.
8.2. Future model additions
Adding a new ModelId (e.g. 'mistral-large') requires editing scoring.ts (ModelId union + EMPTY_SCORES) and propagating through. The router_* tools’ Zod enum schemas would also need an update — but the structural shape stays the same.
8.3. router_call tools parameter (future)
Currently restricted; can be added later by adding an AnthropicTool[] Zod schema to RouterCallInputSchema.options. Backward-compat (additive).
9. Test surface
(See src/__tests__/domains/router/tools.test.ts for the test corpus.)
9.1. Tool count
registerRouterTools(ctx)adds exactly 4 names.- Second call throws.
9.2. router_score
- Valid input → output shape matches
RouterScoreOutputSchema. - Empty-candidate path returns the frozen
{claude: 1.0}constant. - Bad prompt (empty string) → Zod rejection.
- Extra key in input → Zod rejection (strict mode).
rule_version_hashis 64 lowercase hex chars.
9.3. router_call
- Schema-level: valid input parses; bad input rejected.
- Forbidden keys (apiKey, completionFn, fetchFn, …) rejected.
- Note: live
routeRequestinvocation requires real adapters / valid env. Direct-handler tests inject mock options via direct test API calls (NOT through the MCP wrapper), exercising the handler’s projection logic; full network-bound integration testing is P1.5.8’s responsibility.
9.4. router_fallback
reset: truewithmodel_id→ clears that model’s state.reset: truewithoutmodel_id→ clears all state.- Read-only (no reset) → returns current snapshot.
- Returned
circuitStateis a plain object, not a Map.
9.5. router_stats
- Empty state →
{models: {}}(no model touched, sparse). - After one
recordRouterCall(test injected via direct cost.ts API) → that one model’s stats appear in the output. - Strict input rejects extra keys.