drupal-mcp-connector 2.1.1 → 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,12 +1,12 @@
1
1
  ---
2
- description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type."
2
+ description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type. Requires security.allowGraphql (off outside the development preset by default)."
3
3
  argument-hint: "[site] [typeName]"
4
4
  allowed-tools: mcp__drupal__drupal_graphql_introspect
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_graphql_introspect` MCP tool.
8
8
 
9
- Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type.
9
+ Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type. Requires security.allowGraphql (off outside the development preset by default).
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: "Execute a GraphQL query against a Drupal site. Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which exposes a read-only schema; mutations are gated by \"allowGraphqlMutations\". Use drupal_graphql_introspect first to discover available types and fields. Example query: query GetArticle($id: String!) { nodeById(id: $id) { title ... on NodeArticle { body { value } } } } Example mutation (only if your GraphQL Compose schema enables mutations): mutation CreateArticle($title: String!, $body: String!) { createNodeArticle(data: { title: $title, body: { value: $body, format: \"full_html\" } }) { entity { title uuid } errors { message } } }"
2
+ description: "Execute a GraphQL query against a Drupal site. Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which exposes a read-only schema. GraphQL is off unless security.allowGraphql is true (development preset only by default) because raw results bypass entity allowlists and field redaction. Mutations also require allowGraphqlMutations. Use drupal_graphql_introspect first to discover available types and fields. Example query: query GetArticle($id: String!) { nodeById(id: $id) { title ... on NodeArticle { body { value } } } } Example mutation (only if your GraphQL Compose schema enables mutations): mutation CreateArticle($title: String!, $body: String!) { createNodeArticle(data: { title: $title, body: { value: $body, format: \"full_html\" } }) { entity { title uuid } errors { message } } }"
3
3
  argument-hint: "<query> [site] [variables] [operationName]"
4
4
  allowed-tools: mcp__drupal__drupal_graphql
5
5
  ---
@@ -8,7 +8,9 @@ Call the `mcp__drupal__drupal_graphql` MCP tool.
8
8
 
9
9
  Execute a GraphQL query against a Drupal site.
10
10
  Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which
11
- exposes a read-only schema; mutations are gated by "allowGraphqlMutations".
11
+ exposes a read-only schema. GraphQL is off unless security.allowGraphql is true
12
+ (development preset only by default) because raw results bypass entity allowlists
13
+ and field redaction. Mutations also require allowGraphqlMutations.
12
14
  Use drupal_graphql_introspect first to discover available types and fields.
13
15
 
14
16
  Example query:
@@ -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,80 @@ 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
+
60
+ ## [2.2.0] - 2026-07-30
61
+
62
+ ### Security
63
+ - **Default security preset is `production-strict` (#140).** Omitting `security`
64
+ or passing `{}` no longer opens the site under the `development` preset.
65
+ Local and integration work must set `"preset": "development"` (or another
66
+ explicit preset) deliberately. Migration: add
67
+ `"security": { "preset": "development" }` to any site config that relied on
68
+ the old open default.
69
+ - **GraphQL tools fail closed (#142).** `drupal_graphql` and
70
+ `drupal_graphql_introspect` require `security.allowGraphql` (true only on the
71
+ `development` preset). Mutations still need `allowGraphqlMutations` as well.
72
+ When GraphQL is opted in, results remain raw (no entity allowlist/redaction
73
+ on that path); prefer JSON:API entity tools for policy-bound reads.
74
+ - **Dependency advisories cleared (#128).** Bump
75
+ `@modelcontextprotocol/sdk` to `^1.30.0` and pin overrides for
76
+ `@hono/node-server` `^2.0.5` and `postcss` `^8.5.18` so `npm audit` reports
77
+ zero vulnerabilities.
78
+
79
+ ### Documentation
80
+ - README, security guide, threat model, architecture, tools reference, and
81
+ SECURITY.md updated for the secure default, GraphQL gate, and residual-risk
82
+ table.
83
+
10
84
  ## [2.1.1] - 2026-07-31
11
85
 
12
86
  ### Documentation
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
 
@@ -127,9 +134,9 @@ prompts above.
127
134
  ### Security Model
128
135
 
129
136
  Defense-in-depth presets, enforced connector-side and complemented by Drupal-side
130
- governance (MCP Sentinel). **Set an explicit preset on every non-dev site** —
131
- if `security` is omitted, the connector still defaults to `development` (tracked
132
- for change in issue #140).
137
+ governance (MCP Sentinel). **Default when `security` is omitted:**
138
+ `production-strict` (read-only, sensitive types denied). Local development must
139
+ set `"preset": "development"` explicitly.
133
140
 
134
141
  ```json
