drupal-mcp-connector 2.19.1 → 2.20.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.
Files changed (58) hide show
  1. package/.agents/commands/drupal-config-set.md +2 -2
  2. package/.agents/commands/drupal-create-node.md +2 -2
  3. package/.agents/commands/drupal-create-translation.md +1 -1
  4. package/.agents/commands/drupal-delete-node.md +1 -1
  5. package/.agents/commands/drupal-describe-fields.md +2 -2
  6. package/.agents/commands/drupal-drush-config-import.md +2 -2
  7. package/.agents/commands/drupal-drush-module-disable.md +2 -2
  8. package/.agents/commands/drupal-drush-module-list.md +1 -1
  9. package/.agents/commands/drupal-drush-user-list.md +1 -1
  10. package/.agents/commands/drupal-drush-watchdog.md +1 -1
  11. package/.agents/commands/drupal-entity-create.md +2 -2
  12. package/.agents/commands/drupal-entity-delete.md +1 -1
  13. package/.agents/commands/drupal-entity-update.md +4 -4
  14. package/.agents/commands/drupal-report-field-completeness.md +2 -2
  15. package/.agents/commands/drupal-report-missing-field.md +2 -2
  16. package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
  17. package/.agents/commands/drupal-report-status-report.md +1 -1
  18. package/.agents/commands/drupal-update-node.md +4 -4
  19. package/CHANGELOG.md +240 -0
  20. package/README.md +7 -1
  21. package/bin/drupal-mcp-verify.js +4 -3
  22. package/config/config.example.json +52 -2
  23. package/package.json +1 -1
  24. package/scripts/generate-commands.js +40 -5
  25. package/scripts/install-commands.js +148 -11
  26. package/src/index.js +9 -12
  27. package/src/lib/backends/graphql-schema.js +9 -1
  28. package/src/lib/backends/graphql.js +4 -2
  29. package/src/lib/backends/index.js +9 -3
  30. package/src/lib/backends/jsonapi.js +2 -0
  31. package/src/lib/drupal-fetch.js +97 -26
  32. package/src/lib/dry-run-checks.js +78 -0
  33. package/src/lib/error-body.js +448 -0
  34. package/src/lib/error-status.js +38 -0
  35. package/src/lib/mcp-server.js +7 -1
  36. package/src/lib/metatag-audit.js +2 -1
  37. package/src/lib/module-tools.js +23 -2
  38. package/src/lib/patch-preflight.js +23 -5
  39. package/src/lib/reports-support.js +75 -0
  40. package/src/lib/security.js +233 -0
  41. package/src/lib/sentinel-draft.js +3 -2
  42. package/src/lib/server-tools.js +188 -27
  43. package/src/lib/tool-prompts.js +137 -6
  44. package/src/lib/verify.js +161 -58
  45. package/src/tools/config.js +70 -5
  46. package/src/tools/drush.js +191 -17
  47. package/src/tools/entities.js +18 -6
  48. package/src/tools/fields.js +40 -4
  49. package/src/tools/graphql.js +10 -5
  50. package/src/tools/moderation.js +1 -1
  51. package/src/tools/nodes.js +16 -6
  52. package/src/tools/reports-config.js +3 -3
  53. package/src/tools/reports-content.js +34 -26
  54. package/src/tools/reports-extra.js +46 -38
  55. package/src/tools/reports.js +50 -25
  56. package/src/tools/scheduler.js +1 -1
  57. package/src/tools/structure.js +12 -2
  58. package/src/tools/translations.js +5 -1
@@ -64,6 +64,35 @@ export function fieldValue(entity, candidates) {
64
64
  return undefined;
65
65
  }
66
66
 
