drupal-mcp-connector 1.8.1 → 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.
@@ -1,12 +1,12 @@
1
1
  ---
2
- description: "Run a read-only SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN only) via Drush. Write queries are blocked by the security layer."
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 SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN only) via Drush. Write queries are blocked by the security layer.
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,42 @@ 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
- ## [Unreleased]
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.
9
44
 
10
45
  ## [1.8.1] - 2026-07-23
11
46
 
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "1.8.1",
3
+ "version": "2.0.0",
4
4
  "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -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
- const SAFE_SQL_PREFIXES = ["select ", "show ", "describe ", "explain ", "desc "];
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
- * Checks both the query prefix AND secondary injection patterns.
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, SHOW, DESCRIBE, and EXPLAIN statements. " +
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
 
@@ -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 tool enforces SELECT-only allowlist no DDL/DML permitted.
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 (`drush sql:query`). The query is validated against
404
- * a SELECT-only allowlist before execution.
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 = getSiteConfig(siteName);
411
- // Throws SecurityError if query is not read-only
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:query", query]);
414
- return { rows: parseDrush(out) };
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 SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN only) via Drush. Write queries are blocked by the security layer.",
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
  {
@@ -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 entities have no separate enabled/disabled flag a redirect with a
23
- * valid source is active. "Enable an existing redirect" therefore means: correct
24
- * its fields so it matches and fires, which is exactly what drupal_update_redirect
25
- * does. Both tools are governed: writes assert create/update permission for the
26
- * `redirect` entity type against the per-site security policy.
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";