drupal-mcp-connector 2.7.4 → 2.8.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 (141) hide show
  1. package/{.claude → .agents}/commands/drupal-audit-config-best-practices.md +3 -4
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +3 -4
  3. package/{.claude → .agents}/commands/drupal-block-user.md +3 -4
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +3 -4
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +3 -4
  6. package/{.claude → .agents}/commands/drupal-config-get.md +3 -4
  7. package/{.claude → .agents}/commands/drupal-config-list.md +3 -4
  8. package/{.claude → .agents}/commands/drupal-config-set.md +3 -4
  9. package/{.claude → .agents}/commands/drupal-content-by-moderation-state.md +3 -4
  10. package/{.claude → .agents}/commands/drupal-create-block.md +3 -4
  11. package/{.claude → .agents}/commands/drupal-create-media.md +3 -4
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +3 -4
  13. package/{.claude → .agents}/commands/drupal-create-node.md +6 -7
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +3 -4
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +3 -4
  16. package/{.claude → .agents}/commands/drupal-create-taxonomy-term.md +3 -4
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +3 -4
  18. package/{.claude → .agents}/commands/drupal-create-user.md +3 -4
  19. package/{.claude → .agents}/commands/drupal-delete-media.md +3 -4
  20. package/{.claude → .agents}/commands/drupal-delete-node.md +3 -4
  21. package/{.claude → .agents}/commands/drupal-delete-taxonomy-term.md +3 -4
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +3 -4
  23. package/{.claude → .agents}/commands/drupal-drush-cache-rebuild.md +3 -4
  24. package/{.claude → .agents}/commands/drupal-drush-config-export.md +3 -4
  25. package/{.claude → .agents}/commands/drupal-drush-config-import.md +3 -4
  26. package/{.claude → .agents}/commands/drupal-drush-config-status.md +3 -4
  27. package/{.claude → .agents}/commands/drupal-drush-cron.md +3 -4
  28. package/{.claude → .agents}/commands/drupal-drush-module-disable.md +3 -4
  29. package/{.claude → .agents}/commands/drupal-drush-module-enable.md +3 -4
  30. package/{.claude → .agents}/commands/drupal-drush-module-list.md +3 -4
  31. package/{.claude → .agents}/commands/drupal-drush-security-updates.md +3 -4
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +3 -4
  33. package/{.claude → .agents}/commands/drupal-drush-status.md +3 -4
  34. package/{.claude → .agents}/commands/drupal-drush-updatedb.md +3 -4
  35. package/{.claude → .agents}/commands/drupal-drush-user-create.md +3 -4
  36. package/{.claude → .agents}/commands/drupal-drush-user-list.md +3 -4
  37. package/{.claude → .agents}/commands/drupal-drush-watchdog.md +3 -4
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +3 -4
  39. package/{.claude → .agents}/commands/drupal-entity-delete.md +3 -4
  40. package/{.claude → .agents}/commands/drupal-entity-get.md +3 -4
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +3 -4
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +6 -7
  43. package/{.claude → .agents}/commands/drupal-find-orphaned-media.md +3 -4
  44. package/{.claude → .agents}/commands/drupal-get-entity-schema.md +3 -4
  45. package/{.claude → .agents}/commands/drupal-get-media.md +3 -4
  46. package/{.claude → .agents}/commands/drupal-get-node.md +3 -4
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +3 -4
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +3 -4
  49. package/{.claude → .agents}/commands/drupal-get-taxonomy-term.md +3 -4
  50. package/{.claude → .agents}/commands/drupal-get-taxonomy-terms.md +3 -4
  51. package/{.claude → .agents}/commands/drupal-get-user-by-name.md +3 -4
  52. package/{.claude → .agents}/commands/drupal-get-user.md +3 -4
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +3 -4
  54. package/{.claude → .agents}/commands/drupal-graphql-introspect.md +3 -4
  55. package/{.claude → .agents}/commands/drupal-graphql.md +3 -4
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +3 -4
  57. package/{.claude → .agents}/commands/drupal-list-content-types.md +3 -4
  58. package/{.claude → .agents}/commands/drupal-list-entity-types.md +3 -4
  59. package/{.claude → .agents}/commands/drupal-list-media-types.md +3 -4
  60. package/{.claude → .agents}/commands/drupal-list-media.md +3 -4
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +3 -4
  62. package/{.claude → .agents}/commands/drupal-list-moderation-states.md +3 -4
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +3 -4
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +3 -4
  65. package/{.claude → .agents}/commands/drupal-list-roles.md +3 -4
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +3 -4
  68. package/{.claude → .agents}/commands/drupal-list-users.md +3 -4
  69. package/{.claude → .agents}/commands/drupal-list-vocabularies.md +3 -4
  70. package/{.claude → .agents}/commands/drupal-mcp-whoami.md +3 -4
  71. package/{.claude → .agents}/commands/drupal-report-404-log.md +3 -4
  72. package/{.claude → .agents}/commands/drupal-report-accessibility-audit.md +3 -4
  73. package/{.claude → .agents}/commands/drupal-report-alias-coverage.md +3 -4
  74. package/{.claude → .agents}/commands/drupal-report-broken-embeds.md +3 -4
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +3 -4
  76. package/{.claude → .agents}/commands/drupal-report-cache-config.md +3 -4
  77. package/{.claude → .agents}/commands/drupal-report-config-drift.md +3 -4
  78. package/{.claude → .agents}/commands/drupal-report-content-by-author.md +3 -4
  79. package/{.claude → .agents}/commands/drupal-report-content-summary.md +3 -4
  80. package/{.claude → .agents}/commands/drupal-report-duplicate-content.md +3 -4
  81. package/{.claude → .agents}/commands/drupal-report-field-completeness.md +3 -4
  82. package/{.claude → .agents}/commands/drupal-report-menu-integrity.md +3 -4
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +3 -4
  84. package/{.claude → .agents}/commands/drupal-report-module-audit.md +3 -4
  85. package/{.claude → .agents}/commands/drupal-report-orphan-pages.md +3 -4
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +3 -4
  87. package/{.claude → .agents}/commands/drupal-report-permission-audit.md +3 -4
  88. package/{.claude → .agents}/commands/drupal-report-pii-exposure.md +3 -4
  89. package/{.claude → .agents}/commands/drupal-report-readability.md +3 -4
  90. package/{.claude → .agents}/commands/drupal-report-recently-published.md +3 -4
  91. package/{.claude → .agents}/commands/drupal-report-redirect-health.md +3 -4
  92. package/{.claude → .agents}/commands/drupal-report-revision-hotspots.md +3 -4
  93. package/{.claude → .agents}/commands/drupal-report-scheduled-content.md +3 -4
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +3 -4
  95. package/{.claude → .agents}/commands/drupal-report-seo-meta-coverage.md +3 -4
  96. package/{.claude → .agents}/commands/drupal-report-stale-content.md +3 -4
  97. package/{.claude → .agents}/commands/drupal-report-status-report.md +3 -4
  98. package/{.claude → .agents}/commands/drupal-report-taxonomy-usage.md +3 -4
  99. package/{.claude → .agents}/commands/drupal-report-text-format-audit.md +3 -4
  100. package/{.claude → .agents}/commands/drupal-report-translation-coverage.md +3 -4
  101. package/{.claude → .agents}/commands/drupal-report-unpublished.md +3 -4
  102. package/{.claude → .agents}/commands/drupal-report-user-activity.md +3 -4
  103. package/{.claude → .agents}/commands/drupal-report-workflow-bottlenecks.md +3 -4
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +3 -4
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +3 -4
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +3 -4
  107. package/{.claude → .agents}/commands/drupal-search-content.md +3 -4
  108. package/{.claude → .agents}/commands/drupal-search.md +3 -4
  109. package/{.claude → .agents}/commands/drupal-security-info.md +3 -4
  110. package/{.claude → .agents}/commands/drupal-set-moderation-state.md +3 -4
  111. package/{.claude → .agents}/commands/drupal-site-info.md +3 -4
  112. package/{.claude → .agents}/commands/drupal-update-media.md +3 -4
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +3 -4
  114. package/{.claude → .agents}/commands/drupal-update-node.md +9 -10
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +3 -4
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +3 -4
  117. package/{.claude → .agents}/commands/drupal-update-taxonomy-term.md +3 -4
  118. package/{.claude → .agents}/commands/drupal-update-user.md +3 -4
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +3 -4
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +3 -4
  121. package/CHANGELOG.md +56 -0
  122. package/README.md +21 -11
  123. package/config/config.example.json +1 -1
  124. package/package.json +4 -2
  125. package/scripts/generate-commands.js +42 -21
  126. package/scripts/install-commands.js +157 -0
  127. package/src/lib/backends/backend-interface.js +20 -1
  128. package/src/lib/backends/jsonapi.js +47 -4
  129. package/src/lib/body-summary.js +60 -0
  130. package/src/lib/data-flow.js +437 -0
  131. package/src/lib/dispatch.js +36 -15
  132. package/src/lib/drupal-fetch.js +52 -6
  133. package/src/lib/field-definition.js +285 -0
  134. package/src/lib/patch-preflight.js +157 -66
  135. package/src/lib/security.js +14 -0
  136. package/src/lib/server-tools.js +9 -0
  137. package/src/lib/write-revision.js +82 -2
  138. package/src/tools/bulk.js +4 -3
  139. package/src/tools/entities.js +11 -7
  140. package/src/tools/index.js +3 -2
  141. package/src/tools/nodes.js +55 -43
