drupal-mcp-connector 2.19.0 → 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 (70) 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 +254 -0
  20. package/README.md +10 -3
  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/dispatch.js +6 -6
  32. package/src/lib/drupal-fetch.js +97 -26
  33. package/src/lib/dry-run-checks.js +78 -0
  34. package/src/lib/error-body.js +448 -0
  35. package/src/lib/error-status.js +38 -0
  36. package/src/lib/errors.js +0 -11
  37. package/src/lib/evidence.js +0 -6
  38. package/src/lib/governance.js +2 -8
  39. package/src/lib/link-checker.js +3 -3
  40. package/src/lib/mcp-server.js +7 -1
  41. package/src/lib/metatag-audit.js +2 -1
  42. package/src/lib/module-tools.js +23 -2
  43. package/src/lib/operations.js +2 -2
  44. package/src/lib/patch-preflight.js +23 -5
  45. package/src/lib/policy-enforcement.js +4 -4
  46. package/src/lib/principal.js +3 -3
  47. package/src/lib/relay/edge.js +2 -2
  48. package/src/lib/reports-support.js +75 -0
  49. package/src/lib/security.js +234 -18
  50. package/src/lib/sentinel-draft.js +3 -2
  51. package/src/lib/server-tools.js +188 -27
  52. package/src/lib/tool-prompts.js +139 -8
  53. package/src/lib/usage.js +0 -9
  54. package/src/lib/verify.js +164 -61
  55. package/src/tools/config.js +70 -5
  56. package/src/tools/drush.js +191 -17
  57. package/src/tools/entities.js +18 -6
  58. package/src/tools/fields.js +40 -4
  59. package/src/tools/graphql.js +10 -5
  60. package/src/tools/nodes.js +16 -6
  61. package/src/tools/paragraphs.js +1 -1
  62. package/src/tools/reports-config.js +3 -3
  63. package/src/tools/reports-content.js +34 -26
  64. package/src/tools/reports-extra.js +46 -38
  65. package/src/tools/reports.js +50 -25
  66. package/src/tools/scheduler.js +1 -1
  67. package/src/tools/structure.js +12 -2
  68. package/src/tools/translations.js +5 -1
  69. package/src/lib/draft-write.js +0 -19
  70. package/src/lib/node-draft-inventory.js +0 -5
@@ -26,7 +26,10 @@ import { readFileSync } from "fs";
26
26
  import { homedir } from "os";
27
27
  import { join, resolve, normalize } from "path";
28
28
  import { getSiteConfig } from "../lib/config.js";
29
- import { resolveSecurityConfig, assertNotReadOnly, SecurityError } from "../lib/security.js";
29
+ import {
30
+ resolveSecurityConfig, assertNotReadOnly, assertDestructiveAllowed, assertModuleUninstallAllowed,
31
+ assertCoreExtensionChangeAllowed, isCoreExtensionConfig, SecurityError,
32
+ } from "../lib/security.js";
30
33
  import { validateMachineName, validateSqlQuery, sanitizeSshArg } from "../lib/validate.js";
31
34
 
32
35
  // ---------------------------------------------------------------------------
