drupal-mcp-connector 2.16.0 → 2.18.0

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.
@@ -1,430 +1,19 @@
1
1
  /**
2
- * Sentinel's governed draft-continuation contract (d.o #3621022 / GitHub #176).
3
- * Core JSON:API revision selectors support reads, not PATCH requests.
4
- * Translation create/update uses the same surface with X-MCP-Draft-Langcode.
5
- */
6
-
7
- import { entityRevisionId } from "./write-revision.js";
8
-
9
- const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
10
- const MISSING_DRAFT_ENDPOINT =
11
- "The site does not provide Sentinel's governed draft endpoint (d.o #3621022). " +
12
- "Update the server-side module; the draft was not discarded and no canonical fallback was attempted.";
13
- const MISSING_TRANSLATION_ENDPOINT =
14
- "The site does not provide Sentinel's governed draft-translation endpoint. " +
15
- "Update MCP Sentinel; no canonical langcode PATCH was attempted.";
16
-
17
- /**
18
- * @param {unknown} error
19
- * @param {string} message
20
- * @returns {Error}
21
- */
22
- function missingEndpointError(error, message) {
23
- if (/Drupal (404|405)\b/.test(String(error?.message))) {
24
- return new Error(message, { cause: error });
25
- }
26
- return error instanceof Error ? error : new Error(String(error));
27
- }
28
-
29
- /**
30
- * @param {string} langcode
31
- * @returns {string}
32
- */
33
- export function assertDraftLangcode(langcode) {
34
- const value = String(langcode || "").trim();
35
- if (!LANGCODE_RE.test(value)) {
36
- throw new Error("A valid target langcode is required (for example 'es' or 'pt-br').");
37
- }
38
- return value;
39
- }
40
-
41
- /**
42
- * @param {object} [draftRevision]
43
- * @returns {{live: string, working: string}}
44
- */
45
- function requireWorkingPair(draftRevision) {
46
- const live = String(draftRevision?.liveVid ?? "");
47
- const working = String(draftRevision?.workingVid ?? "");
48
- if (!/^[1-9]\d*$/.test(live) || !/^[1-9]\d*$/.test(working) || live === working) {
49
- throw new Error("Draft continuation requires distinct, verified live and working node revision IDs.");
50
- }
51
- return { live, working };
52
- }
53
-
54
- /**
55
- * @param {object} backend
56
- * @param {string} entityType
57
- * @param {string} bundle
58
- * @param {string} id
59
- * @returns {string}
60
- */
61
- function draftResource(backend, entityType, bundle, id) {
62
- if (entityType !== "node" && entityType !== "paragraph") {
63
- throw new Error("Governed draft translation is implemented for nodes and paragraphs.");
64
- }
65
- if (typeof backend.rawQuery !== "function" || typeof backend.resourcePath !== "function") {
66
- throw new Error("This backend does not support governed draft continuation.");
67
- }
68
- return `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
69
- }
70
-
71
- /**
72
- * @param {object} [draftRevision]
73
- * @returns {string}
74
- */
75
- function requireParagraphRevisionId(draftRevision) {
76
- const revisionId = String(draftRevision?.revisionId ?? draftRevision?.workingVid ?? "");
77
- if (!/^[1-9]\d*$/.test(revisionId)) {
78
- throw new Error("Paragraph translation requires a verified paragraph revision ID.");
79
- }
80
- return revisionId;
81
- }
82
-
83
- /**
84
- * Validate or continue a draft, using the same payload and revision precondition.
85
- * No canonical fallback: an absent endpoint or refused precondition stops work.
86
- * @param {object} backend JSON:API backend.
87
- * @param {object} input Canonical update input plus draftRevision.
88
- * @param {boolean} preflight Validate without saving.
89
- * @returns {Promise<object>} Preflight metadata or the written canonical entity.
90
- */
91
- export async function writeDraft(backend, input, preflight = false) {
92
- const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
93
- if (entityType === "paragraph") {
94
- return writeParagraphDraft(backend, input, preflight);
95
- }
96
- const { live, working } = requireWorkingPair(draftRevision);
97
- const base = draftResource(backend, entityType, bundle, id);
98
- const data = { type: `${entityType}--${bundle}`, id, attributes };
99
- if (relationships) data.relationships = relationships;
100
- const headers = {
101
- "If-Match": `"${live}:${working}"`,
102
- "X-MCP-Draft-Preflight": preflight ? "1" : "0",
103
- };
104
- const targetLang = langcode ? assertDraftLangcode(langcode) : null;
105
- if (targetLang) headers["X-MCP-Draft-Langcode"] = targetLang;
106
- let result;
107
- try {
108
- result = await backend.rawQuery({
109
- path: `${base}/mcp-draft`,
110
- options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
111
- });
112
- } catch (error) {
113
- throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
114
- }
115
- if (preflight) {
116
- if (result?.meta?.draft_preflight !== true
117
- || String(result.meta.live) !== live || String(result.meta.working) !== working) {
118
- throw new Error("The site did not confirm a non-saving draft preflight. Refusing to continue.");
119
- }
120
- if (targetLang && result.meta.langcode && String(result.meta.langcode) !== targetLang) {
121
- throw new Error("The site did not confirm the requested translation language. Refusing to continue.");
122
- }
123
- return result;
124
- }
125
- if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
126
- throw new Error("Draft write response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
127
- }
128
- return backend.toCanonical(result.data);
129
- }
130
-
131
- /**
132
- * Create a target-language translation as an unpublished forward revision.
133
- * If-Match is `"live"` when there is no working copy, or `"live:working"` when
134
- * adding the language onto an existing unpublished English draft.
135
- * @param {object} backend
136
- * @param {object} input
137
- * @param {boolean} [preflight]
138
- * @returns {Promise<object>}
139
- */
140
- export async function createTranslationDraft(backend, input, preflight = false) {
141
- const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
142
- const langcode = assertDraftLangcode(input.langcode);
143
- if (entityType === "paragraph") {
144
- return createParagraphTranslationDraft(backend, {
145
- entityType, bundle, id, attributes, relationships, draftRevision, langcode,
146
- }, preflight);
147
- }
148
- const live = String(draftRevision?.liveVid ?? "");
149
- const workingRaw = draftRevision?.workingVid;
150
- const working = workingRaw === undefined || workingRaw === null || workingRaw === ""
151
- ? ""
152
- : String(workingRaw);
153
- if (entityType !== "node" || !/^[1-9]\d*$/.test(live)) {
154
- throw new Error("Translation create requires a verified live node revision ID.");
155
- }
156
- if (working && (!/^[1-9]\d*$/.test(working) || working === live)) {
157
- throw new Error("Translation create requires distinct live and working revision IDs when a working copy exists.");
158
- }
159
- const base = draftResource(backend, entityType, bundle, id);
160
- const safeAttributes = { ...attributes };
161
- delete safeAttributes.langcode;
162
- const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
163
- if (relationships) data.relationships = relationships;
164
- const ifMatch = working ? `"${live}:${working}"` : `"${live}"`;
165
- let result;
166
- try {
167
- result = await backend.rawQuery({
168
- path: `${base}/mcp-draft/translations`,
169
- options: {
170
- method: "POST",
171
- headers: {
172
- "If-Match": ifMatch,
173
- "X-MCP-Draft-Preflight": preflight ? "1" : "0",
174
- "X-MCP-Draft-Langcode": langcode,
175
- },
176
- body: JSON.stringify({ data }),
177
- },
178
- });
179
- } catch (error) {
180
- throw rewriteTranslationWorkingRevisionError(missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT));
181
- }
182
- if (preflight) {
183
- if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== live) {
184
- throw new Error("The site did not confirm a non-saving translation preflight. Refusing to continue.");
185
- }
186
- return result;
187
- }
188
- if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
189
- throw new Error("Translation create response did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
190
- }
191
- return backend.toCanonical(result.data);
192
- }
193
-
194
- /**
195
- * Live + working revision ids for a node translation create.
196
- * Prefers Sentinel's mcp-translations inventory over JSON:API
197
- * `rel:working-copy`, which 403s on some unpublished drafts even when a
198
- * working revision exists (#282).
199
- * @param {object} backend
200
- * @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
201
- * @returns {Promise<{liveVid: ?(number|string), workingVid: ?(number|string)}>}
202
- */
203
- export async function resolveNodeTranslationPair(backend, { entityType, bundle, id, existing }) {
204
- const fromEntity = existing ? entityRevisionId(existing) : null;
205
- try {
206
- const meta = await readTranslationInventory(backend, { entityType, bundle, id });
207
- const liveVid = meta.live?.vid ?? fromEntity;
208
- const workingVid = meta.working?.vid;
209
- const distinct = workingVid !== undefined && workingVid !== null && workingVid !== ""
210
- && liveVid !== undefined && liveVid !== null
211
- && String(workingVid) !== String(liveVid);
212
- return { liveVid, workingVid: distinct ? workingVid : undefined };
213
- } catch {
214
- if (typeof backend.getEntity !== "function") {
215
- return { liveVid: fromEntity, workingVid: undefined };
216
- }
217
- let workingCopy = null;
218
- try {
219
- workingCopy = await backend.getEntity({
220
- entityType, bundle, id, resourceVersion: "rel:working-copy",
221
- });
222
- } catch {
223
- workingCopy = null;
224
- }
225
- const workingVid = workingCopy ? entityRevisionId(workingCopy) : null;
226
- const distinct = workingVid !== null && fromEntity !== null && String(workingVid) !== String(fromEntity);
227
- return { liveVid: fromEntity, workingVid: distinct ? workingVid : undefined };
228
- }
229
- }
230
-
231
- /**
232
- * Rewrite Sentinel's live-only 409 into an actionable connector error.
233
- * @param {unknown} error
234
- * @returns {Error}
235
- */
236
- export function rewriteTranslationWorkingRevisionError(error) {
237
- if (/A working revision exists\. Reload and send both revision IDs/i.test(String(error?.message || ""))) {
238
- return new Error(
239
- "Translation create sent only the live revision, but a working draft exists. " +
240
- "The connector should have sent both live and working revision IDs (If-Match). " +
241
- "Reload with drupal_list_translations and retry. See connector #282.",
242
- { cause: error instanceof Error ? error : undefined },
243
- );
244
- }
245
- return error instanceof Error ? error : new Error(String(error));
246
- }
247
-
248
- /**
249
- * Read live/working translation inventory from Sentinel.
250
- * @param {object} backend
251
- * @param {{entityType: string, bundle: string, id: string, revisionId?: string|number}} ref
252
- * @returns {Promise<object>}
253
- */
254
- export async function readTranslationInventory(backend, { entityType, bundle, id, revisionId }) {
255
- const base = draftResource(backend, entityType, bundle, id);
256
- const headers = {};
257
- if (revisionId !== undefined && revisionId !== null && revisionId !== "") {
258
- headers["If-Match"] = `"${requireParagraphRevisionId({ revisionId })}"`;
259
- }
260
- try {
261
- const result = await backend.rawQuery({
262
- path: `${base}/mcp-translations`,
263
- options: Object.keys(headers).length ? { method: "GET", headers } : undefined,
264
- });
265
- if (!result?.meta?.live) {
266
- throw new Error("The site did not return a translation inventory.");
267
- }
268
- return result.meta;
269
- } catch (error) {
270
- throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
271
- }
272
- }
273
-
274
- /**
275
- * Read one unpublished working translation.
276
- * @param {object} backend
277
- * @param {object} input
278
- * @returns {Promise<object>}
279
- */
280
- export async function readDraftTranslation(backend, input) {
281
- const { entityType, bundle, id, draftRevision } = input;
282
- const langcode = assertDraftLangcode(input.langcode);
283
- if (entityType === "paragraph") {
284
- return readParagraphDraftTranslation(backend, input);
285
- }
286
- const { live, working } = requireWorkingPair(draftRevision);
287
- const base = draftResource(backend, entityType, bundle, id);
288
- let result;
289
- try {
290
- result = await backend.rawQuery({
291
- path: `${base}/mcp-draft`,
292
- options: {
293
- method: "GET",
294
- headers: {
295
- "If-Match": `"${live}:${working}"`,
296
- "X-MCP-Draft-Langcode": langcode,
297
- },
298
- },
299
- });
300
- } catch (error) {
301
- throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
302
- }
303
- if (!result?.data || result.data.id !== id) {
304
- throw new Error("Draft translation read did not identify the requested entity.");
305
- }
306
- return backend.toCanonical(result.data);
307
- }
308
-
309
- /**
310
- * Continue an unpublished paragraph translation on a pinned revision.
311
- * @param {object} backend
312
- * @param {object} input
313
- * @param {boolean} [preflight]
314
- * @returns {Promise<object>}
315
- */
316
- async function writeParagraphDraft(backend, input, preflight = false) {
317
- const { entityType, bundle, id, attributes = {}, relationships, draftRevision } = input;
318
- const langcode = assertDraftLangcode(input.langcode);
319
- const revisionId = requireParagraphRevisionId(draftRevision);
320
- const draftState = input.draftState;
321
- if (typeof draftState !== "string" || !/^[a-f0-9]{64}$/.test(draftState)) {
322
- throw new Error("Paragraph translation update requires draftState from the previous draft read. Re-read the draft; do not retry old copy with a refreshed token.");
323
- }
324
- const base = draftResource(backend, entityType, bundle, id);
325
- const data = { type: `${entityType}--${bundle}`, id, attributes };
326
- if (relationships) data.relationships = relationships;
327
- const headers = {
328
- "If-Match": `"${revisionId}"`,
329
- "X-MCP-Draft-Preflight": preflight ? "1" : "0",
330
- "X-MCP-Draft-Langcode": langcode,
331
- "X-MCP-Draft-State": draftState,
332
- };
333
- let result;
334
- try {
335
- result = await backend.rawQuery({
336
- path: `${base}/mcp-draft`,
337
- options: { method: "PATCH", headers, body: JSON.stringify({ data }) },
338
- });
339
- } catch (error) {
340
- throw missingEndpointError(error, MISSING_DRAFT_ENDPOINT);
341
- }
342
- if (preflight) {
343
- if (result?.meta?.draft_preflight !== true
344
- || String(result.meta.live) !== revisionId
345
- || (result.meta.langcode && String(result.meta.langcode) !== langcode)) {
346
- throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
347
- }
348
- return result;
349
- }
350
- if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
351
- throw new Error("Paragraph translation write did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
352
- }
353
- return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
354
- }
355
-
356
- /**
357
- * Create an unpublished paragraph translation on a pinned revision.
358
- * @param {object} backend
359
- * @param {object} input
360
- * @param {boolean} [preflight]
361
- * @returns {Promise<object>}
362
- */
363
- async function createParagraphTranslationDraft(backend, input, preflight = false) {
364
- const { entityType, bundle, id, attributes = {}, relationships, draftRevision, langcode } = input;
365
- const revisionId = requireParagraphRevisionId(draftRevision);
366
- const base = draftResource(backend, entityType, bundle, id);
367
- const safeAttributes = { ...attributes };
368
- delete safeAttributes.langcode;
369
- const data = { type: `${entityType}--${bundle}`, id, attributes: safeAttributes };
370
- if (relationships) data.relationships = relationships;
371
- let result;
372
- try {
373
- result = await backend.rawQuery({
374
- path: `${base}/mcp-draft/translations`,
375
- options: {
376
- method: "POST",
377
- headers: {
378
- "If-Match": `"${revisionId}"`,
379
- "X-MCP-Draft-Preflight": preflight ? "1" : "0",
380
- "X-MCP-Draft-Langcode": langcode,
381
- },
382
- body: JSON.stringify({ data }),
383
- },
384
- });
385
- } catch (error) {
386
- throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
387
- }
388
- if (preflight) {
389
- if (result?.meta?.draft_preflight !== true || String(result.meta.live) !== revisionId) {
390
- throw new Error("The site did not confirm a non-saving paragraph translation preflight. Refusing to continue.");
391
- }
392
- return result;
393
- }
394
- if (!result?.data || result.data.id !== id || result.data.type !== data.type) {
395
- throw new Error("Paragraph translation create did not identify the requested entity. The write outcome is uncertain; re-read before retrying.");
396
- }
397
- return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
398
- }
399
-
400
- /**
401
- * Read one unpublished paragraph translation of a pinned revision.
402
- * @param {object} backend
403
- * @param {object} input
404
- * @returns {Promise<object>}
405
- */
406
- async function readParagraphDraftTranslation(backend, input) {
407
- const { entityType, bundle, id, draftRevision } = input;
408
- const langcode = assertDraftLangcode(input.langcode);
409
- const revisionId = requireParagraphRevisionId(draftRevision);
410
- const base = draftResource(backend, entityType, bundle, id);
411
- let result;
412
- try {
413
- result = await backend.rawQuery({
414
- path: `${base}/mcp-draft`,
415
- options: {
416
- method: "GET",
417
- headers: {
418
- "If-Match": `"${revisionId}"`,
419
- "X-MCP-Draft-Langcode": langcode,
420
- },
421
- },
422
- });
423
- } catch (error) {
424
- throw missingEndpointError(error, MISSING_TRANSLATION_ENDPOINT);
425
- }
426
- if (!result?.data || result.data.id !== id) {
427
- throw new Error("Paragraph draft translation read did not identify the requested entity.");
428
- }
429
- return { ...backend.toCanonical(result.data), draftState: result.meta?.draft_state };
430
- }
2
+ * Compatibility facade for the Sentinel HTTP client.
3
+ * New call sites should import from `./sentinel-draft.js`.
4
+ */
5
+
6
+ export {
7
+ assertDraftLangcode,
8
+ assertInventoryDraftLanguage,
9
+ createTranslationDraft,
10
+ isMissingDraftEndpoint,
11
+ isMissingTranslationEndpoint,
12
+ readDraftTranslation,
13
+ readNodeDraftInventory,
14
+ readTranslationInventory,
15
+ resolveNodeTranslationPair,
16
+ rewriteTranslationWorkingRevisionError,
17
+ supportsSentinelDraft,
18
+ writeDraft,
19
+ } from "./sentinel-draft.js";
@@ -0,0 +1,241 @@
1
+ /** Module-owned schemas and behavior, exposed only by explicit local tool policy. */
2
+ import { createHash } from "node:crypto";
3
+ import Ajv from "ajv/dist/2020.js";
4
+ import addFormats from "ajv-formats";
5
+ import { listServerTools, callServerTool, toolResultData } from "./server-tools.js";
6
+ import { listResolvableSiteConfigs, securityMiddleware } from "./dispatch.js";
7
+ import { getRequestIdentity, principalHasScope, resolveGrantedSites } from "./principal.js";
8
+ import { resolveSecurityConfig, SecurityError } from "./security.js";
9
+ import { assertSourceGovernance, GovernanceError } from "./governance.js";
10
+ import { DataFlowBudgetError } from "./data-flow.js";
11
+ import { toolError } from "./errors.js";
12
+ import { withResolvedTarget } from "./site-target.js";
13
+
14
+ const PREFIX = "drupal_module_";
15
+ const OPERATIONS = new Set(["read", "write", "delete"]);
16
+ const CAPABILITIES = new Map([
17
+ ["publish", "allowPublish"], ["configRead", "allowConfigRead"],
18
+ ["configWrite", "allowConfigWrite"], ["graphql", "allowGraphql"],
19
+ ]);
20
+ const MAX_BYTES = 262144;
21
+ const MAX_TOOLS = 256;
22
+
23
+ function bounded(value, limit = MAX_BYTES) {
24
+ if (Buffer.byteLength(JSON.stringify(value) ?? "") > limit) {
25
+ throw new SecurityError("Module tool payload exceeds the protocol ceiling.");
26
+ }
27
+ }
28
+
29
+ function entries(sites) {
30
+ const result = new Map();
31
+ for (const site of sites) {
32
+ const config = site.serverTools?.modules;
33
+ if (!config) continue;
34
+ if (!/^[a-z][a-z0-9_]{0,23}$/.test(config.namespace ?? "")) {
35
+ throw new SecurityError("Module tools require a stable namespace.");
36
+ }
37
+ for (const [alias, policy] of Object.entries(config.tools ?? {})) {
38
+ if (!/^[a-z][a-z0-9_]{0,47}$/.test(alias) || !policy ||
39
+ !/^[A-Za-z0-9_.-]{1,128}$/.test(policy.name ?? "") ||
40
+ !/^[a-z][a-z0-9_:-]{0,63}$/.test(policy.scope ?? "") ||
41
+ !OPERATIONS.has(policy.operation) || !Array.isArray(policy.capabilities) ||
42
+ policy.capabilities.some((cap) => !CAPABILITIES.has(cap) && cap !== "rawSql")) {
43
+ throw new SecurityError("Invalid module tool policy.");
44
+ }
45
+ const name = `${PREFIX}${policy.operation}_${config.namespace}__${alias}`;
46
+ if (result.has(name) || result.size >= MAX_TOOLS) {
47
+ throw new SecurityError("Duplicate module namespace or excessive tool policy entries.");
48
+ }
49
+ result.set(name, { name, alias, site, policy });
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function allowed(entry, identity, sites, grants) {
56
+ const { site, policy } = entry;
57
+ if (identity && (!principalHasScope(identity, policy.scope) ||
58
+ !resolveGrantedSites(identity, sites, grants).some((s) => s._name === site._name))) return false;
59
+ const sec = resolveSecurityConfig(site);
60
+ if (policy.operation !== "read" && sec.readOnly) return false;
61
+ if (policy.operation === "delete" && !sec.allowDestructive) return false;
62
+ return policy.capabilities.every((cap) => cap === "rawSql"
63
+ ? site.drushSsh?.rawSql === "governed"
64
+ : Boolean(new Map(Object.entries(sec)).get(CAPABILITIES.get(cap))));
65
+ }
66
+
67
+ function validator(schema) {
68
+ if (!schema || schema.type !== "object") throw new Error("Object schema required.");
69
+ bounded(schema, 65536);
70
+ // Providers may put constraints beside nullable oneOf types. strictTypes
71
+ // rejects that valid JSON Schema shape; runtime type checks still apply.
72
+ const ajv = new Ajv({ strict: true, strictTypes: false, allErrors: false, ownProperties: true });
73
+ addFormats(ajv);
74
+ const check = ajv.compile(schema);
75
+ if (check.$async) throw new Error("Async schemas are unavailable.");
76
+ return check;
77
+ }
78
+
79
+ async function catalog(site, list) {
80
+ // Extensions always require source governance, including development sites.
81
+ await assertSourceGovernance({ ...site, requireGovernance: true });
82
+ const found = new Map();
83
+ const seen = new Set();
84
+ let cursor;
85
+ let bytes = 0;
86
+ for (let page = 0; page < 16; page++) {
87
+ const result = await list(site, cursor);
88
+ bounded(result);
89
+ bytes += Buffer.byteLength(JSON.stringify(result));
90
+ if (bytes > MAX_BYTES || !Array.isArray(result?.tools)) throw new Error("Invalid module catalog.");
91
+ for (const tool of result.tools) {
92
+ if (typeof tool?.name !== "string" || found.has(tool.name) || found.size >= MAX_TOOLS) {
93
+ throw new Error("Invalid or duplicate module tool.");
94
+ }
95
+ found.set(tool.name, tool);
96
+ }
97
+ if (result.nextCursor === undefined || result.nextCursor === null) return found;
98
+ if (typeof result.nextCursor !== "string" || seen.has(result.nextCursor)) throw new Error("Invalid catalog cursor.");
99
+ cursor = result.nextCursor;
100
+ seen.add(cursor);
101
+ }
102
+ throw new Error("Module catalog page limit exceeded.");
103
+ }
104
+
105
+ function describe(entry, remote) {
106
+ const input = validator(remote.inputSchema);
107
+ const output = remote.outputSchema ? validator(remote.outputSchema) : null;
108
+ const revision = createHash("sha256").update(JSON.stringify({
109
+ input: remote.inputSchema, output: remote.outputSchema ?? null, policy: entry.policy,
110
+ })).digest("hex");
111
+ return { input, output, revision, definition: {
112
+ name: entry.name,
113
+ description: `${String(remote.description ?? remote.name).slice(0, 4096)} [${entry.site._name}]`,
114
+ inputSchema: {
115
+ type: "object", additionalProperties: false, required: ["catalogRevision", "arguments"],
116
+ properties: {
117
+ catalogRevision: { type: "string", const: revision },
118
+ arguments: { ...remote.inputSchema, $id: remote.inputSchema.$id ?? `urn:module:input:${revision}` },
119
+ },
120
+ },
121
+ annotations: {
122
+ readOnlyHint: entry.policy.operation === "read",
123
+ destructiveHint: entry.policy.operation !== "read",
124
+ idempotentHint: false, openWorldHint: true,
125
+ },
126
+ ...(remote.outputSchema ? { outputSchema: {
127
+ type: "object", required: ["result", "_target"],
128
+ properties: {
129
+ result: { ...remote.outputSchema, $id: remote.outputSchema.$id ?? `urn:module:output:${revision}` },
130
+ _target: { type: "object" },
131
+ },
132
+ } } : {}),
133
+ } };
134
+ }
135
+
136
+ /** Reserved module names never fall back to built-in handlers. */
137
+ export function isModuleTool(name) {
138
+ return typeof name === "string" && name.startsWith(PREFIX);
139
+ }
140
+
141
+ /** Resolves a local binding without granting access or contacting its provider. */
142
+ export function resolveModuleBinding(site, binding, required) {
143
+ const bindings = new Map(Object.entries(site.serverTools?.bindings ?? {}));
144
+ const alias = bindings.get(binding);
145
+ const entry = [...entries([site]).values()].find((item) => item.alias === alias);
146
+ if (!entry || entry.policy.operation !== required.operation ||
147
+ entry.policy.scope !== required.scope ||
148
+ !required.capabilities.every((cap) => entry.policy.capabilities.includes(cap))) {
149
+ throw new SecurityError("Module binding is missing or does not satisfy the operation contract.");
150
+ }
151
+ return entry;
152
+ }
153
+
154
+ /**
155
+ * Build a registry without cross-request catalog or authorization caches.
156
+ * Transport injection lets unrelated fixture providers prove generic dispatch.
157
+ */
158
+ export function createModuleToolRegistry({ list = listServerTools, call = callServerTool } = {}) {
159
+ const registry = {
160
+ /** Invoke a locally bound compatibility operation through normal discovery. */
161
+ async callBinding(site, binding, args, required, context = {}) {
162
+ const entry = resolveModuleBinding(site, binding, required);
163
+ const ctx = { ...context, sites: [site] };
164
+ const definition = (await registry.list(ctx)).find((item) => item.name === entry.name);
165
+ if (!definition) throw new SecurityError("Bound module tool is unavailable for this caller.");
166
+ const result = await registry.call(entry.name, {
167
+ arguments: args,
168
+ catalogRevision: definition.inputSchema.properties.catalogRevision.const,
169
+ }, ctx);
170
+ if (result.isError) throw new SecurityError("Bound module tool refused the request; no fallback was attempted.");
171
+ // Compatibility callers consume the original Tool API result, not the
172
+ // module registry's result/target envelope. Keep the established shape.
173
+ const data = result.structuredContent.result;
174
+ return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
175
+ },
176
+ async list(context = {}) {
177
+ const sites = context.sites ?? listResolvableSiteConfigs();
178
+ const identity = context.identity === undefined ? getRequestIdentity() : context.identity;
179
+ const enabled = [...entries(sites).values()].filter((entry) => allowed(entry, identity, sites, context.grants));
180
+ const catalogs = new Map();
181
+ const definitions = [];
182
+ for (const entry of enabled) {
183
+ if (!catalogs.has(entry.site._name)) {
184
+ try {
185
+ const found = await securityMiddleware(entry.name, { site: entry.site._name },
186
+ () => catalog(entry.site, list), { ...context, sites, identity, moduleTool: entry.policy });
187
+ catalogs.set(entry.site._name, found);
188
+ }
189
+ catch {
190
+ // A failed catalog fetch invalidates this provider for the request.
191
+ catalogs.set(entry.site._name, new Map());
192
+ }
193
+ }
194
+ try {
195
+ const remote = catalogs.get(entry.site._name).get(entry.policy.name);
196
+ if (remote) definitions.push(describe(entry, remote).definition);
197
+ } catch {
198
+ // A malformed schema disables only that action, not its siblings.
199
+ }
200
+ }
201
+ return definitions.sort((a, b) => a.name.localeCompare(b.name));
202
+ },
203
+ async call(name, args, context = {}) {
204
+ try {
205
+ const sites = context.sites ?? listResolvableSiteConfigs();
206
+ const identity = context.identity === undefined ? getRequestIdentity() : context.identity;
207
+ const entry = entries(sites).get(name);
208
+ if (!entry || !allowed(entry, identity, sites, context.grants)) throw new SecurityError("Module tool is not enabled for this caller.");
209
+ bounded(args);
210
+ if (!args || Object.keys(args).some((key) => !["arguments", "catalogRevision"].includes(key))) {
211
+ throw new SecurityError("Invalid module tool arguments.");
212
+ }
213
+ const invokeContext = { ...context, sites, identity, moduleTool: entry.policy };
214
+ return await securityMiddleware(name, { site: entry.site._name }, async () => {
215
+ const remote = (await catalog(entry.site, list)).get(entry.policy.name);
216
+ if (!remote) throw new SecurityError("Module tool is no longer available.");
217
+ const spec = describe(entry, remote);
218
+ if (args.catalogRevision !== spec.revision || !spec.input(args.arguments)) {
219
+ throw new SecurityError("Module tool schema changed or arguments are invalid. Refresh tools/list.");
220
+ }
221
+ const result = await call(entry.site, entry.policy.name, args.arguments, {
222
+ maxBytes: MAX_BYTES, preserveErrors: true, retryRejected: entry.policy.operation === "read",
223
+ });
224
+ bounded(result);
225
+ if (!Array.isArray(result?.content) || result.content.some((item) => item.type !== "text")) {
226
+ throw new Error("Unsupported module result content.");
227
+ }
228
+ const data = toolResultData(result);
229
+ const failed = result.isError === true || data?.success === false;
230
+ if (!failed && spec.output && !spec.output(data)) throw new Error("Invalid module output schema.");
231
+ const structuredContent = withResolvedTarget({ result: data }, invokeContext.resolvedTarget);
232
+ return { content: [{ type: "text", text: JSON.stringify(structuredContent) }], structuredContent, isError: failed };
233
+ }, invokeContext);
234
+ } catch (error) {
235
+ const safe = error instanceof SecurityError || error instanceof GovernanceError || error instanceof DataFlowBudgetError;
236
+ return toolError(safe ? error : new Error("Module tool unavailable or returned an invalid response. No fallback was attempted."));
237
+ }
238
+ },
239
+ };
240
+ return registry;
241
+ }