drupal-mcp-connector 2.4.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/drupal-create-media.md +1 -1
- package/.claude/commands/drupal-update-media.md +4 -4
- package/CHANGELOG.md +78 -0
- package/README.md +1 -0
- package/bin/drupal-mcp-verify.js +147 -0
- package/config/config.example.json +31 -29
- package/package.json +7 -4
- package/src/lib/canonical.js +48 -0
- package/src/lib/entity-response.js +41 -0
- package/src/lib/security.js +27 -4
- package/src/lib/verify.js +916 -0
- package/src/tools/entities.js +20 -4
- package/src/tools/media.js +34 -12
- package/src/tools/nodes.js +18 -5
|
@@ -17,6 +17,6 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
19
|
- `status` (boolean (true/false)): Published flag. Defaults to false (unpublished). Requires allowPublish when true.
|
|
20
|
-
- `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...')
|
|
20
|
+
- `fields` (object (pass as JSON)): Additional field values — include the source field (e.g. field_media_oembed_video: 'https://youtu.be/...'). Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically.
|
|
21
21
|
|
|
22
22
|
If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "Update a media entity's name, status, or field values."
|
|
2
|
+
description: "Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched."
|
|
3
3
|
argument-hint: "<type> <id> [site] [name] [status] [fields]"
|
|
4
4
|
allowed-tools: mcp__drupal__drupal_update_media
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Call the `mcp__drupal__drupal_update_media` MCP tool.
|
|
8
8
|
|
|
9
|
-
Update a media entity's name, status, or field values.
|
|
9
|
+
Update a media entity's name, status, or field values. Partial: omitted fields (status included) are left untouched.
|
|
10
10
|
|
|
11
11
|
Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
12
12
|
|
|
@@ -17,7 +17,7 @@ Parse the request in `$ARGUMENTS` into this tool's parameters:
|
|
|
17
17
|
**Optional:**
|
|
18
18
|
- `site` (string): omit for the default site
|
|
19
19
|
- `name` (string)
|
|
20
|
-
- `status` (boolean (true/false))
|
|
21
|
-
- `fields` (object (pass as JSON))
|
|
20
|
+
- `status` (boolean (true/false)): Published flag. Only sent when provided; requires allowPublish when true.
|
|
21
|
+
- `fields` (object (pass as JSON)): Field values. Entity-reference values in JSON:API linkage shape ({ data: { type, id } }) are sent as relationships automatically.
|
|
22
22
|
|
|
23
23
|
If a required parameter is missing from `$ARGUMENTS`, ask before calling — do not invent values. Coerce each value to its JSON type (booleans → true/false, numbers → numeric, object/array → parse JSON), then make the single tool call and summarize the result.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.6.0] - 2026-08-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Secure-install verifier (`npm run verify`, #180).** Produces evidence that
|
|
14
|
+
an installation carries the secure, tenant-neutral defaults the governed
|
|
15
|
+
product claims, instead of asserting it in a README. The static half needs no
|
|
16
|
+
network or credentials and runs in CI: transport, principal authentication,
|
|
17
|
+
scope grant, source governance, role separation, entitlement, target
|
|
18
|
+
resolution and tenant neutrality. The live half (`--live --site <name>`)
|
|
19
|
+
proves the same claims against a running target and adds three **negative
|
|
20
|
+
probes** — a mass read, a configuration change and a live-content edit — that
|
|
21
|
+
pass only when the target refuses them (a mass read that is *bounded* rather
|
|
22
|
+
than refused also passes; a cap is the control working). The config probe
|
|
23
|
+
goes through the connector's own bridge client, so it exercises the real MCP
|
|
24
|
+
session and governed tool contract, and is skipped for a principal that
|
|
25
|
+
legitimately holds `mcp_config`. A thrown bridge error is classified before
|
|
26
|
+
it is scored: a tool refusal, a server-defined JSON-RPC error or a 401/403
|
|
27
|
+
is a decision and passes; a missing bridge, a session failure, a network
|
|
28
|
+
error or a malformed-call error never reached policy and is skipped. The
|
|
29
|
+
content probe takes an explicit `--content-target` and counts only a 403/401
|
|
30
|
+
as a refusal — a 404 means the publish gate was never reached, not that it
|
|
31
|
+
held. `--json` prints an evidence document
|
|
32
|
+
(connector version, redacted config digest, per-check outcome, the source's
|
|
33
|
+
own refusal codes) for a release record. A check that cannot run reports
|
|
34
|
+
`skipped` and fails the run, while a check that does not *apply* to this
|
|
35
|
+
shape of install (no OAuth, no tool bridge, an in-tier principal) reports
|
|
36
|
+
`n/a` and does not — a verifier a secure install can never pass is one people
|
|
37
|
+
stop running. Nothing secret ever reaches the output. See
|
|
38
|
+
`docs/verification.md`.
|
|
39
|
+
- **Prompt injection and operator trust documented as managed residuals**
|
|
40
|
+
(`docs/threat-model.md`, `docs/verification.md`) and emitted with every
|
|
41
|
+
evidence document — the stack bounds the blast radius, it does not solve
|
|
42
|
+
them (#180).
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
- **The shipped example configuration is tenant-neutral and secure by default**
|
|
46
|
+
(#180). Every hostname is documentation-reserved (RFC 2606/6761); the four
|
|
47
|
+
tiers are named as roles (production, staging, development, break-glass) and
|
|
48
|
+
each carries **its own OAuth client id and its own secret env var** — the
|
|
49
|
+
previous example shared one consumer and one development secret across tiers.
|
|
50
|
+
Staging and development now declare `requireGovernance`, and the permissive
|
|
51
|
+
`development` preset is confined to a loopback target. `bin/drupal-mcp-launch.sh`
|
|
52
|
+
reads its env-var → Keychain-item mapping from a table (overridable per
|
|
53
|
+
machine via `config/secrets.map`) instead of hardcoding one estate's items.
|
|
54
|
+
CI verifies the shipped example on every run.
|
|
55
|
+
|
|
56
|
+
### Security
|
|
57
|
+
- **The empty-scope bypass is closed for governed setups (#180).** An OAuth site
|
|
58
|
+
that named no scopes previously satisfied *every* scope gate, including
|
|
59
|
+
`mcp_config`: an empty list was read as "unconstrained". It is now read as an
|
|
60
|
+
unnamed grant and satisfies nothing, so a governed site must name the scopes
|
|
61
|
+
its token actually carries. A site with no OAuth block (a plain
|
|
62
|
+
`apiTokenEnv`/anonymous install) is unaffected and stays preset-only.
|
|
63
|
+
|
|
64
|
+
## [2.5.0] - 2026-08-14
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
- **An unrequested published-state change is no longer silent (#171).**
|
|
68
|
+
`status` stays strictly opt-in on updates — the connector never adds it to a
|
|
69
|
+
PATCH — but a server-side gate can still flip it (an unmoderated-entity
|
|
70
|
+
publish backstop, or a write landing as an unpublished forward revision).
|
|
71
|
+
`drupal_entity_update`, `drupal_update_node`, and `drupal_update_media` now
|
|
72
|
+
compare the written state against a pre-write read and, when the caller sent
|
|
73
|
+
neither `status` nor an explicit moderation state, report a flip via a
|
|
74
|
+
`_statusChanged` marker (`from`/`to` plus a verification note) instead of
|
|
75
|
+
returning a clean success. The marker survives `returning: "minimal"`.
|
|
76
|
+
Regression tests pin that relationships-only and field-only updates send
|
|
77
|
+
neither `status` nor `moderation_state` across the entity, media, node, and
|
|
78
|
+
bulk update tools.
|
|
79
|
+
- **Media tools route reference-shaped `fields` to relationships (#171).**
|
|
80
|
+
`drupal_update_media` and `drupal_create_media` forwarded entity-reference
|
|
81
|
+
values under `fields` as JSON:API attributes, which Drupal rejects with a 422
|
|
82
|
+
("relationship fields were provided as attributes"). Values in linkage shape
|
|
83
|
+
(`{ data: { type, id } }`, an array of those, or `{ data: null }` to clear)
|
|
84
|
+
are now sent as relationships, matching what `drupal_entity_update` accepts;
|
|
85
|
+
composite attribute values (`{ value, format }` and friends) are untouched.
|
|
86
|
+
The linkage-shape helpers live in `src/lib/canonical.js` for reuse.
|
|
87
|
+
|
|
10
88
|
## [2.4.1] - 2026-08-14
|
|
11
89
|
|
|
12
90
|
### Fixed
|
package/README.md
CHANGED
|
@@ -291,6 +291,7 @@ an operator channel: keep the agent's credentials off it, and pin
|
|
|
291
291
|
| [Tools Reference](docs/tools-reference.md) | Full reference for all 119 tools |
|
|
292
292
|
| [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
|
|
293
293
|
| [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
|
|
294
|
+
| [Verification](docs/verification.md) | Prove a secure install: static + live checks, negative probes, evidence artefact, managed residuals |
|
|
294
295
|
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
|
295
296
|
| [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
|
|
296
297
|
| [Integration Contract](docs/integration-contract.md) | The connector ↔ Drupal-governance contract (identity, OAuth scopes, compatibility) |
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Secure-install verifier (#180).
|
|
4
|
+
*
|
|
5
|
+
* Produces evidence that this installation carries the secure, tenant-neutral
|
|
6
|
+
* defaults the governed product claims — rather than asserting it in a README.
|
|
7
|
+
*
|
|
8
|
+
* npm run verify # static checks on config/config.json
|
|
9
|
+
* npm run verify -- --config config/config.example.json
|
|
10
|
+
* npm run verify -- --live --site production
|
|
11
|
+
* npm run verify -- --live --site staging --json > evidence.json
|
|
12
|
+
*
|
|
13
|
+
* Exit code is 0 only when every check that ran passed AND none was skipped:
|
|
14
|
+
* a skipped check is not evidence. The JSON form is the artefact to attach to
|
|
15
|
+
* a release proof; it carries hosts, outcomes and refusal codes, never
|
|
16
|
+
* payloads, tokens or secrets.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
import fetch from "node-fetch";
|
|
22
|
+
import { verifyStatic, verifyLive } from "../src/lib/verify.js";
|
|
23
|
+
import { loadConfig, getSiteConfig, resolveOauth } from "../src/lib/config.js";
|
|
24
|
+
import { callServerTool } from "../src/lib/server-tools.js";
|
|
25
|
+
|
|
26
|
+
/** Parses `--flag`, `--key value` and `--key=value`. */
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const args = { _: [] };
|
|
29
|
+
for (let i = 0; i < argv.length; i++) {
|
|
30
|
+
const arg = argv[i];
|
|
31
|
+
if (!arg.startsWith("--")) {
|
|
32
|
+
args._.push(arg);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const [key, inline] = arg.slice(2).split("=");
|
|
36
|
+
if (inline !== undefined) {
|
|
37
|
+
args[key] = inline;
|
|
38
|
+
} else if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
39
|
+
args[key] = argv[++i];
|
|
40
|
+
} else {
|
|
41
|
+
args[key] = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return args;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const USAGE = `drupal-mcp-connector verify
|
|
48
|
+
|
|
49
|
+
--config <path> Verify this configuration file instead of the loaded one.
|
|
50
|
+
--live Also verify a running target (needs credentials in env).
|
|
51
|
+
--site <name> Which site to verify live. Defaults to the default site.
|
|
52
|
+
--content-target <uuid>
|
|
53
|
+
A node the live run may attempt a publish-bearing edit
|
|
54
|
+
against. Without it that probe is skipped: a PATCH at a
|
|
55
|
+
non-existent id returns 404 before any access check, so
|
|
56
|
+
scoring it would claim the publish gate holds without
|
|
57
|
+
reaching it. Use a NON-PRODUCTION target you would not
|
|
58
|
+
mind being published if the gate fails.
|
|
59
|
+
--content-target-type <type>
|
|
60
|
+
JSON:API resource type of that target (default node--article).
|
|
61
|
+
--json Print the evidence document instead of a summary.
|
|
62
|
+
--help Show this message.
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
/** Human-readable one-line-per-check report. */
|
|
66
|
+
function report(evidence) {
|
|
67
|
+
const mark = { pass: "PASS", fail: "FAIL", skipped: "SKIP", "n/a": "N/A " };
|
|
68
|
+
const lines = [
|
|
69
|
+
`${evidence.tool} (${evidence.mode}) — connector ${evidence.connectorVersion}`,
|
|
70
|
+
`subject: ${JSON.stringify(evidence.subject)}`,
|
|
71
|
+
"",
|
|
72
|
+
];
|
|
73
|
+
for (const check of evidence.checks) {
|
|
74
|
+
lines.push(` [${mark[check.status]}] ${check.id} — ${check.title}`);
|
|
75
|
+
for (const finding of check.findings) lines.push(` ${finding}`);
|
|
76
|
+
}
|
|
77
|
+
lines.push("");
|
|
78
|
+
lines.push(
|
|
79
|
+
` ${evidence.summary.pass} passed, ${evidence.summary.fail} failed, ` +
|
|
80
|
+
`${evidence.summary.skipped} skipped (unproven), ${evidence.summary.notApplicable} not applicable`,
|
|
81
|
+
);
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(" Managed residuals (not solved by this stack):");
|
|
84
|
+
for (const residual of evidence.residuals) lines.push(` - ${residual.id}: ${residual.detail}`);
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
const args = parseArgs(process.argv.slice(2));
|
|
90
|
+
if (args.help) {
|
|
91
|
+
process.stdout.write(USAGE);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const config = args.config
|
|
96
|
+
? JSON.parse(readFileSync(String(args.config), "utf8"))
|
|
97
|
+
: loadConfig();
|
|
98
|
+
const source = args.config ? String(args.config) : "config/config.json";
|
|
99
|
+
|
|
100
|
+
// Documentation keys (leading underscore) are prose, not configuration.
|
|
101
|
+
const strip = (value) => {
|
|
102
|
+
if (Array.isArray(value)) return value.map(strip);
|
|
103
|
+
if (value && typeof value === "object") {
|
|
104
|
+
return Object.fromEntries(
|
|
105
|
+
Object.entries(value).filter(([k]) => !k.startsWith("_")).map(([k, v]) => [k, strip(v)]),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const evidence = [verifyStatic(strip(config), { source })];
|
|
112
|
+
|
|
113
|
+
if (args.live) {
|
|
114
|
+
const siteName = args.site ? String(args.site) : undefined;
|
|
115
|
+
// getSiteConfig resolves secrets from the environment; nothing is read
|
|
116
|
+
// from the config file itself.
|
|
117
|
+
const site = args.config
|
|
118
|
+
? resolveOauth({ ...(config.sites?.[siteName ?? config.defaultSite] ?? {}), _name: siteName ?? config.defaultSite })
|
|
119
|
+
: getSiteConfig(siteName);
|
|
120
|
+
// The real bridge client, so the governed-tool probes exercise the real
|
|
121
|
+
// contract (MCP session, tool_api name, tool-level refusal) rather than a
|
|
122
|
+
// hand-rolled JSON-RPC body the server would reject as malformed.
|
|
123
|
+
evidence.push(
|
|
124
|
+
await verifyLive(site, {
|
|
125
|
+
transport: fetch,
|
|
126
|
+
callTool: callServerTool,
|
|
127
|
+
contentTarget: args["content-target"] ? String(args["content-target"]) : null,
|
|
128
|
+
contentTargetType: args["content-target-type"] ? String(args["content-target-type"]) : null,
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (args.json) {
|
|
134
|
+
process.stdout.write(JSON.stringify(evidence.length === 1 ? evidence[0] : evidence, null, 2) + "\n");
|
|
135
|
+
} else {
|
|
136
|
+
process.stdout.write(evidence.map(report).join("\n\n") + "\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return evidence.every((e) => e.summary.ok) ? 0 : 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
main()
|
|
143
|
+
.then((code) => process.exit(code))
|
|
144
|
+
.catch((err) => {
|
|
145
|
+
process.stderr.write(`verify: ${err?.message ?? err}\n`);
|
|
146
|
+
process.exit(2);
|
|
147
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "Copy to config/config.json and fill in your values. This file is safe to commit
|
|
2
|
+
"_comment": "Copy to config/config.json and fill in your values. This file is safe to commit; config.json is gitignored. Every hostname here is a documentation-reserved example (RFC 2606/6761) — replace them with your own. Verify a filled-in config with `npm run verify`.",
|
|
3
3
|
|
|
4
4
|
"_api_modes": {
|
|
5
5
|
"_comment": "Per-site 'api' selects the backend: 'graphql' | 'jsonapi' | a priority array like ['graphql','jsonapi']. Omit to auto-detect (probes both once). GraphQL is read-only (no mutations); writes require a JSON:API site.",
|
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
|
|
9
9
|
"_security_options": {
|
|
10
|
-
"_comment": "
|
|
10
|
+
"_comment": "apiTokenEnv / oauth.clientSecretEnv: read credentials from the environment, never from this file. requireSecureAuth: reject anon/basic, require HTTPS+Bearer — set it on every remote site. requireGovernance: deny every governed path unless the source governance contract (GET /drupal-mcp/readiness, mcp_sentinel) verifies — no ungoverned JSON:API/GraphQL fallback. 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": "
|
|
14
|
+
"_comment": "The sites below model agent governance tiers as ROLES, not as a particular estate: 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 (production, staging), developer (development), admin/break-glass (loopback only). Each tier gets its OWN OAuth client and its OWN secret env var — sharing either makes a compromise of one role a compromise of all of them. serverTools wires the governed config tools (drupal_config_get/list/set); drushSsh is development-only and whitelisted to config export/status. See docs/integration-contract.md."
|
|
15
15
|
},
|
|
16
16
|
|
|
17
|
-
"defaultSite": "
|
|
17
|
+
"defaultSite": "production",
|
|
18
18
|
|
|
19
19
|
"tls": {
|
|
20
20
|
"_comment": "TLS is required for the HTTPS transport (MCP_TRANSPORT=https). Ignored in stdio mode.",
|
|
@@ -24,16 +24,16 @@
|
|
|
24
24
|
},
|
|
25
25
|
|
|
26
26
|
"sites": {
|
|
27
|
-
"
|
|
28
|
-
"_comment": "Content tier. Content/media/term CRUD; config read-only; cannot publish (
|
|
29
|
-
"baseUrl": "https://
|
|
27
|
+
"production": {
|
|
28
|
+
"_comment": "Content tier. Content/media/term CRUD; config read-only; cannot publish (the source-side editorial gate decides publication, not the agent). No drushSsh.",
|
|
29
|
+
"baseUrl": "https://drupal.example.com",
|
|
30
30
|
"requireSecureAuth": true,
|
|
31
31
|
"requireGovernance": true,
|
|
32
32
|
"api": "jsonapi",
|
|
33
33
|
"oauth": {
|
|
34
34
|
"tokenUrl": "/oauth/token",
|
|
35
|
-
"clientId": "
|
|
36
|
-
"clientSecretEnv": "
|
|
35
|
+
"clientId": "content-agent-production",
|
|
36
|
+
"clientSecretEnv": "MCP_CONTENT_PRODUCTION_SECRET",
|
|
37
37
|
"scopes": ["mcp_read", "mcp_write"],
|
|
38
38
|
"grant": "client_credentials"
|
|
39
39
|
},
|
|
@@ -42,14 +42,15 @@
|
|
|
42
42
|
},
|
|
43
43
|
|
|
44
44
|
"staging": {
|
|
45
|
-
"_comment": "Content tier. Same capabilities as
|
|
46
|
-
"baseUrl": "https://
|
|
45
|
+
"_comment": "Content tier against the staging environment. Same capabilities as production, its own principal and its own secret.",
|
|
46
|
+
"baseUrl": "https://drupal-staging.example.com",
|
|
47
47
|
"requireSecureAuth": true,
|
|
48
|
+
"requireGovernance": true,
|
|
48
49
|
"api": "jsonapi",
|
|
49
50
|
"oauth": {
|
|
50
51
|
"tokenUrl": "/oauth/token",
|
|
51
|
-
"clientId": "
|
|
52
|
-
"clientSecretEnv": "
|
|
52
|
+
"clientId": "content-agent-staging",
|
|
53
|
+
"clientSecretEnv": "MCP_CONTENT_STAGING_SECRET",
|
|
53
54
|
"scopes": ["mcp_read", "mcp_write"],
|
|
54
55
|
"grant": "client_credentials"
|
|
55
56
|
},
|
|
@@ -57,23 +58,24 @@
|
|
|
57
58
|
"security": { "preset": "content-editor" }
|
|
58
59
|
},
|
|
59
60
|
|
|
60
|
-
"
|
|
61
|
-
"_comment": "Developer tier
|
|
62
|
-
"baseUrl": "https://
|
|
61
|
+
"development": {
|
|
62
|
+
"_comment": "Developer tier. Content + governed config read/write. The drush bridge is development-only and whitelisted to config export/status; the agent mutates config via drupal_config_set, then exports to YAML for a pull request.",
|
|
63
|
+
"baseUrl": "https://drupal.example.test",
|
|
63
64
|
"requireSecureAuth": true,
|
|
65
|
+
"requireGovernance": true,
|
|
64
66
|
"api": "jsonapi",
|
|
65
67
|
"oauth": {
|
|
66
68
|
"tokenUrl": "/oauth/token",
|
|
67
|
-
"clientId": "
|
|
68
|
-
"clientSecretEnv": "
|
|
69
|
+
"clientId": "developer-agent-development",
|
|
70
|
+
"clientSecretEnv": "MCP_DEVELOPER_DEVELOPMENT_SECRET",
|
|
69
71
|
"scopes": ["mcp_read", "mcp_write", "mcp_config"],
|
|
70
72
|
"grant": "client_credentials"
|
|
71
73
|
},
|
|
72
74
|
"serverTools": { "url": "/mcp" },
|
|
73
75
|
"drushSsh": {
|
|
74
|
-
"_comment": "
|
|
75
|
-
"host": "<
|
|
76
|
-
"user": "<
|
|
76
|
+
"_comment": "Development only. SSH target of the local web container. Whitelisted to config export/status — every other drupal_drush_* tool is blocked here. rawSql is omitted, so drupal_drush_sql_query is refused: raw SQL reads underneath Drupal's entity API and is only available via mcp_sentinel's governed command. To enable it, set rawSql:\"governed\", add \"mcp-sentinel:sql-query\" to allowedCommands, and set allow_raw_sql on the site's policy profile.",
|
|
77
|
+
"host": "<web-container-ssh-host>",
|
|
78
|
+
"user": "<web-container-ssh-user>",
|
|
77
79
|
"keyPath": "~/.ssh/id_ed25519",
|
|
78
80
|
"drupalRoot": "/var/www/html/web",
|
|
79
81
|
"port": 22,
|
|
@@ -81,7 +83,7 @@
|
|
|
81
83
|
},
|
|
82
84
|
"audit": {
|
|
83
85
|
"_comment": "Optional. Controls drupal_report_broken_links live checking (off unless checkLive:true is passed). linkCheckAllowedHosts gates external hosts; same-origin links are always allowed when checking live.",
|
|
84
|
-
"linkCheckAllowedHosts": ["www.drupal.org", "
|
|
86
|
+
"linkCheckAllowedHosts": ["www.drupal.org", "example.com"],
|
|
85
87
|
"linkCheckConcurrency": 5,
|
|
86
88
|
"linkCheckTimeoutMs": 5000,
|
|
87
89
|
"linkCheckMaxLinks": 200
|
|
@@ -89,15 +91,15 @@
|
|
|
89
91
|
"security": { "preset": "config-editor" }
|
|
90
92
|
},
|
|
91
93
|
|
|
92
|
-
"
|
|
93
|
-
"_comment": "Admin / break-glass tier
|
|
94
|
-
"baseUrl": "https://
|
|
94
|
+
"break-glass": {
|
|
95
|
+
"_comment": "Admin / break-glass tier — non-standing, and deliberately LOOPBACK ONLY: the permissive 'development' preset allows every operation including deletes and config import, so it is never pointed at a remote host in a shipped default. Its own principal and secret. No drushSsh (admin operations go through governed, approval-gated server tools).",
|
|
96
|
+
"baseUrl": "https://localhost:8443",
|
|
95
97
|
"requireSecureAuth": true,
|
|
96
98
|
"api": "jsonapi",
|
|
97
99
|
"oauth": {
|
|
98
100
|
"tokenUrl": "/oauth/token",
|
|
99
|
-
"clientId": "
|
|
100
|
-
"clientSecretEnv": "
|
|
101
|
+
"clientId": "admin-agent-breakglass",
|
|
102
|
+
"clientSecretEnv": "MCP_ADMIN_BREAKGLASS_SECRET",
|
|
101
103
|
"scopes": ["mcp_read", "mcp_write", "mcp_config", "mcp_admin"],
|
|
102
104
|
"grant": "client_credentials"
|
|
103
105
|
},
|
|
@@ -107,7 +109,7 @@
|
|
|
107
109
|
},
|
|
108
110
|
|
|
109
111
|
"_security_presets": {
|
|
110
|
-
"development": "All operations allowed, incl. config read/write.
|
|
112
|
+
"development": "All operations allowed, incl. config read/write. Loopback / break-glass only.",
|
|
111
113
|
"content-editor": "Create/edit the full content set (node, media, taxonomy_term, paragraph, block_content, menu_link_content, redirect, path_alias, file). No deletes. Config read-only. Denies user + secrets/governance types.",
|
|
112
114
|
"config-editor": "content-editor + site-building config entities (read/introspection) + governed config read/write (Developer tier). Model changes go via the config bridge, not JSON:API.",
|
|
113
115
|
"auditor": "Read-only. All entity types. User PII fields redacted. Config read-only.",
|
|
@@ -134,7 +136,7 @@
|
|
|
134
136
|
"_https_transport_registration": {
|
|
135
137
|
"_comment": "For multi-client HTTP mode — register the HTTPS endpoint instead",
|
|
136
138
|
"drupal": {
|
|
137
|
-
"url": "https://
|
|
139
|
+
"url": "https://mcp.example.com:3443/mcp"
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drupal-mcp-connector",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal
|
|
3
|
+
"version": "2.6.0",
|
|
4
|
+
"description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal \u2014 dual-protocol JSON:API and GraphQL.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
8
|
-
"drupal-mcp-connector": "src/index.js"
|
|
8
|
+
"drupal-mcp-connector": "src/index.js",
|
|
9
|
+
"drupal-mcp-verify": "bin/drupal-mcp-verify.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src/",
|
|
@@ -55,7 +56,9 @@
|
|
|
55
56
|
"test:watch": "vitest",
|
|
56
57
|
"audit": "npm audit --audit-level=high",
|
|
57
58
|
"check": "npm run lint && npm run audit",
|
|
58
|
-
"syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f
|
|
59
|
+
"syntax-check": "for f in src/lib/*.js src/tools/*.js src/index.js; do node --input-type=module --check < $f && echo \"$f \u2713\"; done",
|
|
60
|
+
"verify": "node bin/drupal-mcp-verify.js",
|
|
61
|
+
"verify:example": "node bin/drupal-mcp-verify.js --config config/config.example.json"
|
|
59
62
|
},
|
|
60
63
|
"dependencies": {
|
|
61
64
|
"@modelcontextprotocol/node": "^2.0.0",
|
package/src/lib/canonical.js
CHANGED
|
@@ -66,3 +66,51 @@ export function normalizeRelationship(ref) {
|
|
|
66
66
|
const [entityType = null, bundle = null] = (ref.type || "").split("--");
|
|
67
67
|
return { id: ref.id, entityType, bundle };
|
|
68
68
|
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a field value is a JSON:API relationship linkage (`{ data: ... }`
|
|
72
|
+
* where data is null, one `{ type, id }` reference, or an array of them —
|
|
73
|
+
* an empty array clears a multi-value reference).
|
|
74
|
+
*
|
|
75
|
+
* Composite attribute values (e.g. `{ value, format }` text fields) have no
|
|
76
|
+
* `data` key and are never matched, so ordinary attributes pass through.
|
|
77
|
+
*
|
|
78
|
+
* @param {*} value A caller-supplied field value.
|
|
79
|
+
* @returns {boolean} True when the value is relationship-shaped.
|
|
80
|
+
*/
|
|
81
|
+
export function isRelationshipLinkage(value) {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
83
|
+
if (!Object.prototype.hasOwnProperty.call(value, "data")) return false;
|
|
84
|
+
const isRef = (d) => Boolean(d) && typeof d === "object" && !Array.isArray(d)
|
|
85
|
+
&& typeof d.type === "string" && typeof d.id === "string";
|
|
86
|
+
const { data } = value;
|
|
87
|
+
if (data === null) return true;
|
|
88
|
+
if (Array.isArray(data)) return data.every(isRef);
|
|
89
|
+
return isRef(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Split a caller field map into JSON:API attributes and relationships (#171).
|
|
94
|
+
*
|
|
95
|
+
* Entity-reference values passed under a `fields` map used to be forwarded as
|
|
96
|
+
* attributes, which Drupal rejects with a 422 ("relationship fields were
|
|
97
|
+
* provided as attributes"). Relationship-shaped values are routed to the
|
|
98
|
+
* `relationships` document member instead, so field-map tools accept the same
|
|
99
|
+
* reference shape as `drupal_entity_update`.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} fields Caller-supplied field map.
|
|
102
|
+
* @returns {{attributes: object, relationships: ?object}} Split maps;
|
|
103
|
+
* `relationships` is null when no value was relationship-shaped.
|
|
104
|
+
*/
|
|
105
|
+
export function splitReferenceFields(fields) {
|
|
106
|
+
const attrEntries = [];
|
|
107
|
+
const relEntries = [];
|
|
108
|
+
for (const entry of Object.entries(fields)) {
|
|
109
|
+
const [, value] = entry;
|
|
110
|
+
(isRelationshipLinkage(value) ? relEntries : attrEntries).push(entry);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
attributes: Object.fromEntries(attrEntries),
|
|
114
|
+
relationships: relEntries.length ? Object.fromEntries(relEntries) : null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -34,6 +34,47 @@ export function shapeWriteResponse(entity, returning = "full") {
|
|
|
34
34
|
return out;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Flag a published-state change the caller never requested (#171).
|
|
39
|
+
*
|
|
40
|
+
* `status` is strictly opt-in on updates: the connector never adds it to a
|
|
41
|
+
* PATCH. A server-side gate can still flip it (e.g. a governance backstop
|
|
42
|
+
* unpublishing an unmoderated entity, or a write landing as an unpublished
|
|
43
|
+
* forward revision), and a silent success that also changed live state is the
|
|
44
|
+
* failure class that surfaces only when content goes missing. When a
|
|
45
|
+
* pre-write read is available and the sent attributes carry neither `status`
|
|
46
|
+
* nor `moderation_state` (an explicit moderation transition legitimately
|
|
47
|
+
* changes the published state, as does the #131 injected draft default), a
|
|
48
|
+
* different `status` in the write result is surfaced as `_statusChanged` — an
|
|
49
|
+
* `_`-prefixed key, so it survives `returning: "minimal"`.
|
|
50
|
+
*
|
|
51
|
+
* Best-effort by design: it requires a readable pre-write entity, and the
|
|
52
|
+
* server-side gate stays authoritative either way.
|
|
53
|
+
*
|
|
54
|
+
* @param {?object} result The entity returned by (or re-read after) the write.
|
|
55
|
+
* @param {?object} existing The pre-write entity, when it could be read.
|
|
56
|
+
* @param {object} sentAttributes The attribute map that was sent.
|
|
57
|
+
* @returns {?object} The result, with `_statusChanged` attached when it applies.
|
|
58
|
+
*/
|
|
59
|
+
export function flagUnrequestedStatusChange(result, existing, sentAttributes) {
|
|
60
|
+
if (!result || !existing) return result;
|
|
61
|
+
const sent = (key) => Object.prototype.hasOwnProperty.call(sentAttributes, key);
|
|
62
|
+
if (sent("status") || sent("moderation_state")) return result;
|
|
63
|
+
if (typeof existing.status !== "boolean" || typeof result.status !== "boolean") return result;
|
|
64
|
+
if (existing.status === result.status) return result;
|
|
65
|
+
return {
|
|
66
|
+
...result,
|
|
67
|
+
_statusChanged: {
|
|
68
|
+
from: existing.status,
|
|
69
|
+
to: result.status,
|
|
70
|
+
note: "The returned published status differs from the pre-write state although the request did not " +
|
|
71
|
+
"include `status`. A server-side gate intervened — the write may have landed as an unpublished " +
|
|
72
|
+
"forward revision (live revision unchanged) or the entity may have been unpublished. Verify which " +
|
|
73
|
+
"revision is live before relying on this content's visibility.",
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
37
78
|
/** JSON Schema fragment for the shared `returning` parameter. */
|
|
38
79
|
export const RETURNING_SCHEMA = {
|
|
39
80
|
type: "string",
|
package/src/lib/security.js
CHANGED
|
@@ -343,16 +343,39 @@ export function assertConfigWriteAllowed(secConfig) {
|
|
|
343
343
|
}
|
|
344
344
|
|
|
345
345
|
/**
|
|
346
|
-
* Whether the site's OAuth token carries a given scope.
|
|
347
|
-
* no
|
|
348
|
-
*
|
|
346
|
+
* Whether the site's OAuth token carries a given scope. A governed setup that
|
|
347
|
+
* names no scopes carries none of them (#180) — an unnamed grant is not a
|
|
348
|
+
* wildcard. A preset-only, non-OAuth site keeps the permissive behaviour.
|
|
349
349
|
* @param {object} site Resolved site config.
|
|
350
350
|
* @param {string} scope OAuth scope machine id (e.g. "mcp_config").
|
|
351
351
|
* @returns {boolean} True if the scope is present, or no scopes are configured.
|
|
352
352
|
*/
|
|
353
353
|
export function hasScope(site, scope) {
|
|
354
354
|
const scopes = site?.oauth?.scopes ?? [];
|
|
355
|
-
|
|
355
|
+
if (scopes.length > 0) return scopes.includes(scope);
|
|
356
|
+
// Empty or absent scopes. For a GOVERNED product setup — a site that claims
|
|
357
|
+
// source governance, or configures OAuth at all — that is not "every scope";
|
|
358
|
+
// it is an unnamed grant, and treating it as a wildcard was a bypass of the
|
|
359
|
+
// very gate the scopes exist to be (#180). Deny, so the operator has to name
|
|
360
|
+
// the scopes the token actually carries.
|
|
361
|
+
//
|
|
362
|
+
// An ungoverned install (a plain apiToken or anonymous site, no OAuth block)
|
|
363
|
+
// has no scope vocabulary to name and keeps the historical permissive
|
|
364
|
+
// behaviour: preset semantics alone decide there.
|
|
365
|
+
return !isGovernedSetup(site);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Whether a site is part of the governed product path.
|
|
370
|
+
*
|
|
371
|
+
* Either it declares the source-governance requirement, or it authenticates
|
|
372
|
+
* with OAuth — both mean the server is deciding on scopes, so the connector
|
|
373
|
+
* must not invent one.
|
|
374
|
+
* @param {object} site Resolved site config.
|
|
375
|
+
* @returns {boolean}
|
|
376
|
+
*/
|
|
377
|
+
export function isGovernedSetup(site) {
|
|
378
|
+
return site?.requireGovernance === true || Boolean(site?.oauth);
|
|
356
379
|
}
|
|
357
380
|
|
|
358
381
|
/**
|