drupal-mcp-connector 1.8.1 → 2.1.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/.claude/commands/drupal-bulk-update.md +2 -2
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-drush-sql-query.md +2 -2
- package/.claude/commands/drupal-entity-update.md +2 -2
- package/.claude/commands/drupal-update-node.md +3 -3
- package/.claude/commands/drupal-upload-file-and-create-media.md +4 -4
- package/.claude/commands/drupal-upload-file.md +1 -1
- package/CHANGELOG.md +94 -0
- package/README.md +31 -0
- package/config/config.example.json +1 -1
- package/package.json +1 -1
- package/scripts/generate-commands.js +4 -0
- package/src/index.js +27 -9
- package/src/lib/drupal-fetch.js +16 -6
- package/src/lib/link-checker.js +3 -1
- package/src/lib/moderation-default.js +106 -0
- package/src/lib/security.js +23 -13
- package/src/lib/validate.js +134 -4
- package/src/tools/bulk.js +13 -3
- package/src/tools/drush.js +67 -10
- package/src/tools/entities.js +12 -4
- package/src/tools/media.js +43 -13
- package/src/tools/moderation.js +7 -2
- package/src/tools/nodes.js +32 -9
- package/src/tools/redirects.js +9 -5
- package/src/tools/taxonomy.js +10 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }."
|
|
2
|
+
description: "Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }."
|
|
3
3
|
argument-hint: "<entityType> <bundle> <items> [site]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_bulk_update
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_bulk_update` MCP tool.
|
|
8
8
|
|
|
9
|
-
Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Returns per-item { index, success, id | error } and a summary { updated, failed }.
|
|
9
|
+
Update many entities of a single type + bundle in one call. Permission is checked once; each item is updated independently, so the batch continues past individual failures (partial success). Each item requires an 'id' (UUID); items missing an id are reported as per-item failures. Published moderated targets without an explicit attributes.moderation_state default to moderation_state 'draft' (forward revision) so bulk edits do not mutate live default revisions. Returns per-item { index, success, id | error } and a summary { updated, failed }.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -16,7 +16,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
16
16
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
|
-
- `status` (boolean (true/false))
|
|
19
|
+
- `status` (boolean (true/false)): Published flag. Defaults to false (unpublished). Requires allowPublish when true.
|
|
20
20
|
- `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')
|
|
21
21
|
|
|
22
22
|
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: "Run a read-only
|
|
2
|
+
description: "Run a single read-only SELECT through mcp_sentinel's governed command (`drush mcp-sentinel:sql-query`). Requires the site to set drushSsh.rawSql=\"governed\" AND the site's policy profile to set allow_raw_sql; both are off by default. The server refuses statements touching a denied entity type, a non-entity table, or a redacted field, and records every attempt in the tamper-evident audit log. Use the site-context or entity-schema tools for schema introspection."
|
|
3
3
|
argument-hint: "<query> [site]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_drush_sql_query
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_drush_sql_query` MCP tool.
|
|
8
8
|
|
|
9
|
-
Run a read-only
|
|
9
|
+
Run a single read-only SELECT through mcp_sentinel's governed command (`drush mcp-sentinel:sql-query`). Requires the site to set drushSsh.rawSql="governed" AND the site's policy profile to set allow_raw_sql; both are off by default. The server refuses statements touching a denied entity type, a non-entity table, or a redacted field, and records every attempt in the tamper-evident audit log. Use the site-context or entity-schema tools for schema introspection.
|
|
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."
|
|
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)."
|
|
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.
|
|
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).
|
|
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 node. Only include fields you want to change. For moderated content types, use moderationState (e.g. 'published') rather than status. 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`."
|
|
3
3
|
argument-hint: "<type> <id> [site] [title] [body] [summary] [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. 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`.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -20,7 +20,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
20
20
|
- `body` (string)
|
|
21
21
|
- `summary` (string)
|
|
22
22
|
- `status` (boolean (true/false)): Published flag for NON-moderated types: true = publish, false = unpublish. Ignored if moderationState is set.
|
|
23
|
-
- `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status.
|
|
23
|
+
- `moderationState` (string): Moderation state transition for content_moderation types, e.g. 'draft', 'published', 'archived'. Takes precedence over status. Required to keep or re-publish a live node — omitting it on a published moderated node defaults the write to 'draft'.
|
|
24
24
|
- `fields` (object (pass as JSON)): Scalar/attribute field values keyed by machine name. Entity-reference fields go in `relationships`, not here.
|
|
25
25
|
- `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 }, …] }.
|
|
26
26
|
- `dryRun` (boolean (true/false)): Validate and return a preview of the update without committing.
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow."
|
|
2
|
+
description: "Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished."
|
|
3
3
|
argument-hint: "<filePath> <mediaType> <fieldName> [site] [mediaName] [altText] [status]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_upload_file_and_create_media
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_upload_file_and_create_media` MCP tool.
|
|
8
8
|
|
|
9
|
-
Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow.
|
|
9
|
+
Convenience tool: upload a local file and immediately create a Media entity in one step. Best for the common 'add an image' workflow. Media defaults to unpublished.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
13
13
|
**Required:**
|
|
14
|
-
- `filePath` (string):
|
|
14
|
+
- `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
|
|
15
15
|
- `mediaType` (string): Media type machine name, e.g. 'image'
|
|
16
16
|
- `fieldName` (string): Source field machine name, e.g. 'field_media_image'
|
|
17
17
|
|
|
@@ -19,6 +19,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
19
19
|
- `site` (string): omit for the default site
|
|
20
20
|
- `mediaName` (string): Name for the media entity (defaults to filename)
|
|
21
21
|
- `altText` (string): Alt text for image media
|
|
22
|
-
- `status` (boolean (true/false))
|
|
22
|
+
- `status` (boolean (true/false)): Published flag. Defaults to false. Requires allowPublish when true.
|
|
23
23
|
|
|
24
24
|
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.
|
|
@@ -11,7 +11,7 @@ Upload a local file to Drupal and create a File entity. Returns the file UUID to
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
13
13
|
**Required:**
|
|
14
|
-
- `filePath` (string):
|
|
14
|
+
- `filePath` (string): Local path to the file (must resolve under MCP_UPLOAD_ROOT or the connector working directory)
|
|
15
15
|
- `bundle` (string): Bundle machine name, e.g. 'image', 'article'
|
|
16
16
|
- `fieldName` (string): Field machine name, e.g. 'field_media_image', 'field_image'
|
|
17
17
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.1.0] - 2026-07-31
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Specialized tools honor entity allowlists/denylists (#138).**
|
|
14
|
+
`drupal_*_node`, media, and taxonomy tools now call the same
|
|
15
|
+
`assertReadAllowed` / `assertWriteAllowed` / `assertDeleteAllowed` gates as
|
|
16
|
+
`drupal_entity_*`, so a denied type (e.g. `media`) cannot be reached by
|
|
17
|
+
switching tool names.
|
|
18
|
+
- **Upload path allowlist and path segment hardening (#137).** File uploads
|
|
19
|
+
must resolve under `MCP_UPLOAD_ROOT` (or the connector cwd by default);
|
|
20
|
+
entity/bundle/field path segments are machine-name validated and encoded;
|
|
21
|
+
Content-Disposition filenames are sanitized. Sensitive paths (`.env*`,
|
|
22
|
+
`.ssh`, connector `config.json`) are refused even under an allowed root.
|
|
23
|
+
- **Media no longer publishes by default (#139).** `drupal_create_media` and
|
|
24
|
+
upload-and-create default to `status: false` and honor `assertPublishAllowed`.
|
|
25
|
+
`moderation_state: published` and `drupal_set_moderation_state` to published
|
|
26
|
+
are treated as publish-bearing.
|
|
27
|
+
- **HTTPS fails closed without auth when non-loopback (#141).** Binding beyond
|
|
28
|
+
loopback requires `MCP_AUTH_TOKEN` unless `MCP_ALLOW_UNAUTHENTICATED=1`.
|
|
29
|
+
Non-loopback HTTPS defaults to 120 req/min rate limiting when
|
|
30
|
+
`MCP_RATE_LIMIT` is unset.
|
|
31
|
+
- **Link-checker does not follow redirects (#143).** Live checks use
|
|
32
|
+
`redirect: "manual"` to avoid SSRF via 302 to private/metadata targets.
|
|
33
|
+
- **auditor / production-strict apply SENSITIVE_DENY** (partial #140). Secrets,
|
|
34
|
+
governance, and account entity types stay denied on those presets. Default
|
|
35
|
+
when `security` is omitted remains `development` (tracked in #140).
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **Published moderated updates default to a draft forward revision (#131).**
|
|
39
|
+
`drupal_update_node`, `drupal_bulk_update`, and `drupal_entity_update` now
|
|
40
|
+
sniff the target entity: when it is published under content_moderation and
|
|
41
|
+
the caller omitted `moderation_state` / `moderationState`, the write is sent
|
|
42
|
+
as `moderation_state: draft` instead of omitting the field. That keeps bulk
|
|
43
|
+
relationship-wiring and field edits as reviewable forward revisions rather
|
|
44
|
+
than live default-revision mutations if a server-side publish gate
|
|
45
|
+
mis-classifies the write. Callers that want a same-state save must pass an
|
|
46
|
+
explicit moderation state.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
- **Provider-agnostic agent instructions.** Root `AGENTS.md` is the only
|
|
50
|
+
committed agent rule file (any coding agent). No vendor-specific
|
|
51
|
+
`.grok/` / `CLAUDE.md` / parallel rule trees. Generated slash stubs under
|
|
52
|
+
`.claude/commands/` remain client ergonomics for MCP operators, not project
|
|
53
|
+
development rules.
|
|
54
|
+
|
|
55
|
+
### Documentation
|
|
56
|
+
- Security, hardening, deployment, tools-reference, threat-model, and
|
|
57
|
+
architecture docs updated for the 2.1 controls (fail-closed HTTPS, upload
|
|
58
|
+
roots, publish/draft defaults, GraphQL policy caveat).
|
|
59
|
+
|
|
60
|
+
## [2.0.0] - 2026-07-29
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
- **Re-pushing a release tag no longer fails the publish job.** The workflow
|
|
64
|
+
now skips publishing a version already on the registry. Moving or recreating
|
|
65
|
+
a tag is a normal recovery operation, and npm's E403 "cannot publish over the
|
|
66
|
+
previously published version" reads as a broken release when the registry is
|
|
67
|
+
in exactly the state you wanted.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
- **BREAKING: `drupal_drush_sql_query` no longer runs ungoverned SQL, and is off
|
|
71
|
+
by default.** It called `drush sql:query`, which executes below Drupal's
|
|
72
|
+
entity API — so a site's `mcp_sentinel` policy profile, its denied entity
|
|
73
|
+
types, its redacted fields and its audit log had no effect on anything this
|
|
74
|
+
tool read. A statement could return exactly the data the same site refused
|
|
75
|
+
over JSON:API, and nothing recorded that it had. That is not fixable on the
|
|
76
|
+
Drupal side: Drush caps `sql:query`'s bootstrap below the level at which
|
|
77
|
+
module command files are discovered, so no module hook can run on its path.
|
|
78
|
+
Nor is it fixable here — this process holds the SSH key, so a client-side
|
|
79
|
+
check is a promise made by the thing being constrained.
|
|
80
|
+
|
|
81
|
+
The tool now calls `drush mcp-sentinel:sql-query` (mcp_sentinel ≥ 1.14),
|
|
82
|
+
where Drupal is fully bootstrapped and the policy profile decides. Two
|
|
83
|
+
independent opt-ins are required, both off by default: `drushSsh.rawSql:
|
|
84
|
+
"governed"` on the site here, and `allow_raw_sql` on the policy profile
|
|
85
|
+
there. There is no ungoverned mode — keeping one behind a flag would have
|
|
86
|
+
left the bypass a config key away and still invisible when used.
|
|
87
|
+
|
|
88
|
+
**To migrate:** set both flags, add `mcp-sentinel:sql-query` to
|
|
89
|
+
`allowedCommands` if the site pins that list, and expect a narrower tool —
|
|
90
|
+
the server accepts a single `SELECT` over entity tables only (no `SHOW` /
|
|
91
|
+
`DESCRIBE` / `EXPLAIN`, no expressions, no `SELECT *` on a table carrying a
|
|
92
|
+
redacted column). Schema introspection moves to the site-context and
|
|
93
|
+
entity-schema tools. Sites that do not run mcp_sentinel lose this tool; raw
|
|
94
|
+
database access belongs to the operator's own shell, not to an agent.
|
|
95
|
+
|
|
96
|
+
### Fixed
|
|
97
|
+
- **Docs: redirect entities are publishable.** The `redirects.js` header claimed
|
|
98
|
+
redirect entities have no enabled/disabled flag. That has been stale since the
|
|
99
|
+
redirect module's dev-1.x made the entity publishable (`enabled` is the published
|
|
100
|
+
key). Corrected the doc and added a verify-after-create caveat for sites on older
|
|
101
|
+
`mcp_sentinel` releases (≤ 1.9), where agent-created redirects can arrive silently
|
|
102
|
+
disabled until the publish gate exempts redirects.
|
|
103
|
+
|
|
10
104
|
## [1.8.1] - 2026-07-23
|
|
11
105
|
|
|
12
106
|
### Security
|
package/README.md
CHANGED
|
@@ -209,6 +209,37 @@ drush mcp-sentinel:setup
|
|
|
209
209
|
|
|
210
210
|
Governance keys off the authenticated account's role and OAuth scopes — not request headers. The connector sends an `X-MCP-Client` identity header purely as a log label. See the [MCP Sentinel project page](https://www.drupal.org/project/mcp_sentinel) for the full contract.
|
|
211
211
|
|
|
212
|
+
### Raw SQL is governed, or it is off
|
|
213
|
+
|
|
214
|
+
The Drush bridge runs over SSH, which is *below* everything above: a `drush`
|
|
215
|
+
subprocess does not make a request Drupal can govern, and most Drush commands
|
|
216
|
+
never load Drupal's module system at all. `drupal_drush_sql_query` used to
|
|
217
|
+
exploit that without meaning to — it called `drush sql:query`, so no policy
|
|
218
|
+
profile, no denied entity type, no field redaction and no audit entry applied
|
|
219
|
+
to anything it read.
|
|
220
|
+
|
|
221
|
+
It now calls `drush mcp-sentinel:sql-query` (mcp_sentinel ≥ 1.14), which runs
|
|
222
|
+
with Drupal fully bootstrapped and enforces the same profile that governs
|
|
223
|
+
JSON:API. **Two opt-ins, both off by default**, are required:
|
|
224
|
+
|
|
225
|
+
```jsonc
|
|
226
|
+
// this connector, per site
|
|
227
|
+
"drushSsh": {
|
|
228
|
+
"rawSql": "governed", // no other value enables it
|
|
229
|
+
"allowedCommands": ["mcp-sentinel:sql-query"] // only if you pin this list
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
…plus `allow_raw_sql` on the policy profile in Drupal. Expect a much narrower
|
|
234
|
+
tool than before: one `SELECT`, entity tables only, no expressions, and no
|
|
235
|
+
`SELECT *` on a table carrying a redacted column. Every attempt — permitted or
|
|
236
|
+
refused — lands in the audit chain with its statement text.
|
|
237
|
+
|
|
238
|
+
The rest of the bridge (`sql:cli`, `sql:dump`, `php:eval` and anything else you
|
|
239
|
+
reach over SSH) is **outside Drupal governance by construction**. Treat SSH as
|
|
240
|
+
an operator channel: keep the agent's credentials off it, and pin
|
|
241
|
+
`allowedCommands` per site.
|
|
242
|
+
|
|
212
243
|
---
|
|
213
244
|
|
|
214
245
|
## Documentation
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
},
|
|
71
71
|
"serverTools": { "url": "/mcp" },
|
|
72
72
|
"drushSsh": {
|
|
73
|
-
"_comment": "Dev only. DDEV web-container SSH target. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here.",
|
|
73
|
+
"_comment": "Dev only. DDEV web-container SSH target. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here. rawSql is omitted, so drupal_drush_sql_query is refused: raw SQL reads underneath Drupal's entity API and is only available via mcp_sentinel's governed command. To enable it, set rawSql:\"governed\", add \"mcp-sentinel:sql-query\" to allowedCommands, and set allow_raw_sql on the site's policy profile.",
|
|
74
74
|
"host": "<ddev-web-container-ssh-host>",
|
|
75
75
|
"user": "<ddev-ssh-user>",
|
|
76
76
|
"keyPath": "~/.ssh/id_ed25519",
|
package/package.json
CHANGED
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* `mcp__drupal__<tool>` and instructs the model to parse `$ARGUMENTS` into the
|
|
9
9
|
* tool's parameters before making a single call.
|
|
10
10
|
*
|
|
11
|
+
* These files are *client ergonomics* for operators using Claude Code as an MCP
|
|
12
|
+
* client against this server — not provider-specific project rules. Development
|
|
13
|
+
* instructions for any agent live only in AGENTS.md.
|
|
14
|
+
*
|
|
11
15
|
* Driven from the same tool definitions as the server (src/tools/index.js), so the
|
|
12
16
|
* command set never drifts from the tools. Run: `npm run generate:commands`.
|
|
13
17
|
*
|
package/src/index.js
CHANGED
|
@@ -384,6 +384,7 @@ if (transport === "stdio") {
|
|
|
384
384
|
|
|
385
385
|
const authToken = process.env.MCP_AUTH_TOKEN || "";
|
|
386
386
|
const checkAuth = makeBearerCheck(authToken);
|
|
387
|
+
const allowUnauth = process.env.MCP_ALLOW_UNAUTHENTICATED === "1";
|
|
387
388
|
if (!authToken) {
|
|
388
389
|
console.error(
|
|
389
390
|
"[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
|
|
@@ -450,11 +451,33 @@ if (transport === "stdio") {
|
|
|
450
451
|
return mcpTransport;
|
|
451
452
|
}
|
|
452
453
|
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
const
|
|
454
|
+
const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
|
|
455
|
+
// Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
|
|
456
|
+
// bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
|
|
457
|
+
const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
|
|
458
|
+
const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
|
|
459
|
+
|
|
460
|
+
// #141: fail closed when HTTPS is network-facing without a bearer token.
|
|
461
|
+
// Loopback binds and explicit MCP_ALLOW_UNAUTHENTICATED=1 remain for local/proxy setups.
|
|
462
|
+
if (!authToken && !isLoopbackBind && !allowUnauth) {
|
|
463
|
+
console.error(
|
|
464
|
+
"[drupal-mcp-connector] FATAL: MCP_AUTH_TOKEN is required when binding beyond loopback.\n" +
|
|
465
|
+
" Set MCP_AUTH_TOKEN, bind to 127.0.0.1 (default without MCP_BIND_HOST), or set\n" +
|
|
466
|
+
" MCP_ALLOW_UNAUTHENTICATED=1 only behind a trusted auth boundary."
|
|
467
|
+
);
|
|
468
|
+
process.exit(1);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Optional fixed-window rate limiting on /mcp, keyed by client IP.
|
|
472
|
+
// HTTPS non-loopback defaults to 120 req/min when MCP_RATE_LIMIT is unset (#141).
|
|
473
|
+
// Set MCP_RATE_LIMIT=0 to disable. Counts are per-process; multi-replica should
|
|
474
|
+
// also rate-limit at the reverse proxy.
|
|
457
475
|
const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
|
|
476
|
+
const rateLimitEnv = process.env.MCP_RATE_LIMIT;
|
|
477
|
+
const rateLimitDefault = (hasTls && !isLoopbackBind) ? 120 : 0;
|
|
478
|
+
const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
|
|
479
|
+
? rateLimitDefault
|
|
480
|
+
: Number(rateLimitEnv);
|
|
458
481
|
const rateLimiter = rateLimit > 0
|
|
459
482
|
? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
|
|
460
483
|
: null;
|
|
@@ -474,11 +497,6 @@ if (transport === "stdio") {
|
|
|
474
497
|
|
|
475
498
|
const nodeServer = createNodeServer(requestHandler);
|
|
476
499
|
|
|
477
|
-
const hasTls = Boolean(tlsCfg.certPath && tlsCfg.keyPath);
|
|
478
|
-
// Unauthenticated plain HTTP must never bind beyond loopback. A non-loopback
|
|
479
|
-
// bind is allowed only alongside TLS, via an explicit MCP_BIND_HOST opt-in.
|
|
480
|
-
const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
|
|
481
|
-
|
|
482
500
|
nodeServer.listen(port, bindHost, () => {
|
|
483
501
|
const proto = hasTls ? "https" : "http";
|
|
484
502
|
console.error(
|
package/src/lib/drupal-fetch.js
CHANGED
|
@@ -7,6 +7,11 @@ import { createReadStream, statSync } from "fs";
|
|
|
7
7
|
import { basename } from "path";
|
|
8
8
|
import { authHeadersAsync, clientHeaders } from "./config.js";
|
|
9
9
|
import { clearToken } from "./oauth.js";
|
|
10
|
+
import {
|
|
11
|
+
assertUploadPathAllowed,
|
|
12
|
+
sanitizeUploadFilename,
|
|
13
|
+
validateMachineName,
|
|
14
|
+
} from "./validate.js";
|
|
10
15
|
|
|
11
16
|
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
|
12
17
|
|
|
@@ -115,10 +120,15 @@ export async function drupalGraphqlFetch(site, body) {
|
|
|
115
120
|
* @throws {Error} on any non-2xx response.
|
|
116
121
|
*/
|
|
117
122
|
export async function drupalUploadFile(site, entityType, bundle, fieldName, filePath) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
// #137: machine-name segments + path allowlist before any FS or network I/O.
|
|
124
|
+
validateMachineName(entityType, "entityType");
|
|
125
|
+
validateMachineName(bundle, "bundle");
|
|
126
|
+
validateMachineName(fieldName, "fieldName");
|
|
127
|
+
const safePath = assertUploadPathAllowed(filePath);
|
|
128
|
+
const filename = sanitizeUploadFilename(basename(safePath));
|
|
129
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
|
|
130
|
+
const stat = statSync(safePath);
|
|
131
|
+
const url = `${site.baseUrl}/jsonapi/${encodeURIComponent(entityType)}/${encodeURIComponent(bundle)}/${encodeURIComponent(fieldName)}`;
|
|
122
132
|
|
|
123
133
|
const res = await fetch(url, {
|
|
124
134
|
method: "POST",
|
|
@@ -129,8 +139,8 @@ export async function drupalUploadFile(site, entityType, bundle, fieldName, file
|
|
|
129
139
|
...clientHeaders(),
|
|
130
140
|
...(await authHeadersAsync(site)),
|
|
131
141
|
},
|
|
132
|
-
// eslint-disable-next-line security/detect-non-literal-fs-filename --
|
|
133
|
-
body: createReadStream(
|
|
142
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename -- path allowlisted via assertUploadPathAllowed
|
|
143
|
+
body: createReadStream(safePath),
|
|
134
144
|
// node-fetch requires explicit size for streams to set Content-Length
|
|
135
145
|
size: stat.size,
|
|
136
146
|
});
|
package/src/lib/link-checker.js
CHANGED
|
@@ -190,7 +190,9 @@ async function timedFetch(url, method, { timeoutMs, fetchImpl }) {
|
|
|
190
190
|
const controller = new AbortController();
|
|
191
191
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
192
192
|
try {
|
|
193
|
-
|
|
193
|
+
// #143: never auto-follow redirects — a public allowlisted host can 302 to
|
|
194
|
+
// private/metadata. Report the redirect status; do not chase Location.
|
|
195
|
+
const res = await fetchImpl(url, { method, redirect: "manual", signal: controller.signal });
|
|
194
196
|
return { status: res.status, error: null };
|
|
195
197
|
} catch (err) {
|
|
196
198
|
const reason = err?.name === "AbortError" ? `timeout after ${timeoutMs}ms` : (err?.message || "request failed");
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe draft default for updates of published, moderated entities (#131).
|
|
3
|
+
*
|
|
4
|
+
* When a PATCH targets a *published* entity under content_moderation and the
|
|
5
|
+
* caller did not pass an explicit `moderation_state`, the write can land on the
|
|
6
|
+
* live default revision if a server-side publish gate mis-classifies it as "no
|
|
7
|
+
* transition". Defaulting to `moderation_state: draft` forces a forward
|
|
8
|
+
* (reviewable) revision instead.
|
|
9
|
+
*
|
|
10
|
+
* Callers that genuinely want a same-state save on a published node must opt in
|
|
11
|
+
* by passing `moderation_state` (or the tool-level `moderationState`) explicitly.
|
|
12
|
+
*
|
|
13
|
+
* Detection is best-effort from the existing entity payload: the entity is
|
|
14
|
+
* treated as moderated when a `moderation_state` field is present on it, and as
|
|
15
|
+
* published when `status === true`. If the entity cannot be read, attributes
|
|
16
|
+
* are left unchanged (fail open for non-moderated bundles; server-side gate
|
|
17
|
+
* remains authoritative).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Default moderation state applied when none was supplied for a published moderated entity. */
|
|
21
|
+
export const SAFE_DRAFT_STATE = "draft";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a canonical entity exposes a content_moderation state field.
|
|
25
|
+
* @param {?object} entity Canonical entity (or null).
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function entityLooksModerated(entity) {
|
|
29
|
+
if (!entity) return false;
|
|
30
|
+
if (entity.fields && Object.prototype.hasOwnProperty.call(entity.fields, "moderation_state")) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
// Tolerate a top-level promotion if a backend ever surfaces it that way.
|
|
34
|
+
return Object.prototype.hasOwnProperty.call(entity, "moderation_state");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a canonical entity is currently published.
|
|
39
|
+
* @param {?object} entity Canonical entity (or null).
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isPublishedEntity(entity) {
|
|
43
|
+
return entity?.status === true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the write attributes already carry an explicit moderation state.
|
|
48
|
+
* @param {?object} attributes Attribute map about to be sent.
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function hasExplicitModerationState(attributes) {
|
|
52
|
+
return Boolean(attributes && Object.prototype.hasOwnProperty.call(attributes, "moderation_state"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether a draft default should be injected for this update.
|
|
57
|
+
* Pure helper — no I/O.
|
|
58
|
+
*
|
|
59
|
+
* @param {{attributes?: object, entity?: ?object}} input
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function shouldDefaultPublishedUpdateToDraft({ attributes, entity }) {
|
|
63
|
+
if (hasExplicitModerationState(attributes)) return false;
|
|
64
|
+
if (!isPublishedEntity(entity)) return false;
|
|
65
|
+
if (!entityLooksModerated(entity)) return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Apply the safe draft default when updating a published moderated entity.
|
|
71
|
+
* Returns a new attributes object when a default is applied; otherwise returns
|
|
72
|
+
* the original attributes reference unchanged.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} args
|
|
75
|
+
* @param {object} args.backend Backend with `getEntity`.
|
|
76
|
+
* @param {string} args.entityType
|
|
77
|
+
* @param {string} args.bundle
|
|
78
|
+
* @param {string} args.id Entity UUID.
|
|
79
|
+
* @param {object} [args.attributes={}] Attribute map for the write.
|
|
80
|
+
* @param {?object} [args.existingEntity] Pre-fetched entity; when omitted a get is issued.
|
|
81
|
+
* @returns {Promise<object>} Attributes to send (possibly with moderation_state: draft).
|
|
82
|
+
*/
|
|
83
|
+
export async function applySafeDraftDefault({
|
|
84
|
+
backend,
|
|
85
|
+
entityType,
|
|
86
|
+
bundle,
|
|
87
|
+
id,
|
|
88
|
+
attributes = {},
|
|
89
|
+
existingEntity,
|
|
90
|
+
}) {
|
|
91
|
+
if (hasExplicitModerationState(attributes)) return attributes;
|
|
92
|
+
|
|
93
|
+
let entity = existingEntity;
|
|
94
|
+
if (entity === undefined) {
|
|
95
|
+
try {
|
|
96
|
+
entity = await backend.getEntity({ entityType, bundle, id });
|
|
97
|
+
} catch {
|
|
98
|
+
// Fail open: without a readable target we cannot sniff moderation.
|
|
99
|
+
return attributes;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!shouldDefaultPublishedUpdateToDraft({ attributes, entity })) {
|
|
103
|
+
return attributes;
|
|
104
|
+
}
|
|
105
|
+
return { ...attributes, moderation_state: SAFE_DRAFT_STATE };
|
|
106
|
+
}
|
package/src/lib/security.js
CHANGED
|
@@ -175,7 +175,8 @@ const PRESETS = {
|
|
|
175
175
|
allowConfigRead: true, // read-only inspection of config
|
|
176
176
|
allowConfigWrite: false,
|
|
177
177
|
allowedEntityTypes: null, // read any entity type
|
|
178
|
-
|
|
178
|
+
// Secrets / governance / account entities stay denied even on broad read (#140).
|
|
179
|
+
deniedEntityTypes: [...SENSITIVE_DENY],
|
|
179
180
|
entityRules: {
|
|
180
181
|
user: {
|
|
181
182
|
allowedOperations: ["read"],
|
|
@@ -192,7 +193,7 @@ const PRESETS = {
|
|
|
192
193
|
allowConfigRead: false, // nothing implicit; opt in per site
|
|
193
194
|
allowConfigWrite: false,
|
|
194
195
|
allowedEntityTypes: null, // set an explicit allowlist in your config
|
|
195
|
-
deniedEntityTypes: [
|
|
196
|
+
deniedEntityTypes: [...SENSITIVE_DENY], // includes user + secrets/governance
|
|
196
197
|
entityRules: {},
|
|
197
198
|
globalRedactedFields: ["pass", "mail", "field_private", "field_api_key", "field_token"],
|
|
198
199
|
},
|
|
@@ -379,23 +380,33 @@ export function assertDestructiveAllowed(secConfig, entityType, id) {
|
|
|
379
380
|
}
|
|
380
381
|
|
|
381
382
|
/**
|
|
382
|
-
* Whether a set of write attributes carries a publish action.
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
383
|
+
* Whether a set of write attributes carries a publish action.
|
|
384
|
+
*
|
|
385
|
+
* Recognized signals:
|
|
386
|
+
* - `status === true` (non-moderated publish)
|
|
387
|
+
* - `moderation_state` equal to `"published"` (case-insensitive) — the common
|
|
388
|
+
* core content_moderation publish state (#139)
|
|
389
|
+
*
|
|
390
|
+
* Other workflow state names are site-specific and remain server-gated.
|
|
386
391
|
* @param {object} [attributes] Attribute map for the write.
|
|
387
392
|
* @returns {boolean}
|
|
388
393
|
*/
|
|
389
394
|
export function isPublishBearing(attributes = {}) {
|
|
390
|
-
|
|
395
|
+
if (attributes?.status === true) return true;
|
|
396
|
+
// Common core content_moderation publish state. Site-specific publish state
|
|
397
|
+
// names remain server-gated; this closes the obvious connector-layer hole (#139).
|
|
398
|
+
const mod = attributes?.moderation_state;
|
|
399
|
+
if (typeof mod === "string" && mod.toLowerCase() === "published") return true;
|
|
400
|
+
return false;
|
|
391
401
|
}
|
|
392
402
|
|
|
393
403
|
/**
|
|
394
404
|
* Local, fail-fast publish gate, symmetric with assertDestructiveAllowed. When
|
|
395
405
|
* the connector is not permitted to publish (allowPublish false — the default in
|
|
396
|
-
* every preset except `development`), a write
|
|
397
|
-
*
|
|
398
|
-
*
|
|
406
|
+
* every preset except `development`), a write that is publish-bearing
|
|
407
|
+
* (`status: true` or `moderation_state: "published"`) is refused before the
|
|
408
|
+
* round-trip, rather than being silently dropped by a moderated-bundle retry or
|
|
409
|
+
* a server-side gate (see #111/#114/#139). Client-side convenience only — the
|
|
399
410
|
* remote Drupal's own permissions remain the real authority.
|
|
400
411
|
* @param {object} secConfig Resolved security config.
|
|
401
412
|
* @param {object} [attributes] Attribute map for the write.
|
|
@@ -407,9 +418,8 @@ export function assertPublishAllowed(secConfig, attributes = {}) {
|
|
|
407
418
|
if (isPublishBearing(attributes)) {
|
|
408
419
|
throw new SecurityError(
|
|
409
420
|
"Publishing is disabled for this connector (allowPublish: false). " +
|
|
410
|
-
"Blocked: a write carrying status:true. " +
|
|
411
|
-
"To enable, set security.allowPublish = true in your config
|
|
412
|
-
"via an operator/server-gated path (e.g. a moderation transition)."
|
|
421
|
+
"Blocked: a write carrying status:true or moderation_state:published. " +
|
|
422
|
+
"To enable, set security.allowPublish = true in your config."
|
|
413
423
|
);
|
|
414
424
|
}
|
|
415
425
|
}
|