drupal-mcp-connector 2.2.0 → 2.2.1

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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: "Create a new content node. Returns the new node UUID, integer ID, and URL. For content types under an editorial (content_moderation) workflow, set moderationState (e.g. 'draft'/'published') instead of status. Entity-reference fields (taxonomy terms, related content, media) go in `relationships`, not `fields`."
3
- argument-hint: "<type> <title> [site] [body] [summary] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
3
+ argument-hint: "<type> <title> [site] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_create_node
5
5
  ---
6
6
 
@@ -17,7 +17,8 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
17
17
  **Optional:**
18
18
  - `site` (string): omit for the default site
19
19
  - `body` (string): Body field HTML
20
- - `summary` (string): Body summary / teaser
20
+ - `summary` (string): 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.
21
+ - `format` (string): 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.
21
22
  - `status` (boolean (true/false)): Published flag for NON-moderated types. true to publish immediately. Ignored if moderationState is set; on a moderated type it is dropped automatically.
22
23
  - `moderationState` (string): Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status.
23
24
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by Drupal machine name. Do NOT put entity-reference fields here — Drupal rejects them as attributes; use `relationships`.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  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`."
3
- argument-hint: "<type> <id> [site] [title] [body] [summary] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
3
+ argument-hint: "<type> <id> [site] [title] [body] [summary] [format] [status] [moderationState] [fields] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_update_node
5
5
  ---
6
6
 
@@ -18,7 +18,8 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
18
18
  - `site` (string): omit for the default site
19
19
  - `title` (string)
20
20
  - `body` (string)
21
- - `summary` (string)
21
+ - `summary` (string): 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.
22
+ - `format` (string): 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.
22
23
  - `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
23
24
  - `moderationState` (string): 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'.
24
25
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.1] - 2026-08-02
11
+
12
+ ### Fixed
13
+ - **The body text format is no longer hardcoded to `full_html`.** Node writes
14
+ that used the `body` convenience parameter always sent `format: "full_html"`,
15
+ so the connector could not write a body at all on a site whose text formats
16
+ omit it — and on sites that do define it, content was silently written into
17
+ the most permissive core format. Writes now resolve the format explicitly: a
18
+ new per-call `format` argument, then the site config's `defaultTextFormat`,
19
+ then `full_html` as the unchanged last-resort fallback. A text format is
20
+ Drupal's HTML-filtering boundary, so it should be a decision rather than an
21
+ assumption.
22
+ - **A body-only update no longer blanks an existing body summary.** The body
23
+ descriptor set `summary: ""` whenever no summary was supplied, so updating
24
+ just the body erased the summary as a side effect. The property is now sent
25
+ only when the caller supplies it; passing `summary: ""` still clears it
26
+ deliberately.
27
+
28
+ ### Changed
29
+ - **`summary` documents what it actually targets.** The parameter writes the
30
+ `summary` property of the core `text_with_summary` body field. Headless sites
31
+ commonly use a dedicated summary/deck field for teasers and meta descriptions
32
+ instead; on those, the value written here is stored but may never be rendered.
33
+ Both node tools now say so, and point at `fields` for the dedicated field.
34
+
35
+ ### Added
36
+ - **`@claude` GitHub Action (inline).** Maintainers can tag `@claude` on an
37
+ issue or PR and Claude acts on the repo and opens a PR. Only
38
+ owner/member/collaborator authors trigger it; bot actors are excluded; the
39
+ job runs on `ubuntu-latest`, stops at opening a PR, and adds no AI
40
+ attribution. Inert until the Claude GitHub App is installed and the
41
+ `CLAUDE_CODE_OAUTH_TOKEN` org secret is made visible to this repo.
42
+ - **CI: No AI attribution gate.** Pull requests fail when commits, the
43
+ PR title, or the PR body credit AI with authorship (shared Wilkes & Liberty
44
+ drop-in). Covers server-side paths that local hooks cannot see.
45
+
46
+ ### Changed
47
+ - **CI: the attribution gate reads its scripts from the base commit.** A
48
+ pull request can no longer supply the code that decides whether it passes.
49
+
50
+ ### Fixed
51
+ - **CI: the `@claude` workflow can fetch its OIDC token.** The action exchanges
52
+ a GitHub OIDC token for its app credentials in every auth mode; without
53
+ `id-token: write` the first activation run failed before reaching Claude.
54
+ - **CI: the attribution gate no longer fails on clean commits.** The stripper
55
+ compared each commit message against a copy that had gained a trailing newline,
56
+ so every commit looked modified and the run ended with `strip count > 0 but tip
57
+ unchanged`.
58
+
59
+
10
60
  ## [2.2.0] - 2026-07-30
