drupal-mcp-connector 2.6.1 → 2.7.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,12 +1,12 @@
1
1
  ---
2
- description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft."
2
+ description: "Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Paragraph items also return relationshipData with meta.target_revision_id for a later host attach. Writes default to unpublished/draft."
3
3
  argument-hint: "<entityType> <bundle> <items> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_bulk_create
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_bulk_create` MCP tool.
8
8
 
9
- Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Writes default to unpublished/draft.
9
+ Create many entities of a single type + bundle in one call. Permission is checked once; each item is created independently, so the batch continues past individual failures (partial success). Returns per-item { index, success, id | error } and a summary { created, failed }. Paragraph items also return relationshipData with meta.target_revision_id for a later host attach. Writes default to unpublished/draft.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id: <uuid> }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy."
2
+ description: "Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Drupal ERR items are empty without that meta key — do not send {type, id} alone. Before creating paragraphs to attach to a published moderated node, call drupal_list_revisions (possiblyPatchBlocked) and dryRun the host update so a doomed PATCH does not orphan them. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy."
3
3
  argument-hint: "<paragraphType> [site] [attributes]"
4
4
  allowed-tools: mcp__drupal__drupal_create_paragraph
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_create_paragraph` MCP tool.
8
8
 
9
- Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id: <uuid> }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.
9
+ Create a Paragraph entity of a given paragraph type (bundle). Paragraphs are content fragments that are NOT standalone — they must be referenced by a host entity's paragraph / Entity Reference Revisions field. Returns the created paragraph plus `relationshipData` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) to drop into a host field's relationships via drupal_entity_update / drupal_update_node. Drupal ERR items are empty without that meta key — do not send {type, id} alone. Before creating paragraphs to attach to a published moderated node, call drupal_list_revisions (possiblyPatchBlocked) and dryRun the host update so a doomed PATCH does not orphan them. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) first to discover fields. Governed by the site security policy.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision)."
2
+ description: "Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents."
3
3
  argument-hint: "<entityType> <bundle> <id> [site] [attributes] [relationships] [dryRun] [returning]"
4
4
  allowed-tools: mcp__drupal__drupal_entity_update
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_entity_update` MCP tool.
8
8
 
9
- Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision).
9
+ Update an existing entity of any Drupal entity type. Only include attributes/relationships you want to change. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision). Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved. On moderated targets an id-mismatch PATCH preflight runs first (including dryRun) so a core working-copy guard failure is reported before the real write and no revision is saved by the probe (#201). Preflight does not un-orphan paragraphs already created — probe the host before creating dependents.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -19,7 +19,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
19
19
  - `site` (string): omit for the default site
20
20
  - `attributes` (object (pass as JSON))
21
21
  - `relationships` (object (pass as JSON))
22
- - `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
22
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun.
23
23
  - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
24
24
 
25
25
  If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field. Note: paragraphs are referenced (by target_id + target_revision_id in the entity API, or by UUID over JSON:API) from a host field rather than queried standalone in production. Governed by the site security policy."
2
+ description: "Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy."
3
3
  argument-hint: "<paragraphType> <id> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_get_paragraph
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_get_paragraph` MCP tool.
8
8
 
9
- Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph plus a `ref` ({ type: 'paragraph--<bundle>', id }) you can use to embed it in a host entity's paragraph / ERR field. Note: paragraphs are referenced (by target_id + target_revision_id in the entity API, or by UUID over JSON:API) from a host field rather than queried standalone in production. Governed by the site security policy.
9
+ Fetch a single Paragraph entity by paragraph type (bundle) and UUID. Returns the redacted paragraph (fields include drupal_internal__revision_id) plus a `ref` ({ type: 'paragraph--<bundle>', id, meta: { target_revision_id } }) you can use to embed it in a host entity's paragraph / ERR field. Paragraphs are referenced from a host field rather than queried standalone in production. Governed by the site security policy.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. NOTE: JSON:API cannot enumerate full chronological revision history it only addresses revisions by id or the latest/working-copy aliases. Full history enumeration requires the Drush bridge. Use drupal_report_revision_hotspots for per-node revision counts."
2
+ description: "Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge."
3
3
  argument-hint: "<type> <id> [site]"
4
4
  allowed-tools: mcp__drupal__drupal_list_revisions
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_list_revisions` MCP tool.
8
8
 
