drupal-mcp-connector 1.8.0 → 2.0.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-drush-sql-query.md +2 -2
- package/CHANGELOG.md +46 -1
- package/README.md +32 -1
- package/config/config.example.json +1 -1
- package/package.json +1 -1
- package/src/lib/validate.js +18 -4
- package/src/tools/drush.js +67 -10
- package/src/tools/redirects.js +9 -5
|
@@ -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
|
|
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,52 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [2.0.0] - 2026-07-29
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- **BREAKING: `drupal_drush_sql_query` no longer runs ungoverned SQL, and is off
|
|
12
|
+
by default.** It called `drush sql:query`, which executes below Drupal's
|
|
13
|
+
entity API — so a site's `mcp_sentinel` policy profile, its denied entity
|
|
14
|
+
types, its redacted fields and its audit log had no effect on anything this
|
|
15
|
+
tool read. A statement could return exactly the data the same site refused
|
|
16
|
+
over JSON:API, and nothing recorded that it had. That is not fixable on the
|
|
17
|
+
Drupal side: Drush caps `sql:query`'s bootstrap below the level at which
|
|
18
|
+
module command files are discovered, so no module hook can run on its path.
|
|
19
|
+
Nor is it fixable here — this process holds the SSH key, so a client-side
|
|
20
|
+
check is a promise made by the thing being constrained.
|
|
21
|
+
|
|
22
|
+
The tool now calls `drush mcp-sentinel:sql-query` (mcp_sentinel ≥ 1.14),
|
|
23
|
+
where Drupal is fully bootstrapped and the policy profile decides. Two
|
|
24
|
+
independent opt-ins are required, both off by default: `drushSsh.rawSql:
|
|
25
|
+
"governed"` on the site here, and `allow_raw_sql` on the policy profile
|
|
26
|
+
there. There is no ungoverned mode — keeping one behind a flag would have
|
|
27
|
+
left the bypass a config key away and still invisible when used.
|
|
28
|
+
|
|
29
|
+
**To migrate:** set both flags, add `mcp-sentinel:sql-query` to
|
|
30
|
+
`allowedCommands` if the site pins that list, and expect a narrower tool —
|
|
31
|
+
the server accepts a single `SELECT` over entity tables only (no `SHOW` /
|
|
32
|
+
`DESCRIBE` / `EXPLAIN`, no expressions, no `SELECT *` on a table carrying a
|
|
33
|
+
redacted column). Schema introspection moves to the site-context and
|
|
34
|
+
entity-schema tools. Sites that do not run mcp_sentinel lose this tool; raw
|
|
35
|
+
database access belongs to the operator's own shell, not to an agent.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- **Docs: redirect entities are publishable.** The `redirects.js` header claimed
|
|
39
|
+
redirect entities have no enabled/disabled flag. That has been stale since the
|
|
40
|
+
redirect module's dev-1.x made the entity publishable (`enabled` is the published
|
|
41
|
+
key). Corrected the doc and added a verify-after-create caveat for sites on older
|
|
42
|
+
`mcp_sentinel` releases (≤ 1.9), where agent-created redirects can arrive silently
|
|
43
|
+
disabled until the publish gate exempts redirects.
|
|
44
|
+
|
|
45
|
+
## [1.8.1] - 2026-07-23
|
|
46
|
+
|
|
47
|
+
### Security
|
|
48
|
+
- Resolved npm audit high/low advisories via a compatible lockfile bump:
|
|
49
|
+
`brace-expansion` DoS (GHSA-3jxr-9vmj-r5cp), `fast-uri` host confusion
|
|
50
|
+
(GHSA-v2hh-gcrm-f6hx, GHSA-4c8g-83qw-93j6), and `body-parser` DoS
|
|
51
|
+
(GHSA-v422-hmwv-36x6). Two residual moderate `@hono/node-server` advisories
|
|
52
|
+
(Windows-only path traversal, GHSA-frvp-7c67-39w9) are tracked in #128, blocked
|
|
53
|
+
on an `@modelcontextprotocol/sdk` release accepting hono 2.x.
|
|
9
54
|
|
|
10
55
|
## [1.8.0] - 2026-07-23
|
|
11
56
|
|
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ Defense-in-depth with four one-line presets, enforced connector-side and complem
|
|
|
134
134
|
| Preset | What it does |
|
|
135
135
|
|--------|-------------|
|
|
136
136
|
| `development` | Everything allowed — local development only |
|
|
137
|
-
| `content-editor` | Create/edit nodes, media, terms; no deletes; no user access |
|
|
137
|
+
| `content-editor` | Create/edit nodes, media, terms; no deletes; no publishing (`allowPublish` off); no user access |
|
|
138
138
|
| `auditor` | Read-only, all entity types, PII fields redacted |
|
|
139
139
|
| `production-strict` | Read-only, no user entities, broad PII redaction |
|
|
140
140
|
|
|
@@ -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
package/src/lib/validate.js
CHANGED
|
@@ -66,7 +66,13 @@ export function validateUuid(value, fieldName = "id") {
|
|
|
66
66
|
// SQL query validation (read-only enforcement for Drush bridge)
|
|
67
67
|
// ---------------------------------------------------------------------------
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
// SELECT only. SHOW / DESCRIBE / EXPLAIN used to be accepted here, but raw SQL
|
|
70
|
+
// now runs through mcp_sentinel's governed command, which accepts SELECT alone
|
|
71
|
+
// — schema introspection has a governed home on the /drupal-mcp/context
|
|
72
|
+
// endpoint and the entity-schema tools. Keeping the wider list here would mean
|
|
73
|
+
// this check passed statements the server then refused, i.e. two policies
|
|
74
|
+
// disagreeing, which is the failure mode this whole change exists to remove.
|
|
75
|
+
const SAFE_SQL_PREFIXES = ["select "];
|
|
70
76
|
|
|
71
77
|
// Patterns that indicate write operations even within SELECT contexts
|
|
72
78
|
const DANGEROUS_SQL_PATTERNS = [
|
|
@@ -80,7 +86,14 @@ const DANGEROUS_SQL_PATTERNS = [
|
|
|
80
86
|
|
|
81
87
|
/**
|
|
82
88
|
* Validate that a SQL query is read-only.
|
|
83
|
-
*
|
|
89
|
+
*
|
|
90
|
+
* This is a fast local reject, NOT the authority. The authority is
|
|
91
|
+
* mcp_sentinel's McpRawSqlGuard, which runs inside Drupal where the policy
|
|
92
|
+
* profile lives and can resolve denied entity types and redacted fields down
|
|
93
|
+
* to real tables and columns — something no client-side check can do. This
|
|
94
|
+
* check exists only to fail obvious cases without an SSH round trip, so it is
|
|
95
|
+
* deliberately kept coarser than, and never wider than, the server's.
|
|
96
|
+
*
|
|
84
97
|
* @param {string} query The SQL query to validate.
|
|
85
98
|
* @returns {string} The validated query.
|
|
86
99
|
* @throws {Error} if the query is empty or exceeds the length cap.
|
|
@@ -95,8 +108,9 @@ export function validateSqlQuery(query) {
|
|
|
95
108
|
|
|
96
109
|
if (!SAFE_SQL_PREFIXES.some((prefix) => normalised.startsWith(prefix))) {
|
|
97
110
|
throw new SecurityError(
|
|
98
|
-
"drupal_drush_sql_query only permits SELECT
|
|
99
|
-
"Use the JSON:API tools for write operations
|
|
111
|
+
"drupal_drush_sql_query only permits SELECT statements. " +
|
|
112
|
+
"Use the JSON:API tools for write operations, and the site-context or " +
|
|
113
|
+
"entity-schema tools for schema introspection."
|
|
100
114
|
);
|
|
101
115
|
}
|
|
102
116
|
|
package/src/tools/drush.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* 1. SSH key auth only — password-based SSH is deliberately unsupported.
|
|
8
8
|
* 2. All command arguments are validated before being passed to SSH.
|
|
9
9
|
* 3. Module names are validated as machine names (a-z, 0-9, _) only.
|
|
10
|
-
* 4. SQL
|
|
10
|
+
* 4. Raw SQL runs ONLY through mcp_sentinel's governed command, and only for
|
|
11
|
+
* a site that opts in with drushSsh.rawSql="governed". The SELECT-only
|
|
12
|
+
* check here is a fast local reject; the site's policy profile is the
|
|
13
|
+
* authority, because this process holds the SSH key and cannot police
|
|
14
|
+
* itself.
|
|
11
15
|
* 5. Key path is validated against path traversal.
|
|
12
16
|
* 6. All operations are logged to stderr with site name and command.
|
|
13
17
|
* 7. Write operations assert non-readOnly via the security layer.
|
|
@@ -58,6 +62,32 @@ function assertCommandAllowed(sshCfg, subcommand) {
|
|
|
58
62
|
}
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Enforce the per-site opt-in for raw SQL.
|
|
67
|
+
*
|
|
68
|
+
* Raw SQL is off unless a site sets `drushSsh.rawSql: "governed"`. There is no
|
|
69
|
+
* ungoverned mode: the previous behaviour — running the statement through
|
|
70
|
+
* `drush sql:query` — bypassed the site's entire policy and left no audit
|
|
71
|
+
* record, so it was removed rather than kept behind a flag. A flag would have
|
|
72
|
+
* meant the bypass was one config key away and still invisible when used.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} sshCfg Resolved drushSsh config block.
|
|
75
|
+
* @param {string} siteName Site name, for the error message.
|
|
76
|
+
* @returns {void}
|
|
77
|
+
* @throws {SecurityError} if the site has not opted in.
|
|
78
|
+
*/
|
|
79
|
+
function assertGovernedRawSql(sshCfg, siteName) {
|
|
80
|
+
if (sshCfg.rawSql !== "governed") {
|
|
81
|
+
throw new SecurityError(
|
|
82
|
+
`Raw SQL is disabled for site "${siteName}". Raw SQL reads underneath Drupal's ` +
|
|
83
|
+
"entity API, so it is only available through mcp_sentinel's governed command. " +
|
|
84
|
+
"To enable it: install mcp_sentinel on the site, set `allow_raw_sql` on the policy " +
|
|
85
|
+
"profile that governs the agent, and set `rawSql: \"governed\"` in this site's " +
|
|
86
|
+
"drushSsh config (plus `mcp-sentinel:sql-query` in allowedCommands if that list is set)."
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
61
91
|
/**
|
|
62
92
|
* Resolve and validate the SSH key path. Prevents path traversal.
|
|
63
93
|
*/
|
|
@@ -400,18 +430,45 @@ async function drushCreateUser({ site: siteName, name, mail, password, roles = [
|
|
|
400
430
|
}
|
|
401
431
|
|
|
402
432
|
/**
|
|
403
|
-
* Run a read-only SQL query
|
|
404
|
-
*
|
|
433
|
+
* Run a read-only SQL query through mcp_sentinel's governed command.
|
|
434
|
+
*
|
|
435
|
+
* This used to call `drush sql:query`, which was a hole straight through the
|
|
436
|
+
* site's policy: `sql:query` runs below Drupal's entity API, so no policy
|
|
437
|
+
* profile, no denied_entity_types, no redacted_fields and no audit entry ever
|
|
438
|
+
* applied to it. That is not a fixable property of the command — Drush caps
|
|
439
|
+
* its bootstrap below the level at which Drupal discovers module command
|
|
440
|
+
* files, so no module code can run on its path at all.
|
|
441
|
+
*
|
|
442
|
+
* `mcp-sentinel:sql-query` is a module-provided command, so Drupal is fully
|
|
443
|
+
* bootstrapped: the policy profile applies, the statement is checked against
|
|
444
|
+
* the same deny and redaction lists that govern JSON:API, and every attempt —
|
|
445
|
+
* permitted or refused — is written to the tamper-evident audit chain with its
|
|
446
|
+
* statement text. Enforcement is therefore server-side, which matters because
|
|
447
|
+
* this process holds the SSH key and cannot be trusted to police itself.
|
|
448
|
+
*
|
|
405
449
|
* @param {object} args - { site?, query }.
|
|
406
|
-
* @returns {Promise<{rows
|
|
407
|
-
* @throws {SecurityError} If the query is not read-only.
|
|
450
|
+
* @returns {Promise<object>} The server's payload: { rows, row_count, truncated, profile }.
|
|
451
|
+
* @throws {SecurityError} If the site has not opted in, or the query is not read-only.
|
|
408
452
|
*/
|
|
409
453
|
async function sqlQuery({ site: siteName, query }) {
|
|
410
|
-
const site
|
|
411
|
-
|
|
454
|
+
const site = getSiteConfig(siteName);
|
|
455
|
+
const sshCfg = getDrushConfig(site);
|
|
456
|
+
assertGovernedRawSql(sshCfg, site._name);
|
|
457
|
+
// Fast local reject only; mcp_sentinel's guard is the authority.
|
|
412
458
|
validateSqlQuery(query);
|
|
413
|
-
const out = await sshDrush(site, ["sql
|
|
414
|
-
|
|
459
|
+
const out = await sshDrush(site, ["mcp-sentinel:sql-query", query]);
|
|
460
|
+
// The command emits a JSON object; a non-JSON reply means the command was
|
|
461
|
+
// not found (mcp_sentinel absent or too old), which must not be reported as
|
|
462
|
+
// an empty result set.
|
|
463
|
+
const payload = parseDrush(out);
|
|
464
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload) || !Array.isArray(payload.rows)) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`Raw SQL on site "${site._name}" did not return a governed result. ` +
|
|
467
|
+
"Confirm mcp_sentinel >= 1.14 is installed and enabled on the target site — " +
|
|
468
|
+
"`drush mcp-sentinel:sql-query` must exist there."
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return payload;
|
|
415
472
|
}
|
|
416
473
|
|
|
417
474
|
/**
|
|
@@ -477,7 +534,7 @@ export const definitions = [
|
|
|
477
534
|
},
|
|
478
535
|
{
|
|
479
536
|
name: "drupal_drush_sql_query",
|
|
480
|
-
description: "Run a read-only
|
|
537
|
+
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.",
|
|
481
538
|
inputSchema: { type: "object", required: ["query"], properties: { site: { type: "string" }, query: { type: "string" } } },
|
|
482
539
|
},
|
|
483
540
|
{
|
package/src/tools/redirects.js
CHANGED
|
@@ -19,11 +19,15 @@
|
|
|
19
19
|
* - `status_code` defaults to 301 and can be set to 302 (or another redirect
|
|
20
20
|
* code) explicitly on create, and changed on an existing redirect via update.
|
|
21
21
|
*
|
|
22
|
-
* Redirect
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
22
|
+
* Redirect module ≥ dev-1.x makes redirects publishable: an `enabled` base field
|
|
23
|
+
* (the published key) decides whether the redirect fires. This connector does not
|
|
24
|
+
* send `enabled` — sites govern that flag server-side (mcp_sentinel's publish
|
|
25
|
+
* gate exempted it as routing metadata as of its post-1.9 release; on older
|
|
26
|
+
* sentinel releases a deny-publish profile silently disables agent-created
|
|
27
|
+
* redirects — verify `enabled` after create on such sites). On pre-publishable
|
|
28
|
+
* redirect releases the field is absent and every redirect with a valid source
|
|
29
|
+
* is active. Both tools are governed: writes assert create/update permission for
|
|
30
|
+
* the `redirect` entity type against the per-site security policy.
|
|
27
31
|
*/
|
|
28
32
|
|
|
29
33
|
import { getSiteConfig } from "../lib/config.js";
|