11
61
 
12
62
  ### Security
package/README.md CHANGED
@@ -44,6 +44,13 @@ Each site declares which backend(s) it exposes via the `api` key:
44
44
  `{ id, entityType, bundle, title, status, langcode, created, changed, url, fields, relationships, _backend }`, so tool output is identical regardless of protocol.
45
45
  - **Capability-aware.** Each backend advertises what it supports (read, write, delete, server-side filter/sort, revisions). GraphQL via GraphQL Compose is **read-only** (no mutations) and has no server-side field filter, so filters are applied client-side over a bounded fetch and flagged `approximate`/`truncated`. Write tools against a read-only backend return a clear capability error rather than failing silently.
46
46
  - **Writes go through JSON:API.** Use a JSON:API-enabled site as the write plane; keep GraphQL as a read plane where that suits your architecture.
47
+ - **`defaultTextFormat` sets the body text format** used by the `body` convenience
48
+ parameter on node writes, e.g. `{ "baseUrl": "…", "defaultTextFormat": "basic_html" }`.
49
+ Individual calls can override it with `format`. Without either, the connector falls
50
+ back to `full_html`, which many governed sites deliberately do not define — and which
51
+ is the most permissive core format, so setting this per site is the safer posture. A
52
+ text format is Drupal's HTML-filtering boundary, so it is worth choosing deliberately
53
+ rather than inheriting.
47
54
 
48
55
  See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction and **[docs/graphql-local-setup.md](docs/graphql-local-setup.md)** for the GraphQL specifics.
49
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -127,18 +127,43 @@ async function createRenameRedirect(backend, sec, redirect) {
127
127
  }
128
128
  }
129
129
 
130
+ /**
131
+ * Fallback text format when neither the call nor the site config names one.
132
+ *
133
+ * Retained for backward compatibility only. A site whose text formats do not
134
+ * include `full_html` — or whose agent account may not use it — must set
135
+ * `defaultTextFormat` in its site config or pass `format` per call.
136
+ */
137
+ const FALLBACK_TEXT_FORMAT = "full_html";
138
+
130
139
  /**
131
140
  * Build a Drupal body field descriptor from plain HTML + optional summary.
132
141
  *
142
+ * The text format is a security boundary in Drupal (it decides which HTML
143
+ * survives filtering), so it is resolved explicitly rather than assumed:
144
+ * an explicit `format` argument wins, then the site's `defaultTextFormat`,
145
+ * then the historical fallback.
146
+ *
147
+ * `summary` is only included when the caller supplied it. Sending an empty
148
+ * string on every write would blank an existing body summary whenever a
149
+ * caller updated the body alone.
150
+ *
133
151
  * @param {string} [body] - Body HTML; when undefined the field is omitted.
134
- * @param {string} [summary] - Teaser/summary text.
135
- * @returns {{value: string, format: string, summary: string}|undefined}
152
+ * @param {string} [summary] - Teaser/summary text; omitted when undefined.
153
+ * @param {string} [format] - Text format machine name.
154
+ * @param {object} [site] - Site config, read for `defaultTextFormat`.
155
+ * @returns {{value: string, format: string, summary?: string}|undefined}
136
156
  * A body attribute object, or undefined when no body was supplied (so callers
137
157
  * can skip the field on update rather than blanking it).
138
158
  */