135
142
  "security": { "preset": "auditor" }
@@ -137,20 +144,20 @@ for change in issue #140).
137
144
 
138
145
  | Preset | What it does |
139
146
  |--------|-------------|
140
- | `development` | Everything allowed — local development only |
147
+ | `development` | Everything allowed, including GraphQL — local development only |
141
148
  | `content-editor` | Create/edit content + structural entities; no deletes; no publishing; secrets/governance/account types denied |
142
149
  | `config-editor` | content-editor + site-building config read + governed config write (developer tier) |
143
150
  | `auditor` | Read-only; secrets/governance/account types denied; user PII redacted when user is allowed |
144
- | `production-strict` | Read-only; same sensitive denylist as auditor; broad PII field redaction |
145
- | `write-plane` | Create/update content set for agents; no deletes; no GraphQL mutations; publish off by default |
151
+ | `production-strict` | Read-only; same sensitive denylist as auditor; broad PII field redaction (**default**) |
152
+ | `write-plane` | Create/update content set for agents; no deletes; no GraphQL; publish off by default |
146
153
 
147
- Additional connector-side gates (2.1+):
154
+ Additional connector-side gates (2.1+ / 2.2+):
148
155
 
149
156
  - **Entity allowlists** apply to specialized tools (`drupal_*_node`, media, taxonomy), not only `drupal_entity_*`.
150
157
  - **Publish gate:** `status: true` and `moderation_state: "published"` require `allowPublish`. Media create defaults **unpublished**. Published moderated node updates without a moderation state default to **draft** (forward revision).
151
158
  - **Uploads** only from `MCP_UPLOAD_ROOT` (or the process cwd); sensitive paths (`.env*`, `.ssh`, connector `config.json`) are refused.
152
159
  - **HTTPS:** non-loopback binds require `MCP_AUTH_TOKEN` (or `MCP_ALLOW_UNAUTHENTICATED=1` behind a trusted proxy); non-loopback TLS defaults to 120 req/min rate limiting.
153
- - **GraphQL caveat:** `drupal_graphql` returns raw query data connector allowlists/redaction do not apply to that path (issue #142).
160
+ - **GraphQL is off by default.** `drupal_graphql` / introspect require `security.allowGraphql` (true only on the `development` preset). Raw GraphQL results still bypass entity allowlists and field redaction prefer JSON:API entity tools when connector policy must hold. Mutations also need `allowGraphqlMutations`.
154
161
 
155
162
  Full detail: **[docs/security.md](docs/security.md)** and **[docs/security-hardening.md](docs/security-hardening.md)**.
156
163
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.1.1",
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",
@@ -58,16 +58,20 @@
58
58
  "syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f ✓\"; done"
59
59
  },
60
60
  "dependencies": {
61
- "@modelcontextprotocol/sdk": "^1.0.0",
61
+ "@modelcontextprotocol/sdk": "^1.30.0",
62
62
  "graphql": "^17.0.0",
63
63
  "node-fetch": "^3.3.2",
64
64
  "ssh2": "^1.16.0"
65
65
  },
66
66
  "devDependencies": {
67
67
  "eslint": "^10.4.1",
68
- "eslint-plugin-security": "^4.0.0",
69
68
  "eslint-plugin-n": "^18.1.0",
69
+ "eslint-plugin-security": "^4.0.0",
70
70
  "globals": "^17.6.0",
71
71
  "vitest": "^4.1.8"
72
+ },
73
+ "overrides": {
74
+ "@hono/node-server": "^2.0.5",
75
+ "postcss": "^8.5.18"
72
76
  }
73
77
  }