67
+ /**
68
+ * Read a field off a canonical entity and say whether its KEY was there.
69
+ *
70
+ * JSON:API keeps the key of an empty field (value `null`, `[]`, or
71
+ * `data: null`) and leaves out the key of a field the account may not view.
72
+ * So an absent key and an empty value are different facts, and callers that
73
+ * count "missing" values must keep them apart (#337).
74
+ *
75
+ * Looks in `fields`, then `relationships`. Promoted base attributes (`title`,
76
+ * `status`, `langcode`, `created`, `changed`, and `path` as `url`) are always
77
+ * carried by the canonical shape, so they always read as present: a denied
78
+ * base attribute cannot be told from an empty one after promotion.
79
+ *
80
+ * @param {object} entity Canonical entity.
81
+ * @param {string} field Field machine name.
82
+ * @returns {{present: boolean, value: *}} `present` is about the key, not the value.
83
+ */
84
+ export function fieldPresence(entity, field) {
85
+ const name = field === "path" ? "url" : field;
86
+ if (BASE_KEYS.has(name)) {
87
+ return { present: true, value: new Map(Object.entries(entity ?? {})).get(name) };
88
+ }
89
+ const fields = new Map(Object.entries(entity?.fields ?? {}));
90
+ if (fields.has(name)) return { present: true, value: fields.get(name) };
91
+ const relationships = new Map(Object.entries(entity?.relationships ?? {}));
92
+ if (relationships.has(name)) return { present: true, value: relationships.get(name) };
93
+ return { present: false, value: undefined };
94
+ }
95
+
67
96
  /**
68
97
  * Whole days elapsed between a date and now.
69
98
  * @param {?(string|number|Date)} dateValue A date parseable by `new Date()`.
@@ -73,3 +102,49 @@ export function daysSince(dateValue) {
73
102
  if (!dateValue) return null;
74
103
  return Math.floor((Date.now() - new Date(dateValue).getTime()) / MS_PER_DAY);
75
104
  }
105
+
106
+ /**
107
+ * Whether a canonical field or relationship value carries no content.
108
+ *
109
+ * Handles scalars, `{value}` text objects, arrays, relationship refs
110
+ * (`{id}` / `[{id}, …]` / `null`), and keyed objects such as a link
111
+ * (`{uri, title}`). A keyed object with at least one key and none of the
112
+ * known value keys counts as populated.
113
+ *
114
+ * @param {*} value A value read off an entity's base props, `fields`, or `relationships`.
115
+ * @returns {boolean} True when the value is absent or carries no content.
116
+ */
117
+ export function isEmptyFieldValue(value) {
118
+ if (value === undefined || value === null || value === "") return true;
119
+ if (Array.isArray(value)) return value.every((item) => isEmptyFieldValue(item));
120
+ if (typeof value === "object") {
121
+ if ("id" in value) return !value.id;
122
+ if ("value" in value) return value.value === undefined || value.value === null || value.value === "";
123
+ if ("target_id" in value) return value.target_id === undefined || value.target_id === null;
124
+ if ("uri" in value) return !value.uri;
125
+ return Object.keys(value).length === 0;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ /**
131
+ * Note attached to a report's `notVisible` list: requested fields whose key is
132
+ * absent from every sampled entity, so the report could not score them (#341).
133
+ */
134
+ export const FIELDS_NOT_VISIBLE_NOTE =
135
+ "These fields are absent from every sampled entity (the key is missing, not empty). " +
136
+ "Each may be denied to this account or not exist on this bundle, or the name may be wrong. " +
137
+ "Drupal leaves a field the account may not view out of the response with no marker, so this report cannot tell whether any value is missing. " +
138
+ "They were not scored.";
139
+
140
+ /**
141
+ * Normalize a caller's `fields` argument to distinct, non-empty field names.
142
+ * Arguments are not schema-validated before they reach a handler, so a single
143
+ * string is read as one name and non-string entries are dropped.
144
+ * @param {*} fields The raw `fields` argument.
145
+ * @returns {string[]} Field names, or [] when the caller named none.
146
+ */
147
+ export function requestedFieldNames(fields) {
148
+ const list = typeof fields === "string" ? [fields] : (Array.isArray(fields) ? fields : []);
149
+ return [...new Set(list.filter((f) => typeof f === "string" && f.trim()).map((f) => f.trim()))];
150
+ }
@@ -53,6 +53,19 @@ import { parse } from "graphql";
53
53
  *
54
54
  * globalRedactedFields string[] stripped from every response, every type
55
55
  *
56
+ * protectedModules string[] module machine names ADDED to the default
57
+ * protected list (DEFAULT_PROTECTED_MODULES).
58
+ * drupal_drush_module_disable refuses them (#346).
59
+ * allowProtectedModuleUninstall string[] explicit opt-out: names REMOVED from the
60
+ * protected list. The only way to shrink it.
61
+ * A malformed value in either key refuses
62
+ * every uninstall rather than being ignored.
63
+ * allowCoreExtensionChange boolean false on every preset. true lets
64
+ * drupal_config_set write core.extension and
65
+ * lets drupal_drush_config_import run when
66
+ * core.extension differs (#349). Anything but
67
+ * true or false keeps both refused.
68
+ *
56
69
  * declaredCeiling string narrow-only X-MCP-Declared-Ceiling
57
70
  * (public|internal|restricted). Invalid
58
71
  * values are dropped, never widened.
@@ -244,6 +257,98 @@ const PRESETS = {
244
257
  * @param {object} site Site config (reads site.security).
245
258
  * @returns {object} Effective security config used by the assert/redact helpers.
246
259
  */
260
+ /**
261
+ * Modules drupal_drush_module_disable refuses to uninstall, on every preset
262
+ * (#346). Uninstalling one removes a control the connector relies on, and
263
+ * Drupal drops the module's hook_schema tables on uninstall, so an audit log or
264
+ * stored keys go with it. Operators extend the list with
265
+ * `security.protectedModules` and shrink it only with the explicit
266
+ * `security.allowProtectedModuleUninstall`.
267
+ */
268
+ export const DEFAULT_PROTECTED_MODULES = Object.freeze([
269
+ // Governance and integrity.
270
+ "mcp_sentinel", "audit_chain", "field_guard", "file_gate",
271
+ // Secrets and authentication.
272
+ "key", "encrypt", "simple_oauth", "consumers",
273
+ // The API and governed tool surface the connector talks to.
274
+ "jsonapi", "serialization", "mcp_server", "mcp_server_tool_bridge", "tool",
275
+ // The editorial gate that decides publication.
276
+ "content_moderation", "workflows",
277
+ ]);
278
+
279
+ /** Drupal module machine name. Same rule as validateMachineName(), kept local to avoid an import cycle. */
280
+ const MODULE_NAME_RE = /^[a-z][a-z0-9_]{0,127}$/;
281
+
282
+ /**
283
+ * Read one module-list key from raw site security config.
284
+ * @param {object} raw site.security.
285
+ * @param {string} key Config key.
286
+ * @param {string[]} problems Collects a message per malformed key.
287
+ * @returns {string[]} The valid names (empty when the key is absent or malformed).
288
+ */
289
+ function readModuleList(raw, key, problems) {
290
+ const value = new Map(Object.entries(raw)).get(key);
291
+ if (value === undefined || value === null) return [];
292
+ if (!Array.isArray(value)) {
293
+ problems.push(`security.${key} must be an array of module machine names.`);
294
+ return [];
295
+ }
296
+ const valid = value.filter((name) => typeof name === "string" && MODULE_NAME_RE.test(name));
297
+ if (valid.length !== value.length) {
298
+ problems.push(
299
+ `security.${key} has ${value.length - valid.length} entr${value.length - valid.length === 1 ? "y that is" : "ies that are"} ` +
300
+ "not a module machine name (lowercase letters, digits and underscores)."
301
+ );
302
+ // A malformed opt-out never removes anything; a malformed extension still adds its valid names.
303
+ return key === "allowProtectedModuleUninstall" ? [] : valid;
304
+ }
305
+ return valid;
306
+ }
307
+
308
+ /**
309
+ * Resolve the effective protected-module list for a site.
310
+ * @param {object} raw site.security.
311
+ * @returns {{modules: string[], optOuts: string[], error: ?string}} `error` is
312
+ * set when either key is malformed; callers must then refuse every uninstall.
313
+ */
314
+ function resolveProtectedModules(raw) {
315
+ const problems = [];
316
+ const added = readModuleList(raw, "protectedModules", problems);
317
+ const optOuts = readModuleList(raw, "allowProtectedModuleUninstall", problems);
318
+ const removed = new Set(optOuts);
319
+ const modules = [...new Set([...DEFAULT_PROTECTED_MODULES, ...added])].filter((name) => !removed.has(name)).sort();
320
+ return { modules, optOuts: [...removed].sort(), error: problems.length ? problems.join(" ") : null };
321
+ }
322
+
323
+ /** The config object that lists installed modules and themes. */
324
+ export const CORE_EXTENSION_CONFIG = "core.extension";
325
+
326
+ /**
327
+ * Whether a config name addresses core.extension. Surrounding space and case
328
+ * are ignored: a source that trims the name would otherwise be reached by a
329
+ * padded one.
330
+ * @param {*} name Config object name.
331
+ * @returns {boolean}
332
+ */
333
+ export function isCoreExtensionConfig(name) {
334
+ return typeof name === "string" && name.trim().toLowerCase() === CORE_EXTENSION_CONFIG;
335
+ }
336
+
337
+ /**
338
+ * Resolve the explicit opt-in for changing core.extension (#349).
339
+ * @param {object} raw site.security.
340
+ * @returns {{allowed: boolean, error: ?string}} `error` is set when the value
341
+ * is neither true nor false; the change then stays refused.
342
+ */
343
+ function resolveCoreExtensionOptIn(raw) {
344
+ const value = new Map(Object.entries(raw)).get("allowCoreExtensionChange");
345
+ if (value === undefined || value === null) return { allowed: false, error: null };
346
+ if (typeof value !== "boolean") {
347
+ return { allowed: false, error: "security.allowCoreExtensionChange must be true or false." };
348
+ }
349
+ return { allowed: value, error: null };
350
+ }
351
+
247
352
  /** Default when `security.preset` is omitted — least privilege, not open mode (#140). */
248
353
  export const DEFAULT_SECURITY_PRESET = "production-strict";
249
354
 
@@ -254,6 +359,9 @@ export function resolveSecurityConfig(site) {
254
359
  const presetName = raw.preset ?? DEFAULT_SECURITY_PRESET;
255
360
  const preset = PRESETS[presetName] ?? PRESETS[DEFAULT_SECURITY_PRESET];
256
361
 
362
+ const protectedModules = resolveProtectedModules(raw);
363
+ const coreExtension = resolveCoreExtensionOptIn(raw);
364
+
257
365
  // Merge: explicit keys in site.security override the preset
258
366
  return {
259
367
  readOnly: raw.readOnly ?? preset.readOnly,
@@ -272,6 +380,13 @@ export function resolveSecurityConfig(site) {
272
380
  ],
273
381
  declaredCeiling: raw.declaredCeiling ?? preset.declaredCeiling,
274
382
  readBudgets: raw.readBudgets ?? preset.readBudgets ?? null,
383
+ // Not a preset value: the default list applies on every preset (#346).
384
+ protectedModules: protectedModules.modules,
385
+ protectedModuleOptOuts: protectedModules.optOuts,
386
+ protectedModulesError: protectedModules.error,
387
+ // Not a preset value either: strict on every preset (#349).
388
+ allowCoreExtensionChange: coreExtension.allowed,
389
+ coreExtensionChangeError: coreExtension.error,
275
390
  };
276
391
  }
277
392
 
@@ -430,6 +545,119 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
430
545
  }
431
546
  }
432
547
 
548
+ /**
549
+ * Gate a module uninstall (drupal_drush_module_disable) against the site's
550
+ * protected-module list (#346). Fails closed: a security config that is
551
+ * malformed, or that never went through resolveSecurityConfig(), refuses every
552
+ * uninstall.
553
+ * @param {object} secConfig Resolved security config.
554
+ * @param {string} moduleName Module machine name, already validated.
555
+ * @returns {void}
556
+ * @throws {SecurityError} if the module is protected or the list cannot be trusted.
557
+ */
558
+ export function assertModuleUninstallAllowed(secConfig, moduleName) {
559
+ if (secConfig?.protectedModulesError || !Array.isArray(secConfig?.protectedModules)) {
560
+ throw new SecurityError(
561
+ "Module uninstall is blocked because this site's protected-module config cannot be read. " +
562
+ `${secConfig?.protectedModulesError ?? "The protected-module list is missing."} ` +
563
+ "No module can be uninstalled through the connector until an operator fixes it."
564
+ );
565
+ }
566
+ if (secConfig.protectedModules.includes(moduleName)) {
567
+ throw new SecurityError(
568
+ `Module "${moduleName}" is protected and cannot be uninstalled through the connector. ` +
569
+ "Uninstalling it removes a control the connector relies on, and Drupal drops the module's tables on uninstall. " +
570
+ `To allow it, an operator must add "${moduleName}" to security.allowProtectedModuleUninstall for this site.`
571
+ );
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Gate a change to core.extension (#349). The object lists installed modules
577
+ * and themes, so a write to it, or a config import that changes it, can
578
+ * uninstall a protected module without going through
579
+ * assertModuleUninstallAllowed(). Refused on every preset unless the operator
580
+ * set `security.allowCoreExtensionChange: true`. Fails closed: a malformed
581
+ * value, or a config that never went through resolveSecurityConfig(), refuses.
582
+ * @param {object} secConfig Resolved security config.
583
+ * @param {string} refusal What is being refused and why, as full sentences.
584
+ * @returns {void}
585
+ * @throws {SecurityError} unless the operator opted in.
586
+ */
587
+ export function assertCoreExtensionChangeAllowed(secConfig, refusal) {
588
+ if (secConfig?.allowCoreExtensionChange === true && !secConfig.coreExtensionChangeError) return;
589
+ throw new SecurityError(
590
+ `${refusal} ` +
591
+ "To install or uninstall a module use drupal_drush_module_enable or drupal_drush_module_disable, " +
592
+ "which check the protected-module list. " +
593
+ (secConfig?.coreExtensionChangeError
594
+ ? `This site's opt-in cannot be read: ${secConfig.coreExtensionChangeError} It stays refused until an operator fixes it.`
595
+ : "To allow it, an operator must set security.allowCoreExtensionChange = true for this site.")
596
+ );
597
+ }
598
+
599
+ /**
600
+ * Check a drupal_config_set value for core.extension against the protected
601
+ * module list (#349). Runs only after assertCoreExtensionChangeAllowed().
602
+ *
603
+ * The value is a map of top-level keys, and the source also accepts a dotted
604
+ * key such as `module.devel`. A `module` key replaces the whole module map, so
605
+ * every protected module in `currentModules` must still be in it. A dotted key
606
+ * under `module.` that names a protected module is refused outright.
607
+ *
608
+ * @param {object} secConfig Resolved security config.
609
+ * @param {object} value The submitted map of config keys to values.
610
+ * @param {?string[]} currentModules Machine names installed now, or null when
611
+ * the caller has not read them. Needed only when `value` has a `module` key.
612
+ * @returns {{needsCurrentModules: boolean}} `needsCurrentModules` is true when
613
+ * the value replaces the module map and `currentModules` was not given; the
614
+ * caller reads the list and calls again.
615
+ * @throws {SecurityError} if the write would remove or alter a protected
616
+ * module, or the protected-module list cannot be trusted.
617
+ */
618
+ export function assertCoreExtensionValueKeepsProtected(secConfig, value, currentModules = null) {
619
+ if (secConfig?.protectedModulesError || !Array.isArray(secConfig?.protectedModules)) {
620
+ throw new SecurityError(
621
+ "The write to core.extension is blocked because this site's protected-module config cannot be read. " +
622
+ `${secConfig?.protectedModulesError ?? "The protected-module list is missing."}`
623
+ );
624
+ }
625
+ const entries = value && typeof value === "object" && !Array.isArray(value) ? Object.entries(value) : [];
626
+ const protectedSet = new Set(secConfig.protectedModules);
627
+
628
+ const dotted = entries
629
+ .map(([key]) => /^module\.([^.]+)/.exec(key)?.[1])
630
+ .filter((name) => name !== undefined && protectedSet.has(name));
631
+ if (dotted.length) {
632
+ throw new SecurityError(
633
+ `The write to core.extension changes the entry of protected module${dotted.length === 1 ? "" : "s"} ` +
634
+ `${[...new Set(dotted)].sort().join(", ")}. Nothing was written. ` +
635
+ "To allow it, an operator must name the module in security.allowProtectedModuleUninstall for this site."
636
+ );
637
+ }
638
+
639
+ const moduleEntry = entries.find(([key]) => key === "module");
640
+ if (!moduleEntry) return { needsCurrentModules: false };
641
+ const next = moduleEntry[1];
642
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
643
+ throw new SecurityError(
644
+ "The write to core.extension is refused: `module` must be a map of module machine names to weights. Nothing was written."
645
+ );
646
+ }
647
+ if (!Array.isArray(currentModules)) return { needsCurrentModules: true };
648
+
649
+ const kept = new Set(Object.keys(next));
650
+ const removed = currentModules.filter((name) => protectedSet.has(name) && !kept.has(name)).sort();
651
+ if (removed.length) {
652
+ throw new SecurityError(
653
+ `The write to core.extension would remove protected module${removed.length === 1 ? "" : "s"} ${removed.join(", ")}. ` +
654
+ "Nothing was written. " +
655
+ "To allow it, an operator must name the module in security.allowProtectedModuleUninstall for this site."
656
+ );
657
+ }
658
+ return { needsCurrentModules: false };
659
+ }
660
+
433
661
  /**
434
662
  * Whether a set of write attributes carries a publish action.
435
663
  *
@@ -751,5 +979,10 @@ export function getSecuritySummary(site) {
751
979
  entityRules: cfg.entityRules,
752
980
  globalRedactedFields: cfg.globalRedactedFields,
753
981
  declaredCeiling: cfg.declaredCeiling ?? null,
982
+ protectedModules: cfg.protectedModules,
983
+ protectedModuleOptOuts: cfg.protectedModuleOptOuts,
984
+ ...(cfg.protectedModulesError ? { protectedModulesError: cfg.protectedModulesError } : {}),
985
+ allowCoreExtensionChange: cfg.allowCoreExtensionChange,
986
+ ...(cfg.coreExtensionChangeError ? { coreExtensionChangeError: cfg.coreExtensionChangeError } : {}),
754
987
  };
755
988
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { entityRevisionId } from "./write-revision.js";
12
+ import { httpStatusOf } from "./error-status.js";
12
13
 
13
14
  const LANGCODE_RE = /^[a-z][a-z0-9_-]{0,11}$/;
14
15
  const MISSING_DRAFT_ENDPOINT =
@@ -19,7 +20,6 @@ const MISSING_TRANSLATION_ENDPOINT =
19
20
  "Update MCP Sentinel; no canonical langcode PATCH was attempted.";
20
21
  const MISSING_DRAFT_RE = /does not provide Sentinel's governed draft endpoint/;
21
22
  const MISSING_TRANSLATION_RE = /does not provide Sentinel's governed draft-translation endpoint/;
22
- const DRUPAL_ABSENCE_RE = /Drupal (404|405)\b/;
23
23
 
24
24
  /**
25
25
  * Whether a backend can issue Sentinel's JSON:API draft/translation routes.
@@ -70,7 +70,8 @@ export function isMissingTranslationEndpoint(error) {
70
70
  * @returns {Error}
71
71
  */
72
72
  function missingEndpointError(error, message) {
73
- if (DRUPAL_ABSENCE_RE.test(String(error?.message))) {
73
+ const status = httpStatusOf(error);
74
+ if (status === 404 || status === 405) {
74
75
  return new Error(message, { cause: error });
75
76
  }
76
77
  return error instanceof Error ? error : new Error(String(error));
@@ -18,8 +18,8 @@
18
18
  * Config (per site):
19
19
  * "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
20
20
  *
21
- * Tools are NOT functional until the Drupal-side governed config tools ship;
22
- * until then the server returns a tool-not-found error, surfaced verbatim.
21
+ * The governed config tools work only when the source advertises them to this
22
+ * account in `tools/list`; otherwise the call fails closed before it is sent.
23
23
  */
24
24
 
25
25
  import fetch from "node-fetch";
@@ -27,6 +27,7 @@ import { createHmac, randomBytes } from "node:crypto";
27
27
  import { authHeadersAsync, clientHeaders, CLIENT_NAME, CLIENT_VERSION } from "./config.js";
28
28
  import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial, getDataFlowContext } from "./data-flow.js";
29
29
  import { clearToken } from "./oauth.js";
30
+ import { cleanErrorText, describeErrorBody } from "./error-body.js";
30
31
 
31
32
  /** Calls a configured module binding through the registry, without fallback. */
32
33
  export async function callBoundModuleTool(site, binding, args, required) {
@@ -37,19 +38,103 @@ export async function callBoundModuleTool(site, binding, args, required) {
37
38
  }
38
39
 
39
40
  /**
40
- * Canonical server-side tool names for governed config operations.
41
+ * Tool API ids of the governed config tools (mcp_sentinel's McpConfigGet/List/Set
42
+ * plugins). These are ids, not wire names: the bridge decides the wire name, so
43
+ * it is resolved from the source's `tools/list` (see resolveServerToolName).
44
+ */
45
+ export const SERVER_TOOL_IDS = Object.freeze({
46
+ configGet: "mcp_sentinel_config_get",
47
+ configList: "mcp_sentinel_config_list",
48
+ configSet: "mcp_sentinel_config_set",
49
+ });
50
+
51
+ /**
52
+ * Wire-name prefixes the Drupal tool bridge has used for a Tool API tool, in
53
+ * order of preference. Current mcp_server releases join the `tool_api` base id
54
+ * and the tool id with `__`; older ones used a dot.
55
+ */
56
+ const WIRE_PREFIXES = ["tool_api__", "tool_api."];
57
+
58
+ /** Upper bound on `tools/list` pages read while resolving a name. */
59
+ const MAX_CATALOG_PAGES = 16;
60
+
61
+ /**
62
+ * Wire names a bridge may advertise for a Tool API id, preferred first.
63
+ * @param {string} id Tool API id, e.g. `mcp_sentinel_config_set`.
64
+ * @returns {string[]} Candidate wire names.
65
+ */
66
+ export function serverToolCandidates(id) {
67
+ return WIRE_PREFIXES.map((prefix) => `${prefix}${id}`);
68
+ }
69
+
70
+ /**
71
+ * Read every tool name the source advertises to this caller.
72
+ * @param {object} site Resolved site config.
73
+ * @param {Function} [list] Catalog page reader `(site, cursor) => {tools, nextCursor}`.
74
+ * @returns {Promise<Set<string>>} Advertised tool names.
75
+ * @throws {Error} on a malformed page, a repeated cursor, or too many pages.
76
+ */
77
+ export async function advertisedServerToolNames(site, list = listServerTools) {
78
+ const names = new Set();
79
+ const seen = new Set();
80
+ let cursor;
81
+ for (let page = 0; page < MAX_CATALOG_PAGES; page++) {
82
+ const result = await list(site, cursor);
83
+ if (!Array.isArray(result?.tools)) {
84
+ throw new Error(`Server-tool catalog for site "${site._name}" is malformed: tools/list returned no tools array.`);
85
+ }
86
+ for (const tool of result.tools) {
87
+ if (typeof tool?.name === "string") names.add(tool.name);
88
+ }
89
+ if (result.nextCursor === undefined || result.nextCursor === null) return names;
90
+ if (typeof result.nextCursor !== "string" || seen.has(result.nextCursor)) {
91
+ throw new Error(`Server-tool catalog for site "${site._name}" returned an invalid or repeated cursor.`);
92
+ }
93
+ cursor = result.nextCursor;
94
+ seen.add(cursor);
95
+ }
96
+ throw new Error(`Server-tool catalog for site "${site._name}" exceeds ${MAX_CATALOG_PAGES} pages.`);
97
+ }
98
+
99
+ /**
100
+ * Resolve the wire name the source advertises for a governed config tool.
41
101
  *
42
- * Drupal's mcp_server_tool_bridge exposes every Tool-API tool through the MCP
43
- * protocol under the derivative name `tool_api.<mcp_tool_config id>`, so the
44
- * governed config tools registered against mcp_sentinel's McpConfigGet/List/Set
45
- * plugins surface as `tool_api.mcp_sentinel_config_*`. Keep the mapping here so
46
- * a server-side rename is a one-line change.
102
+ * Fails closed: when the catalog lists none of the candidate names, no name is
103
+ * guessed and nothing is called.
104
+ * @param {object} site Resolved site config.
105
+ * @param {string} binding Key of SERVER_TOOL_IDS (`configGet` | `configList` | `configSet`).
106
+ * @param {{list?: Function}} [deps] Injectable catalog page reader.
107
+ * @returns {Promise<string>} The advertised wire name.
108
+ * @throws {Error} if the binding is unknown or the tool is not advertised.
47
109
  */
48
- export const SERVER_TOOLS = {
49
- configGet: "tool_api.mcp_sentinel_config_get",
50
- configList: "tool_api.mcp_sentinel_config_list",
51
- configSet: "tool_api.mcp_sentinel_config_set",
52
- };
110
+ export async function resolveServerToolName(site, binding, { list = listServerTools } = {}) {
111
+ const id = new Map(Object.entries(SERVER_TOOL_IDS)).get(binding);
112
+ if (!id) throw new Error(`Unknown server tool binding "${binding}".`);
113
+ const candidates = serverToolCandidates(id);
114
+ const advertised = await advertisedServerToolNames(site, list);
115
+ const name = candidates.find((candidate) => advertised.has(candidate));
116
+ if (!name) {
117
+ throw new Error(
118
+ `Server tool "${id}" is not advertised by the source for site "${site._name}" ` +
119
+ `(looked for ${candidates.join(" and ")} in tools/list). No call was made. ` +
120
+ "Register and enable it as an mcp_tool_config entity, check that this account holds the scope the tool requires, " +
121
+ "or map it under serverTools.bindings. See docs/integration-contract.md."
122
+ );
123
+ }
124
+ return name;
125
+ }
126
+
127
+ /**
128
+ * Call a governed config tool by the wire name the source advertises for it.
129
+ * @param {object} site Resolved site config.
130
+ * @param {string} binding Key of SERVER_TOOL_IDS.
131
+ * @param {object} [args] Tool arguments.
132
+ * @returns {Promise<*>} The tool's structured result.
133
+ * @throws {Error} if the tool is not advertised, or as callServerTool.
134
+ */
135
+ export async function callGovernedServerTool(site, binding, args = {}) {
136
+ return callServerTool(site, await resolveServerToolName(site, binding), args);
137
+ }
53
138
 
54
139
  /** MCP protocol version advertised on the handshake and every subsequent POST. */
55
140
  const MCP_PROTOCOL_VERSION = "2025-06-18";
@@ -158,6 +243,73 @@ function parseSse(text) {
158
243
  return found;
159
244
  }
160
245
 
246
+ /**
247
+ * Message for a non-2xx bridge response.
248
+ *
249
+ * The body is untrusted: an HTML error page from Drupal, PHP or a proxy, a
250
+ * server path or a backtrace. Only a cleaned, bounded detail is shown (see
251
+ * `describeErrorBody`). The prefix holds the HTTP status and always ends with a
252
+ * colon — the documented message shape (`httpStatusOf`, and
253
+ * `classifyBridgeError` when the error has no marker).
254
+ *
255
+ * A body that is a JSON-RPC error keeps its integer code and its cleaned
256
+ * message, so a refusal sent with a 401 or 403 stays readable.
257
+ * @param {string} prefix Message start, e.g. "Server-tool call x failed 502".
258
+ * @param {object} res node-fetch Response with `ok === false`.
259
+ * @param {string} rawText Response body.
260
+ * @param {?object} body Parsed JSON-RPC body, if any.
261
+ * @returns {string} Bounded message.
262
+ */
263
+ function failedResponseMessage(prefix, res, rawText, body) {
264
+ const rpcError = body?.error;
265
+ if (rpcError && typeof rpcError === "object") {
266
+ const code = Number.isInteger(rpcError.code) ? ` ${rpcError.code}` : "";
267
+ const message = cleanErrorText(rpcError.message) || "the server returned no error message";
268
+ return `${prefix}: JSON-RPC error${code}: ${message}`;
269
+ }
270
+ const detail = describeErrorBody(rawText, res.headers?.get?.("content-type") ?? null);
271
+ return `${prefix}: ${detail || "the server returned an empty body"}`;
272
+ }
273
+
274
+ /**
275
+ * Message for a JSON-RPC `error` object.
276
+ *
277
+ * `error.code` is kept when it is an integer, because callers read it.
278
+ * `error.message` is cleaned and bounded. `error.data` is never read: with
279
+ * verbose errors on it holds a backtrace.
280
+ * @param {string} subject Message start, e.g. "Server-tool x".
281
+ * @param {*} rpcError JSON-RPC error object from the response.
282
+ * @returns {string} Bounded message.
283
+ */
284
+ function rpcErrorMessage(subject, rpcError) {
285
+ const code = Number.isInteger(rpcError?.code) ? ` (${rpcError.code})` : "";
286
+ const detail = cleanErrorText(rpcError?.message) || "the server returned no error message";
287
+ return `${subject} error${code}: ${detail}`;
288
+ }
289
+
290
+ /**
291
+ * Build a bridge error that says what failed.
292
+ *
293
+ * The message ends with a response body or tool text, so code that branches on
294
+ * the failure reads these properties, never the message (#361):
295
+ * - `bridgeFailure`: `"session"` (the handshake failed; no tool was reached),
296
+ * `"http"` (non-2xx on the request), `"rpc"` (JSON-RPC error object) or
297
+ * `"tool"` (the tool ran and its result has `isError`);
298
+ * - `status`: the HTTP status, on a non-2xx response only (see `httpStatusOf`);
299
+ * - `rpcCode`: the JSON-RPC `error.code`, when it is an integer.
300
+ * @param {string} message Error message.
301
+ * @param {"session"|"http"|"rpc"|"tool"} failure What failed.
302
+ * @param {{status?: number, rpcCode?: *}} [facts] Response status or JSON-RPC code.
303
+ * @returns {Error} The marked error.
304
+ */
305
+ function bridgeError(message, failure, { status, rpcCode } = {}) {
306
+ const error = new Error(message);
307
+ error.bridgeFailure = failure;
308
+ if (Number.isInteger(status)) error.status = status;
309
+ if (Number.isInteger(rpcCode)) error.rpcCode = rpcCode;
310
+ return error;
311
+ }
312
+
161
313
  /**
162
314
  * Perform the MCP session handshake against the server and cache the resulting
163
315
  * session id: `initialize` (read the `Mcp-Session-Id` response header) followed
@@ -197,18 +349,21 @@ async function initializeSession(site, endpoint, key) {
197
349
 
198
350
  const { body, rawText } = await readBody(res);
199
351
  if (!res.ok) {
200
- throw new Error(`Server-tool session initialize failed ${res.status}: ${rawText}`);
352
+ throw bridgeError(
353
+ failedResponseMessage(`Server-tool session initialize failed ${res.status}`, res, rawText, body),
354
+ "session",
355
+ { status: res.status },
356
+ );
201
357
  }
202
358
  if (body?.error) {
203
- const { code, message } = body.error;
204
- const hasCode = code !== undefined && code !== null;
205
- throw new Error(`Server-tool session initialize error${hasCode ? ` (${code})` : ""}: ${message}`);
359
+ throw bridgeError(rpcErrorMessage("Server-tool session initialize", body.error), "session", { rpcCode: body.error?.code });
206
360
  }
207
361
 
208
362
  const sessionId = res.headers.get("mcp-session-id");
209
363
  if (!sessionId) {
210
- throw new Error(
211
- `Server-tool session initialize for site "${site._name}" returned no Mcp-Session-Id header.`
364
+ throw bridgeError(
365
+ `Server-tool session initialize for site "${site._name}" returned no Mcp-Session-Id header.`,
366
+ "session",
212
367
  );
213
368
  }
214
369
 
@@ -265,7 +420,7 @@ function isSessionError(res, body) {
265
420
  * OAuth sites clears and re-acquires the token then replays (same session); a
266
421
  * server-side session expiry re-initialises the session then replays.
267
422
  * @param {object} site Resolved site config (provides baseUrl + auth).
268
- * @param {string} toolName Server-side MCP tool name (see SERVER_TOOLS).
423
+ * @param {string} toolName Server-side MCP wire name (see resolveServerToolName).
269
424
  * @param {object} [args] Tool arguments object.
270
425
  * @returns {Promise<*>} The tool's structured result.
271
426
  * @throws {Error} on transport failure, JSON-RPC error, or tool error.
@@ -330,23 +485,29 @@ async function requestServerTool(site, method, params, options) {
330
485
  if (!res.ok) {
331
486
  const mapped = sourceBudgetDenial(rawText);
332
487
  if (mapped) throw mapped;
333
- throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${rawText}`);
488
+ throw bridgeError(
489
+ failedResponseMessage(`Server-tool call ${toolName} failed ${res.status}`, res, rawText, body),
490
+ "http",
491
+ { status: res.status },
492
+ );
334
493
  }
335
494
 
336
495
  // JSON-RPC transport-level error.
337
496
  if (body?.error) {
338
- const { code, message } = body.error;
339
- const hasCode = code !== undefined && code !== null;
340
- throw new Error(`Server-tool ${toolName} error${hasCode ? ` (${code})` : ""}: ${message}`);
497
+ throw bridgeError(rpcErrorMessage(`Server-tool ${toolName}`, body.error), "rpc", { rpcCode: body.error?.code });
341
498
  }
342
499
 
343
500
  // MCP tools/call result: { content: [...], isError?: boolean }.
344
501
  const result = body?.result;
345
502
  if (result?.isError && !options.preserveErrors) {
346
- const detail = extractTextContent(result) || "tool reported an error";
347
- const mapped = sourceBudgetDenial(detail);
503
+ // The budget code is looked for in the whole text, before it is cut.
504
+ const text = extractTextContent(result);
505
+ const mapped = sourceBudgetDenial(text);
348
506
  if (mapped) throw mapped;
349
- throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
507
+ // The tool's own words are for the caller (a governed refusal explains
508
+ // itself), so they are kept: cleaned and bounded, never relayed raw.
509
+ const detail = cleanErrorText(text) || "tool reported an error";
510
+ throw bridgeError(`Server-tool ${toolName} reported an error: ${detail}`, "tool");
350
511
  }
351
512
  return result;
352
513
  }