@@ -24,6 +24,7 @@
24
24
 
25
25
  import fetch from "node-fetch";
26
26
  import { authHeadersAsync, clientHeaders, CLIENT_VERSION } from "./config.js";
27
+ import { consumeBudgetIfEnforced, northboundHeaders, sourceBudgetDenial } from "./data-flow.js";
27
28
  import { clearToken } from "./oauth.js";
28
29
 
29
30
  /**
@@ -90,6 +91,7 @@ async function baseHeaders(site, sessionId) {
90
91
  Accept: "application/json, text/event-stream",
91
92
  "MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
92
93
  ...clientHeaders(),
94
+ ...northboundHeaders(),
93
95
  ...(await authHeadersAsync(site)),
94
96
  };
95
97
  if (sessionId) headers["Mcp-Session-Id"] = sessionId;
@@ -263,9 +265,12 @@ export async function callServerTool(site, toolName, args = {}) {
263
265
  let sessionId = await ensureSession(site, endpoint);
264
266
  let refreshedAuth = false;
265
267
  let reinitedSession = false;
268
+ let paid = false;
266
269
 
267
270
  // Retry loop: at most one auth refresh and one session re-init, each replayed once.
268
271
  while (true) {
272
+ consumeBudgetIfEnforced("request", 1, { retry: paid });
273
+ paid = true;
269
274
  const res = await fetch(endpoint, {
270
275
  method: "POST",
271
276
  headers: await baseHeaders(site, sessionId),
@@ -289,6 +294,8 @@ export async function callServerTool(site, toolName, args = {}) {
289
294
  }
290
295
 
291
296
  if (!res.ok) {
297
+ const mapped = sourceBudgetDenial(rawText);
298
+ if (mapped) throw mapped;
292
299
  throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${rawText}`);
293
300
  }
294
301
 
@@ -303,6 +310,8 @@ export async function callServerTool(site, toolName, args = {}) {
303
310
  const result = body?.result;
304
311
  if (result?.isError) {
305
312
  const detail = extractTextContent(result) || "tool reported an error";
313
+ const mapped = sourceBudgetDenial(detail);
314
+ if (mapped) throw mapped;
306
315
  throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
307
316
  }
308
317
  return result;
@@ -5,7 +5,79 @@
5
5
  * published node — after a draft ERR attach it still shows the *old* refs.
6
6
  * Prefer `rel:working-copy`. If that alias is not addressable, return the
7
7
  * PATCH body (or the canonical re-read) plus `_revision.relationshipsUnverified`.
8
+ *
9
+ * After a write, {@link attachWrittenRevisionPair} may add `_revisions`
10
+ * `{ live, working }` when both vids can be read honestly (#166).
11
+ */
12
+
13
+ /**
14
+ * Read a revision id off a canonical (or raw-ish) entity body.
15
+ * @param {?object} entity
16
+ * @returns {?number|string}
17
+ */
18
+ export function entityRevisionId(entity) {
19
+ if (!entity || typeof entity !== "object") return null;
20
+ const fields = entity.fields && typeof entity.fields === "object"
21
+ ? entity.fields
22
+ : {};
23
+ const attrs = entity.attributes && typeof entity.attributes === "object"
24
+ ? entity.attributes
25
+ : {};
26
+ const raw = entity.vid
27
+ ?? fields.drupal_internal__vid
28
+ ?? attrs.drupal_internal__vid
29
+ ?? entity.drupal_internal__vid;
30
+ if (raw === undefined || raw === null || raw === "") return null;
31
+ const n = Number(raw);
32
+ return Number.isFinite(n) ? n : raw;
33
+ }
34
+
35
+ /**
36
+ * Attach distinct live vs working revision ids when both are known.
37
+ * Does not invent vids.
38
+ * @param {object} entity
39
+ * @param {{live: ?number|string, working: ?number|string}} pair
40
+ * @returns {object}
8
41
  */
