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.
- package/.agents/commands/drupal-config-set.md +2 -2
- package/.agents/commands/drupal-create-node.md +2 -2
- package/.agents/commands/drupal-create-translation.md +1 -1
- package/.agents/commands/drupal-delete-node.md +1 -1
- package/.agents/commands/drupal-describe-fields.md +2 -2
- package/.agents/commands/drupal-drush-config-import.md +2 -2
- package/.agents/commands/drupal-drush-module-disable.md +2 -2
- package/.agents/commands/drupal-drush-module-list.md +1 -1
- package/.agents/commands/drupal-drush-user-list.md +1 -1
- package/.agents/commands/drupal-drush-watchdog.md +1 -1
- package/.agents/commands/drupal-entity-create.md +2 -2
- package/.agents/commands/drupal-entity-delete.md +1 -1
- package/.agents/commands/drupal-entity-update.md +4 -4
- package/.agents/commands/drupal-report-field-completeness.md +2 -2
- package/.agents/commands/drupal-report-missing-field.md +2 -2
- package/.agents/commands/drupal-report-seo-meta-coverage.md +2 -2
- package/.agents/commands/drupal-report-status-report.md +1 -1
- package/.agents/commands/drupal-update-node.md +4 -4
- package/CHANGELOG.md +240 -0
- package/README.md +7 -1
- package/bin/drupal-mcp-verify.js +4 -3
- package/config/config.example.json +52 -2
- package/package.json +1 -1
- package/scripts/generate-commands.js +40 -5
- package/scripts/install-commands.js +148 -11
- package/src/index.js +9 -12
- package/src/lib/backends/graphql-schema.js +9 -1
- package/src/lib/backends/graphql.js +4 -2
- package/src/lib/backends/index.js +9 -3
- package/src/lib/backends/jsonapi.js +2 -0
- package/src/lib/drupal-fetch.js +97 -26
- package/src/lib/dry-run-checks.js +78 -0
- package/src/lib/error-body.js +448 -0
- package/src/lib/error-status.js +38 -0
- package/src/lib/mcp-server.js +7 -1
- package/src/lib/metatag-audit.js +2 -1
- package/src/lib/module-tools.js +23 -2
- package/src/lib/patch-preflight.js +23 -5
- package/src/lib/reports-support.js +75 -0
- package/src/lib/security.js +233 -0
- package/src/lib/sentinel-draft.js +3 -2
- package/src/lib/server-tools.js +188 -27
- package/src/lib/tool-prompts.js +137 -6
- package/src/lib/verify.js +161 -58
- package/src/tools/config.js +70 -5
- package/src/tools/drush.js +191 -17
- package/src/tools/entities.js +18 -6
- package/src/tools/fields.js +40 -4
- package/src/tools/graphql.js +10 -5
- package/src/tools/moderation.js +1 -1
- package/src/tools/nodes.js +16 -6
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +34 -26
- package/src/tools/reports-extra.js +46 -38
- package/src/tools/reports.js +50 -25
- package/src/tools/scheduler.js +1 -1
- package/src/tools/structure.js +12 -2
- 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
|
+
}
|
package/src/lib/security.js
CHANGED
|
@@ -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
|
-
|
|
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));
|
package/src/lib/server-tools.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* Config (per site):
|
|
19
19
|
* "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
const
|
|
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
|
-
|
|
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
|
}
|