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:
- Holds a process-singleton round registry
Map<string, RoundEntry>keyed by decimal-stringround_id. - Holds a process-singleton monotonic counter for
round_idallocation. - Holds a process-singleton
KeyObjectpair ({ priv, pub }) generated lazily on first use. - Exposes a
resetConsensusToolsForTesting()mutator for test isolation. - Exposes
registerConsensusTools(ctx: ColibriServerContext): voidforbootstrap()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:
- Allocate a fresh
round_idfrom the singleton counter (initial value 1n; increments monotonically). - Construct a
FinalitySM(round_id, 1n)(n=1 single-arbiter). - Build a synthetic ACCEPT vote signed under the process-singleton key, with the proposed
(merkle_root, rule_version_hash). - Record the vote into the FSM at
currentEpoch = 1n. The FSM advances PENDING → SOFT → QUORUM in one tick becausequorumThreshold(1n) === 1n(P3.1.2). - Push the round into the registry.
- 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:
- Parse
round_idas bigint and look up the registry. If absent: throwError("ROUND_NOT_FOUND: <round_id>"). - Compute
voteGroupKeyfor the incoming tuple. - Look up
tuples_by_arbiterfor the singleton-arbiter id"node-0". If the set already contains thisvoteGroupKey: throwError("ALREADY_VOTED: <round_id>"). - Increment
epoch_counter.currentby1n. - Build + sign the
Voteunder the singleton key. - Insert into
votes_by_arbiter(keyed bysender_id— overwrite is fine, FSM already has the propose vote). - Record the tuple in
tuples_by_arbiter. - Call
fsm.receiveVote(vote, epoch). - 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— unknownround_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:
- Parse
round_idand look up the registry. If absent: throwError("ROUND_NOT_FOUND: <round_id>"). - Read
fsm.current()andfsm.transitions(). - Compute
evidenceas the lowercase hex of the LAST transition’sevidenceBuffer, or omit when no transitions exist (i.e. still PENDING). - 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:
- Always return
{ events_sent: [], events_received: [] }in Phase 0 single-arbiter mode (no peers). peer_idandeventsare 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:
- Decode all 3 hex inputs to Buffers.
- If
priv_key_hexdecodes to a zero-length buffer: throwError("INVALID_KEY: empty privKey"). - Call
vrfEval(seed, input, priv)— P3.6.1. - 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_counteris 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 callingresetConsensusToolsForTesting()(which clears all state) then exercising the API. - Process-singleton state survives across multiple
consensus_*calls. Two parallel calls toconsensus_proposeallocate 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.randomsetTimeout,setInterval,setImmediateprocess.hrtime,performance.now- Wildcard or dotted
crypto.Xaccess 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
npm run buildexits 0.npm run lintexits 0.npm testshows 5 new tools registered (via the in-process smoke test) and baseline + 21 tests passing.src/server.tshas exactly one new import and one new call site.- No new npm deps.
- No new SQL migrations.
- No edits in main checkout (only
.worktrees/claude/p3-7-1-mcp-toolsfiles changed). - 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
9c7165d5source) - Dispatch packet error code taxonomy
- Source prompt §P3.7.1 single-arbiter posture + gotchas
Step 3 (packet) gates on this contract.