9
- Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. NOTE: JSON:API cannot enumerate full chronological revision history it only addresses revisions by id or the latest/working-copy aliases. Full history enumeration requires the Drush bridge. Use drupal_report_revision_hotspots for per-node revision counts.
9
+ Surface the addressable revisions of a content node: the latest default revision and the working-copy (forward) revision, with their version ids and links. workingCopy: null is not an all-clear Drupal core can still reject PATCH when a revision row sits above the default without a content_moderation working copy (#201). The payload includes possiblyPatchBlocked (true when default changed is later than its revision_timestamp) plus changed and revisionTimestamp on latestVersion. Probe the host (this flag, then dryRun on the update) before creating dependent paragraphs. NOTE: JSON:API cannot enumerate full chronological revision history. Full history enumeration requires the Drush bridge.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -1,10 +1,10 @@
1
1
  ---
2
- description: "List all named Drupal sites configured in config.json. Useful for multi-site setups."
2
+ description: "List the Drupal sites this principal may address. Each target includes the authoritative site name and base URL."
3
3
  allowed-tools: mcp__drupal__drupal_list_sites
4
4
  ---
5
5
 
6
6
  Call the `mcp__drupal__drupal_list_sites` MCP tool.
7
7
 
8
- List all named Drupal sites configured in config.json. Useful for multi-site setups.
8
+ List the Drupal sites this principal may address. Each target includes the authoritative site name and base URL.
9
9
 
10
10
  This tool takes no arguments — call it directly (ignore `$ARGUMENTS`).
@@ -1,12 +1,12 @@
1
1
  ---
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`."
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`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents."
3
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
 
7
7
  Call the `mcp__drupal__drupal_update_node` MCP tool.
8
8
 
9
- 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`.
9
+ 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`. Paragraph / ERR identifiers are resolved to include meta.target_revision_id before PATCH; the write fails if any ref cannot be resolved (an unresolved identifier persists as an empty field). On moderated targets an id-mismatch PATCH preflight runs first — including on dryRun — so a core working-copy guard failure is reported before the real write and no revision is saved by the probe. workingCopy:null from drupal_list_revisions is not proof the node is writable (possiblyPatchBlocked / #201). Preflight here does not un-orphan paragraphs already created; probe the host before creating dependents.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
@@ -23,8 +23,8 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
23
23
  - `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
24
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'.
25
25
  - `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
26
- - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }.
27
- - `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
26
+ - `relationships` (object (pass as JSON)): Entity-reference fields as JSON:API relationships, keyed by field machine name. Single-value uses { data: { type, id } }; multi-value uses { data: [{ type, id }, …] }. Paragraph / ERR items must carry meta.target_revision_id — the connector injects it when missing, and fails the write if it cannot.
27
+ - `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, and (on moderated targets) run the core PATCH-guard probe against Drupal, then return a preview without the real write. The probe uses a non-matching data.id so Drupal does not save. A working-copy 400 fails the dryRun.
28
28
  - `returning` (string): Response verbosity. "full" (default) returns the complete saved entity; "minimal" returns just identity + state (id, type, bundle, title, status, changed, url) — much smaller, recommended for bulk writes where the echoed body would dominate the response.
29
29
 
30
30
  If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to this paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy."
2
+ description: "Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy."
3
3
  argument-hint: "<paragraphType> <id> [site] [attributes]"
4
4
  allowed-tools: mcp__drupal__drupal_update_paragraph
5
5
  ---
6
6
 
7
7
  Call the `mcp__drupal__drupal_update_paragraph` MCP tool.
8
8
 
9
- Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to this paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.
9
+ Update an existing Paragraph entity's field values by paragraph type (bundle) and UUID. Only the attributes you pass are changed (partial update); the host entity's reference to the paragraph is unchanged (same UUID), so this maintains a component paragraph in place without re-embedding. Returns relationshipData including meta.target_revision_id for a later host attach. Use drupal_get_entity_schema (entityType 'paragraph', the bundle) to discover fields. Governed by the site security policy.
10
10
 
11
11
  Parse the request in `$ARGUMENTS` into this tool's parameters:
12
12
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.7.1] - 2026-08-18
11
+
12
+ ### Fixed
13
+ - **Paragraph ERR attach sends `meta.target_revision_id` (#192).** JSON:API
14
+ only persists an Entity Reference Revisions item when the resource
15
+ identifier carries the current revision id. The connector used to send
16
+ `{ type, id }` and document that Drupal would fill in the vid — Drupal
17
+ does not, and the field is saved empty. Host writes
18
+ (`drupal_update_node`, `drupal_entity_update`, bulk update) now resolve
19
+ each paragraph identifier (preferring a vid from the create response)
20
+ and fail the whole write if any ref cannot be resolved. Create/update/get
21
+ paragraph tools return `relationshipData` / `ref` with that meta key, and
22
+ paragraph reads surface `drupal_internal__revision_id`. An empty array is
23
+ still an explicit clear.
24
+ - **Write responses no longer treat the canonical re-read as proof a
25
+ relationship landed (#169).** When relationships were sent, the returned
26
+ body is the `rel:working-copy` revision when addressable, otherwise the
27
+ PATCH body plus `_revision.relationshipsUnverified`.
28
+ - **PATCH preflight for the core working-copy guard (#201).**
29
+ `drupal_list_revisions` reports `possiblyPatchBlocked` when the default
30
+ revision's `changed` is later than its `revision_timestamp`, and never
31
+ treats `workingCopy: null` as an all-clear. `drupal_update_node` /
32
+ `drupal_entity_update` (and their `dryRun`) probe the same canonical
33
+ URL before the real write. The probe PATCH uses a non-matching `data.id`
34
+ so core's working-copy guard still runs and `$entity->save()` does not
35
+ (an empty-body 2xx would have written a revision). A core working-copy
36
+ 400 is rewritten to say the stored entity is not the latest revision,
37
+ the JSON:API aliases cannot show the blocking row, and clearing it is
38
+ revision surgery outside JSON:API (Drush / the entity API). Preflight
39
+ on the host write does not un-orphan paragraphs already created — probe
40
+ the host (`possiblyPatchBlocked`, then `dryRun`) *before*
41
+ `drupal_create_paragraph`.
42
+
43
+ ## [2.7.0] - 2026-08-17
44
+
45
+ ### Security
46
+ - **Discovery and invocation follow the inbound principal (#178).** On
47
+ HTTPS resource-server requests the validated JWT — not caller `site`,
48
+ `environment`, `tenant`, `target`, or `scope` arguments — decides which
49
+ tools, resources, prompts, and sites are visible or callable. Empty
50
+ inbound scopes are no grants. `auth.grants` maps a client id to site
51
+ names; a present map is fail-closed for unknown clients. GraphQL and
52
+ raw SQL stay hidden unless an entitled site's preset actually allows
53
+ them. Stdio and loopback without a resource-server identity keep the
54
+ existing local-operator surface. `drupal_list_sites` now also returns
55
+ `targets` with the authoritative name and base URL.
56
+ - **Network-facing HTTPS is an OAuth protected resource (#177).** `/mcp`
57
+ validates inbound JWTs against a configured issuer (RFC 8414 / OIDC
58
+ discovery + JWKS): issuer, audience/resource, expiry and required scopes.
59
+ RFC 9728 metadata is served at `/.well-known/oauth-protected-resource`.
60
+ A revocation file (`jti` / `sub`) is re-read when it changes, so a revoke
61
+ does not require a restart. Optional RFC 7662 introspection is fail-closed
62
+ when configured. Discovery requires the metadata `issuer` to match the
63
+ configured identifier (RFC 8414 §3.3), uses the RFC 8414 well-known path
64
+ for issuers that have a path component, and refuses HTTP issuers,
65
+ `jwks_uri`s, and introspection URLs. Trailing slashes on the issuer
66
+ identifier do not break JWT verification. RFC 9728 `authorization_servers`
67
+ advertises the issuer string returned by discovery. A thrown authenticator, a corrupt revocation file, or a
68
+ failed introspection returns `401` instead of hanging the request.
69
+ Caller-supplied identity headers never become the principal. The inbound
70
+ access token is never forwarded to Drupal. `MCP_AUTH_TOKEN` remains valid
71
+ only on loopback; a network-facing bind that still relies on the shared
72
+ secret refuses to start.
73
+
74
+ ### Changed
75
+ - **Versioning policy states the post-1.0 guarantees it actually operates under
76
+ (#195).** `docs/versioning.md` still opened with a pre-1.0 section explaining
77
+ that minor releases might break the stable surface because the project was on
78
+ `0.x`, and pointed at a closed tracking issue. The package has been past 1.0
79
+ for some time, so the document told operators the compatibility guarantees
80
+ were not yet in force when they were. The stable-surface list, deprecation
81
+ policy and runtime support were already correct and are unchanged.
82
+
83
+ ### Fixed
84
+ - **Whitepaper no longer calls the companion governance module pre-1.0/alpha
85
+ (#195).** `docs/whitepaper.md` described `drupal/mcp_sentinel` as alpha and
86
+ requiring Drupal `^10.3 || ^11`. It is a shipping module at 2.9.0 with a floor
87
+ of `^10.6 || ^11.3` on PHP 8.3 or newer — the stale constraint would have sent
88
+ a reader to install it on a combination it does not support.
89
+
10
90
  ## [2.6.1] - 2026-08-17
11
91
 
12
92
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
11
11
 
12
- **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.6.1**, or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
12
+ **If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.7.1** (or at least 2.6.1), or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
13
13
 
14
14
  ---
15
15
 
@@ -158,7 +158,7 @@ Additional connector-side gates (2.1+ / 2.2+):
158
158
  - **Entity allowlists** apply to specialized tools (`drupal_*_node`, media, taxonomy), not only `drupal_entity_*`.
159
159
  - **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).
160
160
  - **Uploads** only from `MCP_UPLOAD_ROOT` (or the process cwd); sensitive paths (`.env*`, `.ssh`, connector `config.json`) are refused.
161
- - **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.
161
+ - **HTTPS:** non-loopback binds require an inbound OAuth resource server (`auth.issuer` + `auth.audience`), not `MCP_AUTH_TOKEN`. `MCP_AUTH_TOKEN` remains valid on loopback. `MCP_ALLOW_UNAUTHENTICATED=1` is only for a trusted proxy. Non-loopback TLS defaults to 120 req/min rate limiting. Inbound JWT scopes (and optional `auth.grants`) filter which tools, resources, prompts, and sites a principal can discover or invoke.
162
162
  - **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`.
163
163
 
164
164
  Full detail: **[docs/security.md](docs/security.md)** and **[docs/security-hardening.md](docs/security-hardening.md)**.
@@ -7,7 +7,7 @@
7
7
  },
8
8
 
9
9
  "_security_options": {
10
- "_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_AUTH_TOKEN requires bearer auth on the HTTPS /mcp endpoint; MCP_BIND_HOST restricts the listen interface (with TLS). See docs/security-hardening.md."
10
+ "_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. See docs/security-hardening.md."
11
11
  },
12
12
 
13
13
  "_governance_tiers": {
@@ -23,6 +23,21 @@
23
23
  "port": 3443
24
24
  },
25
25
 
26
+ "auth": {
27
+ "_comment": "Inbound OAuth resource-server for MCP_TRANSPORT=https when the process binds beyond loopback. The issuer is any standards-based authorization server (RFC 8414 / OIDC) — not a vendor-specific product. Tokens are JWTs validated against the issuer's JWKS. Optional: revocationFile (JSON {jti,sub}, re-read without restart) and introspectionUrl (RFC 7662). Env overrides: MCP_RESOURCE_ISSUER, MCP_RESOURCE_AUDIENCE, MCP_RESOURCE, MCP_REVOCATION_FILE, MCP_INTROSPECTION_URL.",
28
+ "issuer": "https://idp.example.com",
29
+ "audience": "https://mcp.example.com/mcp",
30
+ "resource": "https://mcp.example.com/mcp",
31
+ "requiredScopes": ["mcp_read"],
32
+ "grants": {
33
+ "_comment": "Optional. Map inbound OAuth client_id (azp) to site names, e.g. \"content-agent\": [\"production\", \"staging\"]. When the map names any client, unknown clients receive no sites. Omit this object (or leave only this comment) to let every resolvable site stay visible; inbound scopes still gate the tool surface."
34
+ },
35
+ "revocationFile": "",
36
+ "introspectionUrl": "",
37
+ "introspectionClientIdEnv": "",
38
+ "introspectionClientSecretEnv": ""
39
+ },
40
+
26
41
  "sites": {
27
42
  "production": {
28
43
  "_comment": "Content tier. Content/media/term CRUD; config read-only; cannot publish (the source-side editorial gate decides publication, not the agent). No drushSsh.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.6.1",
4
- "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal \u2014 dual-protocol JSON:API and GraphQL.",
3
+ "version": "2.7.1",
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",
7
7
  "bin": {
@@ -56,7 +56,7 @@
56
56
  "test:watch": "vitest",
57
57
  "audit": "npm audit --audit-level=high",
58
58
  "check": "npm run lint && npm run audit",
59
- "syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f \u2713\"; done",
59
+ "syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f ✓\"; done",
60
60
  "verify": "node bin/drupal-mcp-verify.js",
61
61
  "verify:example": "node bin/drupal-mcp-verify.js --config config/config.example.json"
62
62
  },
@@ -64,6 +64,7 @@
64
64
  "@modelcontextprotocol/node": "^2.0.0",
65
65
  "@modelcontextprotocol/server": "^2.0.0",
66
66
  "graphql": "^17.0.0",
67
+ "jose": "^6.2.9",
67
68
  "node-fetch": "^3.3.2",
68
69
  "ssh2": "^1.16.0"
69
70
  },
package/src/index.js CHANGED
@@ -15,7 +15,9 @@
15
15
  * DRUPAL_BASE_URL Single-site fallback baseUrl
16
16
  * DRUPAL_API_TOKEN Single-site fallback Bearer token
17
17
  * MCP_ALLOW_HTTP Set to "1" to allow plain HTTP on localhost only (dev)
18
- * MCP_AUTH_TOKEN Bearer token required on /mcp in https mode (warns if unset)
18
+ * MCP_AUTH_TOKEN Loopback-only shared bearer for /mcp (not accepted network-facing)
19
+ * MCP_RESOURCE_ISSUER / MCP_RESOURCE_AUDIENCE / MCP_RESOURCE
20
+ * Inbound OAuth resource-server (required beyond loopback)
19
21
  * MCP_BIND_HOST Bind address for https mode when TLS is present
20
22
  * (default: "0.0.0.0"; ignored without TLS, which forces loopback)
21
23
  * MCP_RATE_LIMIT Max /mcp requests per window per client IP (0/unset = off)
@@ -31,14 +33,27 @@ import { createMcpHandler } from "@modelcontextprotocol/server";
31
33
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
32
34
  import { toNodeHandler } from "@modelcontextprotocol/node";
33
35
 
34
- import { listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
36
+ import { listSiteNames, getTlsConfig, loadConfig, CLIENT_VERSION } from "./lib/config.js";
35
37
  import { loadLocalSecrets, secretLoadFatalMessage } from "./lib/load-secrets.js";
36
- import { makeBearerCheck } from "./lib/http-auth.js";
38
+ import {
39
+ makeBearerCheck,
40
+ resolveInboundAuthConfig,
41
+ resolveInboundAuthMode,
42
+ createInboundHttpsAuth,
43
+ } from "./lib/http-auth.js";
37
44
  import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
38
45
  import { createConnectorServerFactory } from "./lib/mcp-server.js";
39
46
  import { createRateLimiter } from "./lib/rate-limit.js";
40
47
  import { callTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
41
48
  import { filterDiscoverableTools } from "./lib/governance.js";
49
+ import {
50
+ assertPrincipalEntitlement,
51
+ filterPromptsByPrincipal,
52
+ filterResourcesByPrincipal,
53
+ filterToolsByPrincipal,
54
+ getRequestIdentity,
55
+ visibleSiteTargets,
56
+ } from "./lib/principal.js";
42
57
 
43
58
  // Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
44
59
  import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
@@ -95,21 +110,43 @@ const RESOURCES = [
95
110
  * @returns {Promise<object>} The resource data (later JSON-serialized).
96
111
  * @throws {Error} If the URI matches no known resource.
97
112
  */
113
+ async function discoverableTools() {
114
+ const sites = listResolvableSiteConfigs();
115
+ const identity = getRequestIdentity();
116
+ const governed = await filterDiscoverableTools(allDefinitions, sites);
117
+ return filterToolsByPrincipal(governed, sites, identity);
118
+ }
119
+
98
120
  async function readResource(uri) {
121
+ const identity = getRequestIdentity();
122
+ const sites = listResolvableSiteConfigs();
123
+
99
124
  // drupal://sites
100
125
  if (uri === "drupal://sites") {
101
- return { sites: listSiteNames() };
126
+ return visibleSiteTargets(identity, sites, listSiteNames());
102
127
  }
103
128
 
104
129
  // drupal://{site}/content-types
105
130
  const ctMatch = uri.match(/^drupal:\/\/([^/]+)\/content-types$/);
106
131
  if (ctMatch) {
132
+ assertPrincipalEntitlement({
133
+ toolName: "drupal_list_content_types",
134
+ args: { site: ctMatch[1] },
135
+ identity,
136
+ sites,
137
+ });
107
138
  return allHandlers.drupal_list_content_types({ site: ctMatch[1] });
108
139
  }
109
140
 
110
141
  // drupal://{site}/security-policy
111
142
  const spMatch = uri.match(/^drupal:\/\/([^/]+)\/security-policy$/);
112
143
  if (spMatch) {
144
+ assertPrincipalEntitlement({
145
+ toolName: "drupal_security_info",
146
+ args: { site: spMatch[1] },
147
+ identity,
148
+ sites,
149
+ });
113
150
  return allHandlers.drupal_security_info({ site: spMatch[1] });
114
151
  }
115
152
 
@@ -253,12 +290,24 @@ const buildConnectorServer = createConnectorServerFactory({
253
290
  serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
254
291
  tools: {
255
292
  definitions: allDefinitions,
256
- list: () => filterDiscoverableTools(allDefinitions, listResolvableSiteConfigs()),
293
+ list: discoverableTools,
257
294
  call: callTool,
258
295
  },
259
- resources: { definitions: RESOURCES, read: readResource },
296
+ resources: {
297
+ definitions: RESOURCES,
298
+ list: async () => {
299
+ const sites = listResolvableSiteConfigs();
300
+ return filterResourcesByPrincipal(RESOURCES, getRequestIdentity(), sites);
301
+ },
302
+ read: readResource,
303
+ },
260
304
  prompts: {
261
305
  definitions: ALL_PROMPTS,
306
+ list: async () => {
307
+ const identity = getRequestIdentity();
308
+ const tools = await discoverableTools();
309
+ return filterPromptsByPrincipal(ALL_PROMPTS, identity, tools);
310
+ },
262
311
  get: (name, args) => WORKFLOW_PROMPT_NAMES.has(name)
263
312
  ? getPromptMessages(name, args)
264
313
  : getToolPromptMessages(name, args, definitionsByName),
@@ -290,15 +339,7 @@ if (transport === "stdio") {
290
339
  const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
291
340
 
292
341
  const authToken = process.env.MCP_AUTH_TOKEN || "";
293
- const checkAuth = makeBearerCheck(authToken);
294
342
  const allowUnauth = process.env.MCP_ALLOW_UNAUTHENTICATED === "1";
295
- if (!authToken) {
296
- console.error(
297
- "[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
298
- "Set MCP_AUTH_TOKEN to require a bearer token, or front it with a trusted " +
299
- "boundary (private network / auth proxy). Acceptable only behind such a boundary."
300
- );
301
- }
302
343
 
303
344
  // Security headers applied to every response
304
345
  function applySecurityHeaders(res) {
@@ -349,15 +390,42 @@ if (transport === "stdio") {
349
390
  const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
350
391
  const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
351
392
 
352
- // #141: fail closed when HTTPS is network-facing without a bearer token.
353
- // Loopback binds and explicit MCP_ALLOW_UNAUTHENTICATED=1 remain for local/proxy setups.
354
- if (!authToken && !isLoopbackBind && !allowUnauth) {
393
+ const inboundCfg = resolveInboundAuthConfig(loadConfig());
394
+ const inboundMode = resolveInboundAuthMode({
395
+ bindHost,
396
+ allowUnauth,
397
+ sharedToken: authToken,
398
+ resourceServer: inboundCfg,
399
+ });
400
+ if (inboundMode.mode === "fatal") {
401
+ console.error(`[drupal-mcp-connector] FATAL: ${inboundMode.reason}`);
402
+ process.exit(1);
403
+ }
404
+
405
+ let checkAuth = makeBearerCheck(inboundMode.mode === "shared_bearer" ? authToken : "");
406
+ let authenticate = null;
407
+ let protectedResource = null;
408
+ if (inboundMode.mode === "resource_server") {
409
+ try {
410
+ const inbound = await createInboundHttpsAuth({ inboundCfg });
411
+ authenticate = inbound.authenticate;
412
+ protectedResource = inbound.protectedResource;
413
+ checkAuth = () => false;
414
+ console.error(
415
+ `[drupal-mcp-connector] Inbound OAuth resource server: issuer ${inboundCfg.issuer}`
416
+ );
417
+ } catch (error) {
418
+ console.error(
419
+ "[drupal-mcp-connector] FATAL: inbound issuer discovery failed.\n" +
420
+ ` ${error instanceof Error ? error.message : "unknown error"}`
421
+ );
422
+ process.exit(1);
423
+ }
424
+ } else if (inboundMode.mode === "unauthenticated" && !allowUnauth) {
355
425
  console.error(
356
- "[drupal-mcp-connector] FATAL: MCP_AUTH_TOKEN is required when binding beyond loopback.\n" +
357
- " Set MCP_AUTH_TOKEN, bind to 127.0.0.1 (default without MCP_BIND_HOST), or set\n" +
358
- " MCP_ALLOW_UNAUTHENTICATED=1 only behind a trusted auth boundary."
426
+ "[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
427
+ "Acceptable only on loopback or behind a trusted auth boundary."
359
428
  );
360
- process.exit(1);
361
429
  }
362
430
 
363
431
  // Optional fixed-window rate limiting on /mcp, keyed by client IP.
@@ -393,6 +461,8 @@ if (transport === "stdio") {
393
461
  });
394
462
  const requestHandler = createMcpRequestHandler({
395
463
  checkAuth,
464
+ authenticate,
465
+ protectedResource,
396
466
  modernHandler,
397
467
  legacyHandler,
398
468
  toolCount: allDefinitions.length,
@@ -66,7 +66,10 @@ export class Backend {
66
66
 
67
67
  /**
68
68
  * Fetch a single entity by reference.
69
- * @param {{entityType: string, bundle: string, id: string}} _ref
69
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} _ref
70
+ * `resourceVersion` is a JSON:API revision selector (`rel:working-copy`,
71
+ * `rel:latest-version`, `id:<vid>`). Backends that cannot address revisions
72
+ * ignore it.
70
73
  * @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null when absent.
71
74
  */
72
75
  async getEntity(_ref) { return notImplemented("getEntity"); }
@@ -204,9 +204,15 @@ export class JsonApiBackend extends Backend {
204
204
  const bundle = rawBundle || null;
205
205
  const attrs = resource.attributes || {};
206
206
  const fields = Object.fromEntries(
207
- Object.entries(attrs).filter(
208
- ([k]) => !BASE_ATTRIBUTE_FIELDS.includes(k) && !INTERNAL_ATTR_RE.test(k)
209
- )
207
+ Object.entries(attrs).filter(([k]) => {
208
+ if (BASE_ATTRIBUTE_FIELDS.includes(k)) return false;
209
+ if (INTERNAL_ATTR_RE.test(k)) {
210
+ // Paragraph ERR attach needs the current revision id (#192).
211
+ // Other drupal_internal__* attributes stay stripped.
212
+ return entityType === "paragraph" && k === "drupal_internal__revision_id";
213
+ }
214
+ return true;
215
+ })
210
216
  );
211
217
  const relationships = Object.fromEntries(
212
218
  Object.entries(resource.relationships || {}).map(([k, rel]) => [k, normalizeRelationship(rel?.data ?? null)])
@@ -249,12 +255,16 @@ export class JsonApiBackend extends Backend {
249
255
 
250
256
  /**
251
257
  * Fetch a single entity by id.
252
- * @param {{entityType: string, bundle: string, id: string}} ref
258
+ * @param {{entityType: string, bundle: string, id: string, resourceVersion?: string}} ref
253
259
  * @returns {Promise<?import("../canonical.js").CanonicalEntity>} Entity, or null.
254
260
  */
255
- async getEntity({ entityType, bundle, id }) {
261
+ async getEntity({ entityType, bundle, id, resourceVersion }) {
256
262
  validateUuid(id);
257
- const data = await drupalFetch(this.site, `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`);
263
+ let path = `${this.resourcePath(entityType, bundle)}/${encodeURIComponent(id)}`;
264
+ if (resourceVersion) {
265
+ path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
266
+ }
267
+ const data = await drupalFetch(this.site, path);
258
268
  return data?.data ? this.toCanonical(data.data) : null;
259
269
  }
260
270
 
@@ -64,7 +64,17 @@ export function normalizeRelationship(ref) {
64
64
  if (Array.isArray(ref)) return ref.map(normalizeRelationship);
65
65
  // JSON:API encodes type as "entityType--bundle"; split into the two parts.
66
66
  const [entityType = null, bundle = null] = (ref.type || "").split("--");
67
- return { id: ref.id, entityType, bundle };
67
+ const out = { id: ref.id, entityType, bundle };
68
+ // ERR identifiers carry the revision id in JSON:API `meta` (#192). Dropping
69
+ // it made every canonical re-read look like a plain {id, type} even when the
70
+ // write sent target_revision_id.
71
+ if (ref.meta && typeof ref.meta === "object") {
72
+ const vid = new Map(Object.entries(ref.meta)).get("target_revision_id");
73
+ if (vid !== undefined && vid !== null && vid !== "") {
74
+ out.meta = { target_revision_id: vid };
75
+ }
76
+ }
77
+ return out;
68
78
  }
69
79
 
70
80
  /**
package/src/lib/config.js CHANGED
@@ -221,6 +221,29 @@ export function listSiteNames() {
221
221
  return Object.keys(loadConfig().sites);
222
222
  }
223
223
 
224
+ /**
225
+ * Configured default site name (used only as a hint after entitlement).
226
+ * @returns {string}
227
+ */
228
+ export function getDefaultSiteName() {
229
+ return loadConfig().defaultSite;
230
+ }
231
+
232
+ /**
233
+ * Server-resolved inbound site grants keyed by OAuth client id.
234
+ * A missing or empty map means "no grant table" (every resolvable site).
235
+ * A present map is fail-closed: unknown clients receive no sites.
236
+ * @returns {object|null}
237
+ */
238
+ export function getInboundGrants() {
239
+ const grants = loadConfig().auth?.grants;
240
+ if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
241
+ const entries = Object.entries(grants)
242
+ .filter(([clientId, sites]) => !clientId.startsWith("_") && Array.isArray(sites))
243
+ .map(([clientId, sites]) => [clientId, sites.map(String)]);
244
+ return entries.length ? Object.fromEntries(entries) : null;
245
+ }
246
+
224
247
  // ---------------------------------------------------------------------------
225
248
  // Auth headers — never logged, never exposed in tool responses
226
249
  // ---------------------------------------------------------------------------