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

  1. Strict Zod parse on input → INVALID_PARAMS on bad input.
  2. Call scoreIntent(prompt, context ?? {}).
  3. Call computeScoringRuleVersionHash() for rule_version_hash.
  4. 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

  1. Strict Zod parse → INVALID_PARAMS on bad input.
  2. Project the validated options to a RouteOptions instance (TypeScript narrowing — the validated shape is already a subset).
  3. Call await routeRequest(prompt, options).
  4. Return RouteResult directly.

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

  1. Strict Zod parse → INVALID_PARAMS on bad input.
  2. Reset branch (when reset === true):
    • If model_id is provided: resetCircuitBreaker(model_id).
    • If model_id is omitted: resetCircuitBreaker() (clears all).
  3. Call getCircuitBreakerState()ReadonlyMap<ModelId, CircuitState>.
  4. Project to plain object: Object.fromEntries(map)Record<ModelId, CircuitState>.
  5. 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

  1. Strict Zod parse on {}INVALID_PARAMS on extra keys.
  2. Call getRouterStats() directly.
  3. 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_score
  • router_call
  • router_fallback
  • router_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 through scoreIntent’s frozen output, wraps with rule_version_hash.
  • router_call: passes through routeRequest’s Object.freeze‘d output.
  • router_fallback: Object.freeze(Object.freeze({circuitState})).
  • router_stats: passes through getRouterStats’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 one registerRouterTools(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_hash is 64 lowercase hex chars.

9.3. router_call

  • Schema-level: valid input parses; bad input rejected.
  • Forbidden keys (apiKey, completionFn, fetchFn, …) rejected.
  • Note: live routeRequest invocation 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: true with model_id → clears that model’s state.
  • reset: true without model_id → clears all state.
  • Read-only (no reset) → returns current snapshot.
  • Returned circuitState is 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.

Back to top

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

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