42
+ export function attachRevisionPair(entity, { live, working }) {
43
+ if (!entity || live === null || live === undefined || working === null || working === undefined) {
44
+ return entity;
45
+ }
46
+ if (String(live) === String(working)) return entity;
47
+ return { ...entity, _revisions: { live, working } };
48
+ }
49
+
50
+ /**
51
+ * After a write, attach `_revisions` when a working copy is addressable
52
+ * and the live vid is already known. Omits the block when either side
53
+ * cannot be read — never invents a vid.
54
+ *
55
+ * @param {object} args
56
+ * @param {object} args.backend
57
+ * @param {string} args.entityType
58
+ * @param {string} args.bundle
59
+ * @param {string} args.id
60
+ * @param {object} args.entity Write result to annotate.
61
+ * @param {?number|string} [args.liveVid]
62
+ * @returns {Promise<object>}
63
+ */
64
+ export async function attachWrittenRevisionPair({
65
+ backend, entityType, bundle, id, entity, liveVid,
66
+ }) {
67
+ if (liveVid === null || liveVid === undefined || !entity) return entity;
68
+ let workingVid = null;
69
+ if (typeof backend?.getEntity === "function") {
70
+ try {
71
+ const wc = await backend.getEntity({
72
+ entityType, bundle, id, resourceVersion: "rel:working-copy",
73
+ });
74
+ workingVid = entityRevisionId(wc);
75
+ } catch {
76
+ workingVid = null;
77
+ }
78
+ }
79
+ return attachRevisionPair(entity, { live: liveVid, working: workingVid });
80
+ }
9
81
 
