drupal-mcp-connector 2.5.0 → 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/CHANGELOG.md +54 -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/security.js +27 -4
- package/src/lib/verify.js +916 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,60 @@ 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
|
+
|
|
10
64
|
## [2.5.0] - 2026-08-14
|
|
11
65
|
|
|
12
66
|
### 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/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
|
/**
|