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.
- package/.claude/commands/drupal-bulk-create.md +2 -2
- package/.claude/commands/drupal-create-paragraph.md +2 -2
- package/.claude/commands/drupal-entity-update.md +3 -3
- package/.claude/commands/drupal-get-paragraph.md +2 -2
- package/.claude/commands/drupal-list-revisions.md +2 -2
- package/.claude/commands/drupal-list-sites.md +2 -2
- package/.claude/commands/drupal-update-node.md +4 -4
- package/.claude/commands/drupal-update-paragraph.md +2 -2
- package/CHANGELOG.md +80 -0
- package/README.md +2 -2
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +91 -21
- package/src/lib/backends/backend-interface.js +4 -1
- package/src/lib/backends/jsonapi.js +16 -6
- package/src/lib/canonical.js +11 -1
- package/src/lib/config.js +23 -0
- package/src/lib/dispatch.js +36 -10
- package/src/lib/err-relationships.js +286 -0
- package/src/lib/http-auth.js +541 -3
- package/src/lib/http-handler.js +62 -9
- package/src/lib/mcp-server.js +32 -6
- package/src/lib/patch-preflight.js +176 -0
- package/src/lib/principal.js +372 -0
- package/src/lib/verify.js +40 -4
- package/src/lib/write-revision.js +72 -0
- package/src/tools/bulk.js +31 -6
- package/src/tools/config.js +6 -0
- package/src/tools/entities.js +37 -7
- package/src/tools/nodes.js +34 -10
- package/src/tools/paragraphs.js +56 -42
- package/src/tools/revisions.js +42 -7
- package/src/tools/site.js +19 -5
|
@@ -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:
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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;
|
|
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.
|
|
4
|
-
"description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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:
|
|
293
|
+
list: discoverableTools,
|
|
257
294
|
call: callTool,
|
|
258
295
|
},
|
|
259
|
-
resources: {
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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]
|
|
357
|
-
"
|
|
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
|
-
(
|
|
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
|
-
|
|
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
|
|
package/src/lib/canonical.js
CHANGED
|
@@ -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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|