@blamejs/core 0.8.82 → 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.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/index.js +6 -0
- package/lib/a2a-tasks.js +598 -0
- package/lib/a2a.js +10 -0
- package/lib/acme.js +189 -5
- package/lib/audit.js +1 -0
- package/lib/cache-status.js +288 -0
- package/lib/compliance.js +36 -0
- package/lib/framework-error.js +19 -0
- package/lib/mcp-tool-registry.js +473 -0
- package/lib/mcp.js +3 -0
- package/lib/middleware/idempotency-key.js +424 -0
- package/lib/middleware/index.js +10 -0
- package/lib/middleware/no-cache.js +106 -0
- package/lib/problem-details.js +439 -0
- package/lib/server-timing.js +174 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -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
|
};
|