drupal-mcp-connector 0.10.0 → 1.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/CHANGELOG.md CHANGED
@@ -7,6 +7,89 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.1.0] - 2026-06-26
11
+
12
+ ### Security
13
+ - Bump transitive `hono` 4.12.23 → 4.12.27 (via `@modelcontextprotocol/sdk`), clearing
14
+ 5 advisories (1 high, 4 moderate): GHSA-88fw-hqm2-52qc (CORS wildcard-with-credentials),
15
+ GHSA-wwfh-h76j-fc44 (serve-static path traversal), GHSA-j6c9-x7qj-28xf, GHSA-rv63-4mwf-qqc2,
16
+ GHSA-wgpf-jwqj-8h8p. Lockfile-only; the SDK's `^4.11.4` range already permits the fix.
17
+ `npm audit` clean.
18
+
19
+ ### Added
20
+ - Environment-keyed governance tiers. The `config/config.example.json` template now
21
+ models four least-privilege tiers — `prod`/`staging` (content), `dev` (developer),
22
+ `dev-admin` (admin/break-glass) — each pinned by OAuth scopes and a security preset.
23
+ - New `config-editor` security preset (Developer tier): content-editor capabilities plus
24
+ governed config read/write. All presets gained `allowConfigRead` / `allowConfigWrite`
25
+ caps (mirroring the server-side governance profile), with `assertConfigReadAllowed` /
26
+ `assertConfigWriteAllowed` gates. Caps are surfaced by `drupal_security_info`.
27
+ - Governed configuration tools: `drupal_config_get`, `drupal_config_list`,
28
+ `drupal_config_set`. These call Drupal's authoritative server-side MCP config tools via a
29
+ new JSON-RPC bridge (`src/lib/server-tools.js`, per-site `serverTools.url`) — not drush —
30
+ and are gated by the new config caps as a defence-in-depth second layer.
31
+ - `drupal_mcp_whoami` — reports the agent's effective tier, preset, OAuth scopes, and
32
+ capabilities (read/write/delete/config/publish) for a site, so permitted actions are
33
+ visible up front. Publishing is always reported as server-gated.
34
+ - Per-site `drushSsh.allowedCommands` allowlist. When set, only those Drush subcommands
35
+ may run on that site; the example `dev` site is pinned to `config:export` /
36
+ `config:status`, and prod/staging carry no `drushSsh` block at all.
37
+ - CI: Slack release notification (`.github/workflows/release-notify.yml`) — posts to the
38
+ maintainers' release channel on release tags; no-ops without the `SLACK_WEBHOOK_RELEASES` secret.
39
+ - `bin/drupal-mcp-launch.sh` — launcher script for starting the connector
40
+ (secret-manager-friendly local launch).
41
+
42
+ ### Removed
43
+ - `.playwright-mcp/` page snapshots — throwaway browser-automation captures
44
+ that were committed by mistake; the directory is now gitignored.
45
+
46
+ ### Changed
47
+ - CI: the CHANGELOG check now exempts Dependabot PRs automatically (author
48
+ `dependabot[bot]`), so dependency bumps no longer need a changelog entry or the
49
+ `no-changelog` label.
50
+ - CI: made the Dependabot auto-merge workflow self-contained instead of calling
51
+ the private `Wilkes-Liberty/.github` reusable workflow. A public repo cannot use
52
+ a private reusable workflow, so the previous version startup-failed and
53
+ Dependabot PRs never auto-merged. Removed the dead `changelog-autoupdate.yml`
54
+ (also a private-reusable caller that needs an org GitHub App).
55
+
56
+ ### Fixed
57
+ - JSON:API filter values are now DB-portable, fixing report-tool 500s on
58
+ PostgreSQL-backed sites. Boolean filters (e.g. `status`) serialized as
59
+ `'true'`/`'false'` were rejected by Postgres' `smallint` columns ("invalid
60
+ input syntax for type smallint"); they are now `1`/`0`.
61
+ `drupal_report_stale_content` filtered the integer `changed` timestamp with an
62
+ ISO-8601 string (same class of error); it now uses epoch seconds. MySQL coerced
63
+ both, which masked the bug. (#71)
64
+ - `drupal_report_user_activity` now surfaces a top-level `approximate` flag when
65
+ any of its account counts hit the backend's safety ceiling — matching
66
+ `drupal_report_content_summary` and `drupal_report_taxonomy_usage`. Previously a
67
+ capped count (e.g. 1000 users) was presented as exact. (#75)
68
+ - JSON:API `countEntities` now returns the **exact** total by paginating through
69
+ `links.next`, instead of trusting `meta.count` — which Drupal core JSON:API does
70
+ not provide. Previously every report count collapsed to the requested page size
71
+ (e.g. `1` per non-empty content type), reported as exact. Counts beyond a safety
72
+ ceiling (1000 records) are returned and flagged `approximate`. Fixes the
73
+ undercount in `drupal_report_content_summary`, `drupal_report_taxonomy_usage`,
74
+ and `drupal_report_user_activity`. (#73)
75
+
76
+ ## [1.0.0] - 2026-06-15
77
+
78
+ First stable release. The tool surface, security model, and configuration
79
+ schema are now considered stable and will follow semantic versioning.
80
+
81
+ ### Added
82
+ - Stable **1.0** milestone: 89 tools across 20 modules with full read +
83
+ governed-write coverage (node/entity CRUD, revisions, moderation, scheduler,
84
+ fields, references, bulk operations, translations, paragraphs, structure,
85
+ search, and reports), `dryRun` preview on every write tool, the JSON:API and
86
+ GraphQL backends, the `write-plane` security preset, and multi-client launch
87
+ support (Claude Code, Claude Desktop, Grok Build).
88
+
89
+ ### Changed
90
+ - No functional changes since 0.10.0 — this release promotes the 0.10.x feature
91
+ set to a stable 1.0 line.
92
+
10
93
  ## [0.10.0] - 2026-06-15
11
94
 
12
95
  ### Added
@@ -252,6 +335,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
252
335
  - User tools gained explicit PII-access assertions.
253
336
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
254
337
 
338
+ [1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
255
339
  [0.10.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.10.0
256
340
  [0.9.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.1
257
341
  [0.9.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.0
package/README.md CHANGED
@@ -51,7 +51,7 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
51
51
 
52
52
  ## Features
53
53
 
54
- ### 89 Tools Across 20 Modules
54
+ ### 93 Tools Across 21 Modules
55
55
 
56
56
  | Module | Tools |
57
57
  |--------|-------|
@@ -75,6 +75,9 @@ See **[docs/architecture.md](docs/architecture.md)** for the backend abstraction
75
75
  | **Structure** | Menu links + custom blocks (list/create) |
76
76
  | **Search** | Best-effort content search (title match; Search API/Solr-ready) |
77
77
  | **Reports (extra)** | Orphaned references, unpublished content, missing-field audits |
78
+ | **Config & Governance** | Governed config get/list/set via the server-tool bridge; `drupal_mcp_whoami` tier/capability report |
79
+
80
+ **Preview writes with `dryRun`.** The node and entity create/update/delete tools accept an optional `dryRun: true` flag that validates the request and returns a preview of exactly what would be written — without committing anything to Drupal.
78
81
 
79
82
  ### MCP Resources
80
83
  Browsable, always-fresh context the client can read without calling a tool:
@@ -163,7 +166,7 @@ The connector works out of the box against Drupal core's JSON:API and a GraphQL
163
166
  - Role-bound policy profiles (operation gates, entity allow/deny, field redaction)
164
167
  - Tamper-evident audit log of every governed MCP operation, attributed to the acting account
165
168
  - Content locks that prevent edits to content a human is actively editing
166
- - OAuth scope enforcement (`mcp:read` / `mcp:write`) per tool
169
+ - OAuth scope enforcement (`mcp_read` / `mcp_write`) per tool
167
170
  - HMAC-signed webhooks on MCP-driven entity changes
168
171
 
169
172
  ```bash
@@ -185,7 +188,7 @@ Governance keys off the authenticated account's role and OAuth scopes — not re
185
188
  | [OAuth client_credentials](docs/oauth-client-credentials.md) | Production OAuth deploy: scope→role mapping, JSON:API writes, config persistence, secret handling, troubleshooting |
186
189
  | [Architecture](docs/architecture.md) | Backend abstraction, canonical model, and how to extend it |
187
190
  | [GraphQL Setup](docs/graphql-local-setup.md) | GraphQL Compose backend + local TLS notes |
188
- | [Tools Reference](docs/tools-reference.md) | Full reference for all 89 tools |
191
+ | [Tools Reference](docs/tools-reference.md) | Full reference for all 93 tools |
189
192
  | [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
190
193
  | [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
191
194
  | [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
@@ -10,7 +10,11 @@
10
10
  "_comment": "All optional. apiTokenEnv: read the Bearer token from this env var instead of apiToken (keeps secrets out of the config file). requireSecureAuth: reject anon/basic, require HTTPS+Bearer (recommended for production). Env overrides: MCP_CLIENT_ID overrides or disables the outbound identity header; MCP_AUTH_TOKEN requires bearer auth on the HTTPS /mcp endpoint; MCP_BIND_HOST restricts the listen interface (with TLS). See docs/security-hardening.md."
11
11
  },
12
12
 
13
- "defaultSite": "production",
13
+ "_governance_tiers": {
14
+ "_comment": "These four sites model the MCP agent governance tiers: least privilege keyed by environment. The Drupal side (mcp_sentinel) is authoritative; the per-site security preset is a defence-in-depth second layer. Tiers: content (prod/staging), developer (dev), admin/break-glass (dev-admin). serverTools wires the governed config tools (drupal_config_get/list/set); drushSsh is dev-only and whitelisted to config export/status. See docs/integration-contract.md."
15
+ },
16
+
17
+ "defaultSite": "prod",
14
18
 
15
19
  "tls": {
16
20
  "_comment": "TLS is required for the HTTPS transport (MCP_TRANSPORT=https). Ignored in stdio mode.",
@@ -20,89 +24,91 @@
20
24
  },
21
25
 
22
26
  "sites": {
23
- "production": {
24
- "baseUrl": "https://mysite.com",
25
- "apiToken": "eyJhbGciOiJSUzI1NiJ9...",
26
- "apiTokenEnv": "DRUPAL_TOKEN_PRODUCTION",
27
+ "prod": {
28
+ "_comment": "Content tier. Content/media/term CRUD; config read-only; cannot publish (server-side editorial gate). No drushSsh.",
29
+ "baseUrl": "https://api.int.wilkesliberty.com",
27
30
  "requireSecureAuth": true,
28
- "username": "",
29
- "password": "",
30
- "graphqlEndpoint": "/graphql",
31
31
  "api": "jsonapi",
32
-
33
- "drushSsh": {
34
- "_comment": "Optional. Enables drupal_drush_* tools. SSH key auth only — no passwords.",
35
- "host": "ssh.mysite.com",
36
- "user": "deploy",
37
- "keyPath": "~/.ssh/id_ed25519",
38
- "drupalRoot": "/var/www/html/web",
39
- "port": 22
32
+ "oauth": {
33
+ "tokenUrl": "/oauth/token",
34
+ "clientId": "mcp-agent",
35
+ "clientSecretEnv": "MCP_AGENT_CLIENT_SECRET",
36
+ "scopes": ["mcp_read", "mcp_write"],
37
+ "grant": "client_credentials"
40
38
  },
41
-
42
- "security": {
43
- "_comment": "Presets: development | content-editor | auditor | production-strict",
44
- "preset": "auditor",
45
- "readOnly": true,
46
- "allowDestructive": false,
47
- "allowGraphqlMutations": false,
48
- "allowedEntityTypes": null,
49
- "deniedEntityTypes": ["user"],
50
- "globalRedactedFields": ["pass", "mail", "field_api_key", "field_private"],
51
- "entityRules": {
52
- "node": {
53
- "allowedOperations": ["read"],
54
- "deniedBundles": ["private_document", "internal_memo"]
55
- }
56
- }
57
- }
39
+ "serverTools": { "url": "/mcp" },
40
+ "security": { "preset": "content-editor" }
58
41
  },
59
42
 
60
43
  "staging": {
61
- "baseUrl": "https://staging.mysite.com",
62
- "username": "api-user",
63
- "password": "change-me-use-token-instead",
64
- "api": "jsonapi",
65
- "security": { "preset": "content-editor" }
66
- },
67
-
68
- "local": {
69
- "_comment": "Plain HTTP is allowed for localhost targets only. A warning is logged.",
70
- "baseUrl": "http://mysite.lndo.site",
71
- "username": "admin",
72
- "password": "admin",
73
- "security": { "preset": "development" }
44
+ "_comment": "Content tier. Same capabilities as prod against the staging environment.",
45
+ "baseUrl": "https://api-stg.int.wilkesliberty.com",
46
+ "requireSecureAuth": true,
47
+ "api": "jsonapi",
48
+ "oauth": {
49
+ "tokenUrl": "/oauth/token",
50
+ "clientId": "mcp-agent",
51
+ "clientSecretEnv": "MCP_AGENT_CLIENT_SECRET_STG",
52
+ "scopes": ["mcp_read", "mcp_write"],
53
+ "grant": "client_credentials"
54
+ },
55
+ "serverTools": { "url": "/mcp" },
56
+ "security": { "preset": "content-editor" }
74
57
  },
75
58
 
76
- "graphql_only": {
77
- "_comment": "A site that exposes GraphQL only (JSON:API disabled). GraphQL is read-only.",
78
- "baseUrl": "https://api.example.com",
79
- "graphqlEndpoint": "/graphql",
80
- "api": "graphql",
81
- "security": { "preset": "auditor" }
59
+ "dev": {
60
+ "_comment": "Developer tier (DDEV). Content + governed config read/write. Drush bridge is dev-only and whitelisted to config export/status; the agent mutates config via drupal_config_set, then exports to YAML for a PR.",
61
+ "baseUrl": "https://api.wilkesliberty.dev",
62
+ "requireSecureAuth": true,
63
+ "api": "jsonapi",
64
+ "oauth": {
65
+ "tokenUrl": "/oauth/token",
66
+ "clientId": "mcp-agent",
67
+ "clientSecretEnv": "MCP_AGENT_CLIENT_SECRET_DEV",
68
+ "scopes": ["mcp_read", "mcp_write", "mcp_config"],
69
+ "grant": "client_credentials"
70
+ },
71
+ "serverTools": { "url": "/mcp" },
72
+ "drushSsh": {
73
+ "_comment": "Dev only. DDEV web-container SSH target. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here.",
74
+ "host": "<ddev-web-container-ssh-host>",
75
+ "user": "<ddev-ssh-user>",
76
+ "keyPath": "~/.ssh/id_ed25519",
77
+ "drupalRoot": "/var/www/html/web",
78
+ "port": 22,
79
+ "allowedCommands": ["config:export", "config:status"]
80
+ },
81
+ "security": { "preset": "config-editor" }
82
82
  },
83
83
 
84
- "oauth_write_plane": {
85
- "_comment": "OAuth2 client_credentials grant against simple_oauth. The client secret is read from the named env var and never stored here.",
86
- "baseUrl": "https://api.example.com",
84
+ "dev-admin": {
85
+ "_comment": "Admin / break-glass tier (DDEV). All capabilities incl. destructive + config import — non-standing. Reuses the dev secret. No drushSsh (admin ops go through governed/approval-gated server tools).",
86
+ "baseUrl": "https://api.wilkesliberty.dev",
87
87
  "requireSecureAuth": true,
88
88
  "api": "jsonapi",
89
89
  "oauth": {
90
90
  "tokenUrl": "/oauth/token",
91
- "clientId": "mcp-agent-prod",
92
- "clientSecretEnv": "MCP_AGENT_CLIENT_SECRET",
93
- "scopes": ["mcp:read", "mcp:write"],
91
+ "clientId": "mcp-agent",
92
+ "clientSecretEnv": "MCP_AGENT_CLIENT_SECRET_DEV",
93
+ "scopes": ["mcp_read", "mcp_write", "mcp_config", "mcp_admin"],
94
94
  "grant": "client_credentials"
95
95
  },
96
- "security": { "preset": "write-plane" }
96
+ "serverTools": { "url": "/mcp" },
97
+ "security": { "preset": "development" }
97
98
  }
98
99
  },
99
100
 
100
101
  "_security_presets": {
101
- "development": "All operations allowed. Local dev only.",
102
- "content-editor": "Create/edit nodes+media+terms. No deletes. No user entity access.",
103
- "auditor": "Read-only. All entity types. User PII fields redacted.",
104
- "production-strict": "Read-only. No user entities. Broad PII redaction.",
105
- "write-plane": "Governed writes (no delete/mutations) on node, taxonomy_term, media. No user. Redacts pass/mail."
102
+ "development": "All operations allowed, incl. config read/write. Local dev / break-glass only.",
103
+ "content-editor": "Create/edit nodes+media+terms. No deletes. No user entity access. Config read-only.",
104
+ "config-editor": "content-editor + governed config read/write (Developer tier).",
105
+ "auditor": "Read-only. All entity types. User PII fields redacted. Config read-only.",
106
+ "production-strict": "Read-only. No user entities. Broad PII redaction. No config access.",
107
+ "write-plane": "Governed writes (no delete/mutations) on node, taxonomy_term, media. No user. Redacts pass/mail. Config read-only."
108
+ },
109
+
110
+ "_server_tools": {
111
+ "_comment": "serverTools.url is the JSON-RPC endpoint of the Drupal-side governed MCP tools (mcp_server_tool_bridge / mcp_sentinel), resolved against baseUrl. Required for drupal_config_get/list/set. Authenticated with the same OAuth bearer."
106
112
  },
107
113
 
108
114
  "_mcp_client_registration": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "0.10.0",
3
+ "version": "1.1.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",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@modelcontextprotocol/sdk": "^1.0.0",
59
- "graphql": "^16.9.0",
59
+ "graphql": "^17.0.0",
60
60
  "node-fetch": "^3.3.2",
61
61
  "ssh2": "^1.16.0"
62
62
  },
package/src/index.js CHANGED
@@ -67,13 +67,14 @@ import * as paragraphs from "./tools/paragraphs.js";
67
67
  import * as structure from "./tools/structure.js";
68
68
  import * as search from "./tools/search.js";
69
69
  import * as reportsExtra from "./tools/reports-extra.js";
70
+ import * as config from "./tools/config.js";
70
71
 
71
72
  // ---------------------------------------------------------------------------
72
73
  // Aggregate tools
73
74
  // ---------------------------------------------------------------------------
74
75
 
75
76
  const allModules = [nodes, taxonomy, users, media, graphql, site, entities, reports, drush,
76
- revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, search, reportsExtra];
77
+ revisions, moderation, scheduler, fields, references, bulk, translations, paragraphs, structure, search, reportsExtra, config];
77
78
 
78
79
  // Flatten every module's tool definitions into one ListTools payload, and merge
79
80
  // their handler maps into a single closed dispatch table keyed by tool name.
@@ -95,7 +96,9 @@ const WRITE_PREFIXES = ["drupal_create_", "drupal_update_", "drupal_upload
95
96
  "drupal_drush_updatedb", "drupal_drush_module_enable",
96
97
  "drupal_drush_module_disable", "drupal_drush_user_create",
97
98
  // v1.0 feature tools that perform writes but don't start with create_/update_:
98
- "drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_"];
99
+ "drupal_bulk_", "drupal_revert_", "drupal_schedule_", "drupal_set_",
100
+ // Governed config write (also gated inside the handler by the config-write cap):
101
+ "drupal_config_set"];
99
102
  const DESTRUCTIVE_PREFIXES = ["drupal_delete_", "drupal_drush_module_disable"];
100
103
 
101
104
  /**
@@ -20,6 +20,14 @@ import {
20
20
  // these are dropped from canonical `fields` (the canonical id is the UUID).
21
21
  const INTERNAL_ATTR_RE = /^drupal_internal__/;
22
22
 
23
+ // countEntities() pagination. Drupal core JSON:API returns no total in `meta`,
24
+ // so an exact count is obtained by walking pages until `links.next` is gone.
25
+ // COUNT_PAGE_SIZE is Drupal's default max page size; COUNT_MAX_RECORDS bounds
26
+ // the walk so a huge collection can't issue unbounded requests (mirrors the
27
+ // GraphQL backend's MAX_CLIENT_RECORDS) — past it the count is approximate.
28
+ const COUNT_PAGE_SIZE = 50;
29
+ const COUNT_MAX_RECORDS = 1000;
30
+
23
31
  /**
24
32
  * Detect the JSON:API error Drupal returns when a write attempts to set the
25
33
  * `status` (published) field on a content_moderation-governed entity. Such
@@ -88,18 +96,33 @@ function inferType(value) {
88
96
  * @param {{field: string, op?: string, value: *}} cond Filter condition.
89
97
  * @returns {void}
90
98
  */
99
+ /**
100
+ * Serialize a filter value to a DB-portable string. Booleans become "1"/"0":
101
+ * Drupal stores `status` and other boolean fields in integer/smallint columns,
102
+ * and PostgreSQL rejects the literals "true"/"false" there ("invalid input
103
+ * syntax for type smallint"). MySQL coerces them, which hid this. Everything
104
+ * else is stringified unchanged (the string "true" stays "true").
105
+ * @param {*} value
106
+ * @returns {string}
107
+ */
108
+ function filterValue(value) {
109
+ if (value === true) return "1";
110
+ if (value === false) return "0";
111
+ return String(value);
112
+ }
113
+
91
114
  function applyFilter(params, { field, op = "eq", value }) {
92
115
  if (op === "eq") {
93
- params.append(`filter[${field}]`, String(value));
116
+ params.append(`filter[${field}]`, filterValue(value));
94
117
  return;
95
118
  }
96
119
  const key = `c_${field}`;
97
120
  params.append(`filter[${key}][condition][path]`, field);
98
121
  params.append(`filter[${key}][condition][operator]`, OP_MAP.get(op) || "=");
99
122
  if (op === "in" && Array.isArray(value)) {
100
- value.forEach((v, i) => params.append(`filter[${key}][condition][value][${i}]`, String(v)));
123
+ value.forEach((v, i) => params.append(`filter[${key}][condition][value][${i}]`, filterValue(v)));
101
124
  } else if (op !== "isNull") {
102
- params.append(`filter[${key}][condition][value]`, String(value));
125
+ params.append(`filter[${key}][condition][value]`, filterValue(value));
103
126
  }
104
127
  }
105
128
 
@@ -374,17 +397,47 @@ export class JsonApiBackend extends Backend {
374
397
  }
375
398
 
376
399
  /**
377
- * Return an exact entity count. Requests page[limit]=1 and reads the
378
- * server-provided meta.count, so no full result set is transferred.
400
+ * Count entities matching the descriptor. Drupal core JSON:API exposes no
401
+ * total in `meta`, so unless the site provides `meta.count` (jsonapi_extras /
402
+ * a custom normalizer), the count is obtained by walking pages via
403
+ * `links.next`. The total is exact when the walk reaches the end; if it hits
404
+ * the COUNT_MAX_RECORDS safety ceiling first, the partial total is returned
405
+ * with `approximate: true` (i.e. the true count is at least that value).
379
406
  * @param {import("../canonical.js").QueryDescriptor} descriptor
380
- * @returns {Promise<{count: number, approximate: false}>}
407
+ * @returns {Promise<{count: number, approximate: boolean}>}
381
408
  */
382
409
  async countEntities(descriptor) {
383
- const params = this.compileQuery({ ...descriptor, page: { limit: 1 } });
384
- const qs = params.toString();
385
410
  const base = this.resourcePath(descriptor.entityType, descriptor.bundle);
386
- const data = await drupalFetch(this.site, qs ? `${base}?${qs}` : base);
387
- return { count: data.meta?.count ?? (data.data || []).length, approximate: false };
411
+ let total = 0;
412
+ let offset = 0;
413
+ for (;;) {
414
+ const params = this.compileQuery({ ...descriptor, page: { limit: COUNT_PAGE_SIZE, offset } });
415
+ const qs = params.toString();
416
+ const data = await drupalFetch(this.site, qs ? `${base}?${qs}` : base);
417
+
418
+ // Prefer an exact server-supplied total when the site exposes one
419
+ // (jsonapi_extras / a custom normalizer). Only the first page can carry it.
420
+ if (offset === 0 && typeof data?.meta?.count === "number") {
421
+ return { count: data.meta.count, approximate: false };
422
+ }
423
+
424
+ const got = (data?.data || []).length;
425
+ total += got;
426
+
427
+ // No further page advertised → the whole set has been walked: exact.
428
+ if (!data?.links?.next?.href || got === 0) {
429
+ return { count: total, approximate: false };
430
+ }
431
+
432
+ // Advance by the rows actually returned (robust to server-side page-size
433
+ // caps that may return fewer than COUNT_PAGE_SIZE per page).
434
+ offset += got;
435
+
436
+ // Bounded walk: stop and flag the partial total as approximate.
437
+ if (total >= COUNT_MAX_RECORDS) {
438
+ return { count: total, approximate: true };
439
+ }
440
+ }
388
441
  }
389
442
 
390
443
  /**
@@ -13,7 +13,8 @@ import { parse } from "graphql";
13
13
  * ─── Quick presets ────────────────────────────────────────────────────────
14
14
  *
15
15
  * "preset": "development" Everything allowed. Default if no security key.
16
- * "preset": "content-editor" Create/edit nodes+media. No user mgmt, no deletes.
16
+ * "preset": "content-editor" Create/edit nodes+media. No user mgmt, no deletes. Config read-only.
17
+ * "preset": "config-editor" content-editor + governed config read/write (Developer tier).
17
18
  * "preset": "auditor" Read-only. All entity types. User fields redacted.
18
19
  * "preset": "production-strict" Read-only. Explicit allowlist required. Redacts PII.
19
20
  * "preset": "write-plane" Governed writes (no delete/mutations) on node, term, media.
@@ -25,6 +26,11 @@ import { parse } from "graphql";
25
26
  * readOnly true → reject all create/update/delete/graphql-mutation calls
26
27
  * allowDestructive false → reject all delete operations
27
28
  * allowGraphqlMutations false → reject drupal_graphql when mutation is detected
29
+ * allowConfigRead false → reject drupal_config_get / drupal_config_list
30
+ * allowConfigWrite false → reject drupal_config_set
31
+ *
32
+ * Config caps mirror the server-side governance profile (allow_config_read /
33
+ * allow_config_write). Drupal stays authoritative; this is defence in depth.
28
34
  *
29
35
  * allowedEntityTypes string[] | null null = allow all; array = allowlist
30
36
  * deniedEntityTypes string[] always-blocked entity types
@@ -54,6 +60,8 @@ const PRESETS = {
54
60
  readOnly: false,
55
61
  allowDestructive: true,
56
62
  allowGraphqlMutations: true,
63
+ allowConfigRead: true,
64
+ allowConfigWrite: true,
57
65
  allowedEntityTypes: null,
58
66
  deniedEntityTypes: [],
59
67
  entityRules: {},
@@ -64,6 +72,27 @@ const PRESETS = {
64
72
  readOnly: false,
65
73
  allowDestructive: false, // no deletes
66
74
  allowGraphqlMutations: false,
75
+ allowConfigRead: true, // config read-only
76
+ allowConfigWrite: false,
77
+ allowedEntityTypes: ["node", "media", "file", "taxonomy_term", "menu_link_content"],
78
+ deniedEntityTypes: ["user"],
79
+ entityRules: {
80
+ node: { allowedOperations: ["read", "create", "update"] },
81
+ media: { allowedOperations: ["read", "create", "update"] },
82
+ file: { allowedOperations: ["read", "create"] },
83
+ taxonomy_term: { allowedOperations: ["read", "create", "update"] },
84
+ },
85
+ globalRedactedFields: [],
86
+ },
87
+
88
+ "config-editor": {
89
+ // Developer tier: content-editor capabilities PLUS governed config read/write.
90
+ // The Drupal-side governance layer remains authoritative; this is defence in depth.
91
+ readOnly: false,
92
+ allowDestructive: false, // no deletes
93
+ allowGraphqlMutations: false,
94
+ allowConfigRead: true,
95
+ allowConfigWrite: true, // governed config writes via drupal_config_set
67
96
  allowedEntityTypes: ["node", "media", "file", "taxonomy_term", "menu_link_content"],
68
97
  deniedEntityTypes: ["user"],
69
98
  entityRules: {
@@ -79,6 +108,8 @@ const PRESETS = {
79
108
  readOnly: true,
80
109
  allowDestructive: false,
81
110
  allowGraphqlMutations: false,
111
+ allowConfigRead: true, // read-only inspection of config
112
+ allowConfigWrite: false,
82
113
  allowedEntityTypes: null, // read any entity type
83
114
  deniedEntityTypes: [],
84
115
  entityRules: {
@@ -94,6 +125,8 @@ const PRESETS = {
94
125
  readOnly: true,
95
126
  allowDestructive: false,
96
127
  allowGraphqlMutations: false,
128
+ allowConfigRead: false, // nothing implicit; opt in per site
129
+ allowConfigWrite: false,
97
130
  allowedEntityTypes: null, // set an explicit allowlist in your config
98
131
  deniedEntityTypes: ["user"], // no user data at all
99
132
  entityRules: {},
@@ -106,6 +139,8 @@ const PRESETS = {
106
139
  readOnly: false,
107
140
  allowDestructive: false, // no deletes
108
141
  allowGraphqlMutations: false, // writes go through the JSON:API plane
142
+ allowConfigRead: true, // config read-only
143
+ allowConfigWrite: false,
109
144
  allowedEntityTypes: ["node", "taxonomy_term", "media"],
110
145
  deniedEntityTypes: ["user"],
111
146
  entityRules: {},
@@ -132,6 +167,8 @@ export function resolveSecurityConfig(site) {
132
167
  readOnly: raw.readOnly ?? preset.readOnly,
133
168
  allowDestructive: raw.allowDestructive ?? preset.allowDestructive,
134
169
  allowGraphqlMutations: raw.allowGraphqlMutations ?? preset.allowGraphqlMutations,
170
+ allowConfigRead: raw.allowConfigRead ?? preset.allowConfigRead ?? false,
171
+ allowConfigWrite: raw.allowConfigWrite ?? preset.allowConfigWrite ?? false,
135
172
  allowedEntityTypes: raw.allowedEntityTypes ?? preset.allowedEntityTypes,
136
173
  deniedEntityTypes: raw.deniedEntityTypes ?? preset.deniedEntityTypes,
137
174
  entityRules: mergeEntityRules(preset.entityRules, raw.entityRules ?? {}),
@@ -189,6 +226,39 @@ export function assertNotReadOnly(secConfig, operationLabel) {
189
226
  }
190
227
  }
191
228
 
229
+ /**
230
+ * Gate config reads (drupal_config_get / drupal_config_list).
231
+ * @param {object} secConfig Resolved security config.
232
+ * @returns {void}
233
+ * @throws {SecurityError} if config reads are disabled for this site.
234
+ */
235
+ export function assertConfigReadAllowed(secConfig) {
236
+ if (!secConfig.allowConfigRead) {
237
+ throw new SecurityError(
238
+ "Config reads are disabled for this site. " +
239
+ "To enable, use a preset with config access (e.g. config-editor) " +
240
+ "or set security.allowConfigRead = true in your config."
241
+ );
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Gate config writes (drupal_config_set). Server-side governance remains
247
+ * authoritative; this is the connector-side defence-in-depth layer.
248
+ * @param {object} secConfig Resolved security config.
249
+ * @returns {void}
250
+ * @throws {SecurityError} if config writes are disabled for this site.
251
+ */
252
+ export function assertConfigWriteAllowed(secConfig) {
253
+ if (!secConfig.allowConfigWrite) {
254
+ throw new SecurityError(
255
+ "Config writes are disabled for this site. " +
256
+ "To enable, use the config-editor preset (Developer tier) " +
257
+ "or set security.allowConfigWrite = true in your config."
258
+ );
259
+ }
260
+ }
261
+
192
262
  /**
193
263
  * @param {object} secConfig Resolved security config.
194
264
  * @param {string} entityType Entity type targeted by the delete.
@@ -467,6 +537,8 @@ export function getSecuritySummary(site) {
467
537
  readOnly: cfg.readOnly,
468
538
  allowDestructive: cfg.allowDestructive,
469
539
  allowGraphqlMutations: cfg.allowGraphqlMutations,
540
+ allowConfigRead: cfg.allowConfigRead,
541
+ allowConfigWrite: cfg.allowConfigWrite,
470
542
  allowedEntityTypes: cfg.allowedEntityTypes ?? "all",
471
543
  deniedEntityTypes: cfg.deniedEntityTypes,
472
544
  entityRules: cfg.entityRules,
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Server-tool bridge — call governed MCP tools exposed by Drupal.
3
+ *
4
+ * The connector itself is an MCP *server* for the AI client. For governed
5
+ * configuration operations, Drupal exposes its own MCP tools server-side
6
+ * (the `mcp_server_tool_bridge` / `mcp_sentinel` modules). This module makes
7
+ * the connector an MCP *client* of that server so config get/list/set are
8
+ * mediated by Drupal's authoritative governance layer rather than by drush.
9
+ *
10
+ * Transport: JSON-RPC 2.0 over HTTPS POST to the per-site `serverTools.url`
11
+ * endpoint, authenticated with the same OAuth bearer used for JSON:API. The
12
+ * endpoint path is configurable so it tracks whatever route the Drupal-side
13
+ * bridge publishes.
14
+ *
15
+ * Config (per site):
16
+ * "serverTools": { "url": "/mcp" } // path is resolved against site.baseUrl
17
+ *
18
+ * Tools are NOT functional until the Drupal-side governed config tools ship;
19
+ * until then the server returns a tool-not-found error, surfaced verbatim.
20
+ */
21
+
22
+ import fetch from "node-fetch";
23
+ import { authHeadersAsync, clientHeaders } from "./config.js";
24
+ import { clearToken } from "./oauth.js";
25
+
26
+ /**
27
+ * Canonical server-side tool names for governed config operations.
28
+ * Keep the mapping here so a server-side rename is a one-line change.
29
+ */
30
+ export const SERVER_TOOLS = {
31
+ configGet: "config_get",
32
+ configList: "config_list",
33
+ configSet: "config_set",
34
+ };
35
+
36
+ // Monotonic JSON-RPC request id. A simple counter keeps ids unique per process
37
+ // without relying on Math.random()/Date.now().
38
+ let rpcId = 0;
39
+
40
+ /**
41
+ * Resolve a site's server-tools endpoint, or throw a clear, actionable error
42
+ * when the site has no `serverTools` block (mirrors the drush bridge's
43
+ * graceful "not configured" failure).
44
+ * @param {object} site Resolved site config.
45
+ * @returns {string} Fully-qualified endpoint URL.
46
+ * @throws {Error} if the site has no serverTools.url configured.
47
+ */
48
+ function resolveEndpoint(site) {
49
+ const url = site.serverTools?.url;
50
+ if (!url) {
51
+ throw new Error(
52
+ `Server-tool bridge not configured for site "${site._name}". ` +
53
+ "Add a \"serverTools\": { \"url\": \"/mcp\" } block to this site's config. " +
54
+ "See docs/integration-contract.md."
55
+ );
56
+ }
57
+ // Absolute URL wins; otherwise resolve the path against the site base URL.
58
+ return /^https?:\/\//.test(url) ? url : `${site.baseUrl}${url}`;
59
+ }
60
+
61
+ /**
62
+ * Call a governed MCP tool on the Drupal server via JSON-RPC `tools/call`.
63
+ *
64
+ * For OAuth2 sites a 401 triggers a single retry: the cached token is cleared,
65
+ * re-acquired, and the request replayed once (mirrors drupalFetch).
66
+ * @param {object} site Resolved site config (provides baseUrl + auth).
67
+ * @param {string} toolName Server-side MCP tool name (see SERVER_TOOLS).
68
+ * @param {object} [args] Tool arguments object.
69
+ * @returns {Promise<*>} The tool's structured result.
70
+ * @throws {Error} on transport failure, JSON-RPC error, or tool error.
71
+ */
72
+ export async function callServerTool(site, toolName, args = {}) {
73
+ const endpoint = resolveEndpoint(site);
74
+ const payload = {
75
+ jsonrpc: "2.0",
76
+ id: ++rpcId,
77
+ method: "tools/call",
78
+ params: { name: toolName, arguments: args },
79
+ };
80
+
81
+ async function attempt() {
82
+ return fetch(endpoint, {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ Accept: "application/json",
87
+ ...clientHeaders(),
88
+ ...(await authHeadersAsync(site)),
89
+ },
90
+ body: JSON.stringify(payload),
91
+ });
92
+ }
93
+
94
+ let res = await attempt();
95
+
96
+ // OAuth sites: a 401 may mean the token expired server-side. Refresh once.
97
+ if (res.status === 401 && site.oauth) {
98
+ clearToken(site);
99
+ res = await attempt();
100
+ }
101
+
102
+ if (!res.ok) {
103
+ const text = await res.text();
104
+ throw new Error(`Server-tool call ${toolName} failed ${res.status}: ${text}`);
105
+ }
106
+
107
+ const body = await res.json();
108
+
109
+ // JSON-RPC transport-level error.
110
+ if (body.error) {
111
+ const { code, message } = body.error;
112
+ const hasCode = code !== undefined && code !== null;
113
+ throw new Error(`Server-tool ${toolName} error${hasCode ? ` (${code})` : ""}: ${message}`);
114
+ }
115
+
116
+ // MCP tools/call result: { content: [...], isError?: boolean }.
117
+ const result = body.result;
118
+ if (result?.isError) {
119
+ const detail = extractTextContent(result) || "tool reported an error";
120
+ throw new Error(`Server-tool ${toolName} reported an error: ${detail}`);
121
+ }
122
+ return result;
123
+ }
124
+
125
+ /**
126
+ * Pull the concatenated text from an MCP tool result's content array.
127
+ * @param {object} result MCP tools/call result.
128
+ * @returns {string} Joined text content (empty string if none).
129
+ */
130
+ function extractTextContent(result) {
131
+ if (!Array.isArray(result?.content)) return "";
132
+ return result.content
133
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
134
+ .map((c) => c.text)
135
+ .join("\n");
136
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Tool group: Governed configuration + agent identity.
3
+ *
4
+ * Configuration get/list/set are mediated by Drupal's authoritative governance
5
+ * layer: each tool calls a server-side MCP tool over the bridge in
6
+ * lib/server-tools.js (NOT drush). The connector-side security caps
7
+ * (allowConfigRead / allowConfigWrite) are a second, defence-in-depth gate.
8
+ *
9
+ * drupal_mcp_whoami reports the agent's effective tier/profile/capabilities for
10
+ * a site so the agent and a human operator can see what is permitted up front,
11
+ * reducing surprise denials.
12
+ */
13
+
14
+ import { getSiteConfig } from "../lib/config.js";
15
+ import {
16
+ resolveSecurityConfig,
17
+ getSecuritySummary,
18
+ assertNotReadOnly,
19
+ assertConfigReadAllowed,
20
+ assertConfigWriteAllowed,
21
+ } from "../lib/security.js";
22
+ import { callServerTool, SERVER_TOOLS } from "../lib/server-tools.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Config tools (governed via the server-tool bridge)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Read a single configuration object by name (e.g. "system.site").
30
+ * @param {object} args - { site?, name }.
31
+ * @returns {Promise<*>} The server tool's result.
32
+ * @throws {SecurityError} if config reads are disabled for the site.
33
+ */
34
+ async function configGet({ site: siteName, name }) {
35
+ const site = getSiteConfig(siteName);
36
+ assertConfigReadAllowed(resolveSecurityConfig(site));
37
+ return callServerTool(site, SERVER_TOOLS.configGet, { name });
38
+ }
39
+
40
+ /**
41
+ * List configuration object names, optionally filtered by a name prefix.
42
+ * @param {object} args - { site?, prefix? }.
43
+ * @returns {Promise<*>} The server tool's result.
44
+ * @throws {SecurityError} if config reads are disabled for the site.
45
+ */
46
+ async function configList({ site: siteName, prefix }) {
47
+ const site = getSiteConfig(siteName);
48
+ assertConfigReadAllowed(resolveSecurityConfig(site));
49
+ const args = prefix ? { prefix } : {};
50
+ return callServerTool(site, SERVER_TOOLS.configList, args);
51
+ }
52
+
53
+ /**
54
+ * Set a configuration value. Governed and audited server-side; the connector
55
+ * additionally enforces the config-write cap before dispatching.
56
+ * @param {object} args - { site?, name, value }.
57
+ * @returns {Promise<*>} The server tool's result.
58
+ * @throws {SecurityError} if the site is read-only or config writes are disabled.
59
+ */
60
+ async function configSet({ site: siteName, name, value }) {
61
+ const site = getSiteConfig(siteName);
62
+ const sec = resolveSecurityConfig(site);
63
+ assertNotReadOnly(sec, `config:set ${name}`);
64
+ assertConfigWriteAllowed(sec);
65
+ return callServerTool(site, SERVER_TOOLS.configSet, { name, value });
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Identity / capabilities
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Infer the governance tier from OAuth scopes (authoritative signal) when
74
+ * present, else from the security preset. Mirrors the scope-keyed model in
75
+ * the MCP agent governance design.
76
+ * @param {object} site Resolved site config.
77
+ * @param {object} sec Resolved security config.
78
+ * @returns {string} One of "admin" | "developer" | "content" | "read-only".
79
+ */
80
+ function inferTier(site, sec) {
81
+ const scopes = site.oauth?.scopes ?? [];
82
+ if (scopes.includes("mcp_admin")) return "admin";
83
+ if (scopes.includes("mcp_config")) return "developer";
84
+ if (scopes.includes("mcp_write")) return "content";
85
+ if (scopes.length) return "read-only";
86
+
87
+ // No OAuth scopes — fall back to preset semantics.
88
+ if (sec.allowConfigWrite) return "developer";
89
+ if (!sec.readOnly) return "content";
90
+ return "read-only";
91
+ }
92
+
93
+ /**
94
+ * Report the agent's effective tier, profile, and capabilities for a site.
95
+ * Policy only — no credentials, no backend call.
96
+ * @param {object} args - { site? }.
97
+ * @returns {Promise<object>} Effective identity + capability summary.
98
+ */
99
+ async function whoami({ site: siteName }) {
100
+ const site = getSiteConfig(siteName);
101
+ const sec = resolveSecurityConfig(site);
102
+ const summary = getSecuritySummary(site);
103
+ return {
104
+ site: site._name,
105
+ tier: inferTier(site, sec),
106
+ preset: summary.preset,
107
+ scopes: site.oauth?.scopes ?? [],
108
+ api: site.api ?? "auto",
109
+ serverToolsConfigured: Boolean(site.serverTools?.url),
110
+ capabilities: {
111
+ read: true,
112
+ write: !sec.readOnly,
113
+ delete: sec.allowDestructive && !sec.readOnly,
114
+ configRead: sec.allowConfigRead,
115
+ configWrite: sec.allowConfigWrite && !sec.readOnly,
116
+ // Publishing is always gated server-side (editorial workflow); the agent
117
+ // never holds the publish transition. Surfaced here so it is explicit.
118
+ publish: false,
119
+ },
120
+ };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Definitions & handlers
125
+ // ---------------------------------------------------------------------------
126
+
127
+ export const definitions = [
128
+ {
129
+ name: "drupal_config_get",
130
+ description: "Read a single Drupal configuration object by name (e.g. \"system.site\") via the governed server-side config tool. Requires config read access.",
131
+ inputSchema: {
132
+ type: "object",
133
+ required: ["name"],
134
+ properties: { site: { type: "string" }, name: { type: "string" } },
135
+ },
136
+ },
137
+ {
138
+ name: "drupal_config_list",
139
+ description: "List Drupal configuration object names, optionally filtered by a name prefix, via the governed server-side config tool. Requires config read access.",
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: { site: { type: "string" }, prefix: { type: "string" } },
143
+ },
144
+ },
145
+ {
146
+ name: "drupal_config_set",
147
+ description: "Set a Drupal configuration value via the governed server-side config tool. Audited and gated server-side; requires the config-editor (Developer) tier. Then export to YAML for a PR.",
148
+ inputSchema: {
149
+ type: "object",
150
+ required: ["name", "value"],
151
+ properties: {
152
+ site: { type: "string" },
153
+ name: { type: "string" },
154
+ value: { description: "The configuration value to set (object, array, or scalar)." },
155
+ },
156
+ },
157
+ },
158
+ {
159
+ name: "drupal_mcp_whoami",
160
+ description: "Report the agent's effective governance tier, security preset, OAuth scopes, and capabilities (read/write/delete/config/publish) for a site. No credentials, no backend call.",
161
+ inputSchema: {
162
+ type: "object",
163
+ properties: { site: { type: "string" } },
164
+ },
165
+ },
166
+ ];
167
+
168
+ export const handlers = {
169
+ drupal_config_get: configGet,
170
+ drupal_config_list: configList,
171
+ drupal_config_set: configSet,
172
+ drupal_mcp_whoami: whoami,
173
+ };
@@ -21,7 +21,7 @@ import { readFileSync } from "fs";
21
21
  import { homedir } from "os";
22
22
  import { join, resolve, normalize } from "path";
23
23
  import { getSiteConfig } from "../lib/config.js";
24
- import { resolveSecurityConfig, assertNotReadOnly } from "../lib/security.js";
24
+ import { resolveSecurityConfig, assertNotReadOnly, SecurityError } from "../lib/security.js";
25
25
  import { validateMachineName, validateSqlQuery, sanitizeSshArg } from "../lib/validate.js";
26
26
 
27
27
  // ---------------------------------------------------------------------------
@@ -38,6 +38,26 @@ function getDrushConfig(site) {
38
38
  return site.drushSsh;
39
39
  }
40
40
 
41
+ /**
42
+ * Enforce an optional per-site command allowlist. When `drushSsh.allowedCommands`
43
+ * is present, only those Drush subcommands may run on that site — every other
44
+ * drupal_drush_* tool is blocked. Used to pin the dev site to config:export /
45
+ * config:status while keeping the bridge off prod/staging entirely.
46
+ * @param {object} sshCfg Resolved drushSsh config block.
47
+ * @param {string} subcommand The Drush subcommand (drushArgs[0]).
48
+ * @returns {void}
49
+ * @throws {SecurityError} if an allowlist is set and the subcommand is not on it.
50
+ */
51
+ function assertCommandAllowed(sshCfg, subcommand) {
52
+ const allow = sshCfg.allowedCommands;
53
+ if (Array.isArray(allow) && !allow.includes(subcommand)) {
54
+ throw new SecurityError(
55
+ `Drush command "${subcommand}" is not permitted on this site. ` +
56
+ `Allowed: ${allow.join(", ")}.`
57
+ );
58
+ }
59
+ }
60
+
41
61
  /**
42
62
  * Resolve and validate the SSH key path. Prevents path traversal.
43
63
  */
@@ -76,6 +96,7 @@ function resolveKeyPath(rawPath) {
76
96
  */
77
97
  function sshDrush(site, drushArgs, timeoutMs = 30000) {
78
98
  const sshCfg = getDrushConfig(site);
99
+ assertCommandAllowed(sshCfg, drushArgs[0]);
79
100
  const keyPath = resolveKeyPath(sshCfg.keyPath);
80
101
 
81
102
  // Validate drupalRoot is an absolute path with no traversal
@@ -72,8 +72,12 @@ async function staleContent({ site: siteName, type, days = 180, status, limit =
72
72
  assertReadAllowed(sec, "node", type);
73
73
  const backend = await resolveBackend(site);
74
74
  const contentType = type || "article";
75
- const cutoff = new Date(Date.now() - days * 86400000).toISOString();
76
- const filters = [{ field: "changed", op: "lt", value: cutoff }];
75
+ // `changed` is an integer Unix timestamp (seconds). Filter with epoch seconds,
76
+ // not an ISO string PostgreSQL rejects the string against an integer column.
77
+ const cutoffMs = Date.now() - days * 86400000;
78
+ const cutoffTs = Math.floor(cutoffMs / 1000);
79
+ const cutoff = new Date(cutoffMs).toISOString();
80
+ const filters = [{ field: "changed", op: "lt", value: cutoffTs }];
77
81
  if (status !== undefined) filters.push({ field: "status", op: "eq", value: status });
78
82
  const res = await backend.listEntities({
79
83
  entityType: "node", bundle: contentType,
@@ -346,6 +350,10 @@ async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
346
350
  backend.countEntities({ entityType: "user", bundle: "user", filters: [{ field: "status", op: "eq", value: false }] }),
347
351
  backend.countEntities({ entityType: "user", bundle: "user", filters: [{ field: "status", op: "eq", value: true }, { field: "login", op: "eq", value: 0 }] }),
348
352
  ]);
353
+ // A count past the backend's safety ceiling comes back approximate; surface
354
+ // that so the summary totals aren't presented as exact (cf. contentSummary,
355
+ // taxonomyUsage).
356
+ const approximate = active.approximate || blocked.approximate || neverLoggedIn.approximate;
349
357
  let inactiveUsers = [];
350
358
  try {
351
359
  const res = await backend.listEntities({
@@ -369,6 +377,7 @@ async function userActivity({ site: siteName, inactiveDays = 90, limit = 50 }) {
369
377
  inactiveUsers = [];
370
378
  }
371
379
  return {
380
+ approximate,
372
381
  summary: {
373
382
  activeAccounts: active.count,
374
383
  blockedAccounts: blocked.count,