drupal-mcp-connector 2.5.0 → 2.6.1
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 +70 -0
- package/README.md +3 -0
- package/bin/drupal-mcp-verify.js +147 -0
- package/config/config.example.json +31 -29
- package/package.json +7 -4
- package/src/index.js +17 -0
- package/src/lib/config.js +19 -1
- package/src/lib/load-secrets.js +158 -0
- package/src/lib/security.js +27 -4
- package/src/lib/verify.js +916 -0
- package/src/tools/site.js +32 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,76 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.6.1] - 2026-08-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **A process no longer starts when every secret named by the active config
|
|
14
|
+
is unset (#199).** After 2.6.0, a client that spawned `node src/index.js`
|
|
15
|
+
(skipping the launcher) or whose `config.json` still used older
|
|
16
|
+
`clientSecretEnv` names could start with zero resolved sites and advertise
|
|
17
|
+
only `drupal_list_sites` and `drupal_governance_status`. The diagnostic
|
|
18
|
+
then told the operator to provide an oauth block that was already there.
|
|
19
|
+
2.6.1 loads `config/secrets.map` (or the shipped example table) inside
|
|
20
|
+
`node`; names the unset variable; classifies
|
|
21
|
+
`drupal_governance_status` failures; and **refuses to start** when every
|
|
22
|
+
named secret is missing. On 2.6.0 the same recovery is: launch via
|
|
23
|
+
`bin/drupal-mcp-launch.sh` with a `config/secrets.map`
|
|
24
|
+
(`ENV_VAR=keychain-item`), then restart the MCP client.
|
|
25
|
+
|
|
26
|
+
## [2.6.0] - 2026-08-15
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- **Secure-install verifier (`npm run verify`, #180).** Produces evidence that
|
|
30
|
+
an installation carries the secure, tenant-neutral defaults the governed
|
|
31
|
+
product claims, instead of asserting it in a README. The static half needs no
|
|
32
|
+
network or credentials and runs in CI: transport, principal authentication,
|
|
33
|
+
scope grant, source governance, role separation, entitlement, target
|
|
34
|
+
resolution and tenant neutrality. The live half (`--live --site <name>`)
|
|
35
|
+
proves the same claims against a running target and adds three **negative
|
|
36
|
+
probes** — a mass read, a configuration change and a live-content edit — that
|
|
37
|
+
pass only when the target refuses them (a mass read that is *bounded* rather
|
|
38
|
+
than refused also passes; a cap is the control working). The config probe
|
|
39
|
+
goes through the connector's own bridge client, so it exercises the real MCP
|
|
40
|
+
session and governed tool contract, and is skipped for a principal that
|
|
41
|
+
legitimately holds `mcp_config`. A thrown bridge error is classified before
|
|
42
|
+
it is scored: a tool refusal, a server-defined JSON-RPC error or a 401/403
|
|
43
|
+
is a decision and passes; a missing bridge, a session failure, a network
|
|
44
|
+
error or a malformed-call error never reached policy and is skipped. The
|
|
45
|
+
content probe takes an explicit `--content-target` and counts only a 403/401
|
|
46
|
+
as a refusal — a 404 means the publish gate was never reached, not that it
|
|
47
|
+
held. `--json` prints an evidence document
|
|
48
|
+
(connector version, redacted config digest, per-check outcome, the source's
|
|
49
|
+
own refusal codes) for a release record. A check that cannot run reports
|
|
50
|
+
`skipped` and fails the run, while a check that does not *apply* to this
|
|
51
|
+
shape of install (no OAuth, no tool bridge, an in-tier principal) reports
|
|
52
|
+
`n/a` and does not — a verifier a secure install can never pass is one people
|
|
53
|
+
stop running. Nothing secret ever reaches the output. See
|
|
54
|
+
`docs/verification.md`.
|
|
55
|
+
- **Prompt injection and operator trust documented as managed residuals**
|
|
56
|
+
(`docs/threat-model.md`, `docs/verification.md`) and emitted with every
|
|
57
|
+
evidence document — the stack bounds the blast radius, it does not solve
|
|
58
|
+
them (#180).
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- **The shipped example configuration is tenant-neutral and secure by default**
|
|
62
|
+
(#180). Every hostname is documentation-reserved (RFC 2606/6761); the four
|
|
63
|
+
tiers are named as roles (production, staging, development, break-glass) and
|
|
64
|
+
each carries **its own OAuth client id and its own secret env var** — the
|
|
65
|
+
previous example shared one consumer and one development secret across tiers.
|
|
66
|
+
Staging and development now declare `requireGovernance`, and the permissive
|
|
67
|
+
`development` preset is confined to a loopback target. `bin/drupal-mcp-launch.sh`
|
|
68
|
+
reads its env-var → Keychain-item mapping from a table (overridable per
|
|
69
|
+
machine via `config/secrets.map`) instead of hardcoding one estate's items.
|
|
70
|
+
CI verifies the shipped example on every run.
|
|
71
|
+
|
|
72
|
+
### Security
|
|
73
|
+
- **The empty-scope bypass is closed for governed setups (#180).** An OAuth site
|
|
74
|
+
that named no scopes previously satisfied *every* scope gate, including
|
|
75
|
+
`mcp_config`: an empty list was read as "unconstrained". It is now read as an
|
|
76
|
+
unnamed grant and satisfies nothing, so a governed site must name the scopes
|
|
77
|
+
its token actually carries. A site with no OAuth block (a plain
|
|
78
|
+
`apiTokenEnv`/anonymous install) is unaffected and stays preset-only.
|
|
79
|
+
|
|
10
80
|
## [2.5.0] - 2026-08-14
|
|
11
81
|
|
|
12
82
|
### Fixed
|
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
Built by **Jeremy Michael Cerda** (opensource@wilkesliberty.com). Maintained by [Wilkes & Liberty, LLC](https://github.com/Wilkes-Liberty).
|
|
11
11
|
|
|
12
|
+
**If the client only shows `drupal_list_sites` and `drupal_governance_status`**, the secret env vars named in `config.json` are unset. Upgrade to **2.6.1**, or stay on 2.6.0 and launch via `bin/drupal-mcp-launch.sh` with a `config/secrets.map` (`ENV_VAR=keychain-item`). Then restart the MCP server. See [#199](https://github.com/Wilkes-Liberty/drupal-mcp-connector/issues/199).
|
|
13
|
+
|
|
12
14
|
---
|
|
13
15
|
|
|
14
16
|
## What It Does
|
|
@@ -291,6 +293,7 @@ an operator channel: keep the agent's credentials off it, and pin
|
|
|
291
293
|
| [Tools Reference](docs/tools-reference.md) | Full reference for all 119 tools |
|
|
292
294
|
| [Security Guide](docs/security.md) | Presets, entity access control, field redaction |
|
|
293
295
|
| [Security Hardening](docs/security-hardening.md) | Optional transport, identity, and secrets controls |
|
|
296
|
+
| [Verification](docs/verification.md) | Prove a secure install: static + live checks, negative probes, evidence artefact, managed residuals |
|
|
294
297
|
| [Threat Model](docs/threat-model.md) | Trust boundaries, threats & mitigations, residual risks, and the security-pass results |
|
|
295
298
|
| [Deployment](docs/deployment.md) | Run the HTTPS transport in production: Docker, systemd, launchd, reverse proxy, pre-exposure checklist |
|
|
296
299
|
| [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.1",
|
|
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/index.js
CHANGED
|
@@ -32,6 +32,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
|
32
32
|
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
33
33
|
|
|
34
34
|
import { listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
35
|
+
import { loadLocalSecrets, secretLoadFatalMessage } from "./lib/load-secrets.js";
|
|
35
36
|
import { makeBearerCheck } from "./lib/http-auth.js";
|
|
36
37
|
import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
|
|
37
38
|
import { createConnectorServerFactory } from "./lib/mcp-server.js";
|
|
@@ -43,6 +44,22 @@ import { filterDiscoverableTools } from "./lib/governance.js";
|
|
|
43
44
|
import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
|
|
44
45
|
import { buildToolPrompts, getToolPromptMessages } from "./lib/tool-prompts.js";
|
|
45
46
|
|
|
47
|
+
// Apply config/secrets.map (or the shipped example table) before any site
|
|
48
|
+
// resolution. MCP clients spawn this file directly; the shell launcher is
|
|
49
|
+
// not guaranteed to have run.
|
|
50
|
+
const secretLoad = loadLocalSecrets();
|
|
51
|
+
const secretFatal = secretLoadFatalMessage(secretLoad);
|
|
52
|
+
if (secretFatal) {
|
|
53
|
+
console.error(`[drupal-mcp-connector] FATAL: ${secretFatal}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (secretLoad.unset.length) {
|
|
57
|
+
console.error(
|
|
58
|
+
"[drupal-mcp-connector] WARNING: config.json names secret env vars that are unset: " +
|
|
59
|
+
`${secretLoad.unset.join(", ")}. Those sites will fail closed.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
46
63
|
// ---------------------------------------------------------------------------
|
|
47
64
|
// MCP Resources — browsable, always-fresh site context
|
|
48
65
|
// ---------------------------------------------------------------------------
|
package/src/lib/config.js
CHANGED
|
@@ -85,8 +85,26 @@ export function assertSecureAuth(site) {
|
|
|
85
85
|
throw new SecurityError(`Site "${site._name}": requireSecureAuth is set but baseUrl is not HTTPS.`);
|
|
86
86
|
}
|
|
87
87
|
if (!site.apiToken && !hasValidOauth(site)) {
|
|
88
|
+
const siteName = site._name ?? "";
|
|
89
|
+
if (site.oauth?.clientSecretEnv && !site.oauth.clientSecret) {
|
|
90
|
+
throw new SecurityError(
|
|
91
|
+
`Site "${siteName}": requireSecureAuth is set but oauth.clientSecretEnv ` +
|
|
92
|
+
`"${site.oauth.clientSecretEnv}" is not set in the environment.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (site.apiTokenEnv && !site.apiToken) {
|
|
96
|
+
throw new SecurityError(
|
|
97
|
+
`Site "${siteName}": requireSecureAuth is set but apiTokenEnv ` +
|
|
98
|
+
`"${site.apiTokenEnv}" is not set in the environment.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (site.oauth && !site.oauth.clientId) {
|
|
102
|
+
throw new SecurityError(
|
|
103
|
+
`Site "${siteName}": requireSecureAuth is set but the oauth block has no clientId.`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
88
106
|
throw new SecurityError(
|
|
89
|
-
`Site "${
|
|
107
|
+
`Site "${siteName}": requireSecureAuth is set but no Bearer apiToken or OAuth2 client ` +
|
|
90
108
|
"credentials are configured (anonymous and basic auth are not permitted). " +
|
|
91
109
|
"Provide apiToken/apiTokenEnv or an oauth block."
|
|
92
110
|
);
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load connector secrets the same way for every entry point.
|
|
3
|
+
*
|
|
4
|
+
* MCP clients often spawn `node src/index.js` directly. The shell launcher
|
|
5
|
+
* cannot be the only place that applies `config/secrets.map`, or a process
|
|
6
|
+
* that skipped the launcher starts, resolves zero sites, and advertises only
|
|
7
|
+
* diagnostic tools — the 2.6.0/#180 failure on this machine.
|
|
8
|
+
*
|
|
9
|
+
* The default table matches config/config.example.json. A gitignored
|
|
10
|
+
* config/secrets.map replaces that table for a deployment whose env-var
|
|
11
|
+
* names differ. Per-item Keychain misses stay silent (inert break-glass).
|
|
12
|
+
* If the active config.json names secret env vars and none of them are set
|
|
13
|
+
* after this step, the caller must refuse to start.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Shipped env-var → Keychain-item pairs. Matches config/config.example.json. */
|
|
21
|
+
export const DEFAULT_SECRET_PAIRS = Object.freeze([
|
|
22
|
+
["MCP_CONTENT_PRODUCTION_SECRET", "drupal-mcp-content-production"],
|
|
23
|
+
["MCP_CONTENT_STAGING_SECRET", "drupal-mcp-content-staging"],
|
|
24
|
+
["MCP_DEVELOPER_DEVELOPMENT_SECRET", "drupal-mcp-developer-development"],
|
|
25
|
+
["MCP_ADMIN_BREAKGLASS_SECRET", "drupal-mcp-admin-breakglass"],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a secrets.map body. `#` comments and malformed lines are ignored.
|
|
30
|
+
* @param {string} text
|
|
31
|
+
* @returns {Array<[string, string]>}
|
|
32
|
+
*/
|
|
33
|
+
export function parseSecretMap(text) {
|
|
34
|
+
const pairs = [];
|
|
35
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
36
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
37
|
+
if (!line || !line.includes("=")) continue;
|
|
38
|
+
const eq = line.indexOf("=");
|
|
39
|
+
const varName = line.slice(0, eq).trim();
|
|
40
|
+
const item = line.slice(eq + 1).trim();
|
|
41
|
+
if (varName && item) pairs.push([varName, item]);
|
|
42
|
+
}
|
|
43
|
+
return pairs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Collect clientSecretEnv / apiTokenEnv names from a parsed config object.
|
|
48
|
+
* @param {object} cfg
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function namedSecretEnvVars(cfg) {
|
|
52
|
+
const names = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const walk = (value) => {
|
|
55
|
+
if (!value || typeof value !== "object") return;
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
value.forEach(walk);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const [key, val] of Object.entries(value)) {
|
|
61
|
+
if (key.startsWith("_")) continue;
|
|
62
|
+
if ((key === "clientSecretEnv" || key === "apiTokenEnv") && typeof val === "string") {
|
|
63
|
+
const name = val.trim();
|
|
64
|
+
if (name && !seen.has(name)) {
|
|
65
|
+
seen.add(name);
|
|
66
|
+
names.push(name);
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
walk(val);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
walk(cfg);
|
|
74
|
+
return names;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Look up one macOS Keychain generic password. Returns "" when missing or
|
|
79
|
+
* when not on Darwin. Never throws. Never logs the value.
|
|
80
|
+
* @param {string} item
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function lookupKeychainItem(item) {
|
|
84
|
+
if (process.platform !== "darwin" || !item) return "";
|
|
85
|
+
try {
|
|
86
|
+
const value = execFileSync("security", ["find-generic-password", "-s", item, "-w"], {
|
|
87
|
+
encoding: "utf8",
|
|
88
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
89
|
+
});
|
|
90
|
+
return String(value).replace(/\n$/, "");
|
|
91
|
+
} catch {
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply the secret table and report how the active config lines up with env.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} [options]
|
|
100
|
+
* @param {string} [options.cwd]
|
|
101
|
+
* @param {NodeJS.ProcessEnv} [options.env] Mutated when a lookup succeeds.
|
|
102
|
+
* @param {typeof readFileSync} [options.readFile]
|
|
103
|
+
* @param {(item: string) => string} [options.lookup]
|
|
104
|
+
* @returns {{pairs: number, resolved: number, named: string[], unset: string[]}}
|
|
105
|
+
*/
|
|
106
|
+
export function loadLocalSecrets({
|
|
107
|
+
cwd = process.cwd(),
|
|
108
|
+
env = process.env,
|
|
109
|
+
readFile = readFileSync,
|
|
110
|
+
lookup = lookupKeychainItem,
|
|
111
|
+
} = {}) {
|
|
112
|
+
let pairs;
|
|
113
|
+
try {
|
|
114
|
+
pairs = parseSecretMap(readFile(join(cwd, "config", "secrets.map"), "utf8"));
|
|
115
|
+
} catch {
|
|
116
|
+
pairs = DEFAULT_SECRET_PAIRS;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let resolved = 0;
|
|
120
|
+
for (const [varName, item] of pairs) {
|
|
121
|
+
const current = new Map(Object.entries(env)).get(varName);
|
|
122
|
+
if (current) continue;
|
|
123
|
+
const value = lookup(item);
|
|
124
|
+
if (!value) continue;
|
|
125
|
+
Object.defineProperty(env, varName, {
|
|
126
|
+
value,
|
|
127
|
+
writable: true,
|
|
128
|
+
enumerable: true,
|
|
129
|
+
configurable: true,
|
|
130
|
+
});
|
|
131
|
+
resolved += 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let named = [];
|
|
135
|
+
try {
|
|
136
|
+
named = namedSecretEnvVars(JSON.parse(readFile(join(cwd, "config", "config.json"), "utf8")));
|
|
137
|
+
} catch {
|
|
138
|
+
named = [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const envMap = new Map(Object.entries(env));
|
|
142
|
+
const unset = named.filter((name) => !envMap.get(name));
|
|
143
|
+
return { pairs: pairs.length, resolved, named, unset };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Refuse to boot a server that can only advertise diagnostic tools.
|
|
148
|
+
* @param {{named: string[], unset: string[]}} loaded
|
|
149
|
+
* @returns {string|null} Fatal message, or null when start is allowed.
|
|
150
|
+
*/
|
|
151
|
+
export function secretLoadFatalMessage(loaded) {
|
|
152
|
+
if (!loaded.named.length || loaded.unset.length !== loaded.named.length) return null;
|
|
153
|
+
return (
|
|
154
|
+
`every clientSecretEnv/apiTokenEnv named in config.json is unset (${loaded.unset.join(", ")}). ` +
|
|
155
|
+
"Refusing to start. Map those names in config/secrets.map (ENV_VAR=keychain-item) " +
|
|
156
|
+
"or export them before launch."
|
|
157
|
+
);
|
|
158
|
+
}
|