@@ -12,13 +12,14 @@ import { parse } from "graphql";
12
12
  *
13
13
  * ─── Quick presets ────────────────────────────────────────────────────────
14
14
  *
15
- * "preset": "development" Everything allowed. Default if no security key.
15
+ * "preset": "development" Everything allowed. Opt-in only set explicitly.
16
16
  * "preset": "content-editor" Create/edit content (nodes, media, terms, paragraphs, blocks,
17
17
  * menu links, redirects, aliases, files). No deletes. Config read-only.
18
18
  * "preset": "config-editor" content-editor + site-building config READ + governed config
19
19
  * read/write (Developer tier). Model changes go via the config bridge.
20
- * "preset": "auditor" Read-only. All entity types. User fields redacted.
21
- * "preset": "production-strict" Read-only. Explicit allowlist required. Redacts PII.
20
+ * "preset": "auditor" Read-only. Sensitive entity types denied. User fields redacted.
21
+ * "preset": "production-strict" Read-only. Sensitive types denied. Redacts PII. **Default** when
22
+ * security is omitted or has no preset (#140).
22
23
  * "preset": "write-plane" Governed writes (no delete/mutations) on the content set
23
24
  * (node, term, media + structural content entities).
24
25
  *
@@ -31,7 +32,8 @@ import { parse } from "graphql";
31
32
  *
32
33
  * readOnly true → reject all create/update/delete/graphql-mutation calls
33
34
  * allowDestructive false → reject all delete operations
34
- * allowPublish false → reject a write carrying status:true (publishing)
35
+ * allowPublish false → reject a write carrying status:true / moderation_state:published
36
+ * allowGraphql false → reject drupal_graphql + introspect (raw GraphQL is policy-free, #142)
35
37
  * allowGraphqlMutations false → reject drupal_graphql when mutation is detected
36
38
  * allowConfigRead false → reject drupal_config_get / drupal_config_list
37
39
  * allowConfigWrite false → reject drupal_config_set
@@ -118,6 +120,7 @@ const PRESETS = {
118
120
  readOnly: false,
119
121
  allowDestructive: true,
120
122
  allowPublish: true, // mirrors allowDestructive: everything allowed
123
+ allowGraphql: true, // raw GraphQL allowed only in open mode (#142)
121
124
  allowGraphqlMutations: true,
122
125
  allowConfigRead: true,
123
126
  allowConfigWrite: true,
@@ -130,6 +133,7 @@ const PRESETS = {
130
133
  "content-editor": {
131
134
  readOnly: false,
132
135
  allowDestructive: false, // no deletes
136
+ allowGraphql: false, // freeform GraphQL bypasses entity denylists (#142)
133
137
  allowGraphqlMutations: false,
134
138
  allowConfigRead: true, // config read-only
135
139
  allowConfigWrite: false,
@@ -151,6 +155,7 @@ const PRESETS = {
151
155
  // The Drupal-side governance layer remains authoritative; this is defence in depth.
152
156
  readOnly: false,
153
157
  allowDestructive: false, // no deletes
158
+ allowGraphql: false,
154
159
  allowGraphqlMutations: false,
155
160
  allowConfigRead: true,
156
161
  allowConfigWrite: true, // governed config writes via drupal_config_set
@@ -171,6 +176,7 @@ const PRESETS = {
171
176
  auditor: {
172
177
  readOnly: true,
173
178
  allowDestructive: false,
179
+ allowGraphql: false,
174
180
  allowGraphqlMutations: false,
175
181
  allowConfigRead: true, // read-only inspection of config
176
182
  allowConfigWrite: false,
@@ -189,6 +195,7 @@ const PRESETS = {
189
195
  "production-strict": {
190
196
  readOnly: true,
191
197
  allowDestructive: false,
198
+ allowGraphql: false,
192
199
  allowGraphqlMutations: false,
193
200
  allowConfigRead: false, // nothing implicit; opt in per site
194
201
  allowConfigWrite: false,
@@ -203,6 +210,7 @@ const PRESETS = {
203
210
  // Drupal-side governance layer remains authoritative; this is defence in depth.
204
211
  readOnly: false,
205
212
  allowDestructive: false, // no deletes
213
+ allowGraphql: false, // JSON:API write plane; GraphQL is policy-free (#142)
206
214
  allowGraphqlMutations: false, // writes go through the JSON:API plane
207
215
  allowConfigRead: true, // config read-only
208
216
  allowConfigWrite: false,
@@ -225,15 +233,22 @@ const PRESETS = {
225
233
  * @param {object} site Site config (reads site.security).
226
234
  * @returns {object} Effective security config used by the assert/redact helpers.
227
235
  */
236
+ /** Default when `security.preset` is omitted — least privilege, not open mode (#140). */
237
+ export const DEFAULT_SECURITY_PRESET = "production-strict";
238
+
228
239
  export function resolveSecurityConfig(site) {
229
240
  const raw = site.security ?? {};
230
- const preset = PRESETS[raw.preset ?? "development"] ?? PRESETS.development;
241
+ // Explicit preset wins; bare {} / missing security → production-strict (#140).
242
+ // Open mode requires `preset: "development"` (or another named preset).
243
+ const presetName = raw.preset ?? DEFAULT_SECURITY_PRESET;
244
+ const preset = PRESETS[presetName] ?? PRESETS[DEFAULT_SECURITY_PRESET];
231
245
 
232
246
  // Merge: explicit keys in site.security override the preset
233
247
  return {
234
248
  readOnly: raw.readOnly ?? preset.readOnly,
235
249
  allowDestructive: raw.allowDestructive ?? preset.allowDestructive,
236
250
  allowPublish: raw.allowPublish ?? preset.allowPublish ?? false,
251
+ allowGraphql: raw.allowGraphql ?? preset.allowGraphql ?? false,
237
252
  allowGraphqlMutations: raw.allowGraphqlMutations ?? preset.allowGraphqlMutations,
238
253
  allowConfigRead: raw.allowConfigRead ?? preset.allowConfigRead ?? false,
239
254
  allowConfigWrite: raw.allowConfigWrite ?? preset.allowConfigWrite ?? false,
@@ -450,7 +465,30 @@ function graphqlHasMutation(query) {
450
465
  * @returns {void} No-op for read-only (query) documents.
451
466
  * @throws {SecurityError} if the document is a mutation and writes/mutations are disabled.
452
467
  */
468
+ /**
469
+ * Gate GraphQL queries and schema introspection. Freeform GraphQL returns raw
470
+ * data that does not pass through entity allowlists or field redaction (#142),
471
+ * so it is off by default outside the development preset. Opt in with
472
+ * `security.allowGraphql: true`.
473
+ *
474
+ * @param {object} secConfig Resolved security config.
475
+ * @param {string} [operationLabel] Label for the error message.
476
+ * @returns {void}
477
+ * @throws {SecurityError} if GraphQL is disabled for this site.
478
+ */
479
+ export function assertGraphqlAllowed(secConfig, operationLabel = "graphql") {
480
+ if (secConfig.allowGraphql) return;
481
+ throw new SecurityError(
482
+ "GraphQL tools are disabled for this site (allowGraphql: false). " +
483
+ "Raw GraphQL responses bypass connector entity allowlists and field redaction. " +
484
+ `Blocked: ${operationLabel}. ` +
485
+ "Set security.allowGraphql = true to enable (prefer development preset or an explicit opt-in)."
486
+ );
487
+ }
488
+
453
489
  export function assertGraphqlMutationAllowed(secConfig, query) {
490
+ // Queries still require allowGraphql (#142); mutations require both flags.
491
+ assertGraphqlAllowed(secConfig, "graphql mutation");
454
492
  const isMutation = graphqlHasMutation(query);
455
493
  if (!isMutation) return;
456
494
 
@@ -681,9 +719,11 @@ export function getSecuritySummary(site) {
681
719
  const cfg = resolveSecurityConfig(site);
682
720
  return {
683
721
  site: site._name,
684
- preset: site.security?.preset ?? "development (default)",
722
+ preset: site.security?.preset ?? `${DEFAULT_SECURITY_PRESET} (default)`,
685
723
  readOnly: cfg.readOnly,
686
724
  allowDestructive: cfg.allowDestructive,
725
+ allowPublish: cfg.allowPublish,
726
+ allowGraphql: cfg.allowGraphql,
687
727
  allowGraphqlMutations: cfg.allowGraphqlMutations,
688
728
  allowConfigRead: cfg.allowConfigRead,
689
729
  allowConfigWrite: cfg.allowConfigWrite,
@@ -3,9 +3,12 @@
3
3
  *
4
4
  * Raw GraphQL execution and schema introspection against a Drupal site. Require
5
5
  * the GraphQL Compose module (drupal.org/project/graphql_compose), which
6
- * exposes a read-only schema (no mutations); any mutation in a query is
7
- * additionally gated by the per-site "allowGraphqlMutations" security flag,
8
- * enforced by the middleware in index.js before the handler runs.
6
+ * exposes a read-only schema (no mutations).
7
+ *
8
+ * Policy: responses are raw GraphQL data they do **not** pass connector entity
9
+ * allowlists or field redaction (#142). Tools are gated by `allowGraphql`
10
+ * (false outside the development preset unless opted in). Mutations also require
11
+ * `allowGraphqlMutations` (enforced in the handler and by index.js middleware).
9
12
  *
10
13
  * Per-site config: set "graphqlEndpoint" to override "/graphql".
11
14
  * Auth reuses the same credentials as the JSON:API tools.
@@ -13,6 +16,9 @@
13
16
 
14
17
  import { getSiteConfig } from "../lib/config.js";
15
18
  import { drupalGraphqlFetch } from "../lib/drupal-fetch.js";
19
+ import {
20
+ resolveSecurityConfig, assertGraphqlAllowed, assertGraphqlMutationAllowed,
21
+ } from "../lib/security.js";
16
22
 
17
23
  // ---------------------------------------------------------------------------
18
24
  // Implementations
@@ -29,6 +35,9 @@ import { drupalGraphqlFetch } from "../lib/drupal-fetch.js";
29
35
  */
30
36
  async function runGraphql({ site: siteName, query, variables = {}, operationName }) {
31
37
  const site = getSiteConfig(siteName);
38
+ const sec = resolveSecurityConfig(site);
39
+ assertGraphqlAllowed(sec, "drupal_graphql");
40
+ assertGraphqlMutationAllowed(sec, query);
32
41
  const json = await drupalGraphqlFetch(site, { query, variables, operationName });
33
42
 
34
43
  if (json.errors?.length) {
@@ -54,6 +63,7 @@ async function runGraphql({ site: siteName, query, variables = {}, operationName
54
63
  */
55
64
  async function introspectGraphql({ site: siteName, typeName }) {
56
65
  const site = getSiteConfig(siteName);
66
+ assertGraphqlAllowed(resolveSecurityConfig(site), "drupal_graphql_introspect");
57
67
 
58
68
  // If a specific type is requested, get detailed field info for it.
59
69
  if (typeName) {
@@ -128,7 +138,9 @@ export const definitions = [
128
138
  name: "drupal_graphql",
129
139
  description: `Execute a GraphQL query against a Drupal site.
130
140
  Requires the GraphQL Compose module (drupal.org/project/graphql_compose), which
131
- exposes a read-only schema; mutations are gated by "allowGraphqlMutations".
141
+ exposes a read-only schema. GraphQL is off unless security.allowGraphql is true
142
+ (development preset only by default) because raw results bypass entity allowlists
143
+ and field redaction. Mutations also require allowGraphqlMutations.
132
144
  Use drupal_graphql_introspect first to discover available types and fields.
133
145
 
134
146
  Example query:
@@ -158,7 +170,7 @@ Example mutation (only if your GraphQL Compose schema enables mutations):
158
170
  },
159
171
  {
160
172
  name: "drupal_graphql_introspect",
161
- description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type.",
173
+ description: "Introspect the Drupal GraphQL schema. Omit typeName for a full schema overview; provide typeName to get detailed fields and args for a specific type. Requires security.allowGraphql (off outside the development preset by default).",
162
174
  inputSchema: {
163
175
  type: "object",
164
176
  properties: {
@@ -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." },