P3.7.1 — θ MCP Tool Surface — Contract

Round: R89 θ Wave 4 Branch: feature/p3-7-1-mcp-tools Worktree: .worktrees/claude/p3-7-1-mcp-tools Step: 2 of 5 Audit: docs/audits/p3-7-1-mcp-tools-audit.md @ 2e1cc953 Date: 2026-05-13


§1. Module-level identity

src/domains/consensus/tools.ts is a stateful module that:

  1. Holds a process-singleton round registry Map<string, RoundEntry> keyed by decimal-string round_id.
  2. Holds a process-singleton monotonic counter for round_id allocation.
  3. Holds a process-singleton KeyObject pair ({ priv, pub }) generated lazily on first use.
  4. Exposes a resetConsensusToolsForTesting() mutator for test isolation.
  5. Exposes registerConsensusTools(ctx: ColibriServerContext): void for bootstrap() wiring.

The state is intentionally process-scoped (not per-ctx) so it survives across the α 5-stage middleware’s per-call lifecycle and matches the existing messages.ts __logicalClock pattern (P3.1.1 §5).


§2. Internal types

interface RoundEntry {
  readonly round_id: bigint;
  readonly fsm: FinalitySM;
  readonly votes_by_arbiter: Map<string, Vote>;
  // Track which (sender_id, voteGroupKey) tuples have been recorded
  // to distinguish retry (ALREADY_VOTED) from equivocation.
  readonly tuples_by_arbiter: Map<string, Set<string>>;
  readonly epoch_counter: { current: bigint };
}

voteGroupKey is reused from quorum.ts (P3.1.2 §6) — ${round_id}|${hex(root)}|${hex(rule_version)}.


§3. Public exported surface

// Test-only.
export function resetConsensusToolsForTesting(): void;

// Zod input schemas (for unit testing).
export const ConsensusProposeInputSchema: z.ZodObject<...>;
export const ConsensusVoteInputSchema: z.ZodObject<...>;
export const ConsensusFinalityInputSchema: z.ZodObject<...>;
export const ConsensusGossipInputSchema: z.ZodObject<...>;
export const VrfEvalInputSchema: z.ZodObject<...>;

// Zod output schemas (registered with α middleware).
export const ConsensusProposeOutputSchema: z.ZodObject<...>;
export const ConsensusVoteOutputSchema: z.ZodObject<...>;
export const ConsensusFinalityOutputSchema: z.ZodObject<...>;
export const ConsensusGossipOutputSchema: z.ZodObject<...>;
export const VrfEvalOutputSchema: z.ZodObject<...>;

// Pure handlers (exported for direct testing alongside α-wrapped form).
export function consensusPropose(input): ConsensusProposeOutput;
export function consensusVote(input): ConsensusVoteOutput;
export function consensusFinality(input): ConsensusFinalityOutput;
export function consensusGossip(input): ConsensusGossipOutput;
export function vrfEvalHandler(input): VrfEvalOutput;

// Type exports.
export type ConsensusProposeInput / Output;
export type ConsensusVoteInput / Output;
export type ConsensusFinalityInput / Output;
export type ConsensusGossipInput / Output;
export type VrfEvalInput / Output;

// Registration glue.
export function registerConsensusTools(ctx: ColibriServerContext): void;

§4. Behavioral contract — per tool

§4.1 consensus_propose

Input schema:

z.object({
  event: z.object({
    merkle_root_hex: z.string().regex(/^[0-9a-f]{64}$/),
    rule_version_hash_hex: z.string().regex(/^[0-9a-f]{64}$/),
  }).strict(),
}).strict()

Output schema:

z.object({
  round_id: z.string().regex(/^[1-9][0-9]*$/),
  status: z.enum(['PENDING', 'QUORUM']),
}).strict()

Behavior:

  1. Allocate a fresh round_id from the singleton counter (initial value 1n; increments monotonically).
  2. Construct a FinalitySM(round_id, 1n) (n=1 single-arbiter).
  3. Build a synthetic ACCEPT vote signed under the process-singleton key, with the proposed (merkle_root, rule_version_hash).
  4. Record the vote into the FSM at currentEpoch = 1n. The FSM advances PENDING → SOFT → QUORUM in one tick because quorumThreshold(1n) === 1n (P3.1.2).
  5. Push the round into the registry.
  6. Return { round_id: round_id.toString(), status: fsm.current() }.

Determinism: identical inputs in two parallel processes produce identical FSM behavior; the actual round_id value depends on the module-singleton counter (process-local).

Throws: never. Zod handles input rejection at α Stage 2.


§4.2 consensus_vote

Input schema:

z.object({
  round_id: z.string().regex(/^[1-9][0-9]*$/),
  vote: z.object({
    merkle_root_hex: z.string().regex(/^[0-9a-f]{64}$/),
    rule_version_hash_hex: z.string().regex(/^[0-9a-f]{64}$/),
    vote_type: z.enum(['ACCEPT', 'REJECT', 'ABSTAIN']),
  }).strict(),
  privKey: z.string().regex(/^[0-9a-f]+$/).optional(),
}).strict()