@@ -128,8 +131,11 @@ function resolveKeyPath(rawPath) {
128
131
  * Exported so the audit tool groups can reuse the hardened bridge for their
129
132
  * drush fallback paths (e.g. config:status, pm:list, watchdog:show) without
130
133
  * re-implementing SSH handling.
134
+ *
135
+ * @param {{confirm?: "yes"|"no"}} [options] - how Drush confirmation prompts are
136
+ * answered. Default "yes". "no" makes Drush cancel at any prompt.
131
137
  */
132
- export function sshDrush(site, drushArgs, timeoutMs = 30000) {
138
+ export function sshDrush(site, drushArgs, timeoutMs = 30000, { confirm = "yes" } = {}) {
133
139
  const sshCfg = getDrushConfig(site);
134
140
  assertCommandAllowed(sshCfg, drushArgs[0]);
135
141
  const keyPath = resolveKeyPath(sshCfg.keyPath);
@@ -143,7 +149,10 @@ export function sshDrush(site, drushArgs, timeoutMs = 30000) {
143
149
  // Build the command: cd to Drupal root, then run vendor drush with escaped args
144
150
  const escapedArgs = drushArgs.map(sanitizeSshArg).join(" ");
145
151
  const drushBin = `${drupalRoot}/vendor/bin/drush`;
146
- const command = `cd ${sanitizeSshArg(drupalRoot)} && ${drushBin} ${escapedArgs} --yes`;
152
+ // Every prompt is answered for the non-interactive session: yes by default,
153
+ // no when the caller must not let Drush widen the operation (#346).
154
+ const answer = confirm === "no" ? "--no" : "--yes";
155
+ const command = `cd ${sanitizeSshArg(drupalRoot)} && ${drushBin} ${escapedArgs} ${answer}`;
147
156
 
148
157
  console.error(`[drush-bridge] ${site._name}: drush ${redactSecretArgs(drushArgs)}`);
149
158
 
@@ -175,9 +184,17 @@ export function sshDrush(site, drushArgs, timeoutMs = 30000) {
175
184
 
176
185
  stream.on("close", (code) => {
177
186
  if (code !== 0) {
178
- settle(reject, new Error(
187
+ const failure = new Error(
179
188
  `Drush exited ${code}: ${(stderr.trim() || stdout.trim()).slice(0, 500)}`
180
- ));
189
+ );
190
+ // Kept off the message: callers that need to read Drush's own
191
+ // output (the cascade list, #346) get it without it reaching the client.
192
+ Object.defineProperties(failure, {
193
+ exitCode: { value: code },
194
+ drushStdout: { value: stdout },
195
+ drushStderr: { value: stderr },
196
+ });
197
+ settle(reject, failure);
181
198
  } else {
182
199
  settle(resolve, stdout.trim());
183
200
  }
@@ -302,15 +319,94 @@ async function configExport({ site: siteName }) {
302
319
  return { success: true, message: "Configuration exported to sync directory." };
303
320
  }
304
321
 
322
+ /**
323
+ * Read the config names `drush config:status` reports as changed.
324
+ *
325
+ * Drush prints nothing, `[]` or `{}` when active config matches the sync
326
+ * directory. Otherwise it prints an object keyed by config name, or a list of
327
+ * `{ name, state }` rows. Any other output is not understood.
328
+ * @param {string} output Trimmed stdout of `config:status --format=json`.
329
+ * @returns {?string[]} Changed config names, or null when the output is not understood.
330
+ */
331
+ function changedConfigNames(output) {
332
+ if (!output) return [];
333
+ const data = parseDrush(output);
334
+ if (Array.isArray(data)) {
335
+ const names = data.map((row) => (row && typeof row === "object" ? row.name : undefined));
336
+ return names.every((name) => typeof name === "string" && name) ? names : null;
337
+ }
338
+ if (data && typeof data === "object") {
339
+ // Keyed by config name. A row's own `name` is read too, so an object keyed
340
+ // by row index cannot hide a config name from the check.
341
+ const rowNames = Object.values(data)
342
+ .map((row) => (row && typeof row === "object" ? row.name : undefined))
343
+ .filter((name) => typeof name === "string" && name);
344
+ return [...Object.keys(data), ...rowNames];
345
+ }
346
+ return null;
347
+ }
348
+
349
+ /**
350
+ * Refuse a config import that would change core.extension (#349).
351
+ *
352
+ * `config:import` uninstalls every module that core.extension in the sync
353
+ * directory no longer lists, and the bridge answers the prompt "yes". The
354
+ * connector cannot read the sync directory, so it cannot tell which modules
355
+ * those are. It reads `config:status`, a read-only command, and refuses when
356
+ * core.extension is among the changed objects. Fails closed: if the status
357
+ * cannot be read or understood, nothing is imported.
358
+ *
359
+ * The sync directory can change between this read and the import. The check
360
+ * narrows the path; it does not close a race with someone who can write there.
361
+ * @param {object} site Resolved site config.
362
+ * @param {object} sec Resolved security config.
363
+ * @returns {Promise<void>}
364
+ * @throws {SecurityError} if core.extension differs, or the status is unreadable.
365
+ */
366
+ async function assertImportKeepsCoreExtension(site, sec) {
367
+ if (sec.allowCoreExtensionChange === true && !sec.coreExtensionChangeError) return;
368
+ const unchecked = "The config import was refused because core.extension could not be checked first. Nothing was imported.";
369
+ let output;
370
+ try {
371
+ output = await sshDrush(site, ["config:status", "--format=json"]);
372
+ } catch (err) {
373
+ if (err instanceof SecurityError) {
374
+ throw new SecurityError(
375
+ `${unchecked} The check runs \`drush config:status\`, a read-only command, and this site's ` +
376
+ "drushSsh.allowedCommands does not list it. An operator must add \"config:status\" to allowedCommands."
377
+ );
378
+ }
379
+ throw new SecurityError(`${unchecked} \`drush config:status\` failed: ${String(err?.message ?? err).slice(0, 300)}`);
380
+ }
381
+ const names = changedConfigNames(output);
382
+ if (!names) {
383
+ throw new SecurityError(`${unchecked} The output of \`drush config:status --format=json\` was not understood.`);
384
+ }
385
+ if (names.some(isCoreExtensionConfig)) {
386
+ assertCoreExtensionChangeAllowed(
387
+ sec,
388
+ "The config import was refused because core.extension differs between the site and the sync directory. " +
389
+ "Importing it installs and uninstalls modules, and can uninstall a protected one. " +
390
+ "The connector cannot read the sync directory, so it cannot tell which modules would change. Nothing was imported."
391
+ );
392
+ }
393
+ }
394
+
305
395
  /**
306
396
  * Import config from the sync directory into the DB (`drush config:import`).
397
+ *
398
+ * Refused when core.extension differs, unless the operator set
399
+ * `security.allowCoreExtensionChange` (#349).
307
400
  * @param {object} args - { site? }.
308
401
  * @returns {Promise<{success: boolean, message: string}>}
309
- * @throws {SecurityError} If the site is read-only.
402
+ * @throws {SecurityError} If the site is read-only, core.extension differs, or
403
+ * `config:status` cannot be read.
310
404
  */
311
405
  async function configImport({ site: siteName }) {
312
406
  const site = getSiteConfig(siteName);
313
- assertNotReadOnly(resolveSecurityConfig(site), "drush config:import");
407
+ const sec = resolveSecurityConfig(site);
408
+ assertNotReadOnly(sec, "drush config:import");
409
+ await assertImportKeepsCoreExtension(site, sec);
314
410
  await sshDrush(site, ["config:import"]);
315
411
  return { success: true, message: "Configuration imported from sync directory." };
316
412
  }
@@ -366,25 +462,103 @@ async function securityUpdates({ site: siteName }) {
366
462
  * @throws {Error} If moduleName is not a valid machine name.
367
463
  */
368
464
  async function enableModule({ site: siteName, moduleName }) {
465
+ validateMachineName(moduleName, "moduleName"); // first: an invalid name never reaches a message
369
466
  const site = getSiteConfig(siteName);
370
467
  assertNotReadOnly(resolveSecurityConfig(site), `pm:enable ${moduleName}`);
371
- validateMachineName(moduleName, "moduleName"); // throws if invalid
372
468
  await sshDrush(site, ["pm:enable", moduleName]);
373
469
  return { success: true, message: `Module "${moduleName}" enabled.` };
374
470
  }
375
471
 
376
472
  /**
377
- * Uninstall a module (`drush pm:uninstall`). Name is validated as a machine name.
473
+ * Drush prints this before it asks to confirm a cascading uninstall. The list
474
+ * may wrap over several lines. Case-sensitive on purpose: module names are
475
+ * lowercase, so the capture stops at the next sentence ("Do you want to…").
476
+ */
477
+ const CASCADE_LIST_RE = /The following extensions will be uninstalled:\s*([a-z0-9_,\s]+)/;
478
+
479
+ /**
480
+ * Read the modules Drush said it would uninstall from a cancelled run.
481
+ * @param {Error} failure Rejection from sshDrush().
482
+ * @returns {?string[]} Machine names, or null when Drush printed no list.
483
+ */
484
+ function cascadeList(failure) {
485
+ return parseCascadeList(`${failure?.drushStdout ?? ""}\n${failure?.drushStderr ?? ""}`);
486
+ }
487
+
488
+ /**
489
+ * @param {string} output Drush output.
490
+ * @returns {?string[]} Machine names Drush said it would uninstall, or null.
491
+ */
492
+ function parseCascadeList(output) {
493
+ const match = CASCADE_LIST_RE.exec(output);
494
+ if (!match) return null;
495
+ return match[1].split(",").map((name) => name.trim()).filter((name) => /^[a-z][a-z0-9_]*$/.test(name));
496
+ }
497
+
498
+ /**
499
+ * Uninstall a module (`drush pm:uninstall`).
500
+ *
501
+ * Refused for a module on the site's protected list (#346): the default list in
502
+ * `security.js`, plus `security.protectedModules`, minus the explicit
503
+ * `security.allowProtectedModuleUninstall`.
504
+ *
505
+ * `pm:uninstall` also uninstalls every module that depends on the named one,
506
+ * after a prompt. The bridge answers that prompt "no", so a cascade is
507
+ * cancelled by Drush and nothing is uninstalled; a protected module cannot be
508
+ * removed through one of its dependencies. The caller uninstalls each
509
+ * dependent by name, and each name goes through the same check.
510
+ *
378
511
  * @param {object} args - { site?, moduleName }.
379
- * @returns {Promise<{success: boolean, message: string}>}
380
- * @throws {SecurityError} If the site is read-only.
381
- * @throws {Error} If moduleName is not a valid machine name.
512
+ * @returns {Promise<{success: boolean, message: string, alsoUninstalled?: string[], warning?: string}>}
513
+ * `alsoUninstalled` is set only if Drush ran a cascade despite the "no" answer.
514
+ * @throws {Error} If moduleName is not a valid machine name. Checked first, so
515
+ * an invalid name never reaches a message.
516
+ * @throws {SecurityError} If the site is read-only, deletes are not allowed,
517
+ * the module is protected, the protected-module config is malformed, or the
518
+ * uninstall would cascade to dependents.
382
519
  */
383
520
  async function disableModule({ site: siteName, moduleName }) {
384
- const site = getSiteConfig(siteName);
385
- assertNotReadOnly(resolveSecurityConfig(site), `pm:uninstall ${moduleName}`);
386
521
  validateMachineName(moduleName, "moduleName");
387
- await sshDrush(site, ["pm:uninstall", moduleName]);
522
+ const site = getSiteConfig(siteName);
523
+ const sec = resolveSecurityConfig(site);
524
+ assertNotReadOnly(sec, `pm:uninstall ${moduleName}`);
525
+ assertDestructiveAllowed(sec, "module", moduleName);
526
+ assertModuleUninstallAllowed(sec, moduleName);
527
+ let stdout;
528
+ try {
529
+ stdout = await sshDrush(site, ["pm:uninstall", moduleName], 30000, { confirm: "no" });
530
+ } catch (failure) {
531
+ const list = cascadeList(failure);
532
+ if (!list) throw failure;
533
+ const dependents = list.filter((name) => name !== moduleName);
534
+ if (dependents.length === 0) {
535
+ // Drush 13 prompts only for a cascade. Older releases prompt on every
536
+ // uninstall, and the "no" answer cancels those too.
537
+ throw new SecurityError(
538
+ `Drush asked to confirm uninstalling "${moduleName}" alone and the bridge answers "no", so nothing was uninstalled. ` +
539
+ "This Drush release prompts on every uninstall; the cascade guard needs Drush 13 or later on the site."
540
+ );
541
+ }
542
+ const guarded = dependents.filter((name) => sec.protectedModules.includes(name));
543
+ throw new SecurityError(
544
+ `Uninstalling "${moduleName}" would also uninstall: ${dependents.join(", ")}. ` +
545
+ "Nothing was uninstalled. " +
546
+ (guarded.length ? `Protected: ${guarded.join(", ")}. ` : "") +
547
+ "Uninstall each dependent by name first; each one goes through the protected-module check."
548
+ );
549
+ }
550
+ // Drush should have cancelled a cascade. If it printed a cascade list and
551
+ // still exited 0, say what it removed instead of reporting a clean uninstall.
552
+ const alsoUninstalled = (parseCascadeList(String(stdout ?? "")) ?? []).filter((name) => name !== moduleName);
553
+ if (alsoUninstalled.length) {
554
+ return {
555
+ success: true,
556
+ message: `Module "${moduleName}" uninstalled.`,
557
+ alsoUninstalled,
558
+ warning: "Drush did not cancel the cascade prompt, so these dependents were uninstalled too: " +
559
+ `${alsoUninstalled.join(", ")}. Check the Drush version on the site.`,
560
+ };
561
+ }
388
562
  return { success: true, message: `Module "${moduleName}" uninstalled.` };
389
563
  }
390
564
 
@@ -514,7 +688,7 @@ export const definitions = [
514
688
  { name: "drupal_drush_status", description: "Get Drupal site status via `drush status` — version, DB, file paths, active config.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
515
689
  { name: "drupal_drush_config_status", description: "Check if active config is in sync with the sync directory. Returns out-of-sync items if any.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
516
690
  { name: "drupal_drush_config_export", description: "Export active configuration to the sync directory. Requires write access.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
517
- { name: "drupal_drush_config_import", description: "Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
691
+ { name: "drupal_drush_config_import", description: "Import configuration from the sync directory into the database. Requires write access. Confirm with user before running on production. Reads `drush config:status` first and refuses the import when `core.extension` differs, because such an import installs and uninstalls modules and can remove a protected one; nothing is imported, and the status read must be allowed in `drushSsh.allowedCommands`. Use drupal_drush_module_enable or drupal_drush_module_disable for a module change. Only an operator can allow it, with `allowCoreExtensionChange` in site config (see drupal_security_info).", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
518
692
  { name: "drupal_drush_updatedb", description: "Run pending database updates via `drush updatedb`. Always run after deploying module updates.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
519
693
  { name: "drupal_drush_security_updates", description: "List modules with known security advisories via `drush pm:security`.", inputSchema: { type: "object", properties: { site: { type: "string" } } } },
520
694
  {
@@ -529,7 +703,7 @@ export const definitions = [
529
703
  },
530
704
  {
531
705
  name: "drupal_drush_module_disable",
532
- description: "Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user.",
706
+ description: "Uninstall a Drupal module. Irreversible for module-stored data. Confirm with user. Refused for a protected module: governance, integrity, secrets, auth and API modules such as mcp_sentinel, audit_chain, key, simple_oauth and jsonapi (see `protectedModules` in drupal_security_info). Only an operator can change that list, in site config. Also refused when the uninstall would cascade to dependents: nothing is uninstalled, the dependents are named, and each must be uninstalled by name first.",
533
707
  inputSchema: { type: "object", required: ["moduleName"], properties: { site: { type: "string" }, moduleName: { type: "string", pattern: "^[a-z][a-z0-9_]*$" } } },
534
708
  },
535
709
  {
@@ -18,6 +18,7 @@ import {
18
18
  } from "../lib/err-relationships.js";
19
19
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
+ import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
21
22
  import {
22
23
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
23
24
  redactCanonicalEntity, getSecuritySummary,
@@ -71,7 +72,12 @@ async function createEntity({ site: siteName, entityType, bundle, attributes = {
71
72
  assertPublishAllowed(sec, attributes);
72
73
  const backend = await resolveBackend(site);
73
74
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
74
- if (dryRun) return { dryRun: true, operation: "create", entityType, bundle, attributes, relationships: resolvedRelationships };
75
+ if (dryRun) {
76
+ return {
77
+ dryRun: true, operation: "create", entityType, bundle, attributes, relationships: resolvedRelationships,
78
+ ...dryRunChecks({ operation: "create", preflight: PREFLIGHT_NONE }),
79
+ };
80
+ }
75
81
  const created = await backend.createEntity({ entityType, bundle, attributes, relationships: resolvedRelationships });
76
82
  if (entityType === "paragraph") {
77
83
  const revisionId = await resolveParagraphRevisionId(backend, created, bundle);
@@ -125,6 +131,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
125
131
  return {
126
132
  dryRun: true, operation: "update", entityType, bundle, id,
127
133
  attributes: safeAttributes, relationships: resolvedRelationships,
134
+ ...dryRunChecks({ operation: "update", preflight: patchTarget.preflight }),
128
135
  };
129
136
  }
130
137
  const result = await updateEntityGuarded(backend, {
@@ -157,7 +164,12 @@ async function deleteEntity({ site: siteName, entityType, bundle, id, dryRun = f
157
164
  const site = getSiteConfig(siteName);
158
165
  const sec = resolveSecurityConfig(site);
159
166
  assertDeleteAllowed(sec, entityType, bundle, id);
160
- if (dryRun) return { dryRun: true, operation: "delete", entityType, bundle, id };
167
+ if (dryRun) {
168
+ return {
169
+ dryRun: true, operation: "delete", entityType, bundle, id,
170
+ ...dryRunChecks({ operation: "delete", preflight: PREFLIGHT_NONE }),
171
+ };
172
+ }
161
173
  const backend = await resolveBackend(site);
162
174
  await backend.deleteEntity({ entityType, bundle, id });
163
175
  return { success: true, deletedId: id, entityType, bundle };
@@ -277,14 +289,14 @@ export const definitions = [
277
289
  bundle: { type: "string" },
278
290
  attributes: { type: "object", description: "Field values keyed by Drupal machine name" },
279
291
  relationships: { type: "object", description: "Relationship data keyed by field name" },
280
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the create without committing." },
292
+ dryRun: { type: "boolean", default: false, description: "Return a preview of the payload without committing. Drupal does not evaluate the write: entity access, field access and entity validation are NOT checked, so the real create can still fail with a 403 or a validation 422. The result's `checks` block and `caveat` say what was and was not checked." },
281
293
  returning: RETURNING_SCHEMA,
282
294
  },
283
295
  },
284
296
  },
285
297
  {
286
298
  name: "drupal_entity_update",
287
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
299
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets a non-saving PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable node draft uses Sentinel's governed draft endpoint with live/working revision preconditions (#166); a stray revision with no addressable working copy still fails with revision-surgery language (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
288
300
  inputSchema: {
289
301
  type: "object", required: ["entityType", "bundle", "id"],
290
302
  properties: {
@@ -295,7 +307,7 @@ export const definitions = [
295
307
  langcode: { type: "string", description: "Target language for an unpublished working translation (nodes). Continues that translation via Sentinel." },
296
308
  attributes: { type: "object" },
297
309
  relationships: { type: "object" },
298
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
310
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
299
311
  returning: RETURNING_SCHEMA,
300
312
  },
301
313
  },
@@ -310,7 +322,7 @@ export const definitions = [
310
322
  entityType: { type: "string" },
311
323
  bundle: { type: "string" },
312
324
  id: { type: "string" },
313
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the delete without committing." },
325
+ dryRun: { type: "boolean", default: false, description: "Return a preview of the delete without committing. Drupal does not evaluate the delete: Drupal's delete access for the entity is NOT checked, only the connector's own policy. The result's `checks` block says so." },
314
326
  },
315
327
  },
316
328
  },
@@ -27,6 +27,17 @@ const SAMPLING_NOTE =
27
27
  "cardinality, allowed values, widget/storage settings) use the Drush bridge " +
28
28
  "(field config), which reads the Field API directly.";
29
29
 
30
+ const NOT_VISIBLE_NOTE =
31
+ "These fields are defined in Drupal's Field API for this bundle but are absent from the sampled entity. " +
32
+ "JSON:API keeps the key of an empty field and leaves out a field the account may not view, so each one is " +
33
+ "most likely denied to this account. It may also be disabled or renamed in JSON:API. " +
34
+ "An absent field is not an empty field: do not read these as unset. The list covers configurable fields " +
35
+ "only (field_config, first 50) and one sampled entity, so it may be incomplete.";
36
+
37
+ const DEFINITIONS_UNAVAILABLE_NOTE =
38
+ " Field definitions (JSON:API field_config) are not readable here, so a field this account may not view " +
39
+ "cannot be detected: JSON:API leaves a view-denied field out of the resource, and it is simply missing from this list.";
40
+
30
41
  const EMPTY_SCHEMA_NOTE =
31
42
  "No entities of this type/bundle exist yet, so sampling found no fields. " +
32
43
  "Create one entity, or use the Drush bridge (Field API), to introspect fields.";
@@ -75,8 +86,10 @@ function relationshipField(name) {
75
86
  * @param {object} args - { site?, type|entityType, bundle? }. `bundle` defaults
76
87
  * to the entity type (matching Drupal's single-bundle types, e.g. `user`).
77
88
  * @returns {Promise<{entityType: string, bundle: string, resourceType?: string,
78
- * approximate: true, fieldCount: number, fields: object[], note: string,
79
- * authoritativeSource: string}>}
89
+ * approximate: true, fieldCount: number, fields: object[],
90
+ * fieldDefinitions: "available"|"unavailable",
91
+ * notVisible?: Array<{name: string, translatable: boolean}>, notVisibleNote?: string,
92
+ * note: string, authoritativeSource: string}>}
80
93
  * @throws {SecurityError} If reading the type/bundle is not permitted.
81
94
  * @throws {Error} If no entity type is given under either name.
82
95
  */
@@ -111,6 +124,20 @@ async function describeFields({ site: siteName, type, entityType: entityTypeArg,
111
124
 
112
125
  const sampledEmpty = fields.length === 0;
113
126
 
127
+ // #337: a view-denied field is left out of the JSON:API resource, so sampling
128
+ // never sees it. The Field API definitions already read above name the
129
+ // configurable fields of the bundle; any of those missing from the sampled
130
+ // KEYS (not values — an empty field keeps its key) is listed apart. Nothing
131
+ // is claimed when no entity was sampled or the definitions are unreadable.
132
+ const definitionsAvailable = translatableMap.size > 0;
133
+ const sampledNames = new Set(fields.map((field) => field.name));
134
+ const notVisible = definitionsAvailable && !sampledEmpty
135
+ ? [...translatableMap.entries()]
136
+ .filter(([name]) => !sampledNames.has(name))
137
+ .map(([name, value]) => ({ name, translatable: Boolean(value) }))
138
+ .sort((a, b) => a.name.localeCompare(b.name))
139
+ : null;
140
+
114
141
  return {
115
142
  entityType: schema.entityType ?? entityType,
116
143
  bundle: schema.bundle ?? resolvedBundle,
@@ -118,7 +145,12 @@ async function describeFields({ site: siteName, type, entityType: entityTypeArg,
118
145
  approximate: true,
119
146
  fieldCount: fields.length,
120
147
  fields,
121
- note: sampledEmpty ? EMPTY_SCHEMA_NOTE : SAMPLING_NOTE,
148
+ fieldDefinitions: definitionsAvailable ? "available" : "unavailable",
149
+ ...(notVisible ? { notVisible } : {}),
150
+ ...(notVisible?.length ? { notVisibleNote: NOT_VISIBLE_NOTE } : {}),
151
+ note: sampledEmpty
152
+ ? EMPTY_SCHEMA_NOTE
153
+ : SAMPLING_NOTE + (definitionsAvailable ? "" : DEFINITIONS_UNAVAILABLE_NOTE),
122
154
  authoritativeSource: "drush-bridge (Drupal Field API)",
123
155
  };
124
156
  }
@@ -136,7 +168,11 @@ export const definitions = [
136
168
  "schema SAMPLING (an existing entity), so results are approximate — only " +
137
169
  "populated fields are visible and required/cardinality/allowedValues are " +
138
170
  "inferred from value shape. When JSON:API field_config is readable, translatable " +
139
- "is copied from Field API; omitted means unknown, not false. Authoritative field " +
171
+ "is copied from Field API; omitted means unknown, not false. A field this account may not " +
172
+ "view is left out of the JSON:API resource, so it is NOT in `fields` and looks like a field that " +
173
+ "does not exist. When field_config is readable (`fieldDefinitions: 'available'`), fields defined " +
174
+ "for the bundle but absent from the sampled entity are listed apart as `notVisible`; when it is " +
175
+ "not, denied fields cannot be detected. Absent is not empty. Authoritative field " +
140
176
  "metadata comes from the Drush bridge (Field API). Use this before creating/updating entities to learn field names.",
141
177
  inputSchema: {
142
178
  type: "object",
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { getSiteConfig } from "../lib/config.js";
18
18
  import { drupalGraphqlFetch } from "../lib/drupal-fetch.js";
19
+ import { cleanGraphqlErrors, describeGraphqlErrors } from "../lib/error-body.js";
19
20
  import {
20
21
  resolveSecurityConfig, assertGraphqlAllowed, assertGraphqlMutationAllowed,
21
22
  } from "../lib/security.js";
@@ -30,7 +31,8 @@ import {
30
31
  * @param {object} args - { site?, query, variables?, operationName? }.
31
32
  * @returns {Promise<{data: object, warnings?: string[]}>} On a clean response,
32
33
  * just `data`. When the server returns errors alongside partial `data`, the
33
- * error messages are surfaced as `warnings` rather than thrown.
34
+ * error messages are surfaced as `warnings` rather than thrown. Each message
35
+ * is cleaned and the list is bounded (see `cleanGraphqlErrors`).
34
36
  * @throws {Error} If the response carries errors and no data at all.
35
37
  */
36
38
  async function runGraphql({ site: siteName, query, variables = {}, operationName }) {
@@ -41,10 +43,11 @@ async function runGraphql({ site: siteName, query, variables = {}, operationName
41
43
  const json = await drupalGraphqlFetch(site, { query, variables, operationName });
42
44
 
43
45
  if (json.errors?.length) {
44
- const messages = json.errors.map((e) => e.message).join("; ");
45
- if (!json.data) throw new Error(`GraphQL errors: ${messages}`);
46
+ // The messages are untrusted text: cleaned and bounded, like the detail of
47
+ // a non-2xx response (#356). `data` is returned as received.
48
+ if (!json.data) throw new Error(`GraphQL errors: ${describeGraphqlErrors(json.errors)}`);
46
49
  // Partial result — return data AND surface errors as a warning
47
- return { data: json.data, warnings: json.errors.map((e) => e.message) };
50
+ return { data: json.data, warnings: cleanGraphqlErrors(json.errors).map((e) => e.message) };
48
51
  }
49
52
 
50
53
  return { data: json.data };
@@ -90,6 +93,8 @@ async function introspectGraphql({ site: siteName, typeName }) {
90
93
  }
91
94
  `;
92
95
  const json = await drupalGraphqlFetch(site, { query, variables: { name: typeName } });
96
+ // A failed lookup is not a missing type: report what the server said.
97
+ if (!json.data?.__type && json.errors?.length) throw new Error(describeGraphqlErrors(json.errors));
93
98
  if (!json.data?.__type) throw new Error(`Type '${typeName}' not found in schema.`);
94
99
  return json.data.__type;
95
100
  }
@@ -112,7 +117,7 @@ async function introspectGraphql({ site: siteName, typeName }) {
112
117
  `;
113
118
  const json = await drupalGraphqlFetch(site, { query });
114
119
  if (json.errors?.length) {
115
- throw new Error(json.errors.map((e) => e.message).join("; "));
120
+ throw new Error(describeGraphqlErrors(json.errors));
116
121
  }
117
122
 
118
123
  const schema = json.data.__schema;
@@ -18,6 +18,7 @@ import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA, omit
18
18
  import { resolveErrRelationships, relationshipsWereSent, paragraphPinsFromEntity } from "../lib/err-relationships.js";
19
19
  import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
20
  import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
+ import { dryRunChecks, PREFLIGHT_NONE } from "../lib/dry-run-checks.js";
21
22
  import { assertDraftLangcode, readDraftTranslation, readTranslationInventory } from "../lib/sentinel-draft.js";
22
23
  import { paragraphResourceVersion } from "./paragraphs.js";
23
24
  import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
@@ -412,7 +413,10 @@ async function createNode({ site: siteName, type, title, body, summary, format,
412
413
  assertPublishAllowed(sec, attributes);
413
414
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
414
415
  if (dryRun) {
415
- const preview = { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships };
416
+ const preview = {
417
+ dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships,
418
+ ...dryRunChecks({ operation: "create", preflight: PREFLIGHT_NONE }),
419
+ };
416
420
  return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
417
421
  }
418
422
  // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
@@ -497,6 +501,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
497
501
  const preview = {
498
502
  dryRun: true, operation: "update", entityType: "node", bundle: type, id,
499
503
  attributes, relationships: resolvedRelationships,
504
+ ...dryRunChecks({ operation: "update", preflight: patchTarget.preflight }),
500
505
  };
501
506
  return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
502
507
  }
@@ -556,7 +561,12 @@ async function deleteNode({ site: siteName, type, id, dryRun = false }) {
556
561
  const site = getSiteConfig(siteName);
557
562
  const sec = resolveSecurityConfig(site);
558
563
  assertDeleteAllowed(sec, "node", type, id);
559
- if (dryRun) return { dryRun: true, operation: "delete", entityType: "node", bundle: type, id };
564
+ if (dryRun) {
565
+ return {
566
+ dryRun: true, operation: "delete", entityType: "node", bundle: type, id,
567
+ ...dryRunChecks({ operation: "delete", preflight: PREFLIGHT_NONE }),
568
+ };
569
+ }
560
570
  const backend = await resolveBackend(site);
561
571
  await backend.deleteEntity({ entityType: "node", bundle: type, id });
562
572
  return { success: true, deletedId: id };
@@ -627,14 +637,14 @@ export const definitions = [
627
637
  moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
628
638
  fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
629
639
  relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value: { field_resource_type: { data: { type: 'taxonomy_term--resource_type', id: '<uuid>' } } }. Multi-value: { field_tags: { data: [{ type: 'taxonomy_term--tags', id: '<uuid>' }] } }." },
630
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the write without committing." },
640
+ dryRun: { type: "boolean", default: false, description: "Return a preview of the payload without committing. Drupal does not evaluate the write: entity access, field access and entity validation are NOT checked, so the real create can still fail with a 403 or a validation 422. The result's `checks` block and `caveat` say what was and was not checked." },
631
641
  returning: RETURNING_SCHEMA,
632
642
  },
633
643
  },
634
644
  },
635
645
  {
636
646
  name: "drupal_update_node",
637
- description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.",
647
+ description: "Update an existing node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. When the target is published and moderated and you omit moderationState, the connector defaults the write to moderation_state 'draft' (forward revision) so live default revisions are not mutated by accident. Pass langcode to continue an unpublished working translation (Sentinel X-MCP-Draft-Langcode); this does not PATCH canonical langcode and will not create a missing translation — use drupal_create_translation first. Entity-reference fields go in `relationships`, not `fields`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets a non-saving PATCH preflight runs first — including on dryRun — against the same URL the write will hit. Existing node drafts use Sentinel's governed draft endpoint with verified live/working revision preconditions; translation-only drafts are discovered through Sentinel inventory (#297). Pass explicit langcode to continue an unpublished translation. Published languages are not converted into drafts; dryRun uses the same target. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents. A dryRun that returns without a refusal is not proof the write will succeed: field access and entity validation are checked only when Sentinel's draft endpoint ran, and the result's `checks` block says which checks ran.",
638
648
  inputSchema: {
639
649
  type: "object", required: ["type", "id"],
640
650
  properties: {
@@ -650,7 +660,7 @@ export const definitions = [
650
660
  langcode: { type: "string", description: "Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode." },
651
661
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here." },
652
662
  relationships: { type: "object", description: "Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused." },
653
- dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. An existing node draft uses Sentinel's non-saving draft endpoint with the real payload and revision preconditions. Otherwise an id-mismatch core PATCH probes writability without saving. A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
663
+ dryRun: { type: "boolean", default: false, description: "Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A published node with no distinct working copy whose changed timestamp is later than revision_timestamp (possiblyPatchBlocked) fails dryRun the same as the real write (#273). Any refusal fails the dryRun." },
654
664
  returning: RETURNING_SCHEMA,
655
665
  },
656
666
  },
@@ -664,7 +674,7 @@ export const definitions = [
664
674
  site: { type: "string" },
665
675
  type: { type: "string" },
666
676
  id: { type: "string", description: "Node UUID" },
667
- dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the delete without committing." },
677
+ dryRun: { type: "boolean", default: false, description: "Return a preview of the delete without committing. Drupal does not evaluate the delete: Drupal's delete access for the entity is NOT checked, only the connector's own policy. The result's `checks` block says so." },
668
678
  },
669
679
  },
670
680
  },
@@ -57,7 +57,7 @@ import {
57
57
  * @param {number|string|null|undefined} [revisionId] Current revision id.
58
58
  * @returns {{type: string, id: string, meta?: {target_revision_id: number}}}
59
59
  */
60
- export function embedRef(bundle, id, revisionId) {
60
+ function embedRef(bundle, id, revisionId) {
61
61
  return embedParagraphRef(bundle, id, revisionId);
62
62
  }
63
63
 
@@ -20,7 +20,7 @@
20
20
  import { getSiteConfig } from "../lib/config.js";
21
21
  import { resolveSecurityConfig, assertConfigReadAllowed } from "../lib/security.js";
22
22
  import { gatedReport } from "../lib/reports-support.js";
23
- import { callServerTool, callBoundModuleTool, SERVER_TOOLS, toolResultData } from "../lib/server-tools.js";
23
+ import { callGovernedServerTool, callBoundModuleTool, toolResultData } from "../lib/server-tools.js";
24
24
  import { runPrivileged, serverToolsConfigured, drushConfigured } from "../lib/audit-sources.js";
25
25
  import { sshDrush, parseDrush } from "./drush.js";
26
26
 
@@ -71,7 +71,7 @@ async function readConfig(site, name) {
71
71
  }));
72
72
  }
73
73
  if (serverToolsConfigured(site)) {
74
- try { return toolResultData(await callServerTool(site, SERVER_TOOLS.configGet, { name })); }
74
+ try { return toolResultData(await callGovernedServerTool(site, "configGet", { name })); }
75
75
  catch (err) { if (!drushConfigured(site)) throw err; }
76
76
  }
77
77
  return parseDrush(await sshDrush(site, ["config:get", name, "--format=json"]));
@@ -93,7 +93,7 @@ async function listConfigNames(site, prefix) {
93
93
  })));
94
94
  }
95
95
  if (serverToolsConfigured(site)) {
96
- try { return pickConfigNames(toolResultData(await callServerTool(site, SERVER_TOOLS.configList, { prefix }))); }
96
+ try { return pickConfigNames(toolResultData(await callGovernedServerTool(site, "configList", { prefix }))); }
97
97
  catch (err) { if (!drushConfigured(site)) throw err; }
98
98
  }
99
99
  const out = await sshDrush(site, ["sql:query", `SELECT name FROM config WHERE collection = '' AND name LIKE '${prefix}%'`]);