10
82
  /**
11
83
  * @param {object} args
@@ -16,15 +88,23 @@
16
88
  * @param {boolean} args.relationshipsSent
17
89
  * @param {?object} [args.patchResult] Canonicalised PATCH response body.
18
90
  * @param {boolean} [args.preferCanonical] When no relationships were sent,
19
- * re-GET the canonical resource (nodes do this for the persisted alias).
91
+ * re-GET the written resource (nodes do this for the persisted alias).
92
+ * @param {?string} [args.resourceVersion] JSON:API revision selector used
93
+ * for the write (e.g. `rel:working-copy`). PreferCanonical re-reads this
94
+ * resource instead of the default revision so a draft PATCH is not
95
+ * replaced by the live body.
20
96
  * @returns {Promise<object>} Entity to return, with `_revision` when relevant.
21
97
  */
22
98
  export async function readWrittenRevision({
23
99
  backend, entityType, bundle, id, relationshipsSent, patchResult = null, preferCanonical = false,
100
+ resourceVersion,
24
101
  }) {
25
102
  if (!relationshipsSent) {
26
103
  if (preferCanonical && typeof backend.getEntity === "function") {
27
- const fresh = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
104
+ const fresh = await backend.getEntity({
105
+ entityType, bundle, id,
106
+ ...(resourceVersion ? { resourceVersion } : {}),
107
+ }).catch(() => null);
28
108
  return fresh ?? patchResult ?? { id };
29
109
  }
30
110
  return patchResult ?? { id };
package/src/tools/bulk.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  resolveErrRelationships, embedParagraphRef,
20
20
  resolveParagraphRevisionId, missingParagraphRevisionError,
21
21
  } from "../lib/err-relationships.js";
22
- import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
22
+ import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
23
23
 
24
24
  /**
25
25
  * Normalize an unknown thrown value into a human-readable message.
@@ -118,13 +118,14 @@ async function bulkUpdate({ site: siteName, entityType, bundle, items = [] }) {
118
118
  });
119
119
  assertPublishAllowed(sec, attributes);
120
120
  const resolvedRelationships = await resolveErrRelationships(backend, item.relationships ?? {});
121
- await preflightPatchWritable({
122
- backend, entityType, bundle, id: item.id, existing, attributes,
121
+ const patchTarget = await prepareGuardedPatch(backend, {
122
+ entityType, bundle, id: item.id, existing, attributes,
123
123
  });
124
124
  const entity = await updateEntityGuarded(backend, {
125
125
  entityType, bundle, id: item.id,
126
126
  attributes,
127
127
  relationships: resolvedRelationships,
128
+ ...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
128
129
  });
129
130
  updated += 1;
130
131
  results.push({ index, success: true, id: entity?.id ?? item.id });
@@ -16,8 +16,8 @@ import {
16
16
  resolveErrRelationships, relationshipsWereSent, embedParagraphRef,
17
17
  resolveParagraphRevisionId, missingParagraphRevisionError,
18
18
  } from "../lib/err-relationships.js";
19
- import { readWrittenRevision } from "../lib/write-revision.js";
20
- import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
19
+ import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
+ import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
21
  import {
22
22
  resolveSecurityConfig, assertReadAllowed, assertWriteAllowed, assertDeleteAllowed, assertPublishAllowed,
23
23
  redactCanonicalEntity, getSecuritySummary,
@@ -117,8 +117,8 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
117
117
  });
118
118
  assertPublishAllowed(sec, safeAttributes);
119
119
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
120
- await preflightPatchWritable({
121
- backend, entityType, bundle, id, existing, attributes: safeAttributes,
120
+ const patchTarget = await prepareGuardedPatch(backend, {
121
+ entityType, bundle, id, existing, attributes: safeAttributes,
122
122
  });
123
123
  if (dryRun) {
124
124
  return {
@@ -128,6 +128,7 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
128
128
  }
129
129
  const result = await updateEntityGuarded(backend, {
130
130
  entityType, bundle, id, attributes: safeAttributes, relationships: resolvedRelationships,
131
+ ...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
131
132
  });
132
133
  const written = await readWrittenRevision({
133
134
  backend, entityType, bundle, id,
@@ -135,7 +136,10 @@ async function updateEntity({ site: siteName, entityType, bundle, id, attributes
135
136
  patchResult: result,
136
137
  preferCanonical: false,
137
138
  });
138
- return shapeWriteResponse(flagUnrequestedStatusChange(written, existing, safeAttributes), returning);
139
+ const withRevs = await attachWrittenRevisionPair({
140
+ backend, entityType, bundle, id, entity: written, liveVid: patchTarget.liveVid,
141
+ });
142
+ return shapeWriteResponse(flagUnrequestedStatusChange(withRevs, existing, safeAttributes), returning);
139
143
  }
140
144
 
141
145
  /**
@@ -276,7 +280,7 @@ export const definitions = [
276
280
  },
277
281
  {
278
282
  name: "drupal_entity_update",
279
- 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 an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.",
283
+ 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 an id-mismatch PATCH preflight runs first (including dryRun) against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#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.",
280
284
  inputSchema: {
281
285
  type: "object", required: ["entityType", "bundle", "id"],
282
286
  properties: {
@@ -286,7 +290,7 @@ export const definitions = [
286
290
  id: { type: "string" },
287
291
  attributes: { type: "object" },
288
292
  relationships: { type: "object" },
289
- 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. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
293
+ 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. The probe uses a non-matching data.id so Drupal does not save, and hits the same URL as the real write (canonical, or ?resourceVersion=rel:working-copy when a draft is addressable). A working-copy 400 fails the dryRun." },
290
294
  returning: RETURNING_SCHEMA,
291
295
  },
292
296
  },
@@ -4,8 +4,9 @@
4
4
  * Each tool module exports `definitions` (the MCP `{name, description, inputSchema}`
5
5
  * descriptors) and `handlers` (a name → function dispatch map). This module flattens
6
6
  * all of them into the runtime payloads consumed by the server (src/index.js) AND by
7
- * the offline slash-command generator (scripts/generate-commands.js), so the tool
8
- * list, the per-tool MCP prompts, and the `/drupal-*` command files can never drift.
7
+ * the offline slash-command generator (scripts/generate-commands.js
8
+ * `.agents/commands/`), so the tool list, the per-tool MCP prompts, and the
9
+ * `/drupal-*` command files can never drift.
9
10
  *
10
11
  * Importing this module has NO side effects (unlike src/index.js, which boots the
11
12
  * transport), so generators and tests can import it freely.
@@ -16,9 +16,11 @@ import {
16
16
  import { applySafeDraftDefault, hasExplicitModerationState } from "../lib/moderation-default.js";
17
17
  import { shapeWriteResponse, flagUnrequestedStatusChange, RETURNING_SCHEMA } from "../lib/entity-response.js";
18
18
  import { resolveErrRelationships, relationshipsWereSent } from "../lib/err-relationships.js";
19
- import { readWrittenRevision } from "../lib/write-revision.js";
20
- import { preflightPatchWritable, updateEntityGuarded } from "../lib/patch-preflight.js";
19
+ import { attachWrittenRevisionPair, readWrittenRevision } from "../lib/write-revision.js";
20
+ import { prepareGuardedPatch, updateEntityGuarded } from "../lib/patch-preflight.js";
21
+ import { assertBodySummaryWritable, attachSummaryDeprecation } from "../lib/body-summary.js";
21
22
  import { buildRedirectAttributes, REDIRECT_ENTITY_TYPE } from "./redirects.js";
23
+ import { applyAllowedFormatsToAttributes } from "../lib/field-definition.js";
22
24
 
23
25
  /** Fallback language for an alias when the node exposes none. */
24
26
  const DEFAULT_ALIAS_LANGCODE = "en";
@@ -130,22 +132,14 @@ async function createRenameRedirect(backend, sec, redirect) {
130
132
  }
131
133
  }
