Model Context Protocol
Model Context Protocol server hardening — input validation, OAuth integration per RFC 9728, scope enforcement, audit emission.
The guard is the secure-by-default front door for an HTTP endpoint that speaks MCP. Every default refuses; operators opt into capabilities (dynamic client registration, specific tools, specific resources) deliberately. The 2025-2026 CVE class — auth-bypass on unauthenticated tool / resource invocations (CVE-2026-33032 class) plus OAuth redirect_uri abuse (CVE-2025-6514 class) plus the confused-deputy pattern when static client IDs combine with dynamic registration — is what the guard's defaults exist to close.
Wire format is JSON-RPC 2.0; parseRequest is the envelope validator (jsonrpc version, method shape, id type, params type) and refuse is the matching error responder so handlers stay in the same shape the guard rejects with. OAuth redirect_uris are exact-match against an allowlist and required to be HTTPS (or localhost) per RFC 9700 §4.1.1.
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;
}
b.mcp.parseRequest(body, opts) #
{
errorClass: Function, // default McpError; inject for custom error classes
}
Validate a JSON-RPC 2.0 envelope. Accepts a raw string (parsed via b.safeJson.parse with a 1 MiB cap) or an already-parsed object. Throws an McpError with a code matching the violation (BAD_JSON / BAD_ENVELOPE / BAD_VERSION / BAD_METHOD / BAD_ID / BAD_PARAMS). Returns the parsed envelope on success.
var envelope = b.mcp.parseRequest('{"jsonrpc":"2.0","method":"tools/list","id":1}', {});
envelope.method;
// → "tools/list"
b.mcp.refuse(res, code, message, id) #
Write a JSON-RPC 2.0 error reply to res. The code is the negative JSON-RPC error code (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error, -32001 auth required); HTTP status is mapped from it (parse / invalid-request -> 400, method-not-found -> 404, internal -> 500, default -> 400). id defaults to null when undefined per the spec for unidentifiable requests.
var http = require("http");
var srv = http.createServer(function (req, res) {
b.mcp.refuse(res, -32601, "method not found", 7);
});
srv.listen(0);
// → writes { jsonrpc: "2.0", error: { code: -32601, message: "method not found" }, id: 7 }
srv.close();
b.mcp.serverGuard(opts) #
{
requireBearer: boolean, // default true
verifyBearer: function, // (token, req) -> Promise
redirectUriAllowlist: Array, // exact-match URIs
allowDynamicRegister: boolean, // default false
registerClientAllowlist: function, // (body) -> bool — required when allowDynamicRegister
toolAllowlist: Array, // null = allow any shape-valid tool
resourceAllowlist: Array, // null = allow any shape-valid resource
maxBodyBytes: number, // default 1 MiB
errorClass: Function, // default McpError
audit: boolean, // default true
}
Build the MCP request-lifecycle middleware. Bearer-required by default (operator supplies verifyBearer to validate the token); dynamic-client-registration refused by default; redirect_uris exact-match an HTTPS-or-localhost allowlist; tool / resource names are shape-validated and optionally allowlist-gated; the body is read through a bounded chunk collector. Every refusal emits an audit event (mcp.auth.missing-bearer / mcp.tool.refused / etc.) unless audit:false. Returns a (req, res, next) middleware function that attaches req.mcpRequest + req.mcpClaims on success.
var guard = b.mcp.serverGuard({
requireBearer: true,
verifyBearer: function (token, _req) {
return token === "operator-issued-bearer-token-32-chars-min" ? { sub: "ops" } : null;
},
toolAllowlist: ["search.docs", "search.tickets"],
resourceAllowlist: ["mcp://docs/handbook"],
});
typeof guard;
// → "function"
b.mcp.toolResult.sanitize(result, opts?) #
{
{
posture?: "refuse" | "sanitize" | "audit-only", // default "refuse"
maxTextBytes?: number, // default 64 KiB per content block
allowedHosts?: string[], // for image/audio/resource_link refs
}
}
OWASP LLM02 — model/tool-output sanitization. MCP tool calls frequently return content the host model interprets as further instructions; an attacker-controlled tool surface can return { type: "text", text: "Ignore prior instructions and ..." }, , OR markdown image links pointing at exfiltration endpoints. The framework's defense:
- Strip / refuse executable HTML ( / / javascript: URLs) via built-in dangerous-HTML detection - Refuse known prompt-injection markers ("ignore previous instructions", "system: you are now ...", role-claim prefixes) via a built-in injection-marker matcher - Cap text length so a tool can't blow the host's context window out from under it - Refuse content with image_url / audio_url / resource_link pointing at non-allowlisted hosts (data-exfil via auto-fetch)
Returns either the cleaned result (when sanitize: true) or throws McpError("mcp/tool-output-refused", ...) (default — fail-closed). Operators with a known-good tool surface that needs raw passthrough opt out via posture: "audit-only".
var safe = b.mcp.toolResult.sanitize(toolResp, { posture: "sanitize" });
// → { content: [{ type: "text", text: "" }] }
b.mcp.capability.create(scopes) #
OWASP LLM08 — capability primitive. Wraps an MCP tool/resource registration with a scope set the host model's session must hold before the tool/resource is exposed. Defaults to deny-all; the operator's session-decoration step grants scopes per user / per agent / per delegated-actor.
Returns { scopes, satisfiedBy(grantedSet) } — the guard checks satisfiedBy(session.capabilities) before each tool/resource dispatch. Falsy → refuse with mcp/capability-denied.
var fileRead = b.mcp.capability.create(["fs:read"]);
if (!fileRead.satisfiedBy(session.capabilities)) {
throw new Error("mcp/capability-denied");
}
b.mcp.validateToolInput(toolName, input, schema) #
OWASP LLM07 — JSON-Schema enforcement on MCP tool inputs. Tools declare an inputSchema (JSON Schema 2020-12 subset) at registration; before each invocation the framework validates incoming arguments against the schema and refuses on any drift. Composes b.safeSchema for the validation engine — same primitive the OpenAPI surface uses, so the threat model is uniform.
Returns the validated (possibly coerced) input object on success; throws McpError("mcp/tool-input-invalid", ...) on schema breach.
var schema = { type: "object",
properties: { path: { type: "string" } },
required: ["path"] };
var input = b.mcp.validateToolInput("read_file", { path: "/x" }, schema);
b.mcp.assertProtocolVersion(req, opts?) #
{
{
accepted?: string[], // override the default acceptance set
allowMissing?: boolean, // true → return null when header absent
}
}
MCP 2025-11-25 spec §4.1 — every HTTP request after initialize MUST carry an MCP-Protocol-Version header naming a version the server supports. Returns the resolved version on success; throws with a tagged refusal when the header is missing OR names an unsupported version. Clients pre-negotiation (before initialize) may omit the header — the resolved value is null in that case.
var version = b.mcp.assertProtocolVersion(req, { allowMissing: false });
// throws if missing/unsupported; returns e.g. "2025-11-25" on success.
b.mcp.sampling.guard(opts?) #
{
{
maxRequestsPerSession?: number, // default 10
maxMessagesPerRequest?: number, // default 20
maxTokensPerRequest?: number, // default 4096
allowedModelHints?: string[], // null → allow all
refuseStopSequences?: boolean, // refuse client-supplied stop sequences
}
}
MCP server-initiated sampling/createMessage gate — the highest- risk surface in the protocol. A compromised tool can issue sampling/createMessage to make the host model emit attacker- chosen text. This primitive returns a guard function the operator wraps around the sampling endpoint that refuses requests violating size caps, allow-listed models, or budget-per-session.
Returns { enforce(samplingRequest, sessionId), reset(sessionId) }. enforce throws on violation; the operator wraps the actual model call only after enforce returns.
var guard = b.mcp.sampling.guard({ maxRequestsPerSession: 5 });
server.on("sampling/createMessage", function (req, sid) {
guard.enforce(req, sid); // throws on violation
return invokeModel(req);
});
b.mcp.elicitation.guard(opts?) #
{
{
maxMessageBytes?: number, // default 8 KiB
allowedSchemaTypes?: string[], // default ["object"]
posture?: "refuse" | "sanitize" | "audit-only",
}
}
MCP 2025-11-25 elicitation/create gate — server-initiated user prompt requests. Refuses prompts whose message contains prompt-injection markers OR requestedSchema shape is missing. The risk class is symmetric to sampling: a compromised tool can elicit credentials / approval-text from the user. This guard applies the same prompt-injection scan toolResult.sanitize does, plus an allow-listed requestedSchema.type set.
var guard = b.mcp.elicitation.guard({ posture: "refuse" });
guard.enforce({
message: "What's your name?",
requestedSchema: { type: "object", properties: { name: { type: "string" } } },
});
Last updated 2026-08-08T16:39:49.652Z by seeder.