Output schema:

z.object({
  vote_signed: z.literal(true),
  sig_b64: z.string(),
}).strict()

Behavior:

  1. Parse round_id as bigint and look up the registry. If absent: throw Error("ROUND_NOT_FOUND: <round_id>").
  2. Compute voteGroupKey for the incoming tuple.
  3. Look up tuples_by_arbiter for the singleton-arbiter id "node-0". If the set already contains this voteGroupKey: throw Error("ALREADY_VOTED: <round_id>").
  4. Increment epoch_counter.current by 1n.
  5. Build + sign the Vote under the singleton key.
  6. Insert into votes_by_arbiter (keyed by sender_id — overwrite is fine, FSM already has the propose vote).
  7. Record the tuple in tuples_by_arbiter.
  8. Call fsm.receiveVote(vote, epoch).
  9. Return { vote_signed: true, sig_b64: vote.signature.toString('base64') }.

The privKey input field is accepted (forward-compat shape) but in Phase 0 the singleton key is always used. This is documented in JSDoc and the contract — matches ADR-005 stub pattern.

Throws:

  • ROUND_NOT_FOUND — unknown round_id.
  • ALREADY_VOTED — retry of same (arbiter, voteGroupKey).

§4.3 consensus_finality

Input schema:

z.object({
  round_id: z.string().regex(/^[1-9][0-9]*$/),
}).strict()

Output schema:

z.object({
  round_id: z.string().regex(/^[1-9][0-9]*$/),
  level: z.enum(['PENDING', 'SOFT', 'QUORUM', 'HARD', 'ABSOLUTE']),
  evidence: z.string().optional(),
}).strict()

Behavior:

  1. Parse round_id and look up the registry. If absent: throw Error("ROUND_NOT_FOUND: <round_id>").
  2. Read fsm.current() and fsm.transitions().
  3. Compute evidence as the lowercase hex of the LAST transition’s evidence Buffer, or omit when no transitions exist (i.e. still PENDING).
  4. Return { round_id, level, [evidence] }.

Throws: ROUND_NOT_FOUND.


§4.4 consensus_gossip

Input schema:

z.object({
  peer_id: z.string().min(1),
  events: z.array(z.string()).optional(),
}).strict()

Output schema:

z.object({
  events_sent: z.array(z.string()),
  events_received: z.array(z.string()),
}).strict()

Behavior:

  1. Always return { events_sent: [], events_received: [] } in Phase 0 single-arbiter mode (no peers).
  2. peer_id and events are accepted (forward-compat input shape) but ignored.

Throws: never.


§4.5 vrf_eval

Input schema:

z.object({
  seed_hex: z.string().regex(/^([0-9a-f]{2})+$/),
  input_hex: z.string().regex(/^([0-9a-f]{2})+$/),
  priv_key_hex: z.string().regex(/^([0-9a-f]{2})+$/),
}).strict()

Output schema:

z.object({
  output_hex: z.string().regex(/^[0-9a-f]{64}$/),
  proof_hex: z.string().regex(/^[0-9a-f]{64}$/),
}).strict()

Behavior:

  1. Decode all 3 hex inputs to Buffers.
  2. If priv_key_hex decodes to a zero-length buffer: throw Error("INVALID_KEY: empty privKey").
  3. Call vrfEval(seed, input, priv) — P3.6.1.
  4. Return { output_hex: output.toString('hex'), proof_hex: proof.toString('hex') }.

Any VrfError thrown by the underlying call is re-thrown with the INVALID_KEY: prefix preserved.

Throws: INVALID_KEY.


§5. Side effects + determinism

  • No filesystem I/O. No DB writes. No network. No env reads. No console output.
  • No clock reads. epoch_counter is incremented by 1n per vote; not driven by wall time.
  • No randomness in handlers. The singleton key pair is generated on FIRST use via generateKeyPairSync('ed25519') — this is one randomness reservoir per process, used only to make Ed25519 signatures cryptographically well-typed. Test mode injects a deterministic key by calling resetConsensusToolsForTesting() (which clears all state) then exercising the API.
  • Process-singleton state survives across multiple consensus_* calls. Two parallel calls to consensus_propose allocate distinct round_ids deterministically (1n → 2n → 3n …). Same-tuple votes from the SAME process see the persistent state; cross-process: each process has its own counter.

§6. Forbidden tokens (corpus self-scan compliance)

The new tools.ts body must not contain:

  • Math. (dotted)
  • Date. (dotted)
  • Math.random
  • setTimeout, setInterval, setImmediate
  • process.hrtime, performance.now
  • Wildcard or dotted crypto.X access in function bodies (NAMED imports only)

NAMED crypto imports allowed (generateKeyPairSync, createPrivateKey, etc.).