139
- function buildBodyAttribute(body, summary) {
159
+ function buildBodyAttribute(body, summary, format, site) {
140
160
  if (body === undefined) return undefined;
141
- return { value: body, format: "full_html", summary: summary ?? "" };
161
+ const attr = {
162
+ value: body,
163
+ format: format ?? site?.defaultTextFormat ?? FALLBACK_TEXT_FORMAT,
164
+ };
165
+ if (summary !== undefined) attr.summary = summary;
166
+ return attr;
142
167
  }
143
168
 
144
169
  /**
@@ -228,10 +253,10 @@ async function searchContent({ site: siteName, query, type, status, limit = 10 }
228
253
  * `relationships` (JSON:API shape), not `fields`; Drupal rejects reference fields
229
254
  * sent as attributes (#115).
230
255
  *
231
- * @param {object} args - { site?, type, title, body?, summary?, status?, moderationState?, fields?, relationships? }.
256
+ * @param {object} args - { site?, type, title, body?, summary?, format?, status?, moderationState?, fields?, relationships? }.
232
257
  * @returns {Promise<object>} The created node descriptor from the backend.
233
258
  */
234
- async function createNode({ site: siteName, type, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
259
+ async function createNode({ site: siteName, type, title, body, summary, format, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
235
260
  const site = getSiteConfig(siteName);
236
261
  const sec = resolveSecurityConfig(site);
237
262
  assertWriteAllowed(sec, "create", "node", type);
@@ -241,7 +266,7 @@ async function createNode({ site: siteName, type, title, body, summary, status,
241
266
  } else {
242
267
  attributes.status = status === undefined ? false : status;
243
268
  }
244
- const bodyAttr = buildBodyAttribute(body, summary);
269
+ const bodyAttr = buildBodyAttribute(body, summary, format, site);
245
270
  if (bodyAttr) attributes.body = bodyAttr;
246
271
  assertPublishAllowed(sec, attributes);
247
272
  if (dryRun) return { dryRun: true, operation: "create", entityType: "node", bundle: type, attributes, relationships };
@@ -279,10 +304,10 @@ async function createNode({ site: siteName, type, title, body, summary, status,
279
304
  *
280
305
  * Entity-reference fields go in `relationships` (JSON:API shape), not `fields` (#115).
281
306
  *
282
- * @param {object} args - { site?, type, id, title?, body?, summary?, status?, moderationState?, fields?, relationships? }.
307
+ * @param {object} args - { site?, type, id, title?, body?, summary?, format?, status?, moderationState?, fields?, relationships? }.
283
308
  * @returns {Promise<object>} The updated node descriptor.
284
309
  */
285
- async function updateNode({ site: siteName, type, id, title, body, summary, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
310
+ async function updateNode({ site: siteName, type, id, title, body, summary, format, status, moderationState, fields = {}, relationships = {}, dryRun = false, returning = "full" }) {
286
311
  const site = getSiteConfig(siteName);
287
312
  const sec = resolveSecurityConfig(site);
288
313
  assertWriteAllowed(sec, "update", "node", type);
@@ -291,7 +316,7 @@ async function updateNode({ site: siteName, type, id, title, body, summary, stat
291
316
  if (title !== undefined) attributes.title = title;
292
317
  if (moderationState !== undefined) attributes.moderation_state = moderationState;
293
318
  else if (status !== undefined) attributes.status = status;
294
- const bodyAttr = buildBodyAttribute(body, summary);
319
+ const bodyAttr = buildBodyAttribute(body, summary, format, site);
295
320
  if (bodyAttr) attributes.body = bodyAttr;
296
321
  // #131: published moderated nodes without an explicit state → draft forward revision.
297
322
  // Runs before the publish gate and on dryRun so previews match the real write.
@@ -390,7 +415,8 @@ export const definitions = [
390
415
  type: { type: "string", description: "Content type machine name" },
391
416
  title: { type: "string" },
392
417
  body: { type: "string", description: "Body field HTML" },
393
- summary: { type: "string", description: "Body summary / teaser" },
418
+ 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." },
419
+ 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." },
394
420
  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." },
395
421
  moderationState: { type: "string", description: "Moderation state for content_moderation types, e.g. 'draft' or 'published'. Takes precedence over status." },
396
422
  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`." },
@@ -411,7 +437,8 @@ export const definitions = [
411
437
  id: { type: "string", description: "Node UUID" },
412
438
  title: { type: "string" },
413
439
  body: { type: "string" },
414
- summary: { type: "string" },
440
+ 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." },
441
+ 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." },
415
442
  status: { type: "boolean", description: "Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set." },
416
443
  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'." },
417
444
  fields: { type: "object", description: "Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here." },