drupal-mcp-connector 2.7.3 → 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 (229) hide show
  1. package/.agents/commands/drupal-audit-config-best-practices.md +15 -0
  2. package/{.claude → .agents}/commands/drupal-audit-site-health.md +4 -5
  3. package/.agents/commands/drupal-block-user.md +19 -0
  4. package/{.claude → .agents}/commands/drupal-bulk-create.md +4 -5
  5. package/{.claude → .agents}/commands/drupal-bulk-update.md +4 -5
  6. package/.agents/commands/drupal-config-get.md +18 -0
  7. package/.agents/commands/drupal-config-list.md +16 -0
  8. package/.agents/commands/drupal-config-set.md +19 -0
  9. package/.agents/commands/drupal-content-by-moderation-state.md +21 -0
  10. package/.agents/commands/drupal-create-block.md +20 -0
  11. package/{.claude → .agents}/commands/drupal-create-media.md +4 -5
  12. package/{.claude → .agents}/commands/drupal-create-menu-link.md +4 -5
  13. package/{.claude → .agents}/commands/drupal-create-node.md +7 -8
  14. package/{.claude → .agents}/commands/drupal-create-paragraph.md +4 -5
  15. package/{.claude → .agents}/commands/drupal-create-redirect.md +4 -5
  16. package/.agents/commands/drupal-create-taxonomy-term.md +22 -0
  17. package/{.claude → .agents}/commands/drupal-create-translation.md +4 -5
  18. package/.agents/commands/drupal-create-user.md +23 -0
  19. package/.agents/commands/drupal-delete-media.md +21 -0
  20. package/.agents/commands/drupal-delete-node.md +22 -0
  21. package/.agents/commands/drupal-delete-taxonomy-term.md +21 -0
  22. package/{.claude → .agents}/commands/drupal-describe-fields.md +4 -5
  23. package/.agents/commands/drupal-drush-cache-rebuild.md +15 -0
  24. package/.agents/commands/drupal-drush-config-export.md +15 -0
  25. package/.agents/commands/drupal-drush-config-import.md +15 -0
  26. package/.agents/commands/drupal-drush-config-status.md +15 -0
  27. package/.agents/commands/drupal-drush-cron.md +15 -0
  28. package/.agents/commands/drupal-drush-module-disable.md +20 -0
  29. package/.agents/commands/drupal-drush-module-enable.md +18 -0
  30. package/.agents/commands/drupal-drush-module-list.md +16 -0
  31. package/.agents/commands/drupal-drush-security-updates.md +15 -0
  32. package/{.claude → .agents}/commands/drupal-drush-sql-query.md +4 -5
  33. package/.agents/commands/drupal-drush-status.md +15 -0
  34. package/.agents/commands/drupal-drush-updatedb.md +15 -0
  35. package/.agents/commands/drupal-drush-user-create.md +21 -0
  36. package/.agents/commands/drupal-drush-user-list.md +18 -0
  37. package/.agents/commands/drupal-drush-watchdog.md +18 -0
  38. package/{.claude → .agents}/commands/drupal-entity-create.md +4 -5
  39. package/.agents/commands/drupal-entity-delete.md +23 -0
  40. package/.agents/commands/drupal-entity-get.md +21 -0
  41. package/{.claude → .agents}/commands/drupal-entity-list.md +4 -5
  42. package/{.claude → .agents}/commands/drupal-entity-update.md +7 -8
  43. package/.agents/commands/drupal-find-orphaned-media.md +17 -0
  44. package/.agents/commands/drupal-get-entity-schema.md +19 -0
  45. package/.agents/commands/drupal-get-media.md +19 -0
  46. package/.agents/commands/drupal-get-node.md +19 -0
  47. package/{.claude → .agents}/commands/drupal-get-paragraph.md +4 -5
  48. package/{.claude → .agents}/commands/drupal-get-revision.md +4 -5
  49. package/.agents/commands/drupal-get-taxonomy-term.md +19 -0
  50. package/.agents/commands/drupal-get-taxonomy-terms.md +20 -0
  51. package/.agents/commands/drupal-get-user-by-name.md +18 -0
  52. package/.agents/commands/drupal-get-user.md +18 -0
  53. package/{.claude → .agents}/commands/drupal-governance-status.md +4 -5
  54. package/.agents/commands/drupal-graphql-introspect.md +16 -0
  55. package/{.claude → .agents}/commands/drupal-graphql.md +4 -5
  56. package/{.claude → .agents}/commands/drupal-list-blocks.md +4 -5
  57. package/.agents/commands/drupal-list-content-types.md +15 -0
  58. package/.agents/commands/drupal-list-entity-types.md +15 -0
  59. package/.agents/commands/drupal-list-media-types.md +15 -0
  60. package/.agents/commands/drupal-list-media.md +20 -0
  61. package/{.claude → .agents}/commands/drupal-list-menu-links.md +4 -5
  62. package/.agents/commands/drupal-list-moderation-states.md +19 -0
  63. package/{.claude → .agents}/commands/drupal-list-nodes.md +4 -5
  64. package/{.claude → .agents}/commands/drupal-list-revisions.md +4 -5
  65. package/.agents/commands/drupal-list-roles.md +15 -0
  66. package/{.claude → .agents}/commands/drupal-list-sites.md +2 -3
  67. package/{.claude → .agents}/commands/drupal-list-translations.md +4 -5
  68. package/.agents/commands/drupal-list-users.md +19 -0
  69. package/.agents/commands/drupal-list-vocabularies.md +15 -0
  70. package/.agents/commands/drupal-mcp-whoami.md +15 -0
  71. package/.agents/commands/drupal-report-404-log.md +16 -0
  72. package/.agents/commands/drupal-report-accessibility-audit.md +17 -0
  73. package/.agents/commands/drupal-report-alias-coverage.md +17 -0
  74. package/.agents/commands/drupal-report-broken-embeds.md +17 -0
  75. package/{.claude → .agents}/commands/drupal-report-broken-links.md +4 -5
  76. package/.agents/commands/drupal-report-cache-config.md +15 -0
  77. package/.agents/commands/drupal-report-config-drift.md +15 -0
  78. package/.agents/commands/drupal-report-content-by-author.md +17 -0
  79. package/.agents/commands/drupal-report-content-summary.md +15 -0
  80. package/.agents/commands/drupal-report-duplicate-content.md +17 -0
  81. package/.agents/commands/drupal-report-field-completeness.md +20 -0
  82. package/.agents/commands/drupal-report-menu-integrity.md +16 -0
  83. package/{.claude → .agents}/commands/drupal-report-missing-field.md +4 -5
  84. package/.agents/commands/drupal-report-module-audit.md +15 -0
  85. package/.agents/commands/drupal-report-orphan-pages.md +17 -0
  86. package/{.claude → .agents}/commands/drupal-report-orphaned-references.md +4 -5
  87. package/.agents/commands/drupal-report-permission-audit.md +15 -0
  88. package/.agents/commands/drupal-report-pii-exposure.md +18 -0
  89. package/.agents/commands/drupal-report-readability.md +18 -0
  90. package/.agents/commands/drupal-report-recently-published.md +17 -0
  91. package/.agents/commands/drupal-report-redirect-health.md +16 -0
  92. package/.agents/commands/drupal-report-revision-hotspots.md +17 -0
  93. package/.agents/commands/drupal-report-scheduled-content.md +17 -0
  94. package/{.claude → .agents}/commands/drupal-report-seo-audit.md +4 -5
  95. package/.agents/commands/drupal-report-seo-meta-coverage.md +18 -0
  96. package/.agents/commands/drupal-report-stale-content.md +19 -0
  97. package/.agents/commands/drupal-report-status-report.md +16 -0
  98. package/.agents/commands/drupal-report-taxonomy-usage.md +21 -0
  99. package/.agents/commands/drupal-report-text-format-audit.md +15 -0
  100. package/.agents/commands/drupal-report-translation-coverage.md +18 -0
  101. package/.agents/commands/drupal-report-unpublished.md +17 -0
  102. package/.agents/commands/drupal-report-user-activity.md +17 -0
  103. package/.agents/commands/drupal-report-workflow-bottlenecks.md +19 -0
  104. package/{.claude → .agents}/commands/drupal-resolve-reference.md +4 -5
  105. package/{.claude → .agents}/commands/drupal-revert-revision.md +4 -5
  106. package/{.claude → .agents}/commands/drupal-schedule-publish.md +4 -5
  107. package/.agents/commands/drupal-search-content.md +21 -0
  108. package/.agents/commands/drupal-search.md +20 -0
  109. package/.agents/commands/drupal-security-info.md +15 -0
  110. package/.agents/commands/drupal-set-moderation-state.md +20 -0
  111. package/.agents/commands/drupal-site-info.md +15 -0
  112. package/.agents/commands/drupal-update-media.md +22 -0
  113. package/{.claude → .agents}/commands/drupal-update-menu-link.md +4 -5
  114. package/{.claude → .agents}/commands/drupal-update-node.md +10 -11
  115. package/{.claude → .agents}/commands/drupal-update-paragraph.md +4 -5
  116. package/{.claude → .agents}/commands/drupal-update-redirect.md +4 -5
  117. package/.agents/commands/drupal-update-taxonomy-term.md +22 -0
  118. package/.agents/commands/drupal-update-user.md +24 -0
  119. package/{.claude → .agents}/commands/drupal-upload-file-and-create-media.md +4 -5
  120. package/{.claude → .agents}/commands/drupal-upload-file.md +4 -5
  121. package/CHANGELOG.md +80 -0
  122. package/README.md +23 -12
  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 +99 -30
  132. package/src/lib/drupal-fetch.js +52 -6
  133. package/src/lib/field-definition.js +285 -0
  134. package/src/lib/operations.js +39 -2
  135. package/src/lib/patch-preflight.js +157 -66
  136. package/src/lib/security.js +15 -1
  137. package/src/lib/server-tools.js +9 -0
  138. package/src/lib/site-target.js +78 -0
  139. package/src/lib/tool-prompts.js +2 -1
  140. package/src/lib/write-revision.js +82 -2
  141. package/src/tools/bulk.js +4 -3
  142. package/src/tools/config.js +5 -2
  143. package/src/tools/entities.js +11 -7
  144. package/src/tools/index.js +24 -3
  145. package/src/tools/moderation.js +88 -9
  146. package/src/tools/nodes.js +55 -43
  147. package/.claude/commands/drupal-audit-config-best-practices.md +0 -16
  148. package/.claude/commands/drupal-block-user.md +0 -20
  149. package/.claude/commands/drupal-config-get.md +0 -19
  150. package/.claude/commands/drupal-config-list.md +0 -17
  151. package/.claude/commands/drupal-config-set.md +0 -20
  152. package/.claude/commands/drupal-content-by-moderation-state.md +0 -22
  153. package/.claude/commands/drupal-create-block.md +0 -21
  154. package/.claude/commands/drupal-create-taxonomy-term.md +0 -23
  155. package/.claude/commands/drupal-create-user.md +0 -24
  156. package/.claude/commands/drupal-delete-media.md +0 -22
  157. package/.claude/commands/drupal-delete-node.md +0 -23
  158. package/.claude/commands/drupal-delete-taxonomy-term.md +0 -22
  159. package/.claude/commands/drupal-drush-cache-rebuild.md +0 -16
  160. package/.claude/commands/drupal-drush-config-export.md +0 -16
  161. package/.claude/commands/drupal-drush-config-import.md +0 -16
  162. package/.claude/commands/drupal-drush-config-status.md +0 -16
  163. package/.claude/commands/drupal-drush-cron.md +0 -16
  164. package/.claude/commands/drupal-drush-module-disable.md +0 -21
  165. package/.claude/commands/drupal-drush-module-enable.md +0 -19
  166. package/.claude/commands/drupal-drush-module-list.md +0 -17
  167. package/.claude/commands/drupal-drush-security-updates.md +0 -16
  168. package/.claude/commands/drupal-drush-status.md +0 -16
  169. package/.claude/commands/drupal-drush-updatedb.md +0 -16
  170. package/.claude/commands/drupal-drush-user-create.md +0 -22
  171. package/.claude/commands/drupal-drush-user-list.md +0 -19
  172. package/.claude/commands/drupal-drush-watchdog.md +0 -19
  173. package/.claude/commands/drupal-entity-delete.md +0 -24
  174. package/.claude/commands/drupal-entity-get.md +0 -22
  175. package/.claude/commands/drupal-find-orphaned-media.md +0 -18
  176. package/.claude/commands/drupal-get-entity-schema.md +0 -20
  177. package/.claude/commands/drupal-get-media.md +0 -20
  178. package/.claude/commands/drupal-get-node.md +0 -20
  179. package/.claude/commands/drupal-get-taxonomy-term.md +0 -20
  180. package/.claude/commands/drupal-get-taxonomy-terms.md +0 -21
  181. package/.claude/commands/drupal-get-user-by-name.md +0 -19
  182. package/.claude/commands/drupal-get-user.md +0 -19
  183. package/.claude/commands/drupal-graphql-introspect.md +0 -17
  184. package/.claude/commands/drupal-list-content-types.md +0 -16
  185. package/.claude/commands/drupal-list-entity-types.md +0 -16
  186. package/.claude/commands/drupal-list-media-types.md +0 -16
  187. package/.claude/commands/drupal-list-media.md +0 -21
  188. package/.claude/commands/drupal-list-moderation-states.md +0 -20
  189. package/.claude/commands/drupal-list-roles.md +0 -16
  190. package/.claude/commands/drupal-list-users.md +0 -20
  191. package/.claude/commands/drupal-list-vocabularies.md +0 -16
  192. package/.claude/commands/drupal-mcp-whoami.md +0 -16
  193. package/.claude/commands/drupal-report-404-log.md +0 -17
  194. package/.claude/commands/drupal-report-accessibility-audit.md +0 -18
  195. package/.claude/commands/drupal-report-alias-coverage.md +0 -18
  196. package/.claude/commands/drupal-report-broken-embeds.md +0 -18
  197. package/.claude/commands/drupal-report-cache-config.md +0 -16
  198. package/.claude/commands/drupal-report-config-drift.md +0 -16
  199. package/.claude/commands/drupal-report-content-by-author.md +0 -18
  200. package/.claude/commands/drupal-report-content-summary.md +0 -16
  201. package/.claude/commands/drupal-report-duplicate-content.md +0 -18
  202. package/.claude/commands/drupal-report-field-completeness.md +0 -21
  203. package/.claude/commands/drupal-report-menu-integrity.md +0 -17
  204. package/.claude/commands/drupal-report-module-audit.md +0 -16
  205. package/.claude/commands/drupal-report-orphan-pages.md +0 -18
  206. package/.claude/commands/drupal-report-permission-audit.md +0 -16
  207. package/.claude/commands/drupal-report-pii-exposure.md +0 -19
  208. package/.claude/commands/drupal-report-readability.md +0 -19
  209. package/.claude/commands/drupal-report-recently-published.md +0 -18
  210. package/.claude/commands/drupal-report-redirect-health.md +0 -17
  211. package/.claude/commands/drupal-report-revision-hotspots.md +0 -18
  212. package/.claude/commands/drupal-report-scheduled-content.md +0 -18
  213. package/.claude/commands/drupal-report-seo-meta-coverage.md +0 -19
  214. package/.claude/commands/drupal-report-stale-content.md +0 -20
  215. package/.claude/commands/drupal-report-status-report.md +0 -17
  216. package/.claude/commands/drupal-report-taxonomy-usage.md +0 -22
  217. package/.claude/commands/drupal-report-text-format-audit.md +0 -16
  218. package/.claude/commands/drupal-report-translation-coverage.md +0 -19
  219. package/.claude/commands/drupal-report-unpublished.md +0 -18
  220. package/.claude/commands/drupal-report-user-activity.md +0 -18
  221. package/.claude/commands/drupal-report-workflow-bottlenecks.md +0 -20
  222. package/.claude/commands/drupal-search-content.md +0 -22
  223. package/.claude/commands/drupal-search.md +0 -21
  224. package/.claude/commands/drupal-security-info.md +0 -16
  225. package/.claude/commands/drupal-set-moderation-state.md +0 -21
  226. package/.claude/commands/drupal-site-info.md +0 -16
  227. package/.claude/commands/drupal-update-media.md +0 -23
  228. package/.claude/commands/drupal-update-taxonomy-term.md +0 -23
  229. package/.claude/commands/drupal-update-user.md +0 -25