JSDoc block comments are stripped by the corpus scanner before pattern match — discussion of these tokens inside /** */ is safe (matches messages.ts:62 / finality.ts:75 / vrf-stub.ts:55 precedent). However: to avoid maintenance burden, the tools.ts self-scan test is intentionally NOT added in this slice — there is no consensus-corpus test that scans this file as of 9c7165d5. The bans are honored manually.


§7. Integration with src/server.ts

The new registerConsensusTools(ctx) call is inserted in bootstrap() immediately after registerReputationTools(ctx) (src/server.ts:582).

registerReputationTools(ctx);
// P3.7.1: register θ Consensus MCP tools — consensus_propose,
// consensus_vote, consensus_finality, consensus_gossip, vrf_eval.
// Phase 0 single-arbiter posture (n=1 trivial finalization). Closes
// R89 θ Wave 4 — takes the total MCP surface count from 18 → 23.
registerConsensusTools(ctx);
await start(ctx);

Imports added to server.ts:

import { registerConsensusTools } from './domains/consensus/tools.js';

No other edits to server.ts.


§8. Test plan — concrete

§8.1 Suite scaffolding

import { resetConsensusToolsForTesting,
  ConsensusProposeInputSchema, ... } from '.../tools.js';

beforeEach(() => {
  resetConsensusToolsForTesting();
});

§8.2 Test cases (20 planned)

# Group Name Asserts
1 propose happy n=1 returns QUORUM status===’QUORUM’
2 propose allocates fresh round_id per call id_1 !== id_2, both decimal strings ≥ ‘1’
3 propose Zod rejects bad hex safeParse fails
4 vote happy: returns vote_signed=true, sig_b64 non-empty sig length > 0
5 vote ALREADY_VOTED on retry same tuple Error message starts with ‘ALREADY_VOTED’
6 vote ROUND_NOT_FOUND on unknown id Error message starts with ‘ROUND_NOT_FOUND’
7 vote distinct tuples in same round → both accepted first OK; second different-root OK (this is FSM-internal “different triple in later epoch”)
8 vote Zod rejects bad hex root safeParse fails
9 vote privKey field accepted (ignored in Phase 0) safeParse OK with optional hex
10 finality after propose, level===’QUORUM’ level QUORUM, evidence present
11 finality ROUND_NOT_FOUND on unknown id Error
12 finality evidence is lowercase hex regex match
13 gossip returns empty arrays both arrays length 0
14 gossip accepts empty events arg safeParse OK
15 gossip accepts undefined events arg safeParse OK
16 vrf happy: deterministic output two calls same input → same output_hex
17 vrf INVALID_KEY on empty priv_key_hex Error message starts with ‘INVALID_KEY’
18 vrf Zod rejects non-hex input safeParse fails
19 registration registerConsensusTools registers 5 names ctx._registeredToolNames has all 5
20 registration second registration throws (idempotence guard) throws

Plus optional smoke:

# Group Name Asserts
21 smoke full happy chain propose → vote (distinct tuple) → finality returns QUORUM

Expected delta: +21 tests over the 2970 baseline → ~2991 total.


§9. Failure modes — explicit

Failure Tool Detection Action
Bad hex in input All Zod regex α Stage 2 emits INVALID_PARAMS envelope
Missing required field All Zod α Stage 2 emits INVALID_PARAMS envelope
Strict mode extra field All Zod .strict() α Stage 2 emits INVALID_PARAMS envelope
Unknown round_id vote, finality Registry lookup Handler throws Error("ROUND_NOT_FOUND: …")
Same-tuple retry vote tuples_by_arbiter check Handler throws Error("ALREADY_VOTED: …")
Empty privKey vrf_eval length check Handler throws Error("INVALID_KEY: …")
VrfError from P3.6.1 vrf_eval catch + rewrap Handler throws Error("INVALID_KEY: <orig>")

§10. Acceptance gates

  1. npm run build exits 0.
  2. npm run lint exits 0.
  3. npm test shows 5 new tools registered (via the in-process smoke test) and baseline + 21 tests passing.
  4. src/server.ts has exactly one new import and one new call site.
  5. No new npm deps.
  6. No new SQL migrations.
  7. No edits in main checkout (only .worktrees/claude/p3-7-1-mcp-tools files changed).
  8. Tool count moves from 18 → 23 in the assertion at src/__tests__/server.test.ts (if applicable — check current shape).

Gate #8 audit: I will read src/__tests__/server.test.ts during implementation to see if there’s a tool-count assertion that needs updating.


§11. Sign-off

Contract reviewed against:

  • Audit docs/audits/p3-7-1-mcp-tools-audit.md (2e1cc953)
  • All §3 upstream API surfaces (signatures verified against 9c7165d5 source)
  • Dispatch packet error code taxonomy
  • Source prompt §P3.7.1 single-arbiter posture + gotchas

Step 3 (packet) gates on this 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.