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.
- package/.claude/commands/drupal-create-node.md +3 -2
- package/.claude/commands/drupal-update-node.md +3 -2
- package/CHANGELOG.md +50 -0
- package/README.md +7 -0
- package/package.json +1 -1
- package/src/tools/nodes.js +39 -12
|
@@ -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 /
|
|
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
package/src/tools/nodes.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
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 /
|
|
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." },
|