@@ -1,5 +1,5 @@
1
1
  /**
2
- * JSON:API PATCH preflight for Drupal core's working-copy guard (#201).
2
+ * JSON:API PATCH preflight and working-copy targeting (#201 / #166).
3
3
  *
4
4
  * `EntityResource::patchIndividual()` rejects a canonical PATCH when the
5
5
  * stored entity is not both the latest and the default revision. That check
@@ -8,23 +8,33 @@
8
8
  * often `setNewRevision`) — so the probe must fail *after* the guard and
9
9
  * *before* save. Core next compares `data.id` to the URL entity UUID; a
10
10
  * well-formed but non-matching id yields 400 "does not match the ID in the
11
- * payload" with no row written. Content-moderation's `rel:latest-version` /
12
- * `rel:working-copy` aliases can disagree with storage (a revision row with
13
- * no `content_moderation_state`), which is how every read tool reports
14
- * clean and the write then 400s.
11
+ * payload" with no row written.
15
12
  *
16
- * `workingCopy: null` and "latest-version vid === default vid" are not proof
17
- * the node is writable. That is the #201 lie.
13
+ * Two distinct cases share that core 400:
18
14
  *
19
- * Distinct from #166: there a working copy is visible and the fix is PATCH
20
- * `?resourceVersion=rel:working-copy`. This module does not implement that.
15
+ * - **#166** `rel:working-copy` resolves. PATCH (and the dryRun probe)
16
+ * target `?resourceVersion=rel:working-copy`. Do not discard, do not
17
+ * retry the canonical URL, and do not tell the caller to publish first.
18
+ * - **#201** — the working-copy alias does not resolve, but core still
19
+ * blocks. That is a stray revision row. Refuse with revision-surgery
20
+ * language. `workingCopy: null` is not proof the node is writable.
21
+ *
22
+ * A working-copy 400 *after* targeting the working copy is a stale or
23
+ * concurrent write — refuse, do not fall back to discard.
21
24
  */
