drupal-mcp-connector 2.6.0 → 2.7.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-list-sites.md +2 -2
- package/CHANGELOG.md +63 -0
- package/README.md +3 -1
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +108 -21
- package/src/lib/config.js +42 -1
- package/src/lib/dispatch.js +36 -10
- package/src/lib/http-auth.js +541 -3
- package/src/lib/http-handler.js +62 -9
- package/src/lib/load-secrets.js +158 -0
- package/src/lib/mcp-server.js +32 -6
- package/src/lib/principal.js +372 -0
- package/src/lib/verify.js +40 -4
- package/src/tools/config.js +6 -0
- package/src/tools/site.js +51 -6
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: "List
|
|
2
|
+
description: "List the Drupal sites this principal may address. Each target includes the authoritative site name and base URL."
|
|
3
3
|
allowed-tools: mcp__drupal__drupal_list_sites
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Call the `mcp__drupal__drupal_list_sites` MCP tool.
|
|
7
7
|
|
|
8
|
-
List
|
|
8
|
+
List the Drupal sites this principal may address. Each target includes the authoritative site name and base URL.
|
|
9
9
|
|
|
10
10
|
This tool takes no arguments — call it directly (ignore `$ARGUMENTS`).
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.7.0] - 2026-08-17
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Discovery and invocation follow the inbound principal (#178).** On
|
|
14
|
+
HTTPS resource-server requests the validated JWT — not caller `site`,
|
|
15
|
+
`environment`, `tenant`, `target`, or `scope` arguments — decides which
|
|
16
|
+
tools, resources, prompts, and sites are visible or callable. Empty
|
|
17
|
+
inbound scopes are no grants. `auth.grants` maps a client id to site
|
|
18
|
+
names; a present map is fail-closed for unknown clients. GraphQL and
|
|
19
|
+
raw SQL stay hidden unless an entitled site's preset actually allows
|
|
20
|
+
them. Stdio and loopback without a resource-server identity keep the
|
|
21
|
+
existing local-operator surface. `drupal_list_sites` now also returns
|
|
22
|
+
`targets` with the authoritative name and base URL.
|
|
23
|
+
- **Network-facing HTTPS is an OAuth protected resource (#177).** `/mcp`
|
|
24
|
+
validates inbound JWTs against a configured issuer (RFC 8414 / OIDC
|
|
25
|
+
discovery + JWKS): issuer, audience/resource, expiry and required scopes.
|
|
26
|
+
RFC 9728 metadata is served at `/.well-known/oauth-protected-resource`.
|
|
27
|
+
A revocation file (`jti` / `sub`) is re-read when it changes, so a revoke
|
|
28
|
+
does not require a restart. Optional RFC 7662 introspection is fail-closed
|
|
29
|
+
when configured. Discovery requires the metadata `issuer` to match the
|
|
30
|
+
configured identifier (RFC 8414 §3.3), uses the RFC 8414 well-known path
|
|
31
|
+
for issuers that have a path component, and refuses HTTP issuers,
|
|
32
|
+
`jwks_uri`s, and introspection URLs. Trailing slashes on the issuer
|
|
33
|
+
identifier do not break JWT verification. RFC 9728 `authorization_servers`
|
|
34
|
+
advertises the issuer string returned by discovery. A thrown authenticator, a corrupt revocation file, or a
|
|
35
|
+
failed introspection returns `401` instead of hanging the request.
|
|
36
|
+
Caller-supplied identity headers never become the principal. The inbound
|
|
37
|
+
access token is never forwarded to Drupal. `MCP_AUTH_TOKEN` remains valid
|
|
38
|
+
only on loopback; a network-facing bind that still relies on the shared
|
|
39
|
+
secret refuses to start.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
- **Versioning policy states the post-1.0 guarantees it actually operates under
|
|
43
|
+
(#195).** `docs/versioning.md` still opened with a pre-1.0 section explaining
|
|
44
|
+
that minor releases might break the stable surface because the project was on
|
|
45
|
+
`0.x`, and pointed at a closed tracking issue. The package has been past 1.0
|
|
46
|
+
for some time, so the document told operators the compatibility guarantees
|
|
47
|
+
were not yet in force when they were. The stable-surface list, deprecation
|
|
48
|
+
policy and runtime support were already correct and are unchanged.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
- **Whitepaper no longer calls the companion governance module pre-1.0/alpha
|
|
52
|
+
(#195).** `docs/whitepaper.md` described `drupal/mcp_sentinel` as alpha and
|
|
53
|
+
requiring Drupal `^10.3 || ^11`. It is a shipping module at 2.9.0 with a floor
|
|
54
|
+
of `^10.6 || ^11.3` on PHP 8.3 or newer — the stale constraint would have sent
|
|
55
|
+
a reader to install it on a combination it does not support.
|
|
56
|
+
|
|
57
|
+
## [2.6.1] - 2026-08-17
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- **A process no longer starts when every secret named by the active config
|
|
61
|
+
is unset (#199).** After 2.6.0, a client that spawned `node src/index.js`
|
|
62
|
+
(skipping the launcher) or whose `config.json` still used older
|
|
63
|
+
`clientSecretEnv` names could start with zero resolved sites and advertise
|
|
64
|
+
only `drupal_list_sites` and `drupal_governance_status`. The diagnostic
|
|
65
|
+
then told the operator to provide an oauth block that was already there.
|
|
66
|
+
2.6.1 loads `config/secrets.map` (or the shipped example table) inside
|
|
67
|
+
`node`; names the unset variable; classifies
|
|
68
|
+
`drupal_governance_status` failures; and **refuses to start** when every
|
|
69
|
+
named secret is missing. On 2.6.0 the same recovery is: launch via
|
|
70
|
+
`bin/drupal-mcp-launch.sh` with a `config/secrets.map`
|
|
71
|
+
(`ENV_VAR=keychain-item`), then restart the MCP client.
|
|
72
|
+
|
|
10
73
|
## [2.6.0] - 2026-08-15
|
|
11
74
|
|
|
12
75
|
### Added
|
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.7.0** (or at least 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
|
|
@@ -156,7 +158,7 @@ Additional connector-side gates (2.1+ / 2.2+):
|
|
|
156
158
|
- **Entity allowlists** apply to specialized tools (`drupal_*_node`, media, taxonomy), not only `drupal_entity_*`.
|
|
157
159
|
- **Publish gate:** `status: true` and `moderation_state: "published"` require `allowPublish`. Media create defaults **unpublished**. Published moderated node updates without a moderation state default to **draft** (forward revision).
|
|
158
160
|
- **Uploads** only from `MCP_UPLOAD_ROOT` (or the process cwd); sensitive paths (`.env*`, `.ssh`, connector `config.json`) are refused.
|
|
159
|
-
- **HTTPS:** non-loopback binds require `MCP_AUTH_TOKEN`
|
|
161
|
+
- **HTTPS:** non-loopback binds require an inbound OAuth resource server (`auth.issuer` + `auth.audience`), not `MCP_AUTH_TOKEN`. `MCP_AUTH_TOKEN` remains valid on loopback. `MCP_ALLOW_UNAUTHENTICATED=1` is only for a trusted proxy. Non-loopback TLS defaults to 120 req/min rate limiting. Inbound JWT scopes (and optional `auth.grants`) filter which tools, resources, prompts, and sites a principal can discover or invoke.
|
|
160
162
|
- **GraphQL is off by default.** `drupal_graphql` / introspect require `security.allowGraphql` (true only on the `development` preset). Raw GraphQL results still bypass entity allowlists and field redaction — prefer JSON:API entity tools when connector policy must hold. Mutations also need `allowGraphqlMutations`.
|
|
161
163
|
|
|
162
164
|
Full detail: **[docs/security.md](docs/security.md)** and **[docs/security-hardening.md](docs/security-hardening.md)**.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
|
|
9
9
|
"_security_options": {
|
|
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;
|
|
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_BIND_HOST restricts the listen interface (with TLS). Network-facing HTTPS /mcp requires the top-level auth block (issuer + audience), not MCP_AUTH_TOKEN. MCP_AUTH_TOKEN remains valid only on loopback. See docs/security-hardening.md."
|
|
11
11
|
},
|
|
12
12
|
|
|
13
13
|
"_governance_tiers": {
|
|
@@ -23,6 +23,21 @@
|
|
|
23
23
|
"port": 3443
|
|
24
24
|
},
|
|
25
25
|
|
|
26
|
+
"auth": {
|
|
27
|
+
"_comment": "Inbound OAuth resource-server for MCP_TRANSPORT=https when the process binds beyond loopback. The issuer is any standards-based authorization server (RFC 8414 / OIDC) — not a vendor-specific product. Tokens are JWTs validated against the issuer's JWKS. Optional: revocationFile (JSON {jti,sub}, re-read without restart) and introspectionUrl (RFC 7662). Env overrides: MCP_RESOURCE_ISSUER, MCP_RESOURCE_AUDIENCE, MCP_RESOURCE, MCP_REVOCATION_FILE, MCP_INTROSPECTION_URL.",
|
|
28
|
+
"issuer": "https://idp.example.com",
|
|
29
|
+
"audience": "https://mcp.example.com/mcp",
|
|
30
|
+
"resource": "https://mcp.example.com/mcp",
|
|
31
|
+
"requiredScopes": ["mcp_read"],
|
|
32
|
+
"grants": {
|
|
33
|
+
"_comment": "Optional. Map inbound OAuth client_id (azp) to site names, e.g. \"content-agent\": [\"production\", \"staging\"]. When the map names any client, unknown clients receive no sites. Omit this object (or leave only this comment) to let every resolvable site stay visible; inbound scopes still gate the tool surface."
|
|
34
|
+
},
|
|
35
|
+
"revocationFile": "",
|
|
36
|
+
"introspectionUrl": "",
|
|
37
|
+
"introspectionClientIdEnv": "",
|
|
38
|
+
"introspectionClientSecretEnv": ""
|
|
39
|
+
},
|
|
40
|
+
|
|
26
41
|
"sites": {
|
|
27
42
|
"production": {
|
|
28
43
|
"_comment": "Content tier. Content/media/term CRUD; config read-only; cannot publish (the source-side editorial gate decides publication, not the agent). No drushSsh.",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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.7.0",
|
|
4
|
+
"description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal — dual-protocol JSON:API and GraphQL.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"test:watch": "vitest",
|
|
57
57
|
"audit": "npm audit --audit-level=high",
|
|
58
58
|
"check": "npm run lint && npm run audit",
|
|
59
|
-
"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 ✓\"; done",
|
|
60
60
|
"verify": "node bin/drupal-mcp-verify.js",
|
|
61
61
|
"verify:example": "node bin/drupal-mcp-verify.js --config config/config.example.json"
|
|
62
62
|
},
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"@modelcontextprotocol/node": "^2.0.0",
|
|
65
65
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
66
66
|
"graphql": "^17.0.0",
|
|
67
|
+
"jose": "^6.2.9",
|
|
67
68
|
"node-fetch": "^3.3.2",
|
|
68
69
|
"ssh2": "^1.16.0"
|
|
69
70
|
},
|
package/src/index.js
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* DRUPAL_BASE_URL Single-site fallback baseUrl
|
|
16
16
|
* DRUPAL_API_TOKEN Single-site fallback Bearer token
|
|
17
17
|
* MCP_ALLOW_HTTP Set to "1" to allow plain HTTP on localhost only (dev)
|
|
18
|
-
* MCP_AUTH_TOKEN
|
|
18
|
+
* MCP_AUTH_TOKEN Loopback-only shared bearer for /mcp (not accepted network-facing)
|
|
19
|
+
* MCP_RESOURCE_ISSUER / MCP_RESOURCE_AUDIENCE / MCP_RESOURCE
|
|
20
|
+
* Inbound OAuth resource-server (required beyond loopback)
|
|
19
21
|
* MCP_BIND_HOST Bind address for https mode when TLS is present
|
|
20
22
|
* (default: "0.0.0.0"; ignored without TLS, which forces loopback)
|
|
21
23
|
* MCP_RATE_LIMIT Max /mcp requests per window per client IP (0/unset = off)
|
|
@@ -31,18 +33,48 @@ import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
|
31
33
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
32
34
|
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
33
35
|
|
|
34
|
-
import { listSiteNames, getTlsConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
35
|
-
import {
|
|
36
|
+
import { listSiteNames, getTlsConfig, loadConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
37
|
+
import { loadLocalSecrets, secretLoadFatalMessage } from "./lib/load-secrets.js";
|
|
38
|
+
import {
|
|
39
|
+
makeBearerCheck,
|
|
40
|
+
resolveInboundAuthConfig,
|
|
41
|
+
resolveInboundAuthMode,
|
|
42
|
+
createInboundHttpsAuth,
|
|
43
|
+
} from "./lib/http-auth.js";
|
|
36
44
|
import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
|
|
37
45
|
import { createConnectorServerFactory } from "./lib/mcp-server.js";
|
|
38
46
|
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
39
47
|
import { callTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
|
|
40
48
|
import { filterDiscoverableTools } from "./lib/governance.js";
|
|
49
|
+
import {
|
|
50
|
+
assertPrincipalEntitlement,
|
|
51
|
+
filterPromptsByPrincipal,
|
|
52
|
+
filterResourcesByPrincipal,
|
|
53
|
+
filterToolsByPrincipal,
|
|
54
|
+
getRequestIdentity,
|
|
55
|
+
visibleSiteTargets,
|
|
56
|
+
} from "./lib/principal.js";
|
|
41
57
|
|
|
42
58
|
// Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
|
|
43
59
|
import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
|
|
44
60
|
import { buildToolPrompts, getToolPromptMessages } from "./lib/tool-prompts.js";
|
|
45
61
|
|
|
62
|
+
// Apply config/secrets.map (or the shipped example table) before any site
|
|
63
|
+
// resolution. MCP clients spawn this file directly; the shell launcher is
|
|
64
|
+
// not guaranteed to have run.
|
|
65
|
+
const secretLoad = loadLocalSecrets();
|
|
66
|
+
const secretFatal = secretLoadFatalMessage(secretLoad);
|
|
67
|
+
if (secretFatal) {
|
|
68
|
+
console.error(`[drupal-mcp-connector] FATAL: ${secretFatal}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (secretLoad.unset.length) {
|
|
72
|
+
console.error(
|
|
73
|
+
"[drupal-mcp-connector] WARNING: config.json names secret env vars that are unset: " +
|
|
74
|
+
`${secretLoad.unset.join(", ")}. Those sites will fail closed.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
46
78
|
// ---------------------------------------------------------------------------
|
|
47
79
|
// MCP Resources — browsable, always-fresh site context
|
|
48
80
|
// ---------------------------------------------------------------------------
|
|
@@ -78,21 +110,43 @@ const RESOURCES = [
|
|
|
78
110
|
* @returns {Promise<object>} The resource data (later JSON-serialized).
|
|
79
111
|
* @throws {Error} If the URI matches no known resource.
|
|
80
112
|
*/
|
|
113
|
+
async function discoverableTools() {
|
|
114
|
+
const sites = listResolvableSiteConfigs();
|
|
115
|
+
const identity = getRequestIdentity();
|
|
116
|
+
const governed = await filterDiscoverableTools(allDefinitions, sites);
|
|
117
|
+
return filterToolsByPrincipal(governed, sites, identity);
|
|
118
|
+
}
|
|
119
|
+
|
|
81
120
|
async function readResource(uri) {
|
|
121
|
+
const identity = getRequestIdentity();
|
|
122
|
+
const sites = listResolvableSiteConfigs();
|
|
123
|
+
|
|
82
124
|
// drupal://sites
|
|
83
125
|
if (uri === "drupal://sites") {
|
|
84
|
-
return
|
|
126
|
+
return visibleSiteTargets(identity, sites, listSiteNames());
|
|
85
127
|
}
|
|
86
128
|
|
|
87
129
|
// drupal://{site}/content-types
|
|
88
130
|
const ctMatch = uri.match(/^drupal:\/\/([^/]+)\/content-types$/);
|
|
89
131
|
if (ctMatch) {
|
|
132
|
+
assertPrincipalEntitlement({
|
|
133
|
+
toolName: "drupal_list_content_types",
|
|
134
|
+
args: { site: ctMatch[1] },
|
|
135
|
+
identity,
|
|
136
|
+
sites,
|
|
137
|
+
});
|
|
90
138
|
return allHandlers.drupal_list_content_types({ site: ctMatch[1] });
|
|
91
139
|
}
|
|
92
140
|
|
|
93
141
|
// drupal://{site}/security-policy
|
|
94
142
|
const spMatch = uri.match(/^drupal:\/\/([^/]+)\/security-policy$/);
|
|
95
143
|
if (spMatch) {
|
|
144
|
+
assertPrincipalEntitlement({
|
|
145
|
+
toolName: "drupal_security_info",
|
|
146
|
+
args: { site: spMatch[1] },
|
|
147
|
+
identity,
|
|
148
|
+
sites,
|
|
149
|
+
});
|
|
96
150
|
return allHandlers.drupal_security_info({ site: spMatch[1] });
|
|
97
151
|
}
|
|
98
152
|
|
|
@@ -236,12 +290,24 @@ const buildConnectorServer = createConnectorServerFactory({
|
|
|
236
290
|
serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
|
|
237
291
|
tools: {
|
|
238
292
|
definitions: allDefinitions,
|
|
239
|
-
list:
|
|
293
|
+
list: discoverableTools,
|
|
240
294
|
call: callTool,
|
|
241
295
|
},
|
|
242
|
-
resources: {
|
|
296
|
+
resources: {
|
|
297
|
+
definitions: RESOURCES,
|
|
298
|
+
list: async () => {
|
|
299
|
+
const sites = listResolvableSiteConfigs();
|
|
300
|
+
return filterResourcesByPrincipal(RESOURCES, getRequestIdentity(), sites);
|
|
301
|
+
},
|
|
302
|
+
read: readResource,
|
|
303
|
+
},
|
|
243
304
|
prompts: {
|
|
244
305
|
definitions: ALL_PROMPTS,
|
|
306
|
+
list: async () => {
|
|
307
|
+
const identity = getRequestIdentity();
|
|
308
|
+
const tools = await discoverableTools();
|
|
309
|
+
return filterPromptsByPrincipal(ALL_PROMPTS, identity, tools);
|
|
310
|
+
},
|
|
245
311
|
get: (name, args) => WORKFLOW_PROMPT_NAMES.has(name)
|
|
246
312
|
? getPromptMessages(name, args)
|
|
247
313
|
: getToolPromptMessages(name, args, definitionsByName),
|
|
@@ -273,15 +339,7 @@ if (transport === "stdio") {
|
|
|
273
339
|
const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
|
|
274
340
|
|
|
275
341
|
const authToken = process.env.MCP_AUTH_TOKEN || "";
|
|
276
|
-
const checkAuth = makeBearerCheck(authToken);
|
|
277
342
|
const allowUnauth = process.env.MCP_ALLOW_UNAUTHENTICATED === "1";
|
|
278
|
-
if (!authToken) {
|
|
279
|
-
console.error(
|
|
280
|
-
"[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
|
|
281
|
-
"Set MCP_AUTH_TOKEN to require a bearer token, or front it with a trusted " +
|
|
282
|
-
"boundary (private network / auth proxy). Acceptable only behind such a boundary."
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
343
|
|
|
286
344
|
// Security headers applied to every response
|
|
287
345
|
function applySecurityHeaders(res) {
|
|
@@ -332,15 +390,42 @@ if (transport === "stdio") {
|
|
|
332
390
|
const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
|
|
333
391
|
const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
|
|
334
392
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
393
|
+
const inboundCfg = resolveInboundAuthConfig(loadConfig());
|
|
394
|
+
const inboundMode = resolveInboundAuthMode({
|
|
395
|
+
bindHost,
|
|
396
|
+
allowUnauth,
|
|
397
|
+
sharedToken: authToken,
|
|
398
|
+
resourceServer: inboundCfg,
|
|
399
|
+
});
|
|
400
|
+
if (inboundMode.mode === "fatal") {
|
|
401
|
+
console.error(`[drupal-mcp-connector] FATAL: ${inboundMode.reason}`);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let checkAuth = makeBearerCheck(inboundMode.mode === "shared_bearer" ? authToken : "");
|
|
406
|
+
let authenticate = null;
|
|
407
|
+
let protectedResource = null;
|
|
408
|
+
if (inboundMode.mode === "resource_server") {
|
|
409
|
+
try {
|
|
410
|
+
const inbound = await createInboundHttpsAuth({ inboundCfg });
|
|
411
|
+
authenticate = inbound.authenticate;
|
|
412
|
+
protectedResource = inbound.protectedResource;
|
|
413
|
+
checkAuth = () => false;
|
|
414
|
+
console.error(
|
|
415
|
+
`[drupal-mcp-connector] Inbound OAuth resource server: issuer ${inboundCfg.issuer}`
|
|
416
|
+
);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
console.error(
|
|
419
|
+
"[drupal-mcp-connector] FATAL: inbound issuer discovery failed.\n" +
|
|
420
|
+
` ${error instanceof Error ? error.message : "unknown error"}`
|
|
421
|
+
);
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
} else if (inboundMode.mode === "unauthenticated" && !allowUnauth) {
|
|
338
425
|
console.error(
|
|
339
|
-
"[drupal-mcp-connector]
|
|
340
|
-
"
|
|
341
|
-
" MCP_ALLOW_UNAUTHENTICATED=1 only behind a trusted auth boundary."
|
|
426
|
+
"[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
|
|
427
|
+
"Acceptable only on loopback or behind a trusted auth boundary."
|
|
342
428
|
);
|
|
343
|
-
process.exit(1);
|
|
344
429
|
}
|
|
345
430
|
|
|
346
431
|
// Optional fixed-window rate limiting on /mcp, keyed by client IP.
|
|
@@ -376,6 +461,8 @@ if (transport === "stdio") {
|
|
|
376
461
|
});
|
|
377
462
|
const requestHandler = createMcpRequestHandler({
|
|
378
463
|
checkAuth,
|
|
464
|
+
authenticate,
|
|
465
|
+
protectedResource,
|
|
379
466
|
modernHandler,
|
|
380
467
|
legacyHandler,
|
|
381
468
|
toolCount: allDefinitions.length,
|
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
|
);
|
|
@@ -203,6 +221,29 @@ export function listSiteNames() {
|
|
|
203
221
|
return Object.keys(loadConfig().sites);
|
|
204
222
|
}
|
|
205
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Configured default site name (used only as a hint after entitlement).
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
export function getDefaultSiteName() {
|
|
229
|
+
return loadConfig().defaultSite;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Server-resolved inbound site grants keyed by OAuth client id.
|
|
234
|
+
* A missing or empty map means "no grant table" (every resolvable site).
|
|
235
|
+
* A present map is fail-closed: unknown clients receive no sites.
|
|
236
|
+
* @returns {object|null}
|
|
237
|
+
*/
|
|
238
|
+
export function getInboundGrants() {
|
|
239
|
+
const grants = loadConfig().auth?.grants;
|
|
240
|
+
if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
|
|
241
|
+
const entries = Object.entries(grants)
|
|
242
|
+
.filter(([clientId, sites]) => !clientId.startsWith("_") && Array.isArray(sites))
|
|
243
|
+
.map(([clientId, sites]) => [clientId, sites.map(String)]);
|
|
244
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
206
247
|
// ---------------------------------------------------------------------------
|
|
207
248
|
// Auth headers — never logged, never exposed in tool responses
|
|
208
249
|
// ---------------------------------------------------------------------------
|
package/src/lib/dispatch.js
CHANGED
|
@@ -16,6 +16,7 @@ import { toolError, toolResult } from "./errors.js";
|
|
|
16
16
|
import { BackendCapabilityError, BackendResolutionError } from "./backends/errors.js";
|
|
17
17
|
import { inferOperation } from "./operations.js";
|
|
18
18
|
import { assertSourceGovernance, GovernanceError, GOVERNANCE_DIAGNOSTIC_TOOLS } from "./governance.js";
|
|
19
|
+
import { assertPrincipalEntitlement, getRequestIdentity } from "./principal.js";
|
|
19
20
|
import { allHandlers } from "../tools/index.js";
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -59,17 +60,41 @@ function extractEntityType(toolName, args) {
|
|
|
59
60
|
* @param {string} toolName - The MCP tool name.
|
|
60
61
|
* @param {object} args - Tool arguments (may carry `site`, `id`, etc.).
|
|
61
62
|
* @param {Function} handler - The resolved tool handler.
|
|
63
|
+
* @param {object} [context] Optional inbound identity / grant overrides (tests).
|
|
62
64
|
* @returns {Promise<*>} The handler's result.
|
|
63
65
|
* @throws {GovernanceError} If the site requires source governance and the
|
|
64
66
|
* contract is not verified — checked FIRST, so no assertion below can be
|
|
65
67
|
* read as an ungoverned fallback verdict.
|
|
66
68
|
* @throws {SecurityError} If the resolved policy forbids the inferred operation.
|
|
67
69
|
*/
|
|
68
|
-
export async function securityMiddleware(toolName, args, handler) {
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
export async function securityMiddleware(toolName, args, handler, context = {}) {
|
|
71
|
+
const rawArgs = args ?? {};
|
|
72
|
+
const identity = context.identity !== undefined ? context.identity : getRequestIdentity();
|
|
73
|
+
let nextArgs = rawArgs;
|
|
71
74
|
|
|
72
|
-
|
|
75
|
+
if (identity) {
|
|
76
|
+
const resolved = assertPrincipalEntitlement({
|
|
77
|
+
toolName,
|
|
78
|
+
args: rawArgs,
|
|
79
|
+
identity,
|
|
80
|
+
sites: context.sites ?? listResolvableSiteConfigs(),
|
|
81
|
+
grants: context.grants,
|
|
82
|
+
defaultSite: context.defaultSite,
|
|
83
|
+
});
|
|
84
|
+
if (resolved) {
|
|
85
|
+
nextArgs = { ...rawArgs, site: resolved.name };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Tools with no site context skip per-site checks. governance_status
|
|
90
|
+
// without a hint reports every granted/configured site and must not
|
|
91
|
+
// resolve (or fail on) the configured default first.
|
|
92
|
+
if (toolName === "drupal_list_sites") return handler(nextArgs);
|
|
93
|
+
if (toolName === "drupal_governance_status" && !nextArgs.site) {
|
|
94
|
+
return handler(nextArgs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const site = getSiteConfig(nextArgs.site);
|
|
73
98
|
|
|
74
99
|
// Source-governance gate (#176). The diagnostic tools stay callable while
|
|
75
100
|
// governance fails — they are how an operator learns which condition failed.
|
|
@@ -81,15 +106,15 @@ export async function securityMiddleware(toolName, args, handler) {
|
|
|
81
106
|
const op = inferOperation(toolName);
|
|
82
107
|
|
|
83
108
|
if (op === "delete") {
|
|
84
|
-
assertDestructiveAllowed(sec, extractEntityType(toolName,
|
|
109
|
+
assertDestructiveAllowed(sec, extractEntityType(toolName, nextArgs), nextArgs?.id ?? "?");
|
|
85
110
|
assertNotReadOnly(sec, toolName);
|
|
86
111
|
} else if (op === "write") {
|
|
87
112
|
assertNotReadOnly(sec, toolName);
|
|
88
|
-
} else if (op === "graphql" &&
|
|
89
|
-
assertGraphqlMutationAllowed(sec,
|
|
113
|
+
} else if (op === "graphql" && nextArgs?.query) {
|
|
114
|
+
assertGraphqlMutationAllowed(sec, nextArgs.query);
|
|
90
115
|
}
|
|
91
116
|
|
|
92
|
-
return handler(
|
|
117
|
+
return handler(nextArgs);
|
|
93
118
|
}
|
|
94
119
|
|
|
95
120
|
/**
|
|
@@ -98,9 +123,10 @@ export async function securityMiddleware(toolName, args, handler) {
|
|
|
98
123
|
*
|
|
99
124
|
* @param {string} name - The MCP tool name.
|
|
100
125
|
* @param {object} args - The tool arguments.
|
|
126
|
+
* @param {object} [context] Optional inbound identity / grant overrides.
|
|
101
127
|
* @returns {Promise<object>} An MCP tool result payload.
|
|
102
128
|
*/
|
|
103
|
-
export async function callTool(name, args) {
|
|
129
|
+
export async function callTool(name, args, context = {}) {
|
|
104
130
|
// eslint-disable-next-line security/detect-object-injection -- name is an MCP tool name from validated schema; allHandlers is a closed dispatch table built at startup
|
|
105
131
|
const handler = allHandlers[name];
|
|
106
132
|
|
|
@@ -111,7 +137,7 @@ export async function callTool(name, args) {
|
|
|
111
137
|
}
|
|
112
138
|
|
|
113
139
|
try {
|
|
114
|
-
const result = await securityMiddleware(name, args ?? {}, handler);
|
|
140
|
+
const result = await securityMiddleware(name, args ?? {}, handler, context);
|
|
115
141
|
return toolResult(result);
|
|
116
142
|
} catch (err) {
|
|
117
143
|
// Translate known error classes into clear, non-leaky isError responses;
|