drupal-mcp-connector 2.24.2 → 2.26.0
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/.agents/commands/drupal-entity-update.md +1 -1
- package/.agents/commands/drupal-list-menu-links.md +2 -2
- package/.agents/commands/drupal-schedule-publish.md +5 -3
- package/.agents/commands/drupal-update-menu-link.md +3 -3
- package/.agents/commands/drupal-update-node.md +1 -1
- package/CHANGELOG.md +87 -0
- package/README.md +57 -1
- package/bin/drupal-mcp-doctor.js +16 -0
- package/bin/drupal-mcp-wizard.js +17 -0
- package/package.json +10 -5
- package/src/index.js +19 -19
- package/src/lib/backends/jsonapi.js +71 -8
- package/src/lib/dispatch.js +22 -0
- package/src/lib/doctor.js +645 -0
- package/src/lib/module-tools.js +33 -2
- package/src/lib/operations.js +1 -1
- package/src/lib/patch-preflight.js +32 -32
- package/src/lib/principal.js +13 -5
- package/src/lib/security.js +42 -44
- package/src/lib/sentinel-draft.js +59 -3
- package/src/lib/server-tools.js +3 -3
- package/src/lib/transports.js +153 -0
- package/src/lib/version-skew.js +193 -0
- package/src/lib/wizard.js +902 -0
- package/src/lib/workflow-prompts.js +5 -5
- package/src/tools/codegen.js +2 -2
- package/src/tools/config.js +11 -6
- package/src/tools/entities.js +1 -1
- package/src/tools/index.js +1 -1
- package/src/tools/media.js +2 -2
- package/src/tools/nodes.js +5 -3
- package/src/tools/reports-config.js +3 -3
- package/src/tools/reports-content.js +4 -2
- package/src/tools/revisions.js +2 -1
- package/src/tools/scheduler.js +97 -10
- package/src/tools/structure.js +4 -4
- package/src/tools/translations.js +7 -1
|
@@ -19,7 +19,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
19
19
|
- `langcode` (string): Target language for an unpublished working translation (nodes). Continues that translation via Sentinel.
|
|
20
20
|
- `attributes` (object (pass as JSON))
|
|
21
21
|
- `relationships` (object (pass as JSON))
|
|
22
|
-
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A
|
|
22
|
+
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; field access and entity validation are NOT checked (`core_patch_guard`), so the real write can still fail with a field-access 403 or a 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun.
|
|
23
23
|
- `returning` (one of: full, minimal): 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, 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,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, and
|
|
2
|
+
description: "List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, weight, and fields.drupal_internal__id (numeric id for /admin/structure/menu/item/{id}/edit). Note: this lists editable menu_link_content entities, not code-defined static links."
|
|
3
3
|
argument-hint: "[site] [menu] [limit] [offset] [sort]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_list_menu_links`.
|
|
7
7
|
|
|
8
|
-
List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, and
|
|
8
|
+
List custom (content) menu links, optionally scoped to a single menu (e.g. 'main', 'footer'). Returns each link's title, target URI, menu, weight, and fields.drupal_internal__id (numeric id for /admin/structure/menu/item/{id}/edit). Note: this lists editable menu_link_content entities, not code-defined static links.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. Sets the publish_on and unpublish_on fields on the node. Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. Provide at least one of publishOn or unpublishOn."
|
|
3
|
-
argument-hint: "<type> <id> [site] [publishOn] [unpublishOn]"
|
|
2
|
+
description: "Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. Sets the publish_on and unpublish_on fields on the node. On a bundle under Content Moderation, also set publishState / unpublishState (written as publish_state / unpublish_state) — dates alone never go live. Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. Moderated bundles additionally need Scheduler Content Moderation Integration; when those state fields exist, the matching state is required whenever a date is set. Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. Provide at least one of publishOn or unpublishOn."
|
|
3
|
+
argument-hint: "<type> <id> [site] [publishOn] [unpublishOn] [publishState] [unpublishState]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_schedule_publish`.
|
|
7
7
|
|
|
8
|
-
Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. Sets the publish_on and unpublish_on fields on the node. Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. Provide at least one of publishOn or unpublishOn.
|
|
8
|
+
Schedule a content node to publish and/or unpublish at a future time using the Drupal Scheduler module. Sets the publish_on and unpublish_on fields on the node. On a bundle under Content Moderation, also set publishState / unpublishState (written as publish_state / unpublish_state) — dates alone never go live. Requires the Scheduler module to be installed and enabled for the content type, with the publish_on / unpublish_on fields present on the bundle — otherwise the call fails with a clear capability error. Moderated bundles additionally need Scheduler Content Moderation Integration; when those state fields exist, the matching state is required whenever a date is set. Timestamps accept ISO 8601 (e.g. '2026-07-01T12:00:00Z') or a Unix epoch and are passed through unchanged. Provide at least one of publishOn or unpublishOn.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -17,5 +17,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
17
17
|
- `site` (string): Named site from connector config. Omit only on reads: multi-site configs fall back to defaultSite (often local/dev, not production). Writes require an explicit site when more than one site is configured. Every response includes `_target` { name, baseUrl, source } (`hint` when you passed site, `default` when you did not).
|
|
18
18
|
- `publishOn` (string): When to publish — ISO 8601 datetime or Unix epoch. Sets the Scheduler publish_on field.
|
|
19
19
|
- `unpublishOn` (string): When to unpublish — ISO 8601 datetime or Unix epoch. Sets the Scheduler unpublish_on field.
|
|
20
|
+
- `publishState` (string): Moderation state to enter at publishOn (e.g. 'published'). Required on moderated bundles when publishOn is set.
|
|
21
|
+
- `unpublishState` (string): Moderation state to enter at unpublishOn (e.g. 'draft'). Required on moderated bundles when unpublishOn is set.
|
|
20
22
|
|
|
21
23
|
If a required parameter is missing, 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,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Checked against the site security config."
|
|
2
|
+
description: "Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Pass langcode to update an existing translation via JSON:API language negotiation (URL prefix, then language headers). Does not create a missing translation. Responses include fields.drupal_internal__id. Checked against the site security config."
|
|
3
3
|
argument-hint: "<id> [site] [title] [link] [menu] [weight] [parent] [enabled] [langcode]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the MCP tool `drupal_update_menu_link`.
|
|
7
7
|
|
|
8
|
-
Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Checked against the site security config.
|
|
8
|
+
Update a custom menu link by UUID (rename, re-weight, re-target, re-parent, enable/disable). Only the fields you pass change. The link's enabled state is preserved across edits — an unrelated change will not disable a live link — unless you pass 'enabled' explicitly. Pass langcode to update an existing translation via JSON:API language negotiation (URL prefix, then language headers). Does not create a missing translation. Responses include fields.drupal_internal__id. Checked against the site security config.
|
|
9
9
|
|
|
10
10
|
Parse the arguments supplied with this command into this tool's parameters:
|
|
11
11
|
|
|
@@ -20,6 +20,6 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
20
20
|
- `weight` (number): New ordering weight. Omit to leave unchanged.
|
|
21
21
|
- `parent` (string): New parent link plugin id (e.g. 'menu_link_content:<uuid>'), or '' for top level. Omit to leave unchanged.
|
|
22
22
|
- `enabled` (boolean (true/false)): Enable/disable the link. Omit to preserve the current state.
|
|
23
|
-
- `langcode` (string): Existing translation to update (e.g. 'es'). Omit for the default language. Does not create a missing translation.
|
|
23
|
+
- `langcode` (string): Existing translation to update (e.g. 'es'). Uses /{langcode}/jsonapi/… when the site prefixes JSON:API, then language headers. Omit for the default language. Does not create a missing translation.
|
|
24
24
|
|
|
25
25
|
If a required parameter is missing, 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.
|
|
@@ -24,7 +24,7 @@ Parse the arguments supplied with this command into this tool's parameters:
|
|
|
24
24
|
- `langcode` (string): Target language for an unpublished working translation (e.g. 'es'). Continues that translation via Sentinel; does not create a missing translation and does not PATCH canonical langcode.
|
|
25
25
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Formatted text: a string or { value, format?, summary? }. format must be in the field's allowed_formats; a single allowed format is used when omitted. Entity-reference fields go in `relationships`, not here.
|
|
26
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. Image alt on a translation uses the existing file UUID plus meta.alt; replacing the file is refused.
|
|
27
|
-
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A
|
|
27
|
+
- `dryRun` (boolean (true/false)): Validate, resolve ERR identifiers, run the server-side preflight when one applies, and return a preview without the real write. The result's `checks` block says what was checked; `caveat` names what was not. Only an existing node draft (or a langcode translation draft) is checked with the real payload: Sentinel's non-saving draft endpoint applies the submitted fields through field access and validates the entity (`serverPreflight: sentinel_draft`). On other moderated targets an id-mismatch core PATCH with no fields checks entity update access and core's working-copy guard only; Drupal does not check field access or entity validation on that probe (`core_patch_guard`). Text format is resolved before the preview returns, including on that path: a known allowed_formats list is enforced, and an unknown list reuses the format stored on the field. Other fields can still fail the real write with a field-access 403 or a validation 422. Unmoderated targets get no server-side check at all (`none`). A changed/revision_timestamp gap on the default revision (possiblyPatchBlocked) is a warning on list_revisions, not a local dryRun refusal — the write proceeds to the core probe / Sentinel (#405). An actual Sentinel stale-copy refusal is still rewritten. Any refusal fails the dryRun.
|
|
28
28
|
- `returning` (one of: full, minimal): 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, 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.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.26.0] - 2026-10-01
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Nightly leftover cleanup (#399).** CI pins Node 20 (no 1-cell matrix) and
|
|
14
|
+
runs `npm run syntax-check` recursively over `src/`. The two
|
|
15
|
+
`resources/read` URIs (`content-types`, `security-policy`) go through
|
|
16
|
+
`invokeReadTool` → `callTool`. AGENTS, CONTRIBUTING, threat-model, and
|
|
17
|
+
ops docs point middleware at `src/lib/dispatch.js`.
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
- **Production-dead `redactResource`.** Canonical `redactCanonicalEntity` is
|
|
21
|
+
the only redaction path; JSON:API-shape tests folded into it.
|
|
22
|
+
- **Leftover public exports** from `workflow-prompts.js`, `server-tools.js`,
|
|
23
|
+
and `allModules` (module-private after #395).
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **Terminal install wizard (`wizard` / `init`).**
|
|
27
|
+
`npx drupal-mcp-connector init` (also `wizard`, `npm run init`,
|
|
28
|
+
`drupal-mcp-wizard`) collects transport (stdio vs remote HTTPS), auth
|
|
29
|
+
(OAuth vs sealed token), and agent client id, then prints Cursor
|
|
30
|
+
`.cursor/mcp.json` and Claude Code `mcpServers` snippets. Files are
|
|
31
|
+
written only with `--write` and never clobbered without confirm unless
|
|
32
|
+
`--yes`. Post-config `whoami` + `contract_ready` print pass/fail with a
|
|
33
|
+
next fix, or a clear skip when the host is unreachable. Remote HTTPS is
|
|
34
|
+
Tailscale / local-VPN only — not a public SaaS path.
|
|
35
|
+
- **`doctor` CLI.** `npx drupal-mcp-connector doctor` runs ordered
|
|
36
|
+
gates (reachability → auth/401 → agent client → allowlist →
|
|
37
|
+
`contract_ready` → version skew), prints one primary failure plus the
|
|
38
|
+
exact next fix, exits non-zero on fail, and supports `--json` with no
|
|
39
|
+
secrets.
|
|
40
|
+
- **Transport presets.** Wizard `--preset local-stdio|tailscale|public-https`
|
|
41
|
+
and docs table. Public HTTPS is listed as a preset and is not a hosted service.
|
|
42
|
+
- **Version skew.** Doctor and wizard print “you are on X;
|
|
43
|
+
site needs Y” when readiness reports versions. No auto-upgrade.
|
|
44
|
+
- **Happy-path recipe.** README walkthrough using
|
|
45
|
+
`drupal_mcp_whoami` → `drupal_list_sites` → unpublished
|
|
46
|
+
`drupal_list_nodes` / `dryRun` `drupal_create_node`.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
- **A `changed` / `revision_timestamp` gap on the default revision no longer blocks the next draft (#405).**
|
|
50
|
+
`#273` treated `possiblyPatchBlocked` as local proof of a hidden revision and
|
|
51
|
+
refused before Sentinel ran. When inventory `working` is null and both
|
|
52
|
+
JSON:API aliases report the same published vid, a `moderationState: draft`
|
|
53
|
+
write now proceeds to the core probe / Sentinel. An actual Sentinel
|
|
54
|
+
stale-copy refusal is still rewritten. `drupal_list_revisions` still reports
|
|
55
|
+
the warning.
|
|
56
|
+
- **`drupal_report_scheduled_content` no longer reports Scheduler fields missing when every date is empty (#403).**
|
|
57
|
+
Field presence is the key on the JSON:API attributes, not a non-empty value.
|
|
58
|
+
Keys present and all null: `summary: { pending: 0, overdue: 0 }`. `gated`
|
|
59
|
+
only when the keys are actually absent.
|
|
60
|
+
- **`drupal_schedule_publish` writes `publish_state` / `unpublish_state` on moderated bundles (#402).**
|
|
61
|
+
Optional `publishState` / `unpublishState` are accepted. When the bundle is
|
|
62
|
+
moderated and those fields exist, the matching state is required whenever a
|
|
63
|
+
date is set. Dates alone were accepted by Drupal and then failed at cron.
|
|
64
|
+
- **Revise guard uses `moderation_state`, not `status: false`, to decide "already a draft" (#400).**
|
|
65
|
+
After revising a translation over a working copy, Sentinel copies the live
|
|
66
|
+
published moderation record onto the carried default language (`status: false`
|
|
67
|
+
+ `moderation_state: published`). Neither `drupal_update_node` nor
|
|
68
|
+
`drupal_create_translation` revise can open a draft of that language while
|
|
69
|
+
the other translation draft exists (drupal.org #3626919). Both tools now
|
|
70
|
+
say so and name the manual path: publish or discard the translation draft,
|
|
71
|
+
or edit in the UI.
|
|
72
|
+
- **Config reads accept the read-only `mcp_config_read` scope (#397).**
|
|
73
|
+
`drupal_config_get` / `drupal_config_list`, config reports, and codegen
|
|
74
|
+
inspect/diff accept `mcp_config` or `mcp_config_read`. Writes still require
|
|
75
|
+
`mcp_config`. `drupal_mcp_whoami` reports `configRead: true` for a
|
|
76
|
+
read-scope token. A token with neither scope is still refused.
|
|
77
|
+
|
|
78
|
+
## [2.25.0] - 2026-10-01
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
- **Menu tools return `fields.drupal_internal__id`.** The numeric menu-link
|
|
82
|
+
id is kept on JSON:API canonicalization so list/create/update responses
|
|
83
|
+
can build `/admin/structure/menu/item/{id}/edit` (and `…/edit/{langcode}`).
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
- **`drupal_update_menu_link` with `langcode` targets the translation via a JSON:API URL prefix (#398).**
|
|
87
|
+
Stock Drupal negotiates content language from `/{langcode}/jsonapi/…` (the
|
|
88
|
+
same path that returns Spanish for nodes). The connector only sent
|
|
89
|
+
`Accept-Language` / `Content-Language` headers, which most production sites
|
|
90
|
+
ignore unless Browser detection is enabled — so a Spanish menu-link
|
|
91
|
+
translation was served as English and the write refused. Reads and writes
|
|
92
|
+
with `langcode` now try the prefixed path first and fall back to the
|
|
93
|
+
unprefixed path plus headers when the prefix 404s. Taxonomy get/update
|
|
94
|
+
with `langcode` use the same backend path. A mismatch after both attempts
|
|
95
|
+
still fails loud.
|
|
96
|
+
|
|
10
97
|
## [2.24.2] - 2026-09-30
|
|
11
98
|
|
|
12
99
|
### Removed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Drupal MCP Connector
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Multi-site MCP server for Drupal over JSON:API and GraphQL, with governed writes and an SSH Drush bridge. `init` writes a stdio or Tailscale client config from a transport preset. `doctor` checks reachability, auth, and readiness, and reports version skew. A timestamp gap on a published revision no longer blocks the next draft.
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://nodejs.org)
|
|
@@ -205,6 +205,62 @@ Full detail: **[docs/security.md](docs/security.md)** and **[docs/security-harde
|
|
|
205
205
|
|
|
206
206
|
## Quick Start
|
|
207
207
|
|
|
208
|
+
### One-liner (recommended for operators)
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
npx -y drupal-mcp-connector init
|
|
212
|
+
# aliases: npx -y drupal-mcp-connector wizard
|
|
213
|
+
# npx -y drupal-mcp-wizard
|
|
214
|
+
# CI / dogfood: npx -y drupal-mcp-connector init --yes --json
|
|
215
|
+
npx -y drupal-mcp-connector doctor
|
|
216
|
+
# CI: npx -y drupal-mcp-connector doctor --json
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The wizard prints Cursor (`.cursor/mcp.json`) and Claude Code `mcpServers`
|
|
220
|
+
snippets, optional `auth.grants` allowlist hints, and a whoami / contract_ready
|
|
221
|
+
check. It writes files only with `--write` (never clobbers without confirm
|
|
222
|
+
unless `--yes`). Doctor prints **one** primary failing gate and the exact next
|
|
223
|
+
fix (exit non-zero on fail; `--json` is secret-free).
|
|
224
|
+
|
|
225
|
+
### Transport presets
|
|
226
|
+
|
|
227
|
+
| Preset | URL shape | Notes |
|
|
228
|
+
|--------|-----------|--------|
|
|
229
|
+
| **Local stdio** (`--preset local-stdio`) | `https://drupal.ddev.site` | Client launches this connector as a subprocess. Drupal must be reachable from this machine. |
|
|
230
|
+
| **Tailscale VPN** (`--preset tailscale`) | `https://drupal.<tailnet>.ts.net` | VPN-required. The MCP client host must be on the same tailnet. Not a public internet path. |
|
|
231
|
+
| **Public HTTPS** (`--preset public-https`) | `https://mcp.example.com/mcp` | **Gated / later.** Operator-run `MCP_TRANSPORT=https` + TLS + inbound OAuth. There is **no** hosted Wilkes & Liberty SaaS MCP URL. |
|
|
232
|
+
|
|
233
|
+
**Remote HTTPS is Tailscale / local-VPN only.** This package does not ship a
|
|
234
|
+
public SaaS remote endpoint. stdio is the default: the client launches the
|
|
235
|
+
connector on your machine and inherits VPN/Tailscale/localhost reachability to
|
|
236
|
+
Drupal. See **[docs/mcp-clients.md](docs/mcp-clients.md)**.
|
|
237
|
+
|
|
238
|
+
`requireGovernance: true` remains the listing default for a later marketplace
|
|
239
|
+
entry — this repo does not submit listings. If the client only shows
|
|
240
|
+
`drupal_list_sites` and `drupal_governance_status`, run `doctor`.
|
|
241
|
+
|
|
242
|
+
### Two-minute happy path
|
|
243
|
+
|
|
244
|
+
After `init` and a green `doctor`, in the MCP client (real tool names):
|
|
245
|
+
|
|
246
|
+
1. **Who am I?** Call `drupal_mcp_whoami`. Confirm `tier`, `preset`, and
|
|
247
|
+
`capabilities.publish` (false on `production-strict` / `content-editor`).
|
|
248
|
+
2. **What can I see?** Call `drupal_list_sites`, then `drupal_list_nodes` with
|
|
249
|
+
`{ "type": "article", "status": false, "limit": 5 }` (unpublished only) or
|
|
250
|
+
`drupal_get_node` on a staging UUID. Prefer `drupal_search_content` for a
|
|
251
|
+
title substring.
|
|
252
|
+
3. **Safe unpublished write (optional, non-prod):** Preview with
|
|
253
|
+
`drupal_create_node`
|
|
254
|
+
`{ "type": "article", "title": "MCP draft", "status": false, "dryRun": true }`.
|
|
255
|
+
Commit without `dryRun` only on staging, still `status: false` (or
|
|
256
|
+
`moderationState: "draft"`). Never set `status: true` or
|
|
257
|
+
`moderationState: "published"` unless `drupal_mcp_whoami` shows
|
|
258
|
+
`capabilities.publish: true` **and** you intend to go live.
|
|
259
|
+
|
|
260
|
+
Fail-closed: default `production-strict` is read-only. A deny is expected when
|
|
261
|
+
the allowlist or Sentinel profile excludes the type — run `doctor` and
|
|
262
|
+
`drupal_governance_status`; do not auto-widen allowlists.
|
|
263
|
+
|
|
208
264
|
### From npm (recommended for operators)
|
|
209
265
|
|
|
210
266
|
```bash
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Connection doctor (DEV-759).
|
|
4
|
+
*
|
|
5
|
+
* npx drupal-mcp-connector doctor
|
|
6
|
+
* node bin/drupal-mcp-doctor.js --json
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { runDoctorCli } from "../src/lib/doctor.js";
|
|
10
|
+
|
|
11
|
+
runDoctorCli(process.argv.slice(2))
|
|
12
|
+
.then((code) => process.exit(code))
|
|
13
|
+
.catch((err) => {
|
|
14
|
+
process.stderr.write(`doctor: ${err?.message ?? err}\n`);
|
|
15
|
+
process.exit(2);
|
|
16
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Operator install wizard (DEV-758).
|
|
4
|
+
*
|
|
5
|
+
* npx drupal-mcp-connector init
|
|
6
|
+
* npx drupal-mcp-connector wizard
|
|
7
|
+
* node bin/drupal-mcp-wizard.js --yes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { runWizardCli } from "../src/lib/wizard.js";
|
|
11
|
+
|
|
12
|
+
runWizardCli(process.argv.slice(2))
|
|
13
|
+
.then((code) => process.exit(code))
|
|
14
|
+
.catch((err) => {
|
|
15
|
+
process.stderr.write(`wizard: ${err?.message ?? err}\n`);
|
|
16
|
+
process.exit(2);
|
|
17
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.26.0",
|
|
4
|
+
"description": "Multi-site MCP server for Drupal over JSON:API and GraphQL, with governed writes and an SSH Drush bridge. init writes a stdio or Tailscale client config from a transport preset. doctor checks reachability, auth, and readiness, and reports version skew. A timestamp gap on a published revision no longer blocks the next draft.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
"drupal-mcp-verify": "bin/drupal-mcp-verify.js",
|
|
10
10
|
"drupal-mcp-edge": "bin/drupal-mcp-edge.js",
|
|
11
11
|
"drupal-mcp-agent": "bin/drupal-mcp-agent.js",
|
|
12
|
-
"drupal-mcp-anchor": "bin/drupal-mcp-anchor.js"
|
|
12
|
+
"drupal-mcp-anchor": "bin/drupal-mcp-anchor.js",
|
|
13
|
+
"drupal-mcp-wizard": "bin/drupal-mcp-wizard.js",
|
|
14
|
+
"drupal-mcp-doctor": "bin/drupal-mcp-doctor.js"
|
|
13
15
|
},
|
|
14
16
|
"files": [
|
|
15
17
|
"src/",
|
|
@@ -61,9 +63,12 @@
|
|
|
61
63
|
"test:watch": "vitest",
|
|
62
64
|
"audit": "npm audit --audit-level=high",
|
|
63
65
|
"check": "npm run lint && npm run audit",
|
|
64
|
-
"syntax-check": "
|
|
66
|
+
"syntax-check": "find src -name '*.js' -exec sh -c 'node --input-type=module --check < \"$1\" && echo \"$1 ✓\"' _ {} \\;",
|
|
65
67
|
"verify": "node bin/drupal-mcp-verify.js",
|
|
66
|
-
"verify:example": "node bin/drupal-mcp-verify.js --config config/config.example.json"
|
|
68
|
+
"verify:example": "node bin/drupal-mcp-verify.js --config config/config.example.json",
|
|
69
|
+
"wizard": "node bin/drupal-mcp-wizard.js",
|
|
70
|
+
"init": "node bin/drupal-mcp-wizard.js",
|
|
71
|
+
"doctor": "node bin/drupal-mcp-doctor.js"
|
|
67
72
|
},
|
|
68
73
|
"dependencies": {
|
|
69
74
|
"@modelcontextprotocol/node": "^2.0.0",
|
package/src/index.js
CHANGED
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* MCP_RATE_LIMIT Max /mcp requests per window per client IP (0/unset = off)
|
|
24
24
|
* MCP_RATE_WINDOW_SEC Rate-limit window in seconds (default: 60)
|
|
25
25
|
* MCP_LEGACY_TRANSPORT "serve" (default) | "reject" for 2025-era clients
|
|
26
|
+
*
|
|
27
|
+
* CLI subcommands (anything else starts the MCP server):
|
|
28
|
+
* wizard | init Operator install wizard — see src/lib/wizard.js
|
|
29
|
+
* doctor Connection doctor — see src/lib/doctor.js
|
|
26
30
|
*/
|
|
27
31
|
|
|
28
32
|
import { createServer as createHttpsServer } from "https";
|
|
@@ -45,10 +49,9 @@ import {
|
|
|
45
49
|
import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
|
|
46
50
|
import { createConnectorServerFactory } from "./lib/mcp-server.js";
|
|
47
51
|
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
48
|
-
import { callTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
|
|
52
|
+
import { callTool, invokeReadTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
|
|
49
53
|
import { filterDiscoverableTools } from "./lib/governance.js";
|
|
50
54
|
import {
|
|
51
|
-
assertPrincipalEntitlement,
|
|
52
55
|
filterPromptsByPrincipal,
|
|
53
56
|
filterResourcesByPrincipal,
|
|
54
57
|
filterToolsByPrincipal,
|
|
@@ -57,7 +60,7 @@ import {
|
|
|
57
60
|
} from "./lib/principal.js";
|
|
58
61
|
|
|
59
62
|
// Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
|
|
60
|
-
import { allDefinitions,
|
|
63
|
+
import { allDefinitions, definitionsByName } from "./tools/index.js";
|
|
61
64
|
import { createModuleToolRegistry, isModuleTool } from "./lib/module-tools.js";
|
|
62
65
|
import { buildToolPrompts, createPromptSurface } from "./lib/tool-prompts.js";
|
|
63
66
|
import {
|
|
@@ -69,6 +72,16 @@ import {
|
|
|
69
72
|
replaceModuleWorkflows,
|
|
70
73
|
} from "./lib/workflow-prompts.js";
|
|
71
74
|
import { builtinWorkflowProvider } from "./lib/workflows/builtin.js";
|
|
75
|
+
import { runWizardCli } from "./lib/wizard.js";
|
|
76
|
+
import { runDoctorCli } from "./lib/doctor.js";
|
|
77
|
+
|
|
78
|
+
const cliCommand = process.argv[2];
|
|
79
|
+
if (cliCommand === "wizard" || cliCommand === "init") {
|
|
80
|
+
process.exit(await runWizardCli(process.argv.slice(3)));
|
|
81
|
+
}
|
|
82
|
+
if (cliCommand === "doctor") {
|
|
83
|
+
process.exit(await runDoctorCli(process.argv.slice(3)));
|
|
84
|
+
}
|
|
72
85
|
|
|
73
86
|
// Apply config/secrets.map (or the shipped example table) before any site
|
|
74
87
|
// resolution. MCP clients spawn this file directly; the shell launcher is
|
|
@@ -129,8 +142,7 @@ async function discoverableTools() {
|
|
|
129
142
|
/**
|
|
130
143
|
* Resolve a resource URI to its JSON payload. URIs are matched in order; the
|
|
131
144
|
* templated forms (content-types, security-policy) capture the site name and
|
|
132
|
-
*
|
|
133
|
-
* always return the same shape.
|
|
145
|
+
* go through {@link invokeReadTool} so resources and tools share middleware.
|
|
134
146
|
*
|
|
135
147
|
* @param {string} uri - A drupal:// resource URI.
|
|
136
148
|
* @returns {Promise<object>} The resource data (later JSON-serialized).
|
|
@@ -148,25 +160,13 @@ async function readResource(uri) {
|
|
|
148
160
|
// drupal://{site}/content-types
|
|
149
161
|
const ctMatch = uri.match(/^drupal:\/\/([^/]+)\/content-types$/);
|
|
150
162
|
if (ctMatch) {
|
|
151
|
-
|
|
152
|
-
toolName: "drupal_list_content_types",
|
|
153
|
-
args: { site: ctMatch[1] },
|
|
154
|
-
identity,
|
|
155
|
-
sites,
|
|
156
|
-
});
|
|
157
|
-
return allHandlers.drupal_list_content_types({ site: ctMatch[1] });
|
|
163
|
+
return invokeReadTool("drupal_list_content_types", { site: ctMatch[1] });
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
// drupal://{site}/security-policy
|
|
161
167
|
const spMatch = uri.match(/^drupal:\/\/([^/]+)\/security-policy$/);
|
|
162
168
|
if (spMatch) {
|
|
163
|
-
|
|
164
|
-
toolName: "drupal_security_info",
|
|
165
|
-
args: { site: spMatch[1] },
|
|
166
|
-
identity,
|
|
167
|
-
sites,
|
|
168
|
-
});
|
|
169
|
-
return allHandlers.drupal_security_info({ site: spMatch[1] });
|
|
169
|
+
return invokeReadTool("drupal_security_info", { site: spMatch[1] });
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
throw new Error(`Unknown resource URI: ${uri}`);
|
|
@@ -11,6 +11,7 @@ import { drupalFetch, drupalUploadFile } from "../drupal-fetch.js";
|
|
|
11
11
|
import { getRequestIdentity } from "../principal.js";
|
|
12
12
|
import { validateUuid, validateMachineName } from "../validate.js";
|
|
13
13
|
import { parseFieldConfigObject } from "../field-definition.js";
|
|
14
|
+
import { httpStatusOf } from "../error-status.js";
|
|
14
15
|
import { Backend } from "./backend-interface.js";
|
|
15
16
|
import {
|
|
16
17
|
makeCanonicalEntity,
|
|
@@ -33,8 +34,9 @@ const COUNT_PAGE_SIZE = 50;
|
|
|
33
34
|
const COUNT_MAX_RECORDS = 1000;
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
|
-
* JSON:API language headers. Drupal
|
|
37
|
-
*
|
|
37
|
+
* JSON:API language headers. Stock Drupal ignores these unless Browser
|
|
38
|
+
* (Accept-Language) detection is enabled for content language. Production
|
|
39
|
+
* sites usually negotiate via URL prefixes instead — see jsonApiLanguagePath.
|
|
38
40
|
* @param {?string} langcode
|
|
39
41
|
* @returns {object}
|
|
40
42
|
*/
|
|
@@ -43,6 +45,59 @@ function languageFetchOptions(langcode) {
|
|
|
43
45
|
return { headers: { "Content-Language": langcode, "Accept-Language": langcode } };
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Prefix a JSON:API path with the language code stock Drupal URL negotiation
|
|
50
|
+
* expects (`/es/jsonapi/...`). Does not add `?langCode=` — core JSON:API
|
|
51
|
+
* rejects unknown query parameters unless jsonapi_multilingual is installed.
|
|
52
|
+
* @param {string} path JSON:API path, optionally with a query string.
|
|
53
|
+
* @param {?string} langcode
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function jsonApiLanguagePath(path, langcode) {
|
|
57
|
+
if (!langcode) return path;
|
|
58
|
+
const prefix = `/${encodeURIComponent(langcode)}`;
|
|
59
|
+
if (path === prefix || path.startsWith(`${prefix}/`)) return path;
|
|
60
|
+
return `${prefix}${path}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Merge language headers onto a drupalFetch options object.
|
|
65
|
+
* @param {object} [options]
|
|
66
|
+
* @param {?string} langcode
|
|
67
|
+
* @returns {object}
|
|
68
|
+
*/
|
|
69
|
+
function languageRequestOptions(options, langcode) {
|
|
70
|
+
const lang = languageFetchOptions(langcode);
|
|
71
|
+
if (!lang.headers) return { ...options };
|
|
72
|
+
return {
|
|
73
|
+
...options,
|
|
74
|
+
headers: { ...(options?.headers || {}), ...lang.headers },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fetch a JSON:API resource in a requested language.
|
|
80
|
+
*
|
|
81
|
+
* Prefer the URL prefix (how `/es/jsonapi/node/...` works on multilingual
|
|
82
|
+
* Drupal). If that 404s — the site has no language prefixes — retry the
|
|
83
|
+
* unprefixed path with language headers only (#398 / #303).
|
|
84
|
+
* @param {object} site Resolved site config.
|
|
85
|
+
* @param {string} path Unprefixed JSON:API path.
|
|
86
|
+
* @param {object} [options] node-fetch options (method, body, extra headers).
|
|
87
|
+
* @param {?string} langcode
|
|
88
|
+
* @returns {Promise<object|null>}
|
|
89
|
+
*/
|
|
90
|
+
async function fetchJsonApiWithLanguage(site, path, options = {}, langcode) {
|
|
91
|
+
const opts = languageRequestOptions(options, langcode);
|
|
92
|
+
if (!langcode) return drupalFetch(site, path, opts);
|
|
93
|
+
try {
|
|
94
|
+
return await drupalFetch(site, jsonApiLanguagePath(path, langcode), opts);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (httpStatusOf(err) !== 404) throw err;
|
|
97
|
+
return drupalFetch(site, path, opts);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
46
101
|
/**
|
|
47
102
|
* Fail if JSON:API served a different language than requested.
|
|
48
103
|
* @param {?object} entity
|
|
@@ -59,7 +114,8 @@ function assertServedLanguage(entity, langcode) {
|
|
|
59
114
|
if (entity.langcode !== langcode) {
|
|
60
115
|
throw new Error(
|
|
61
116
|
`JSON:API served language "${entity.langcode}" for requested langcode "${langcode}". ` +
|
|
62
|
-
"The translation may not exist, or this site does not negotiate JSON:API by language
|
|
117
|
+
"The translation may not exist, or this site does not negotiate JSON:API by language " +
|
|
118
|
+
`(URL prefix /${langcode}/jsonapi/… or Accept-Language).`,
|
|
63
119
|
);
|
|
64
120
|
}
|
|
65
121
|
}
|
|
@@ -291,10 +347,12 @@ export class JsonApiBackend extends Backend {
|
|
|
291
347
|
// while the UUID remains the canonical top-level id (#237).
|
|
292
348
|
// Paragraph ERR attach needs the current revision id (#192).
|
|
293
349
|
// Node / revisionable writes need the working vs live vid (#166).
|
|
350
|
+
// Menu-link admin edit URLs need the numeric id (#398).
|
|
294
351
|
// Other drupal_internal__* attributes stay stripped.
|
|
295
352
|
return (entityType === "node" && k === "drupal_internal__nid")
|
|
296
353
|
|| k === "drupal_internal__vid"
|
|
297
|
-
|| (entityType === "paragraph" && k === "drupal_internal__revision_id")
|
|
354
|
+
|| (entityType === "paragraph" && k === "drupal_internal__revision_id")
|
|
355
|
+
|| (entityType === "menu_link_content" && k === "drupal_internal__id");
|
|
298
356
|
}
|
|
299
357
|
return true;
|
|
300
358
|
})
|
|
@@ -397,7 +455,7 @@ export class JsonApiBackend extends Backend {
|
|
|
397
455
|
if (resourceVersion) {
|
|
398
456
|
path += `?resourceVersion=${encodeURIComponent(resourceVersion)}`;
|
|
399
457
|
}
|
|
400
|
-
const data = await
|
|
458
|
+
const data = await fetchJsonApiWithLanguage(this.site, path, {}, langcode);
|
|
401
459
|
const entity = data?.data ? this.toCanonical(data.data) : null;
|
|
402
460
|
assertServedLanguage(entity, langcode);
|
|
403
461
|
return entity;
|
|
@@ -514,14 +572,19 @@ export class JsonApiBackend extends Backend {
|
|
|
514
572
|
* @returns {Promise<object>} The JSON:API response body.
|
|
515
573
|
*/
|
|
516
574
|
async writeWithModerationFallback(path, method, buildPayload, attributes, langcode) {
|
|
517
|
-
const
|
|
575
|
+
const send = (attrs) => fetchJsonApiWithLanguage(
|
|
576
|
+
this.site,
|
|
577
|
+
path,
|
|
578
|
+
{ method, body: JSON.stringify(buildPayload(attrs)) },
|
|
579
|
+
langcode,
|
|
580
|
+
);
|
|
518
581
|
try {
|
|
519
|
-
return await
|
|
582
|
+
return await send(attributes);
|
|
520
583
|
} catch (err) {
|
|
521
584
|
if (!isModeratedStatusError(err) || !("status" in attributes)) throw err;
|
|
522
585
|
const withoutStatus = { ...attributes };
|
|
523
586
|
delete withoutStatus.status;
|
|
524
|
-
return
|
|
587
|
+
return send(withoutStatus);
|
|
525
588
|
}
|
|
526
589
|
}
|
|
527
590
|
|
package/src/lib/dispatch.js
CHANGED
|
@@ -236,3 +236,25 @@ export async function callTool(name, args, context = {}) {
|
|
|
236
236
|
return toolError(err);
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Run a read tool through {@link callTool} and unwrap the MCP envelope.
|
|
242
|
+
* `resources/read` uses this so those URIs cannot skip middleware.
|
|
243
|
+
*
|
|
244
|
+
* @param {string} name Tool name.
|
|
245
|
+
* @param {object} args Tool arguments.
|
|
246
|
+
* @param {object} [context] Optional inbound identity / grant overrides.
|
|
247
|
+
* @returns {Promise<*>} The tool payload.
|
|
248
|
+
* @throws {Error} When the tool is missing, denied, or returns isError.
|
|
249
|
+
*/
|
|
250
|
+
export async function invokeReadTool(name, args, context = {}) {
|
|
251
|
+
const result = await callTool(name, args, context);
|
|
252
|
+
if (result?.isError) {
|
|
253
|
+
throw new Error(result.content?.[0]?.text ?? `Tool "${name}" failed`);
|
|
254
|
+
}
|
|
255
|
+
const text = result?.content?.[0]?.text;
|
|
256
|
+
if (typeof text !== "string") {
|
|
257
|
+
throw new Error(`Tool "${name}" returned no payload`);
|
|
258
|
+
}
|
|
259
|
+
return JSON.parse(text);
|
|
260
|
+
}
|