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
@@ -7,6 +7,13 @@ import { createReadStream, statSync } from "fs";
7
7
  import { basename } from "path";
8
8
  import { authHeadersAsync, clientHeaders } from "./config.js";
9
9
  import { clearToken } from "./oauth.js";
10
+ import {
11
+ accountNorthboundBody,
12
+ consumeBudgetIfEnforced,
13
+ isCollectionJsonApiPath,
14
+ northboundHeaders,
15
+ sourceBudgetDenial,
16
+ } from "./data-flow.js";
10
17
  import {
11
18
  assertUploadPathAllowed,
12
19
  sanitizeUploadFilename,
@@ -15,6 +22,25 @@ import {
15
22
 
16
23
  const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
17
24
 
25
+ /**
26
+ * Read a 2xx body once. Prefers text() so byte accounting can see the payload;
27
+ * falls back to json() for test doubles that only implement that.
28
+ * @param {object} res node-fetch Response or test double.
29
+ * @returns {Promise<{json: object|null, text: string}>}
30
+ */
31
+ async function readOkBody(res) {
32
+ if (typeof res.text === "function") {
33
+ const text = await res.text();
34
+ if (!text) return { json: null, text: "" };
35
+ try {
36
+ return { json: JSON.parse(text), text };
37
+ } catch {
38
+ return { json: null, text };
39
+ }
40
+ }
41
+ return { json: await res.json(), text: "" };
42
+ }
43
+
18
44
  /**
19
45
  * Standard JSON:API request against a site.
20
46
  *
@@ -28,30 +54,38 @@ const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
28
54
  */
29
55
  export async function drupalFetch(site, path, options = {}) {
30
56
  const url = `${site.baseUrl}${path}`;
57
+ const collection = isCollectionJsonApiPath(path);
58
+ let paid = false;
31
59
 
32
- async function attempt() {
60
+ async function attempt(isRetry = false) {
61
+ consumeBudgetIfEnforced("request", 1, { retry: isRetry || paid });
62
+ if (collection) consumeBudgetIfEnforced("page", 1, { retry: isRetry || paid });
63
+ paid = true;
33
64
  return fetch(url, {
34
65
  ...options,
35
66
  headers: {
36
67
  "Content-Type": JSON_API_CONTENT_TYPE,
37
68
  Accept: JSON_API_CONTENT_TYPE,
38
69
  ...clientHeaders(),
39
- ...(await authHeadersAsync(site)),
40
70
  ...(options.headers || {}),
71
+ ...northboundHeaders(),
72
+ ...(await authHeadersAsync(site)),
41
73
  },
42
74
  });
43
75
  }
44
76
 
45
- let res = await attempt();
77
+ let res = await attempt(false);
46
78
 
47
79
  // OAuth sites: a 401 may mean the token expired server-side. Refresh once.
48
80
  if (res.status === 401 && site.oauth) {
49
81
  clearToken(site);
50
- res = await attempt();
82
+ res = await attempt(true);
51
83
  }
52
84
 
53
85
  if (!res.ok) {
54
86
  const body = await res.text();
87
+ const mapped = sourceBudgetDenial(body);
88
+ if (mapped) throw mapped;
55
89
  let detail = body;
56
90
  try {
57
91
  const parsed = JSON.parse(body);
@@ -64,7 +98,9 @@ export async function drupalFetch(site, path, options = {}) {
64
98
  }
65
99
 
66
100
  if (res.status === 204) return null; // No Content (e.g. DELETE success)
67
- return res.json();
101
+ const { json, text } = await readOkBody(res);
102
+ accountNorthboundBody(json, text);
103
+ return json;
68
104
  }
69
105
 
70
106
  /**
@@ -78,12 +114,14 @@ export async function drupalGraphqlFetch(site, body) {
78
114
  const endpoint = site.graphqlEndpoint || "/graphql";
79
115
  const url = `${site.baseUrl}${endpoint}`;
80
116
 
117
+ consumeBudgetIfEnforced("request");
81
118
  const res = await fetch(url, {
82
119
  method: "POST",
83
120
  headers: {
84
121
  "Content-Type": "application/json",
85
122
  Accept: "application/json",
86
123
  ...clientHeaders(),
124
+ ...northboundHeaders(),
87
125
  ...(await authHeadersAsync(site)),
88
126
  },
89
127
  body: JSON.stringify(body),
@@ -94,10 +132,14 @@ export async function drupalGraphqlFetch(site, body) {
94
132
  // cached token so the next request re-acquires, then surface the error.
95
133
  if (res.status === 401 && site.oauth) clearToken(site);
96
134
  const text = await res.text();
135
+ const mapped = sourceBudgetDenial(text);
136
+ if (mapped) throw mapped;
97
137
  throw new Error(`GraphQL request failed ${res.status}: ${text}`);
98
138
  }
99
139
 
100
- return res.json();
140
+ const { json, text } = await readOkBody(res);
141
+ accountNorthboundBody(json, text);
142
+ return json;
101
143
  }
102
144
 
103
145
  /**
@@ -130,6 +172,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
130
172
  const stat = statSync(safePath);
131
173
  const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
132
174
 
175
+ consumeBudgetIfEnforced("request");
133
176
  const res = await fetch(url, {
134
177
  method: "POST",
135
178
  headers: {
@@ -137,6 +180,7 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
137
180
  "Content-Disposition": `file; filename="${filename}"`,
138
181
  Accept: JSON_API_CONTENT_TYPE,
139
182
  ...clientHeaders(),
183
+ ...northboundHeaders(),
140
184
  ...(await authHeadersAsync(site)),
141
185
  },
142
186
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
@@ -147,6 +191,8 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
147
191
 
148
192
  if (!res.ok) {
149
193
  const body = await res.text();
194
+ const mapped = sourceBudgetDenial(body);
195
+ if (mapped) throw mapped;
150
196
  throw new Error(`File upload failed ${res.status}: ${body}`);
151
197
  }
152
198
 
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Authoritative text-format resolution for node writes (#168).
3
+ *
4
+ * `backend.getEntitySchema` is sampling-only: it can label a field
5
+ * `text_formatted` / `text_with_summary` but does NOT expose Field API
6
+ * `allowed_formats`. Never invent that list from `defaultTextFormat` or
7
+ * `FALLBACK_TEXT_FORMAT` (`full_html`).
8
+ *
9
+ * Resolution chain for a field definition:
10
+ * 1. `backend.getFieldDefinition` — JSON:API `field_config` via an internal
11
+ * adapter fetch (not `drupal_entity_get`; `field_config` is on the agent
12
+ * deny list).
13
+ * 2. Drush `config:get field.field.{entityType}.{bundle}.{field}` when a
14
+ * Drush bridge is configured.
15
+ * 3. If the definition cannot be resolved: return null. Callers keep the
16
+ * historical default chain only while the list is unknown. Once the list
17
+ * is known, never persist a format outside it.
18
+ */
19
+
20
+ import { drushConfigured } from "./audit-sources.js";
21
+ import { validateMachineName } from "./validate.js";
22
+ import { parseDrush, sshDrush } from "../tools/drush.js";
23
+
24
+ /** Last-resort body format when the Field API list cannot be resolved. */
25
+ export const FALLBACK_TEXT_FORMAT = "full_html";
26
+
27
+ const FORMATTED_FIELD_TYPES = new Set(["text", "text_long", "text_with_summary"]);
28
+
29
+ const SKIP_FORMAT_FIELDS = new Set([
30
+ "title", "status", "moderation_state", "path", "promote", "sticky",
31
+ "created", "changed", "langcode",
32
+ ]);
33
+
34
+ /**
35
+ * Normalize a Field API `allowed_formats` value to a string list.
36
+ * @param {*} raw settings.allowed_formats from field_config / Drush.
37
+ * @returns {string[]}
38
+ */
39
+ export function asFormatList(raw) {
40
+ if (Array.isArray(raw)) return raw.filter((f) => typeof f === "string" && f);
41
+ if (raw && typeof raw === "object") {
42
+ return [...new Map(Object.entries(raw)).values()].filter((f) => typeof f === "string" && f);
43
+ }
44
+ return [];
45
+ }
46
+
47
+ /**
48
+ * Parse a field_config resource or `field.field.*` config object.
49
+ * @param {object} obj Config / JSON:API attributes.
50
+ * @param {string} [fallbackName] Field name when the object omits `field_name`.
51
+ * @returns {?{fieldName: string, fieldType: ?string, allowedFormats: string[]}}
52
+ */
53
+ export function parseFieldConfigObject(obj, fallbackName) {
54
+ if (!obj || typeof obj !== "object") return null;
55
+ const attrs = new Map(Object.entries(obj));
56
+ const fieldName = attrs.get("field_name") || fallbackName;
57
+ if (typeof fieldName !== "string" || !fieldName) return null;
58
+ const settings = attrs.get("settings");
59
+ const settingsMap = settings && typeof settings === "object" && !Array.isArray(settings)
60
+ ? new Map(Object.entries(settings))
61
+ : new Map();
62
+ const fieldType = attrs.get("field_type");
63
+ return {
64
+ fieldName,
65
+ fieldType: typeof fieldType === "string" ? fieldType : null,
66
+ allowedFormats: asFormatList(settingsMap.get("allowed_formats")),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Pick the config object out of a Drush `config:get --format=json` payload.
72
+ * @param {*} raw Parsed Drush JSON.
73
+ * @param {string} configName `field.field.{type}.{bundle}.{field}`.
74
+ * @returns {?object}
75
+ */
76
+ function unwrapDrushConfig(raw, configName) {
77
+ if (!raw || typeof raw !== "object") return null;
78
+ const map = new Map(Object.entries(raw));
79
+ if (map.has("field_type") || map.has("settings")) return raw;
80
+ const named = map.get(configName);
81
+ if (named && typeof named === "object") return named;
82
+ if (map.size === 1) {
83
+ const only = [...map.values()][0];
84
+ if (only && typeof only === "object") return only;
85
+ }
86
+ return raw;
87
+ }
88
+
89
+ /**
90
+ * Load a field definition via Drush `config:get` (chain step 2).
91
+ * @param {object} site Site config with `drushSsh`.
92
+ * @param {string} entityType
93
+ * @param {string} bundle
94
+ * @param {string} fieldName
95
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
96
+ */
97
+ export async function fieldDefinitionFromDrush(site, entityType, bundle, fieldName) {
98
+ validateMachineName(entityType, "entityType");
99
+ validateMachineName(bundle, "bundle");
100
+ validateMachineName(fieldName, "fieldName");
101
+ const configName = `field.field.${entityType}.${bundle}.${fieldName}`;
102
+ try {
103
+ const raw = parseDrush(await sshDrush(site, ["config:get", configName, "--format=json"]));
104
+ return parseFieldConfigObject(unwrapDrushConfig(raw, configName), fieldName);
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve Field API metadata. See the file header for the chain.
112
+ * @param {object} backend Resolved backend.
113
+ * @param {object} site Site config.
114
+ * @param {string} entityType
115
+ * @param {string} bundle
116
+ * @param {string} fieldName
117
+ * @returns {Promise<?{fieldName: string, fieldType: ?string, allowedFormats: string[]}>}
118
+ */
119
+ export async function resolveFieldDefinition(backend, site, entityType, bundle, fieldName) {
120
+ if (typeof backend?.getFieldDefinition === "function") {
121
+ try {
122
+ const def = await backend.getFieldDefinition({ entityType, bundle, fieldName });
123
+ if (def) return parseFieldConfigObject({
124
+ field_name: def.fieldName,
125
+ field_type: def.fieldType,
126
+ settings: { allowed_formats: def.allowedFormats },
127
+ }, fieldName) ?? def;
128
+ } catch {
129
+ // JSON:API field_config is optional; try Drush next.
130
+ }
131
+ }
132
+ if (drushConfigured(site)) {
133
+ const def = await fieldDefinitionFromDrush(site, entityType, bundle, fieldName);
134
+ if (def) return def;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Choose the format to persist, or throw before mutation.
141
+ *
142
+ * Known list + exactly one entry → that format when the caller omits one.
143
+ * Known list + caller format outside it → refuse.
144
+ * Known list + several entries + omitted format → site `defaultTextFormat`
145
+ * only if it is in the list; otherwise refuse (never `full_html` when it
146
+ * is not allowed).
147
+ * Unknown list (`allowedFormats` is null): historical default chain only
148
+ * when `defaultWhenUnknown` is true (body); otherwise leave format omitted.
149
+ *
150
+ * @param {{fieldName: string, requested: ?string, allowedFormats: ?string[],
151
+ * site?: object, defaultWhenUnknown?: boolean}} input
152
+ * @returns {string|undefined}
153
+ */
154
+ export function resolveTextFormat({
155
+ fieldName, requested, allowedFormats, site, defaultWhenUnknown = false,
156
+ }) {
157
+ const asked = requested === undefined || requested === null || requested === ""
158
+ ? undefined
159
+ : String(requested);
160
+
161
+ if (!Array.isArray(allowedFormats)) {
162
+ if (asked !== undefined) return asked;
163
+ if (!defaultWhenUnknown) return undefined;
164
+ return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
165
+ }
166
+
167
+ if (allowedFormats.length === 0) {
168
+ if (asked !== undefined) return asked;
169
+ if (!defaultWhenUnknown) return undefined;
170
+ return site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT;
171
+ }
172
+
173
+ if (asked !== undefined) {
174
+ if (!allowedFormats.includes(asked)) {
175
+ throw textFormatError(fieldName, asked, allowedFormats);
176
+ }
177
+ return asked;
178
+ }
179
+
180
+ if (allowedFormats.length === 1) {
181
+ return allowedFormats[0];
182
+ }
183
+
184
+ const siteDefault = site?.defaultTextFormat;
185
+ if (siteDefault && allowedFormats.includes(siteDefault)) {
186
+ return siteDefault;
187
+ }
188
+
189
+ throw textFormatError(fieldName, siteDefault ?? FALLBACK_TEXT_FORMAT, allowedFormats);
190
+ }
191
+
192
+ /**
193
+ * @param {string} fieldName
194
+ * @param {string} requested
195
+ * @param {string[]} allowed
196
+ * @returns {Error}
197
+ */
198
+ function textFormatError(fieldName, requested, allowed) {
199
+ return new Error(
200
+ `Field "${fieldName}" does not allow format "${requested}" ` +
201
+ `(allowed: ${allowed.join(", ")}).`
202
+ );
203
+ }
204
+
205
+ /**
206
+ * Whether a payload value is already a formatted-text object.
207
+ * @param {*} value
208
+ * @returns {boolean}
209
+ */
210
+ function isFormattedShape(value) {
211
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
212
+ if (!Object.prototype.hasOwnProperty.call(value, "value")) return false;
213
+ return ["format", "summary", "processed"].some((k) => Object.prototype.hasOwnProperty.call(value, k));
214
+ }
215
+
216
+ /**
217
+ * @param {*} value
218
+ * @returns {string|undefined}
219
+ */
220
+ function requestedFormatOf(value) {
221
+ if (value && typeof value === "object" && !Array.isArray(value)
222
+ && Object.prototype.hasOwnProperty.call(value, "format")) {
223
+ return value.format;
224
+ }
225
+ return undefined;
226
+ }
227
+
228
+ /**
229
+ * @param {*} raw
230
+ * @param {string|undefined} format
231
+ * @returns {{value: *, format?: string, summary?: *}}
232
+ */
233
+ function normalizeFormattedValue(raw, format) {
234
+ if (typeof raw === "string") {
235
+ const out = { value: raw };
236
+ if (format !== undefined) out.format = format;
237
+ return out;
238
+ }
239
+ const out = { value: raw.value };
240
+ if (format !== undefined) out.format = format;
241
+ if (Object.prototype.hasOwnProperty.call(raw, "summary")) out.summary = raw.summary;
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Default and validate text formats on a write attribute map (mutates it).
247
+ * Same checks run for dry-run and real writes so a disallowed format never
248
+ * reaches create/update.
249
+ *
250
+ * @param {{backend: object, site: object, entityType: string, bundle: string, attributes: object}} input
251
+ * @returns {Promise<object>} The same attributes object, with formats resolved.
252
+ */
253
+ export async function applyAllowedFormatsToAttributes({
254
+ backend, site, entityType, bundle, attributes,
255
+ }) {
256
+ const names = Object.keys(attributes).filter((name) => !SKIP_FORMAT_FIELDS.has(name));
257
+ for (const fieldName of names) {
258
+ const value = new Map(Object.entries(attributes)).get(fieldName);
259
+ if (value === undefined || value === null) continue;
260
+ if (typeof value !== "string" && (typeof value !== "object" || Array.isArray(value))) continue;
261
+
262
+ const def = await resolveFieldDefinition(backend, site, entityType, bundle, fieldName);
263
+ const formattedType = Boolean(def?.fieldType && FORMATTED_FIELD_TYPES.has(def.fieldType));
264
+ const restricted = Boolean(def?.allowedFormats?.length);
265
+ const isBody = fieldName === "body";
266
+ const shaped = isFormattedShape(value);
267
+
268
+ if (def && !formattedType && !restricted && !isBody) continue;
269
+ if (!def && !shaped && !isBody) continue;
270
+
271
+ const format = resolveTextFormat({
272
+ fieldName,
273
+ requested: requestedFormatOf(value),
274
+ allowedFormats: def ? def.allowedFormats : null,
275
+ site,
276
+ defaultWhenUnknown: isBody,
277
+ });
278
+
279
+ if (format === undefined && !formattedType && !isBody && !shaped) continue;
280
+ Object.assign(attributes, Object.fromEntries([
281
+ [fieldName, normalizeFormattedValue(value, format)],
282
+ ]));
283
+ }
284
+ return attributes;
285
+ }
@@ -10,6 +10,8 @@
10
10
  * warnings surfaced to users can never drift apart.
11
11
  */
12
12
 
13
+ import { graphqlHasMutation } from "./security.js";
14
+
13
15
  export const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload_",
14
16
  "drupal_block_", "drupal_drush_cache", "drupal_drush_cron",
15
17
  "drupal_drush_config_export", "drupal_drush_config_import",
@@ -39,8 +41,13 @@ export function inferOperation(toolName) {
39
41
  // Generic entity tools carry their target type/bundle in ARGS, not the tool name,
40
42
  // so the security middleware gates them inside their handlers (assertDeleteAllowed /
41
43
  // assertWriteAllowed with full context) and inferOperation() intentionally leaves
42
- // them classified "read". For the user-facing confirm-first WARNING, though, we
43
- // still want the destructive hint to show, so classify them by name here.
44
+ // them classified "read". Write-site gating and the user-facing confirm-first
45
+ // WARNING still classify them by name.
46
+ const ENTITY_WRITE_TOOLS = new Set([
47
+ "drupal_entity_create",
48
+ "drupal_entity_update",
49
+ "drupal_entity_delete",
50
+ ]);
44
51
  const DESTRUCTIVE_ENTITY_TOOLS = new Set(["drupal_entity_delete"]);
45
52
 
46
53
  /**
@@ -55,3 +62,33 @@ const DESTRUCTIVE_ENTITY_TOOLS = new Set(["drupal_entity_delete"]);
55
62
  export function isDestructiveTool(toolName) {
56
63
  return inferOperation(toolName) === "delete" || DESTRUCTIVE_ENTITY_TOOLS.has(toolName);
57
64
  }
65
+
66
+ /**
67
+ * Whether a tool is a write or delete regardless of how inferOperation classifies it.
68
+ * Covers the generic `drupal_entity_{create,update,delete}` tools that self-gate
69
+ * inside their handlers and are therefore left as "read" by prefix inference.
70
+ *
71
+ * @param {string} toolName - The MCP tool name.
72
+ * @returns {boolean}
73
+ */
74
+ export function isWriteLikeTool(toolName) {
75
+ if (ENTITY_WRITE_TOOLS.has(toolName)) return true;
76
+ const op = inferOperation(toolName);
77
+ return op === "write" || op === "delete";
78
+ }
79
+
80
+ /**
81
+ * Whether this invocation mutates the target. GraphQL is a write only when the
82
+ * document contains a mutation; a query is still a read.
83
+ *
84
+ * @param {string} toolName
85
+ * @param {object} [args]
86
+ * @returns {boolean}
87
+ */
88
+ export function isWriteLikeCall(toolName, args = {}) {
89
+ if (isWriteLikeTool(toolName)) return true;
90
+ if (inferOperation(toolName) === "graphql" && typeof args.query === "string") {
91
+ return graphqlHasMutation(args.query);
92
+ }
93
+ return false;
94
+ }