drupal-mcp-connector 2.6.1 → 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 +47 -0
- package/README.md +2 -2
- package/config/config.example.json +16 -1
- package/package.json +4 -3
- package/src/index.js +91 -21
- package/src/lib/config.js +23 -0
- 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/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 +19 -5
|
@@ -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,53 @@ 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
|
+
|
|
10
57
|
## [2.6.1] - 2026-08-17
|
|
11
58
|
|
|
12
59
|
### Fixed
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
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
|
|
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
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
@@ -158,7 +158,7 @@ Additional connector-side gates (2.1+ / 2.2+):
|
|
|
158
158
|
- **Entity allowlists** apply to specialized tools (`drupal_*_node`, media, taxonomy), not only `drupal_entity_*`.
|
|
159
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).
|
|
160
160
|
- **Uploads** only from `MCP_UPLOAD_ROOT` (or the process cwd); sensitive paths (`.env*`, `.ssh`, connector `config.json`) are refused.
|
|
161
|
-
- **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.
|
|
162
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`.
|
|
163
163
|
|
|
164
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,14 +33,27 @@ 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";
|
|
36
|
+
import { listSiteNames, getTlsConfig, loadConfig, CLIENT_VERSION } from "./lib/config.js";
|
|
35
37
|
import { loadLocalSecrets, secretLoadFatalMessage } from "./lib/load-secrets.js";
|
|
36
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
makeBearerCheck,
|
|
40
|
+
resolveInboundAuthConfig,
|
|
41
|
+
resolveInboundAuthMode,
|
|
42
|
+
createInboundHttpsAuth,
|
|
43
|
+
} from "./lib/http-auth.js";
|
|
37
44
|
import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
|
|
38
45
|
import { createConnectorServerFactory } from "./lib/mcp-server.js";
|
|
39
46
|
import { createRateLimiter } from "./lib/rate-limit.js";
|
|
40
47
|
import { callTool, listResolvableSiteConfigs } from "./lib/dispatch.js";
|
|
41
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";
|
|
42
57
|
|
|
43
58
|
// Tools — aggregated (single source of truth, side-effect-free) and per-tool prompts
|
|
44
59
|
import { allDefinitions, allHandlers, definitionsByName } from "./tools/index.js";
|
|
@@ -95,21 +110,43 @@ const RESOURCES = [
|
|
|
95
110
|
* @returns {Promise<object>} The resource data (later JSON-serialized).
|
|
96
111
|
* @throws {Error} If the URI matches no known resource.
|
|
97
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
|
+
|
|
98
120
|
async function readResource(uri) {
|
|
121
|
+
const identity = getRequestIdentity();
|
|
122
|
+
const sites = listResolvableSiteConfigs();
|
|
123
|
+
|
|
99
124
|
// drupal://sites
|
|
100
125
|
if (uri === "drupal://sites") {
|
|
101
|
-
return
|
|
126
|
+
return visibleSiteTargets(identity, sites, listSiteNames());
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
// drupal://{site}/content-types
|
|
105
130
|
const ctMatch = uri.match(/^drupal:\/\/([^/]+)\/content-types$/);
|
|
106
131
|
if (ctMatch) {
|
|
132
|
+
assertPrincipalEntitlement({
|
|
133
|
+
toolName: "drupal_list_content_types",
|
|
134
|
+
args: { site: ctMatch[1] },
|
|
135
|
+
identity,
|
|
136
|
+
sites,
|
|
137
|
+
});
|
|
107
138
|
return allHandlers.drupal_list_content_types({ site: ctMatch[1] });
|
|
108
139
|
}
|
|
109
140
|
|
|
110
141
|
// drupal://{site}/security-policy
|
|
111
142
|
const spMatch = uri.match(/^drupal:\/\/([^/]+)\/security-policy$/);
|
|
112
143
|
if (spMatch) {
|
|
144
|
+
assertPrincipalEntitlement({
|
|
145
|
+
toolName: "drupal_security_info",
|
|
146
|
+
args: { site: spMatch[1] },
|
|
147
|
+
identity,
|
|
148
|
+
sites,
|
|
149
|
+
});
|
|
113
150
|
return allHandlers.drupal_security_info({ site: spMatch[1] });
|
|
114
151
|
}
|
|
115
152
|
|
|
@@ -253,12 +290,24 @@ const buildConnectorServer = createConnectorServerFactory({
|
|
|
253
290
|
serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
|
|
254
291
|
tools: {
|
|
255
292
|
definitions: allDefinitions,
|
|
256
|
-
list:
|
|
293
|
+
list: discoverableTools,
|
|
257
294
|
call: callTool,
|
|
258
295
|
},
|
|
259
|
-
resources: {
|
|
296
|
+
resources: {
|
|
297
|
+
definitions: RESOURCES,
|
|
298
|
+
list: async () => {
|
|
299
|
+
const sites = listResolvableSiteConfigs();
|
|
300
|
+
return filterResourcesByPrincipal(RESOURCES, getRequestIdentity(), sites);
|
|
301
|
+
},
|
|
302
|
+
read: readResource,
|
|
303
|
+
},
|
|
260
304
|
prompts: {
|
|
261
305
|
definitions: ALL_PROMPTS,
|
|
306
|
+
list: async () => {
|
|
307
|
+
const identity = getRequestIdentity();
|
|
308
|
+
const tools = await discoverableTools();
|
|
309
|
+
return filterPromptsByPrincipal(ALL_PROMPTS, identity, tools);
|
|
310
|
+
},
|
|
262
311
|
get: (name, args) => WORKFLOW_PROMPT_NAMES.has(name)
|
|
263
312
|
? getPromptMessages(name, args)
|
|
264
313
|
: getToolPromptMessages(name, args, definitionsByName),
|
|
@@ -290,15 +339,7 @@ if (transport === "stdio") {
|
|
|
290
339
|
const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
|
|
291
340
|
|
|
292
341
|
const authToken = process.env.MCP_AUTH_TOKEN || "";
|
|
293
|
-
const checkAuth = makeBearerCheck(authToken);
|
|
294
342
|
const allowUnauth = process.env.MCP_ALLOW_UNAUTHENTICATED === "1";
|
|
295
|
-
if (!authToken) {
|
|
296
|
-
console.error(
|
|
297
|
-
"[drupal-mcp-connector] WARNING: the /mcp endpoint is UNAUTHENTICATED. " +
|
|
298
|
-
"Set MCP_AUTH_TOKEN to require a bearer token, or front it with a trusted " +
|
|
299
|
-
"boundary (private network / auth proxy). Acceptable only behind such a boundary."
|
|
300
|
-
);
|
|
301
|
-
}
|
|
302
343
|
|
|
303
344
|
// Security headers applied to every response
|
|
304
345
|
function applySecurityHeaders(res) {
|
|
@@ -349,15 +390,42 @@ if (transport === "stdio") {
|
|
|
349
390
|
const bindHost = hasTls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
|
|
350
391
|
const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
|
|
351
392
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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) {
|
|
355
425
|
console.error(
|
|
356
|
-
"[drupal-mcp-connector]
|
|
357
|
-
"
|
|
358
|
-
" 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."
|
|
359
428
|
);
|
|
360
|
-
process.exit(1);
|
|
361
429
|
}
|
|
362
430
|
|
|
363
431
|
// Optional fixed-window rate limiting on /mcp, keyed by client IP.
|
|
@@ -393,6 +461,8 @@ if (transport === "stdio") {
|
|
|
393
461
|
});
|
|
394
462
|
const requestHandler = createMcpRequestHandler({
|
|
395
463
|
checkAuth,
|
|
464
|
+
authenticate,
|
|
465
|
+
protectedResource,
|
|
396
466
|
modernHandler,
|
|
397
467
|
legacyHandler,
|
|
398
468
|
toolCount: allDefinitions.length,
|
package/src/lib/config.js
CHANGED
|
@@ -221,6 +221,29 @@ export function listSiteNames() {
|
|
|
221
221
|
return Object.keys(loadConfig().sites);
|
|
222
222
|
}
|
|
223
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
|
+
|
|
224
247
|
// ---------------------------------------------------------------------------
|
|
225
248
|
// Auth headers — never logged, never exposed in tool responses
|
|
226
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;
|