22
25
 
23
26
  import { entityLooksModerated, hasExplicitModerationState } from "./moderation-default.js";
27
+ import { entityRevisionId } from "./write-revision.js";
24
28
 
25
29
  /** Stable error code for a core working-copy / not-latest-revision block. */
26
30
  export const PATCH_BLOCKED_CODE = "PATCH_BLOCKED";
27
31
 
32
+ /** Stable error code when a working-copy PATCH itself is rejected. */
33
+ export const PATCH_WORKING_COPY_STALE_CODE = "PATCH_WORKING_COPY_STALE";
34
+
35
+ /** Stable error code when the working-copy resource does not match the target. */
36
+ export const PATCH_TARGET_AMBIGUOUS_CODE = "PATCH_TARGET_AMBIGUOUS";
37
+
28
38
  const WORKING_COPY_PATCH_RE = /has a working copy is not yet supported/i;
29
39
 
30
40
  /**
@@ -39,61 +49,75 @@ export const PATCH_BLOCKED_MESSAGE =
39
49
  "See connector #201. Do not retry the same canonical PATCH.";
40
50
 
41
51
  /**
42
- * Read a revision id off a working-copy body (canonical or raw-ish).
43
- * @param {?object} workingCopy
44
- * @returns {?number|string}
45
- */
46
- function workingCopyVid(workingCopy) {
47
- if (!workingCopy || typeof workingCopy !== "object") return null;
48
- const fields = workingCopy.fields && typeof workingCopy.fields === "object"
49
- ? workingCopy.fields
50
- : {};
51
- const attrs = workingCopy.attributes && typeof workingCopy.attributes === "object"
52
- ? workingCopy.attributes
53
- : {};
54
- const raw = workingCopy.vid
55
- ?? fields.drupal_internal__vid
56
- ?? attrs.drupal_internal__vid
57
- ?? workingCopy.drupal_internal__vid;
58
- if (raw === undefined || raw === null || raw === "") return null;
59
- const n = Number(raw);
60
- return Number.isFinite(n) ? n : raw;
61
- }
52
+ * A working-copy-targeted PATCH (or its probe) hit core's working-copy guard.
53
+ * That is a stale or concurrent conflict, not an invitation to discard.
54
+ */
55
+ export const PATCH_WORKING_COPY_STALE_MESSAGE =
56
+ "This entity's working-copy revision could not be updated (stale or concurrent write). " +
57
+ "The connector will not retry the canonical URL or discard the draft. " +
58
+ "Re-read rel:working-copy and retry, or resolve the conflict in Drupal. See connector #166.";
62
59
 