132
134
 
133
- /**
134
- * Fallback text format when neither the call nor the site config names one.
135
- *
136
- * Retained for backward compatibility only. A site whose text formats do not
137
- * include `full_html` — or whose agent account may not use it — must set
138
- * `defaultTextFormat` in its site config or pass `format` per call.
139
- */
140
- const FALLBACK_TEXT_FORMAT = "full_html";
141
-
142
135
  /**
143
136
  * Build a Drupal body field descriptor from plain HTML + optional summary.
144
137
  *
145
- * The text format is a security boundary in Drupal (it decides which HTML
146
- * survives filtering), so it is resolved explicitly rather than assumed:
147
- * an explicit `format` argument wins, then the site's `defaultTextFormat`,
148
- * then the historical fallback.
138
+ * Format is left omitted when the caller did not pass one so
139
+ * `applyAllowedFormatsToAttributes` can honor Field API `allowed_formats`
140
+ * before applying the historical `defaultTextFormat` / `full_html` chain.
141
+ * Applying that fallback first would treat `full_html` as a caller format
142
+ * and refuse a single-allowed-format field (#168).
149
143
  *
150
144
  * `summary` is only included when the caller supplied it. Sending an empty
151
145
  * string on every write would blank an existing body summary whenever a
@@ -154,17 +148,14 @@ const FALLBACK_TEXT_FORMAT = "full_html";
154
148
  * @param {string} [body] - Body HTML; when undefined the field is omitted.
155
149
  * @param {string} [summary] - Teaser/summary text; omitted when undefined.
156
150
  * @param {string} [format] - Text format machine name.
157
- * @param {object} [site] - Site config, read for `defaultTextFormat`.
158
- * @returns {{value: string, format: string, summary?: string}|undefined}
151
+ * @returns {{value: string, format?: string, summary?: string}|undefined}
159
152
  * A body attribute object, or undefined when no body was supplied (so callers
160
153
  * can skip the field on update rather than blanking it).
161
154
  */
