@blamejs/core 0.8.83 → 0.8.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,473 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mcp.toolRegistry
4
+ * @nav MCP
5
+ * @title MCP Tool Registry
6
+ * @order 500
7
+ *
8
+ * @intro
9
+ * Model Context Protocol tool registry — operator-side primitive
10
+ * that pairs every registered tool with a signed descriptor (so the
11
+ * downstream LLM can verify the tool's input/output contract hasn't
12
+ * been tampered with by a MCP middleman) and signs every outbound
13
+ * tool-call envelope (so the upstream server can verify the call
14
+ * actually came from this operator's MCP client, not an injected
15
+ * prompt that synthesized a tool call).
16
+ *
17
+ * The MCP threat model surfaced by ATLAS v5.3.0 (Jan 2026) is:
18
+ *
19
+ * 1. Compromised MCP server — operator's LLM-side tool descriptor
20
+ * differs from what the server actually executes (parameter
21
+ * renames, type narrowing). Tool registry signs the descriptor
22
+ * at registration so any drift is detectable at call time.
23
+ * 2. MCP middleman / indirect prompt injection — adversarial
24
+ * content reaches the LLM and convinces it to emit a synthetic
25
+ * tool call. Tool-call signing requires every call to carry an
26
+ * ML-DSA-87 signature over `{ tool, argsHash, nonce, iat, exp }`
27
+ * — the server-side verifier refuses unsigned calls or calls
28
+ * whose nonce has been seen.
29
+ *
30
+ * Public surface:
31
+ *
32
+ * b.mcp.toolRegistry.create({ tools, signingKey, verifyingKey? })
33
+ * → { register, list, get, descriptorsManifest,
34
+ * signCall, verifyCall }
35
+ *
36
+ * - register(tool) → stores + re-signs the descriptor
37
+ * - list() → frozen array of descriptors
38
+ * - get(name) → descriptor or null
39
+ * - descriptorsManifest() → JSON document with signature over the
40
+ * full descriptor set; suitable for
41
+ * operator-side attestation / shipping
42
+ * alongside the MCP server URL
43
+ * - signCall({...}) → envelope + signature for outbound calls
44
+ * - verifyCall({...},opts) → verifies inbound signed envelope
45
+ *
46
+ * PQC-first per the framework rule: default algorithm is ML-DSA-87.
47
+ * Operators using a legacy MCP peer override via opts.alg
48
+ * ("ed25519" | "es256" | "es384" | "es512" | "ml-dsa-44" |
49
+ * "ml-dsa-65" | "ml-dsa-87" | "slh-dsa-shake-256f").
50
+ *
51
+ * Replay defense: `verifyCall` takes an operator-supplied
52
+ * `seen(jti)` callback. Same shape as `b.auth.oauth.refreshAccess
53
+ * Token({ seen })` — returns truthy if the nonce has been seen, false
54
+ * otherwise. Operator persists nonces in a TTL store (Redis,
55
+ * SQLite) for the call's exp window.
56
+ *
57
+ * @card
58
+ * Model Context Protocol tool registry — signed tool descriptors + signed tool-call envelopes to mitigate MCP middleman + indirect prompt injection (ATLAS v5.3.0).
59
+ */
60
+
61
+ var nodeCrypto = require("node:crypto");
62
+ var canonicalJson = require("./canonical-json");
63
+ var lazyRequire = require("./lazy-require");
64
+ var validateOpts = require("./validate-opts");
65
+ var { McpError } = require("./framework-error");
66
+
67
+ var crypto = lazyRequire(function () { return require("./crypto"); });
68
+ var audit = lazyRequire(function () { return require("./audit"); });
69
+ var C = require("./constants");
70
+
71
+ // Shared name-shape regex across mcp.js / mcp-tool-registry.js / a2a-tasks.js
72
+ // — every agent-protocol identifier follows the same RFC-3986-unreserved
73
+ // shape with a 64-char cap (MCP tool name, A2A skill name).
74
+ var TOOL_NAME_RE = /^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/; // allow:duplicate-regex — common identifier shape; consolidating would couple the protocols
75
+
76
+ var ALLOWED_ALGS = Object.freeze([
77
+ "ed25519",
78
+ "es256", "es384", "es512",
79
+ "ml-dsa-44", "ml-dsa-65", "ml-dsa-87",
80
+ "slh-dsa-shake-256f",
81
+ ]);
82
+
83
+ function _emitAudit(action, metadata, outcome) {
84
+ try {
85
+ audit().safeEmit({
86
+ action: action,
87
+ outcome: outcome || "success",
88
+ metadata: metadata,
89
+ });
90
+ } catch (_e) { /* best-effort */ }
91
+ }
92
+
93
+ function _validateAlg(alg, label) {
94
+ if (alg === undefined || alg === null) return "ml-dsa-87";
95
+ validateOpts.requireNonEmptyString(alg, label, McpError, "mcp/bad-alg");
96
+ if (ALLOWED_ALGS.indexOf(alg) === -1) {
97
+ throw new McpError("mcp/bad-alg",
98
+ label + ": alg '" + alg + "' not in registry; allowed: " + ALLOWED_ALGS.join(", "));
99
+ }
100
+ return alg;
101
+ }
102
+
103
+ function _hashArgs(args) {
104
+ // SHA3-256 over the canonical-JSON serialization of the args object.
105
+ // Canonical-JSON ensures a server and client agree on bytes despite
106
+ // JSON object-key ordering differences.
107
+ var bytes = Buffer.from(canonicalJson.stringify(args === undefined ? null : args), "utf8");
108
+ return nodeCrypto.createHash("sha3-256").update(bytes).digest("hex");
109
+ }
110
+
111
+ function _validateTool(tool, where) {
112
+ if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
113
+ throw new McpError("mcp/bad-tool",
114
+ where + ": tool must be a non-null object", true);
115
+ }
116
+ validateOpts.requireNonEmptyString(tool.name, where + ".name", McpError, "mcp/bad-tool-name");
117
+ if (tool.name.length > 64 || !TOOL_NAME_RE.test(tool.name)) { // allow:raw-byte-literal — MCP tool-name length cap, not byte count
118
+ throw new McpError("mcp/bad-tool-name",
119
+ where + ".name '" + tool.name + "' must match " + TOOL_NAME_RE);
120
+ }
121
+ if (!tool.inputSchema || typeof tool.inputSchema !== "object" || Array.isArray(tool.inputSchema)) {
122
+ throw new McpError("mcp/bad-tool-schema",
123
+ where + ".inputSchema must be a JSON-Schema-shaped object", true);
124
+ }
125
+ validateOpts.optionalNonEmptyString(
126
+ tool.description, where + ".description", McpError, "mcp/bad-tool-description");
127
+ }
128
+
129
+ /**
130
+ * @primitive b.mcp.toolRegistry.create
131
+ * @signature b.mcp.toolRegistry.create(opts)
132
+ * @since 0.8.85
133
+ * @status stable
134
+ *
135
+ * Build an MCP tool registry. Operator passes `tools` (array of tool
136
+ * descriptors) and `signingKey` (PEM). Each tool gets a signed
137
+ * descriptor blob `{ tool, alg, signature }` that the operator can
138
+ * ship to the LLM-side runtime as an attestation. `signCall` /
139
+ * `verifyCall` produce + verify the per-call envelope.
140
+ *
141
+ * Returned object is frozen at construction; tool changes go through
142
+ * `register(tool)` which re-signs the descriptor.
143
+ *
144
+ * @opts
145
+ * tools: array of { name, inputSchema, outputSchema?, description? }
146
+ * signingKey: PEM string (required for signCall + register)
147
+ * verifyingKey: PEM string (required for verifyCall on inbound calls)
148
+ * alg: algorithm name (default "ml-dsa-87")
149
+ * ttlMs: default call envelope TTL (default 5 minutes)
150
+ *
151
+ * @example
152
+ * var registry = b.mcp.toolRegistry.create({
153
+ * tools: [
154
+ * { name: "search", inputSchema: { type: "object", properties: {
155
+ * query: { type: "string" } }, required: ["query"] } },
156
+ * ],
157
+ * signingKey: pair.privateKey,
158
+ * verifyingKey: pair.publicKey,
159
+ * });
160
+ *
161
+ * // Outbound call
162
+ * var envelope = registry.signCall({
163
+ * toolName: "search",
164
+ * args: { query: "blamejs" },
165
+ * });
166
+ * // → { envelope: { tool, argsHash, nonce, iat, exp }, signature: "..." }
167
+ *
168
+ * // Inbound verify (operator supplies a seen() callback for replay defense)
169
+ * var ok = registry.verifyCall(envelope, {
170
+ * seen: function (nonce) { return nonceStore.has(nonce); },
171
+ * });
172
+ */
173
+ function create(opts) {
174
+ if (!opts || typeof opts !== "object") {
175
+ throw new McpError("mcp/bad-registry-opts",
176
+ "toolRegistry.create: opts required (tools + signingKey)", true);
177
+ }
178
+ if (!Array.isArray(opts.tools)) {
179
+ throw new McpError("mcp/bad-registry-opts",
180
+ "toolRegistry.create: opts.tools must be an array", true);
181
+ }
182
+ validateOpts.requireNonEmptyString(
183
+ opts.signingKey, "toolRegistry.create.signingKey", McpError, "mcp/bad-signing-key");
184
+ validateOpts.optionalNonEmptyString(
185
+ opts.verifyingKey, "toolRegistry.create.verifyingKey", McpError, "mcp/bad-verifying-key");
186
+ var alg = _validateAlg(opts.alg, "toolRegistry.create.alg");
187
+ var defaultTtlMs = opts.ttlMs !== undefined ? opts.ttlMs : C.TIME.minutes(5);
188
+ if (typeof defaultTtlMs !== "number" || !isFinite(defaultTtlMs) || defaultTtlMs < 1000) { // allow:raw-byte-literal — minimum-ttl threshold (1 second), not bytes
189
+ throw new McpError("mcp/bad-ttl",
190
+ "toolRegistry.create.ttlMs must be >= 1000 ms", true);
191
+ }
192
+
193
+ var signingKey = opts.signingKey;
194
+ var verifyingKey = opts.verifyingKey || null;
195
+ var tools = Object.create(null);
196
+
197
+ function _signDescriptor(tool) {
198
+ var payload = canonicalJson.stringify({
199
+ tool: tool.name,
200
+ description: tool.description || "",
201
+ inputSchema: tool.inputSchema,
202
+ outputSchema: tool.outputSchema || null,
203
+ alg: alg,
204
+ });
205
+ var sig = crypto().sign(Buffer.from(payload, "utf8"), signingKey);
206
+ return {
207
+ tool: tool.name,
208
+ description: tool.description || "",
209
+ inputSchema: tool.inputSchema,
210
+ outputSchema: tool.outputSchema || null,
211
+ alg: alg,
212
+ signature: sig.toString("base64"),
213
+ };
214
+ }
215
+
216
+ function register(tool) {
217
+ _validateTool(tool, "toolRegistry.register");
218
+ var descriptor = _signDescriptor(tool);
219
+ tools[tool.name] = Object.freeze(descriptor);
220
+ _emitAudit("mcp.tool_registry.registered",
221
+ { tool: tool.name, alg: alg });
222
+ return descriptor;
223
+ }
224
+
225
+ // Bootstrap-register every tool in opts.tools at construction.
226
+ for (var i = 0; i < opts.tools.length; i += 1) {
227
+ register(opts.tools[i]);
228
+ }
229
+
230
+ function list() {
231
+ var names = Object.keys(tools).sort(); // allow:bare-canonicalize-walk — deterministic-ordering of registered-tool names, not JSON canonicalization
232
+ var out = new Array(names.length);
233
+ for (var i2 = 0; i2 < names.length; i2 += 1) {
234
+ out[i2] = tools[names[i2]];
235
+ }
236
+ return Object.freeze(out);
237
+ }
238
+
239
+ function get(name) {
240
+ if (typeof name !== "string" || name.length === 0) return null;
241
+ return tools[name] || null;
242
+ }
243
+
244
+ function descriptorsManifest() {
245
+ var rows = list();
246
+ var manifestBody = canonicalJson.stringify({
247
+ alg: alg,
248
+ tools: rows.map(function (d) {
249
+ return {
250
+ tool: d.tool,
251
+ inputSchema: d.inputSchema,
252
+ outputSchema: d.outputSchema,
253
+ };
254
+ }),
255
+ issuedAt: new Date().toISOString(),
256
+ });
257
+ var sig = crypto().sign(Buffer.from(manifestBody, "utf8"), signingKey);
258
+ return {
259
+ body: manifestBody,
260
+ signature: sig.toString("base64"),
261
+ alg: alg,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * @primitive b.mcp.toolRegistry.create.signCall
267
+ * @signature b.mcp.toolRegistry.create.signCall(opts)
268
+ * @since 0.8.85
269
+ *
270
+ * Build + sign an outbound tool-call envelope. Returns
271
+ * `{ envelope, signature, alg }`. The envelope shape is
272
+ * `{ tool, argsHash, nonce, iat, exp }` where `argsHash` is the
273
+ * SHA3-256 hex digest of canonical-JSON(args) so the server can
274
+ * verify the args bytes match without including them in the
275
+ * signature (smaller envelope, no double-encoding).
276
+ *
277
+ * @opts
278
+ * toolName: string, // required — must match a registered tool
279
+ * args: object, // tool input arguments
280
+ * nonce: string, // optional — caller-supplied; default 128-bit random hex
281
+ * ttlMs: number, // optional — overrides registry default
282
+ *
283
+ * @example
284
+ * var env = registry.signCall({
285
+ * toolName: "search",
286
+ * args: { query: "blamejs" },
287
+ * });
288
+ * // env.envelope.tool === "search"
289
+ * // env.envelope.argsHash === <hex>
290
+ * // env.envelope.nonce === <hex>
291
+ * // env.envelope.iat === ISO timestamp
292
+ * // env.envelope.exp === ISO timestamp (iat + ttlMs)
293
+ */
294
+ function signCall(callOpts) {
295
+ if (!callOpts || typeof callOpts !== "object") {
296
+ throw new McpError("mcp/bad-call-opts",
297
+ "signCall: opts required (toolName + args)", true);
298
+ }
299
+ validateOpts.requireNonEmptyString(
300
+ callOpts.toolName, "signCall.toolName", McpError, "mcp/bad-tool-name");
301
+ if (!tools[callOpts.toolName]) {
302
+ throw new McpError("mcp/unregistered-tool",
303
+ "signCall: tool '" + callOpts.toolName + "' not registered", true);
304
+ }
305
+ var ttlMs = callOpts.ttlMs !== undefined ? callOpts.ttlMs : defaultTtlMs;
306
+ if (typeof ttlMs !== "number" || !isFinite(ttlMs) || ttlMs < 1000) { // allow:raw-byte-literal — minimum-ttl threshold (1 second), not bytes
307
+ throw new McpError("mcp/bad-ttl",
308
+ "signCall: ttlMs must be >= 1000 ms", true);
309
+ }
310
+ var nonce = typeof callOpts.nonce === "string" && callOpts.nonce.length > 0
311
+ ? callOpts.nonce
312
+ : crypto().generateToken(16); // allow:raw-byte-literal — 128-bit nonce, not byte arithmetic on a payload
313
+ var iat = new Date();
314
+ var exp = new Date(iat.getTime() + ttlMs);
315
+ var envelope = {
316
+ tool: callOpts.toolName,
317
+ argsHash: _hashArgs(callOpts.args),
318
+ nonce: nonce,
319
+ iat: iat.toISOString(),
320
+ exp: exp.toISOString(),
321
+ };
322
+ var payload = Buffer.from(canonicalJson.stringify(envelope), "utf8");
323
+ var sig = crypto().sign(payload, signingKey);
324
+ _emitAudit("mcp.tool_registry.call_signed",
325
+ { tool: envelope.tool, nonce: nonce, alg: alg });
326
+ return {
327
+ envelope: envelope,
328
+ signature: sig.toString("base64"),
329
+ alg: alg,
330
+ };
331
+ }
332
+
333
+ /**
334
+ * @primitive b.mcp.toolRegistry.create.verifyCall
335
+ * @signature b.mcp.toolRegistry.create.verifyCall(signedCall, opts?)
336
+ * @since 0.8.85
337
+ *
338
+ * Verify an inbound tool-call envelope. Required `signedCall` shape
339
+ * is `{ envelope, signature, alg }`. Returns `true` on success;
340
+ * throws `mcp/call-verify-failed` (or a more specific code) on any
341
+ * failure:
342
+ *
343
+ * - mcp/call-verify-failed — signature mismatch
344
+ * - mcp/call-expired — `exp` past current wall clock
345
+ * - mcp/call-replay — seen(nonce) returned truthy
346
+ * - mcp/call-unregistered-tool — envelope.tool not in registry
347
+ * - mcp/call-args-mismatch — argsHash doesn't match the supplied args
348
+ *
349
+ * Replay defense: operator supplies `opts.seen(nonce) → boolean`.
350
+ * Common shape: `Map.has(nonce)` against an in-memory cache with
351
+ * TTL matching the envelope's `exp - iat`. Without a seen()
352
+ * callback, replay defense is skipped (caller's choice).
353
+ *
354
+ * @opts
355
+ * args: object, // optional — when present, argsHash is checked
356
+ * seen: function (nonce) → boolean, // optional — replay-defense callback
357
+ * nowMs: number, // optional — override Date.now() (testing only)
358
+ *
359
+ * @example
360
+ * try {
361
+ * var ok = registry.verifyCall(signedFromClient, {
362
+ * args: actualArgs,
363
+ * seen: function (nonce) { return nonceCache.has(nonce); },
364
+ * });
365
+ * } catch (e) {
366
+ * if (e.code === "mcp/call-replay") return refuseReplay();
367
+ * if (e.code === "mcp/call-expired") return refuseExpired();
368
+ * throw e;
369
+ * }
370
+ */
371
+ function verifyCall(signedCall, verifyOpts) {
372
+ if (!signedCall || typeof signedCall !== "object") {
373
+ throw new McpError("mcp/bad-signed-call",
374
+ "verifyCall: signedCall must be an object", true);
375
+ }
376
+ if (!signedCall.envelope || typeof signedCall.envelope !== "object") {
377
+ throw new McpError("mcp/bad-signed-call",
378
+ "verifyCall: signedCall.envelope required", true);
379
+ }
380
+ validateOpts.requireNonEmptyString(
381
+ signedCall.signature, "verifyCall.signature", McpError, "mcp/bad-signature");
382
+ var env = signedCall.envelope;
383
+ validateOpts.requireNonEmptyString(env.tool, "verifyCall.envelope.tool", McpError, "mcp/bad-tool-name");
384
+ validateOpts.requireNonEmptyString(env.nonce, "verifyCall.envelope.nonce", McpError, "mcp/bad-nonce");
385
+ validateOpts.requireNonEmptyString(env.argsHash, "verifyCall.envelope.argsHash", McpError, "mcp/bad-args-hash");
386
+ validateOpts.requireNonEmptyString(env.iat, "verifyCall.envelope.iat", McpError, "mcp/bad-iat");
387
+ validateOpts.requireNonEmptyString(env.exp, "verifyCall.envelope.exp", McpError, "mcp/bad-exp");
388
+
389
+ if (!tools[env.tool]) {
390
+ _emitAudit("mcp.tool_registry.call_unregistered",
391
+ { tool: env.tool, nonce: env.nonce }, "denied");
392
+ throw new McpError("mcp/call-unregistered-tool",
393
+ "verifyCall: tool '" + env.tool + "' not registered");
394
+ }
395
+
396
+ var nowMs = (verifyOpts && typeof verifyOpts.nowMs === "number") ? verifyOpts.nowMs : Date.now();
397
+ var expMs = Date.parse(env.exp);
398
+ if (!isFinite(expMs)) {
399
+ throw new McpError("mcp/bad-exp",
400
+ "verifyCall: envelope.exp is not a valid ISO timestamp");
401
+ }
402
+ if (nowMs > expMs) {
403
+ _emitAudit("mcp.tool_registry.call_expired",
404
+ { tool: env.tool, nonce: env.nonce, exp: env.exp }, "denied");
405
+ throw new McpError("mcp/call-expired",
406
+ "verifyCall: envelope expired at " + env.exp);
407
+ }
408
+
409
+ // Replay-defense — operator-supplied seen() callback.
410
+ if (verifyOpts && typeof verifyOpts.seen === "function" && verifyOpts.seen(env.nonce)) {
411
+ _emitAudit("mcp.tool_registry.call_replay",
412
+ { tool: env.tool, nonce: env.nonce }, "denied");
413
+ throw new McpError("mcp/call-replay",
414
+ "verifyCall: nonce '" + env.nonce + "' already seen");
415
+ }
416
+
417
+ // Optional args-hash binding when operator passes the raw args.
418
+ if (verifyOpts && verifyOpts.args !== undefined) {
419
+ var expected = _hashArgs(verifyOpts.args);
420
+ if (expected !== env.argsHash) {
421
+ _emitAudit("mcp.tool_registry.call_args_mismatch",
422
+ { tool: env.tool, nonce: env.nonce }, "denied");
423
+ throw new McpError("mcp/call-args-mismatch",
424
+ "verifyCall: args don't match envelope.argsHash");
425
+ }
426
+ }
427
+
428
+ // Signature verify.
429
+ if (!verifyingKey) {
430
+ throw new McpError("mcp/no-verifying-key",
431
+ "verifyCall: registry was created without a verifyingKey; verifyCall is inbound-only and requires one");
432
+ }
433
+ var payload = Buffer.from(canonicalJson.stringify(env), "utf8");
434
+ var sigBuf;
435
+ try { sigBuf = Buffer.from(signedCall.signature, "base64"); }
436
+ catch (_e) {
437
+ throw new McpError("mcp/bad-signature",
438
+ "verifyCall: signature not valid base64");
439
+ }
440
+ var ok;
441
+ try { ok = crypto().verify(payload, sigBuf, verifyingKey); }
442
+ catch (verifyErr) {
443
+ _emitAudit("mcp.tool_registry.call_verify_error",
444
+ { tool: env.tool, nonce: env.nonce, error: String(verifyErr.message || verifyErr) }, "denied");
445
+ throw new McpError("mcp/call-verify-failed",
446
+ "verifyCall: " + (verifyErr.message || verifyErr));
447
+ }
448
+ if (!ok) {
449
+ _emitAudit("mcp.tool_registry.call_verify_failed",
450
+ { tool: env.tool, nonce: env.nonce }, "denied");
451
+ throw new McpError("mcp/call-verify-failed",
452
+ "verifyCall: signature did not verify against registry's verifyingKey");
453
+ }
454
+ _emitAudit("mcp.tool_registry.call_verified",
455
+ { tool: env.tool, nonce: env.nonce });
456
+ return true;
457
+ }
458
+
459
+ return Object.freeze({
460
+ register: register,
461
+ list: list,
462
+ get: get,
463
+ descriptorsManifest: descriptorsManifest,
464
+ signCall: signCall,
465
+ verifyCall: verifyCall,
466
+ alg: alg,
467
+ });
468
+ }
469
+
470
+ module.exports = {
471
+ create: create,
472
+ ALLOWED_ALGS: ALLOWED_ALGS,
473
+ };
package/lib/mcp.js CHANGED
@@ -923,6 +923,8 @@ function _elicitationGuard(opts) {
923
923
  return { enforce: enforce };
924
924
  }
925
925
 
926
+ var mcpToolRegistry = require("./mcp-tool-registry");
927
+
926
928
  module.exports = {
927
929
  serverGuard: serverGuard,
928
930
  parseRequest: parseRequest,
@@ -933,5 +935,6 @@ module.exports = {
933
935
  assertProtocolVersion: _assertProtocolVersion,
934
936
  sampling: { guard: _samplingGuard },
935
937
  elicitation: { guard: _elicitationGuard },
938
+ toolRegistry: mcpToolRegistry,
936
939
  MCP_PROTOCOL_VERSIONS_ACCEPTED: MCP_PROTOCOL_VERSIONS_ACCEPTED,
937
940
  };