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 +84 -0
- package/README.md +6 -3
- package/config/config.example.json +70 -64
- package/package.json +2 -2
- package/src/index.js +5 -2
- package/src/lib/backends/jsonapi.js +63 -10
- package/src/lib/security.js +73 -1
- package/src/lib/server-tools.js +136 -0
- package/src/tools/config.js +173 -0
- package/src/tools/drush.js +22 -1
- package/src/tools/reports.js +11 -2
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
|
-
###
|
|
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 (`
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
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
|
-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
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
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"api":
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
"security":
|
|
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
|
-
"
|
|
77
|
-
"_comment": "
|
|
78
|
-
"baseUrl":
|
|
79
|
-
"
|
|
80
|
-
"api":
|
|
81
|
-
"
|
|
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
|
-
"
|
|
85
|
-
"_comment": "
|
|
86
|
-
"baseUrl": "https://api.
|
|
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
|
|
92
|
-
"clientSecretEnv": "
|
|
93
|
-
"scopes": ["
|
|
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
|
-
"
|
|
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
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
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": "
|
|
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": "^
|
|
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}]`,
|
|
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}]`,
|
|
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]`,
|
|
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
|
-
*
|
|
378
|
-
*
|
|
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:
|
|
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
|
-
|
|
387
|
-
|
|
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
|
/**
|
package/src/lib/security.js
CHANGED
|
@@ -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
|
+
};
|
package/src/tools/drush.js
CHANGED
|
@@ -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
|
package/src/tools/reports.js
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
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,
|