162
- function buildBodyAttribute(body, summary, format, site) {
155
+ function buildBodyAttribute(body, summary, format) {
163
156
  if (body === undefined) return undefined;
164
- const attr = {
165
- value: body,
166
- format: format ?? site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT,
167
- };
157
+ const attr = { value: body };
158
+ if (format !== undefined) attr.format = format;
168
159
  if (summary !== undefined) attr.summary = summary;
169
160
  return attr;
170
161
  }
@@ -263,18 +254,25 @@ async function createNode({ site: siteName, type, title, body, summary, format,
263
254
  const site = getSiteConfig(siteName);
264
255
  const sec = resolveSecurityConfig(site);
265
256
  assertWriteAllowed(sec, "create", "node", type);
257
+ const backend = await resolveBackend(site);
266
258
  const attributes = { title, ...fields };
267
259
  if (moderationState !== undefined) {
268
260
  attributes.moderation_state = moderationState;
269
261
  } else {
270
262
  attributes.status = status === undefined ? false : status;
271
263
  }
272
- const bodyAttr = buildBodyAttribute(body, summary, format, site);
264
+ const bodyAttr = buildBodyAttribute(body, summary, format);
273
265
  if (bodyAttr) attributes.body = bodyAttr;
266
+ await applyAllowedFormatsToAttributes({
267
+ backend, site, entityType: "node", bundle: type, attributes,
268
+ });
269
+ const summaryWrite = await assertBodySummaryWritable(backend, type, summary);
274
270
  assertPublishAllowed(sec, attributes);
275
- const backend = await resolveBackend(site);
276
271
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
277
- if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships };
272
+ if (dryRun) {
273
+ const preview = { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships: resolvedRelationships };
274
+ return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
275
+ }
278
276
  // Alias handling: an explicit `path.alias` is set as a manual alias; otherwise
279
277
  // `path` is omitted so pathauto generates the alias (DEV-116).
280
278
  const { pathAttr } = await resolvePathWrite({ backend, type, id: null, providedPath: attributes.path, isCreate: true });
@@ -284,7 +282,8 @@ async function createNode({ site: siteName, type, title, body, summary, format,
284
282
  // Honest response: re-read so the persisted alias (explicit or pathauto-generated)
285
283
  // is reflected rather than the pre-alias write response.
286
284
  const fresh = await backend.getEntity({ entityType: "node", bundle: type, id: created.id }).catch(() => null);
287
- return shapeWriteResponse(fresh ?? created, returning);
285
+ const shaped = shapeWriteResponse(fresh ?? created, returning);
286
+ return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
288
287
  }
289
288
 
290
289
  /**
@@ -316,12 +315,16 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
316
315
  const sec = resolveSecurityConfig(site);
317
316
  assertWriteAllowed(sec, "update", "node", type);
318
317
  const backend = await resolveBackend(site);
318
+ const summaryWrite = await assertBodySummaryWritable(backend, type, summary);
319
319
  let attributes = { ...fields };
320
320
  if (title !== undefined) attributes.title = title;
321
321
  if (moderationState !== undefined) attributes.moderation_state = moderationState;
322
322
  else if (status !== undefined) attributes.status = status;
323
- const bodyAttr = buildBodyAttribute(body, summary, format, site);
323
+ const bodyAttr = buildBodyAttribute(body, summary, format);
324
324
  if (bodyAttr) attributes.body = bodyAttr;
325
+ await applyAllowedFormatsToAttributes({
326
+ backend, site, entityType: "node", bundle: type, attributes,
327
+ });
325
328
  // One pre-read serves the #131 draft default and the #171 unrequested-
326
329
  // status-change flag. Skipped when the caller pinned the moderation state.
327
330
  let existing = null;
@@ -341,16 +344,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
341
344
  // #192: resolve paragraph ERR identifiers before any host PATCH. An unresolved
342
345
  // list would persist empty — fail the whole write instead.
343
346
  const resolvedRelationships = await resolveErrRelationships(backend, relationships);
344
- // #201: the core working-copy guard runs before deserialize. A no-op probe
345
- // against the same canonical URL is a true preflight, including on dryRun.
346
- await preflightPatchWritable({
347
- backend, entityType: "node", bundle: type, id, existing, attributes,
347
+ // #201 / #166: probe the same URL the write will hit. An addressable
348
+ // working copy is PATCHed via rel:working-copy; a stray revision still
349
+ // fails the probe with revision-surgery language.
350
+ const patchTarget = await prepareGuardedPatch(backend, {
351
+ entityType: "node", bundle: type, id, existing, attributes,
348
352
  });
349
353
  if (dryRun) {
350
- return {
354
+ const preview = {
351
355
  dryRun: true, operation: "update", entityType: "node", bundle: type, id,
352
356
  attributes, relationships: resolvedRelationships,
353
357
  };
358
+ return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(preview) : preview;
354
359
  }
355
360
  // Alias handling (DEV-116): an explicit `path.alias` is set in place by
356
361
  // round-tripping the existing alias's pid (no duplicate); a path-less update
@@ -361,6 +366,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
361
366
  else attributes.path = pathAttr;
362
367
  const patched = await updateEntityGuarded(backend, {
363
368
  entityType: "node", bundle: type, id, attributes, relationships: resolvedRelationships,
369
+ ...(patchTarget.resourceVersion ? { resourceVersion: patchTarget.resourceVersion } : {}),
364
370
  });
365
371
  const redirectResult = redirect ? await createRenameRedirect(backend, sec, redirect) : null;
366
372
  // #169: when relationships were sent, the canonical re-read is the published
@@ -370,12 +376,18 @@ async function updateNode({ site: siteName, type, id, title, body, summary, form
370
376
  relationshipsSent: relationshipsWereSent(resolvedRelationships),
371
377
  patchResult: patched,
372
378
  preferCanonical: true,
379
+ resourceVersion: patchTarget.resourceVersion,
380
+ });
381
+ const withRevs = await attachWrittenRevisionPair({
382
+ backend, entityType: "node", bundle: type, id, entity: fresh, liveVid: patchTarget.liveVid,
373
383
  });
374
384
  // #171: an unrequested published-state flip in the persisted node is
375
385
  // reported via _statusChanged rather than returned as a clean success.
376
- const flagged = flagUnrequestedStatusChange(fresh, existing, attributes);
377
- if (flagged && redirectResult) return shapeWriteResponse({ ...flagged, _redirect: redirectResult }, returning);
378
- return shapeWriteResponse(flagged ?? { id }, returning);
386
+ const flagged = flagUnrequestedStatusChange(withRevs, existing, attributes);
387
+ const shaped = flagged && redirectResult
388
+ ? shapeWriteResponse({ ...flagged, _redirect: redirectResult }, returning)
389
+ : shapeWriteResponse(flagged ?? { id }, returning);
390
+ return summaryWrite.deprecated && bodyAttr ? attachSummaryDeprecation(shaped) : shaped;
379
391
  }
380
392
 
381
393
  /**
@@ -452,11 +464,11 @@ export const definitions = [
452
464
  type: { type: "string", description: "Content type machine name" },
453
465
  title: { type: "string" },
454
466
  body: { type: "string", description: "Body field HTML" },
455
- summary: { type: "string", description: "Body summary/teaser — writes the `summary` property of the body field (core `text_with_summary`). Many headless sites instead use a dedicated summary/deck field for teasers and meta descriptions; on those, set that field in `fields` a value written here will be stored but may never be rendered." },
456
- format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. Defaults to the site config's `defaultTextFormat`, then 'full_html'. Set this when the site's formats do not include full_html, or to avoid writing content into a more permissive format than intended." },
467
+ summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
468
+ format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
457
469
  status: { type: "boolean", default: false, description: "Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically." },
458
470
  moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
459
- fields: { type: "object", description: "Scalar/attribute field values keyed by Drupal machine name. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`." },
471
+ 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`." },
460
472
  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>' }] } }." },
461
473
  dryRun: { type: "boolean", default: false, description: "Validate and return a preview of the write without committing." },
462
474
  returning: RETURNING_SCHEMA,
@@ -465,7 +477,7 @@ export const definitions = [
465
477
  },
466
478
  {
467
479
  name: "drupal_update_node",
468
- 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. 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 an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. 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.",
480
+ 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. 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 an id-mismatch PATCH preflight runs first — including on dryRun — against the same URL the write will hit. An addressable working copy is PATCHed via ?resourceVersion=rel:working-copy (#166); dryRun uses that 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.",
469
481
  inputSchema: {
470
482
  type: "object", required: ["type", "id"],
471
483
  properties: {
@@ -474,13 +486,13 @@ export const definitions = [
474
486
  id: { type: "string", description: "Node UUID" },
475
487
  title: { type: "string" },
476
488
  body: { type: "string" },
477
- summary: { type: "string", description: "Body summary/teaser — writes the `summary` property of the body field (core `text_with_summary`). Many headless sites instead use a dedicated summary/deck field for teasers and meta descriptions; on those, set that field in `fields` a value written here will be stored but may never be rendered." },
478
- format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. Defaults to the site config's `defaultTextFormat`, then 'full_html'. Set this when the site's formats do not include full_html, or to avoid writing content into a more permissive format than intended." },
489
+ summary: { type: "string", description: "Body summary/teaser — writes body.summary on core text_with_summary only. Refused when the sampled body field has no summary property (text_long / text_formatted) or the schema cannot be determined. Prefer the site's dedicated deck/summary field via `fields`." },
490
+ format: { type: "string", description: "Text format machine name for the body, e.g. 'basic_html'. When the body field's allowed_formats lists exactly one format, that is the default. A caller format outside that list is refused before write. When allowed_formats cannot be resolved, defaults to the site config's `defaultTextFormat`, then 'full_html'." },
479
491
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
480
492
  moderationState: { type: "string", description: "Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'." },
481
- fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },
493
+ 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." },
482
494
  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." },
483
- 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. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun." },
495
+ 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. The probe uses a non-matching data.id so Drupal does not save, and hits the same URL as the real write (canonical, or ?resourceVersion=rel:working-copy when a draft is addressable). A working-copy 400 fails the dryRun." },
484
496
  returning: RETURNING_SCHEMA,
485
497
  },
486
498
  },