@blamejs/core 0.8.83 → 0.8.87
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/index.js +7 -0
- package/lib/a2a-tasks.js +598 -0
- package/lib/a2a.js +10 -0
- package/lib/audit.js +1 -0
- package/lib/auth/fal.js +210 -0
- package/lib/cache-status.js +288 -0
- package/lib/compliance.js +36 -0
- package/lib/framework-error.js +19 -0
- package/lib/mail.js +84 -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/network-dns.js +39 -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
package/lib/mail.js
CHANGED
|
@@ -1735,8 +1735,92 @@ function create(opts) {
|
|
|
1735
1735
|
};
|
|
1736
1736
|
}
|
|
1737
1737
|
|
|
1738
|
+
/**
|
|
1739
|
+
* @primitive b.mail.feedbackId
|
|
1740
|
+
* @signature b.mail.feedbackId(opts)
|
|
1741
|
+
* @since 0.8.87
|
|
1742
|
+
* @status stable
|
|
1743
|
+
* @related b.mail.create
|
|
1744
|
+
*
|
|
1745
|
+
* Build a Gmail Feedback Loop (FBL) Feedback-ID header value per
|
|
1746
|
+
* Google's FBL convention: a colon-separated 4-tuple
|
|
1747
|
+
* `CampaignID:CustomerID:MailType:SenderID`. Setting Feedback-ID on
|
|
1748
|
+
* outbound mail lets Gmail surface per-campaign abuse-rate metrics
|
|
1749
|
+
* back via the Postmaster Tools API so operators see spam
|
|
1750
|
+
* complaints aggregated by their own campaign vocabulary instead of
|
|
1751
|
+
* by SMTP envelope-sender alone.
|
|
1752
|
+
*
|
|
1753
|
+
* Refuses missing / empty fields (`mail/bad-feedback-id-field`),
|
|
1754
|
+
* fields containing `:` (would corrupt the 4-tuple separator), and
|
|
1755
|
+
* fields longer than 64 bytes (Gmail truncates beyond ~64 chars per
|
|
1756
|
+
* field). Operators set the result via `mail.create({ headers:
|
|
1757
|
+
* { "Feedback-ID": b.mail.feedbackId({...}) } })` or attach it to
|
|
1758
|
+
* an individual send().
|
|
1759
|
+
*
|
|
1760
|
+
* @opts
|
|
1761
|
+
* campaignId: string, // operator's campaign tag (e.g. "wk26-promo")
|
|
1762
|
+
* customerId: string, // operator's tenant or user-segment id
|
|
1763
|
+
* mailType: string, // operator-defined message type (e.g. "marketing")
|
|
1764
|
+
* senderId: string, // operator's app / IP-pool / domain reputation id
|
|
1765
|
+
*
|
|
1766
|
+
* @example
|
|
1767
|
+
* var feedbackId = b.mail.feedbackId({
|
|
1768
|
+
* campaignId: "wk26-promo",
|
|
1769
|
+
* customerId: "acme",
|
|
1770
|
+
* mailType: "marketing",
|
|
1771
|
+
* senderId: "mail-pool-1",
|
|
1772
|
+
* });
|
|
1773
|
+
* // → "wk26-promo:acme:marketing:mail-pool-1"
|
|
1774
|
+
*
|
|
1775
|
+
* mail.send({
|
|
1776
|
+
* to: "...",
|
|
1777
|
+
* headers: { "Feedback-ID": feedbackId },
|
|
1778
|
+
* });
|
|
1779
|
+
*/
|
|
1780
|
+
function feedbackId(opts) {
|
|
1781
|
+
if (!opts || typeof opts !== "object") {
|
|
1782
|
+
throw new MailError("mail/bad-feedback-id-opts",
|
|
1783
|
+
"feedbackId: opts required (campaignId + customerId + mailType + senderId)");
|
|
1784
|
+
}
|
|
1785
|
+
var fields = [
|
|
1786
|
+
{ key: "campaignId", value: opts.campaignId },
|
|
1787
|
+
{ key: "customerId", value: opts.customerId },
|
|
1788
|
+
{ key: "mailType", value: opts.mailType },
|
|
1789
|
+
{ key: "senderId", value: opts.senderId },
|
|
1790
|
+
];
|
|
1791
|
+
var parts = [];
|
|
1792
|
+
for (var i = 0; i < fields.length; i += 1) {
|
|
1793
|
+
var f = fields[i];
|
|
1794
|
+
if (typeof f.value !== "string" || f.value.length === 0) {
|
|
1795
|
+
throw new MailError("mail/bad-feedback-id-field",
|
|
1796
|
+
"feedbackId: " + f.key + " must be a non-empty string");
|
|
1797
|
+
}
|
|
1798
|
+
if (f.value.length > 64) { // allow:raw-byte-literal — Gmail FBL per-field cap, not byte arithmetic
|
|
1799
|
+
throw new MailError("mail/bad-feedback-id-field",
|
|
1800
|
+
"feedbackId: " + f.key + " exceeds 64 chars (Gmail FBL truncation threshold)");
|
|
1801
|
+
}
|
|
1802
|
+
if (f.value.indexOf(":") !== -1) {
|
|
1803
|
+
throw new MailError("mail/bad-feedback-id-field",
|
|
1804
|
+
"feedbackId: " + f.key + " contains ':' which is the field separator");
|
|
1805
|
+
}
|
|
1806
|
+
// Refuse CR/LF (header-injection) + control chars. Walk codepoints
|
|
1807
|
+
// manually because eslint's no-control-regex refuses control-char
|
|
1808
|
+
// ranges in regex literals regardless of escape form.
|
|
1809
|
+
for (var ci = 0; ci < f.value.length; ci += 1) {
|
|
1810
|
+
var code = f.value.charCodeAt(ci);
|
|
1811
|
+
if (code < 32 || code === 127) { // allow:raw-byte-literal — C0 + DEL codepoint range
|
|
1812
|
+
throw new MailError("mail/bad-feedback-id-field",
|
|
1813
|
+
"feedbackId: " + f.key + " contains control characters");
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
parts.push(f.value);
|
|
1817
|
+
}
|
|
1818
|
+
return parts.join(":");
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1738
1821
|
module.exports = {
|
|
1739
1822
|
create: create,
|
|
1823
|
+
feedbackId: feedbackId,
|
|
1740
1824
|
MailError: MailError,
|
|
1741
1825
|
unsubscribe: mailUnsubscribe,
|
|
1742
1826
|
// RFC 3492 Punycode IDN domain encode/decode (b.mail.toAscii /
|
|
@@ -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
|
};
|