63
60
  /**
64
61
  * Operator message for a core working-copy 400.
65
- * A resolvable working copy is an ordinary pending draftdo not prescribe
66
- * revision surgery. Surgery is only for the invisible-row case (#201 follow-up).
67
- * @param {?object} [workingCopy]
62
+ * A resolvable working copy is edited in place (#166)this message is only
63
+ * for the invisible-row case (#201).
68
64
  * @returns {string}
69
65
  */
70
- export function patchBlockedMessage(workingCopy) {
71
- if (workingCopy) {
72
- const vid = workingCopyVid(workingCopy);
73
- const which = vid !== null && vid !== undefined ? ` (vid ${vid})` : "";
74
- return `This node has a pending draft${which}. Publish or discard it `
75
- + "before a canonical PATCH.";
76
- }
66
+ export function patchBlockedMessage() {
77
67
  return PATCH_BLOCKED_MESSAGE;
78
68
  }
79
69
 
80
70
  /**
81
- * Thrown when the core working-copy PATCH guard rejects a write (or its probe).
71
+ * Thrown when the core working-copy PATCH guard rejects a canonical write
72
+ * (or its probe) and no working copy is addressable (#201).
82
73
  */
83
74
  export class PatchBlockedError extends Error {
84
75
  /**
85
76
  * @param {?Error} [cause] The original Drupal 400.
86
- * @param {{workingCopy?: ?object}} [options]
87
77
  */
88
- constructor(cause, { workingCopy } = {}) {
89
- super(patchBlockedMessage(workingCopy ?? null));
78
+ constructor(cause) {
79
+ super(PATCH_BLOCKED_MESSAGE);
90
80
  this.name = "PatchBlockedError";
91
81
  this.code = PATCH_BLOCKED_CODE;
92
- if (workingCopy) this.workingCopyVid = workingCopyVid(workingCopy);
93
82
  if (cause) this.cause = cause;
94
83
  }
95
84
  }
96
85
 
86
+ /**
87
+ * Thrown when a working-copy-targeted PATCH or probe is rejected (#166).
88
+ */
89
+ export class WorkingCopyStaleError extends Error {
90
+ /**
91
+ * @param {?Error} [cause] The original Drupal 400.
92
+ */
93
+ constructor(cause) {
94
+ super(PATCH_WORKING_COPY_STALE_MESSAGE);
95
+ this.name = "WorkingCopyStaleError";
96
+ this.code = PATCH_WORKING_COPY_STALE_CODE;
97
+ if (cause) this.cause = cause;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Thrown when `rel:working-copy` resolves to a different UUID than the
103
+ * entity being updated.
104
+ */
105
+ export class PatchTargetAmbiguousError extends Error {
106
+ /**
107
+ * @param {string} expectedId Requested entity UUID.
108
+ * @param {string} actualId Working-copy resource UUID.
109
+ */
110
+ constructor(expectedId, actualId) {
111
+ super(
112
+ `The working-copy resource id (${actualId}) does not match the entity ` +
113
+ `being updated (${expectedId}). Refusing an ambiguous PATCH target. ` +
114
+ "See connector #166."
115
+ );
116
+ this.name = "PatchTargetAmbiguousError";
117
+ this.code = PATCH_TARGET_AMBIGUOUS_CODE;
118
+ }
119
+ }
120
+
97
121
  /**
98
122
  * Whether an error is Drupal core's working-copy PATCH guard (core #2795279).
99
123
  * @param {unknown} err
@@ -105,27 +129,22 @@ export function isWorkingCopyPatchError(err) {
105
129
 
106
130
  /**
107
131
  * Rewrite a core working-copy 400 into {@link PatchBlockedError}; otherwise
108
- * return the original value. Pass `workingCopy` when the pending draft is
109
- * addressable so the message does not prescribe revision surgery.
132
+ * return the original value. Used only for the stray-revision (#201) case.
110
133
  * @param {unknown} err
111
- * @param {{workingCopy?: ?object}} [options]
112
134
  * @returns {unknown}
113
135
  */
114
- export function rewriteWorkingCopyPatchError(err, { workingCopy } = {}) {
136
+ export function rewriteWorkingCopyPatchError(err) {
115
137
  if (!isWorkingCopyPatchError(err)) return err;
116
- return new PatchBlockedError(
117
- err instanceof Error ? err : new Error(String(err)),
118
- { workingCopy },
119
- );
138
+ return new PatchBlockedError(err instanceof Error ? err : new Error(String(err)));
120
139
  }
121
140
 
122
141
  /**
123
- * Load `rel:working-copy` so a blocked PATCH can name a pending draft.
142
+ * Load `rel:working-copy` so a write can target the pending draft (#166).
124
143
  * @param {object} backend
125
144
  * @param {{entityType: string, bundle: string, id: string}} ref
126
145
  * @returns {Promise<?object>}
127
146
  */
128
- async function loadWorkingCopy(backend, { entityType, bundle, id }) {
147
+ export async function loadWorkingCopy(backend, { entityType, bundle, id }) {
129
148
  if (typeof backend?.getEntity !== "function") return null;
130
149
  try {
131
150
  const wc = await backend.getEntity({
@@ -137,6 +156,38 @@ async function loadWorkingCopy(backend, { entityType, bundle, id }) {
137
156
  }
138
157
  }
139
158
 
159
+ /**
160
+ * Decide whether this PATCH should target `rel:working-copy`.
161
+ *
162
+ * A 200 from the working-copy alias is treated as an addressable draft unless
163
+ * both live and working vids are known and equal (the alias echoed the
164
+ * default revision). An id mismatch is refused rather than guessed.
165
+ *
166
+ * @param {object} backend
167
+ * @param {{entityType: string, bundle: string, id: string, existing?: ?object}} ref
168
+ * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
169
+ * @throws {PatchTargetAmbiguousError}
170
+ */
171
+ export async function resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing }) {
172
+ const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
173
+ let liveVid = entityRevisionId(existing);
174
+ if (workingCopy && (liveVid === null || liveVid === undefined) && typeof backend?.getEntity === "function") {
175
+ const live = await backend.getEntity({ entityType, bundle, id }).catch(() => null);
176
+ liveVid = entityRevisionId(live);
177
+ }
178
+ if (!workingCopy) {
179
+ return { resourceVersion: undefined, workingCopy: null, liveVid, workingVid: null };
180
+ }
181
+ if (workingCopy.id && workingCopy.id !== id) {
182
+ throw new PatchTargetAmbiguousError(id, workingCopy.id);
183
+ }
184
+ const workingVid = entityRevisionId(workingCopy);
185
+ if (workingVid !== null && liveVid !== null && String(workingVid) === String(liveVid)) {
186
+ return { resourceVersion: undefined, workingCopy, liveVid, workingVid };
187
+ }
188
+ return { resourceVersion: "rel:working-copy", workingCopy, liveVid, workingVid };
189
+ }
190
+
140
191
  /**
141
192
  * Whether this update should run the PATCH probe.
142
193
  * Skip unmoderated / non-revisionable bundles — the guard is about
@@ -170,14 +221,16 @@ export function isProbePassedWithoutSave(err) {
170
221
  }
171
222
 
172
223
  /**
173
- * Probe the same guard core uses on the canonical PATCH URL.
224
+ * Probe the same guard core uses on the PATCH about to be sent.
174
225
  *
175
226
  * Sends a PATCH whose `data.id` does not match the URL entity. Core runs
176
227
  * the working-copy check first; a match on that phrase means no row was
177
228
  * written. An id-mismatch 400 (or deserialize 422) means the guard passed
178
229
  * and save was not reached. A 2xx would have saved a revision and is
179
- * treated as a probe failure. Do not treat "latest-version vid === default
180
- * vid" as writable.
230
+ * treated as a probe failure.
231
+ *
232
+ * When `resourceVersion` is `rel:working-copy`, the probe hits that same
233
+ * URL so dryRun cannot succeed when the real write would 400 (#166).
181
234
  *
182
235
  * @param {object} args
183
236
  * @param {object} args.backend Backend with `rawQuery` + `resourcePath`.
@@ -186,11 +239,12 @@ export function isProbePassedWithoutSave(err) {
186
239
  * @param {string} args.id
187
240
  * @param {?object} [args.existing]
188
241
  * @param {object} [args.attributes]
242
+ * @param {?string} [args.resourceVersion]
189
243
  * @returns {Promise<{probed: boolean, writable?: boolean|string, skipped?: string}>}
190
- * @throws {PatchBlockedError} When the guard rejects the probe.
244
+ * @throws {PatchBlockedError|WorkingCopyStaleError} When the guard rejects the probe.
191
245
  */
192
246
  export async function preflightPatchWritable({
193
- backend, entityType, bundle, id, existing, attributes,
247
+ backend, entityType, bundle, id, existing, attributes, resourceVersion,
194
248
  }) {
195
249
  if (!shouldPreflightPatch({ existing, attributes })) {
196
250
  return { probed: false };
@@ -198,7 +252,10 @@ export async function preflightPatchWritable({
198
252
  if (typeof backend?.rawQuery !== "function" || typeof backend?.resourcePath !== "function") {
199
253
  return { probed: false, skipped: "backend cannot issue a raw PATCH probe" };
200
254
  }
201
- const path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
255
+ let path = `${backend.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
256
+ if (resourceVersion) {
257
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
258
+ }
202
259
  const type = `${entityType}--${bundle}`;
203
260
  const probeId = id === PATCH_PROBE_MISMATCH_ID
204
261
  ? "00000000-0000-4000-a000-000000000002"
@@ -217,11 +274,15 @@ export async function preflightPatchWritable({
217
274
  );
218
275
  } catch (err) {
219
276
  if (isWorkingCopyPatchError(err)) {
277
+ const cause = err instanceof Error ? err : new Error(String(err));
278
+ if (resourceVersion === "rel:working-copy") {
279
+ throw new WorkingCopyStaleError(cause);
280
+ }
220
281
  const workingCopy = await loadWorkingCopy(backend, { entityType, bundle, id });
221
- throw new PatchBlockedError(
222
- err instanceof Error ? err : new Error(String(err)),
223
- { workingCopy },
224
- );
282
+ if (workingCopy) {
283
+ throw new WorkingCopyStaleError(cause);
284
+ }
285
+ throw new PatchBlockedError(cause);
225
286
  }
226
287
  if (isProbePassedWithoutSave(err)) {
227
288
  return { probed: true, writable: true };
@@ -230,19 +291,49 @@ export async function preflightPatchWritable({
230
291
  }
231
292
  }
232
293
 
294
+ /**
295
+ * Resolve the PATCH target, then run the same probe the real write will use.
296
+ * Callers inherit #166 targeting by going through this before dryRun or write.
297
+ *
298
+ * @param {object} backend
299
+ * @param {{entityType: string, bundle: string, id: string, existing?: ?object, attributes?: object}} args
300
+ * @returns {Promise<{resourceVersion: ?string, workingCopy: ?object, liveVid: ?number|string, workingVid: ?number|string}>}
301
+ */
302
+ export async function prepareGuardedPatch(backend, {
303
+ entityType, bundle, id, existing, attributes,
304
+ }) {
305
+ const target = shouldPreflightPatch({ existing, attributes })
306
+ ? await resolveWorkingCopyPatchTarget(backend, { entityType, bundle, id, existing })
307
+ : { resourceVersion: undefined, workingCopy: null, liveVid: null, workingVid: null };
308
+ await preflightPatchWritable({
309
+ backend, entityType, bundle, id, existing, attributes,
310
+ resourceVersion: target.resourceVersion,
311
+ });
312
+ return target;
313
+ }
314
+
233
315
  /**
234
316
  * `backend.updateEntity` with the core working-copy 400 rewritten.
317
+ * A working-copy-targeted 400 is stale/concurrent (#166). A canonical 400
318
+ * with no addressable working copy is the #201 stray-revision case.
235
319
  * @param {object} backend
236
320
  * @param {object} input updateEntity argument.
237
321
  * @returns {Promise<*>}
238
- * @throws {PatchBlockedError|*}
322
+ * @throws {PatchBlockedError|WorkingCopyStaleError|*}
239
323
  */
240
324
  export async function updateEntityGuarded(backend, input) {
241
325
  try {
242
326
  return await backend.updateEntity(input);
243
327
  } catch (err) {
244
328
  if (!isWorkingCopyPatchError(err)) throw err;
329
+ const cause = err instanceof Error ? err : new Error(String(err));
330
+ if (input?.resourceVersion === "rel:working-copy") {
331
+ throw new WorkingCopyStaleError(cause);
332
+ }
245
333
  const workingCopy = await loadWorkingCopy(backend, input);
246
- throw rewriteWorkingCopyPatchError(err, { workingCopy });
334
+ if (workingCopy) {
335
+ throw new WorkingCopyStaleError(cause);
336
+ }
337
+ throw new PatchBlockedError(cause);
247
338
  }
248
339
  }
@@ -53,6 +53,17 @@ import { parse } from "graphql";
53
53
  *
54
54
  * globalRedactedFields string[] stripped from every response, every type
55
55
  *
56
+ * declaredCeiling string narrow-only X-MCP-Declared-Ceiling
57
+ * (public|internal|restricted). Invalid
58
+ * values are dropped, never widened.
59
+ * readBudgets object finite northbound budgets bound to
60
+ * principal+target (#179). Same classes
61
+ * as mcp_sentinel: results, bytes,
62
+ * requests, requestWindowSec, pages,
63
+ * pageWindowSec, chainedActions,
64
+ * chainedActionWindowSec. Omitted keys
65
+ * use the module defaults.
66
+ *
56
67
  * ─── Field redaction ──────────────────────────────────────────────────────
57
68
  *
58
69
  * Redacted fields are replaced with "[REDACTED]" in response attributes.
@@ -259,6 +270,8 @@ export function resolveSecurityConfig(site) {
259
270
  ...(preset.globalRedactedFields ?? []),
260
271
  ...(raw.globalRedactedFields ?? []),
261
272
  ],
273
+ declaredCeiling: raw.declaredCeiling ?? preset.declaredCeiling,
274
+ readBudgets: raw.readBudgets ?? preset.readBudgets ?? null,
262
275
  };
263
276
  }
264
277
 
@@ -470,7 +483,7 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
470
483
  * @param {string} query GraphQL document text.
471
484
  * @returns {boolean} True if any operation is a mutation.
472
485
  */
473
- function graphqlHasMutation(query) {
486
+ export function graphqlHasMutation(query) {
474
487
  try {
475
488
  const doc = parse(query);
476
489
  return doc.definitions.some(
@@ -754,5 +767,6 @@ export function getSecuritySummary(site) {
754
767
  deniedEntityTypes: cfg.deniedEntityTypes,
755
768
  entityRules: cfg.entityRules,
756
769
  globalRedactedFields: cfg.globalRedactedFields,
770
+ declaredCeiling: cfg.declaredCeiling ?? null,
757
771
  };
758
772
  }
@@ -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;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Resolved-target disclosure and the multi-site write guard (#167).
3
+ *
4
+ * A silent default that returns plausible content from the wrong environment
5
+ * is the failure class: the response looks like success and every field a
6
+ * caller would sanity-check (id, title, url) is correct. `_target` uses the
7
+ * same `{ name, baseUrl, source }` block as `drupal_mcp_whoami` so there is
8
+ * one vocabulary. `source` is load-bearing — `hint` when the caller named a
9
+ * target, `default` when `defaultSite` was used, `grant` when a principal
10
+ * had exactly one entitled site.
11
+ *
12
+ * Reads may still default. Writes (and GraphQL mutations) may not when more
13
+ * than one site is configured: a write on the wrong site is not recoverable.
14
+ */
15
+
16
+ import { describeTarget } from "./principal.js";
17
+ import { isWriteLikeCall } from "./operations.js";
18
+ import { SecurityError } from "./security.js";
19
+
20
+ /** Shared `site` argument schema. Injected onto every tool that accepts `site`. */
21
+ export const SITE_PARAM = {
22
+ type: "string",
23
+ description:
24
+ "Named site from connector config. Omit only on reads: multi-site configs " +
25
+ "fall back to defaultSite (often local/dev, not production). Writes require " +
26
+ "an explicit site when more than one site is configured. Every response " +
27
+ "includes `_target` { name, baseUrl, source } (`hint` when you passed site, " +
28
+ "`default` when you did not).",
29
+ };
30
+
31
+ /**
32
+ * Attach the resolved target to a tool payload so JSON.stringify keeps it.
33
+ *
34
+ * Object results get `_target` as a sibling. Arrays are wrapped as
35
+ * `{ items, _target }` because extra properties on an array are dropped by
36
+ * JSON.stringify. Tools that do not address a single site pass `resolved` as
37
+ * null and are returned unchanged.
38
+ *
39
+ * @param {*} result Handler return value.
40
+ * @param {?{site: object, source: string}} resolved
41
+ * @returns {*}
42
+ */
43
+ export function withResolvedTarget(result, resolved) {
44
+ if (!resolved?.site) return result;
45
+ const _target = describeTarget(resolved.site, resolved.source);
46
+ if (result && typeof result === "object" && !Array.isArray(result)) {
47
+ return { ...result, _target };
48
+ }
49
+ if (Array.isArray(result)) {
50
+ return { items: result, _target };
51
+ }
52
+ if (result === undefined || result === null) {
53
+ return { _target };
54
+ }
55
+ return { result, _target };
56
+ }
57
+
58
+ /**
59
+ * Refuse a write that would silently land on defaultSite when more than one
60
+ * site is configured. Resolution behaviour for reads is unchanged.
61
+ *
62
+ * @param {string} toolName
63
+ * @param {object} args Raw caller arguments (before any default rewrite).
64
+ * @param {?{site: object, source: string, name: string}} resolved
65
+ * @param {string[]} siteNames Configured site names.
66
+ * @returns {void}
67
+ * @throws {SecurityError}
68
+ */
69
+ export function assertExplicitSiteForWrite(toolName, args, resolved, siteNames) {
70
+ if (!resolved || resolved.source !== "default") return;
71
+ if (!Array.isArray(siteNames) || siteNames.length < 2) return;
72
+ if (!isWriteLikeCall(toolName, args)) return;
73
+ throw new SecurityError(
74
+ "Write tools require an explicit site when more than one site is configured. " +
75
+ "Omitted site would default to \"" + resolved.name + "\" (" + resolved.site.baseUrl + "). " +
76
+ "Pass site explicitly. Configured sites: " + siteNames.join(", ") + ".",
77
+ );
78
+ }
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { isDestructiveTool } from "./operations.js";
18
+ import { SITE_PARAM } from "./site-target.js";
18
19
 
19
20
  /** Convert a tool name to its prompt/command name: `drupal_create_node` → `drupal-create-node`. */
20
21
  export const toolNameToPromptName = (name) => name.replace(/_/g, "-");
@@ -54,7 +55,7 @@ export function paramList(inputSchema) {
54
55
  name,
55
56
  required: required.has(name),
56
57
  hint: typeHint(spec),
57
- description: spec?.description || (name === "site" ? "omit for the default site" : ""),
58
+ description: spec?.description || (name === "site" ? SITE_PARAM.description : ""),
58
59
  }));
59
60
  }
60
61
 
@@ -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 });