MCP Tool Registry
Model Context Protocol tool registry — operator-side primitive that pairs every registered tool with a signed descriptor (so the downstream LLM can verify the tool's input/output contract hasn't been tampered with by a MCP middleman) and signs every outbound tool-call envelope (so the upstream server can verify the call actually came from this operator's MCP client, not an injected prompt that synthesized a tool call).
The MCP threat model surfaced by ATLAS v5.3.0 (Jan 2026) is:
1. Compromised MCP server — operator's LLM-side tool descriptor differs from what the server actually executes (parameter renames, type narrowing). Tool registry signs the descriptor at registration so any drift is detectable at call time. 2. MCP middleman / indirect prompt injection — adversarial content reaches the LLM and convinces it to emit a synthetic tool call. Tool-call signing requires every call to carry an ML-DSA-87 signature over { tool, argsHash, nonce, iat, exp } — the server-side verifier refuses unsigned calls or calls whose nonce has been seen.
Public surface:
b.mcp.toolRegistry.create({ tools, signingKey, verifyingKey? }) → { register, list, get, descriptorsManifest, signCall, verifyCall }
- register(tool) → stores + re-signs the descriptor - list() → frozen array of descriptors - get(name) → descriptor or null - descriptorsManifest() → JSON document with signature over the full descriptor set; suitable for operator-side attestation / shipping alongside the MCP server URL - signCall({...}) → envelope + signature for outbound calls - verifyCall({...},opts) → verifies inbound signed envelope
PQC-first per the framework rule: default algorithm is ML-DSA-87. Operators using a legacy MCP peer override via opts.alg ("ed25519" | "es256" | "es384" | "es512" | "ml-dsa-44" | "ml-dsa-65" | "ml-dsa-87" | "slh-dsa-shake-256f").
Replay defense: verifyCall takes an operator-supplied seen(jti) callback. Same shape as b.auth.oauth.refreshAccess Token({ seen }) — returns truthy if the nonce has been seen, false otherwise. Operator persists nonces in a TTL store (Redis, SQLite) for the call's exp window.
b.mcp.toolRegistry.create(opts) #
{
tools: array of { name, inputSchema, outputSchema?, description? }
signingKey: PEM string (required for signCall + register)
verifyingKey: PEM string (required for verifyCall on inbound calls)
alg: algorithm name (default "ml-dsa-87")
ttlMs: default call envelope TTL (default 5 minutes)
}
Build an MCP tool registry. Operator passes tools (array of tool descriptors) and signingKey (PEM). Each tool gets a signed descriptor blob { tool, alg, signature } that the operator can ship to the LLM-side runtime as an attestation. signCall / verifyCall produce + verify the per-call envelope.
Returned object is frozen at construction; tool changes go through register(tool) which re-signs the descriptor.
var registry = b.mcp.toolRegistry.create({
tools: [
{ name: "search", inputSchema: { type: "object", properties: {
query: { type: "string" } }, required: ["query"] } },
],
signingKey: pair.privateKey,
verifyingKey: pair.publicKey,
});
// Outbound call
var envelope = registry.signCall({
toolName: "search",
args: { query: "blamejs" },
});
// → { envelope: { tool, argsHash, nonce, iat, exp }, signature: "..." }
// Inbound verify (operator supplies a seen() callback for replay defense)
var ok = registry.verifyCall(envelope, {
seen: function (nonce) { return nonceStore.has(nonce); },
});
b.mcp.toolRegistry.create.signCall(opts) #
{
toolName: string, // required — must match a registered tool
args: object, // tool input arguments
nonce: string, // optional — caller-supplied; default 128-bit random hex
ttlMs: number, // optional — overrides registry default
}
Build + sign an outbound tool-call envelope. Returns { envelope, signature, alg }. The envelope shape is { tool, argsHash, nonce, iat, exp } where argsHash is the SHA3-256 hex digest of canonical-JSON(args) so the server can verify the args bytes match without including them in the signature (smaller envelope, no double-encoding).
var env = registry.signCall({
toolName: "search",
args: { query: "blamejs" },
});
// env.envelope.tool === "search"
// env.envelope.argsHash ===
// env.envelope.nonce ===
// env.envelope.iat === ISO timestamp
// env.envelope.exp === ISO timestamp (iat + ttlMs)
b.mcp.toolRegistry.create.verifyCall(signedCall, opts?) #
{
args: object, // optional — when present, argsHash is checked
seen: function (nonce) → boolean, // optional — replay-defense callback
nowMs: number, // optional — override Date.now() (testing only)
}
Verify an inbound tool-call envelope. Required signedCall shape is { envelope, signature, alg }. Returns true on success; throws mcp/call-verify-failed (or a more specific code) on any failure:
- mcp/call-verify-failed — signature mismatch
- mcp/call-expired —
exppast current wall clock - mcp/call-replay — seen(nonce) returned truthy
- mcp/call-unregistered-tool — envelope.tool not in registry
- mcp/call-args-mismatch — argsHash doesn't match the supplied args
Replay defense: operator supplies opts.seen(nonce) → boolean. Common shape: Map.has(nonce) against an in-memory cache with TTL matching the envelope's exp - iat. Without a seen() callback, replay defense is skipped (caller's choice).
try {
var ok = registry.verifyCall(signedFromClient, {
args: actualArgs,
seen: function (nonce) { return nonceCache.has(nonce); },
});
} catch (e) {
if (e.code === "mcp/call-replay") return refuseReplay();
if (e.code === "mcp/call-expired") return refuseExpired();
throw e;
}
Last updated 2026-08-08T16:39:49.652Z by seeder.