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.
@@ -1,10 +1,10 @@
1
1
  ---
2
- description: "List all named Drupal sites configured in config.json. Useful for multi-site setups."
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 all named Drupal sites configured in config.json. Useful for multi-site setups.
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**, 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).
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` (or `MCP_ALLOW_UNAUTHENTICATED=1` behind a trusted proxy); non-loopback TLS defaults to 120 req/min rate limiting.
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; 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."
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.6.1",
4
- "description": "A secure, multi-site Model Context Protocol (MCP) connector for Drupal \u2014 dual-protocol JSON:API and GraphQL.",
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 \u2713\"; done",
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 Bearer token required on /mcp in https mode (warns if unset)
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 { makeBearerCheck } from "./lib/http-auth.js";
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 { sites: listSiteNames() };
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: () => filterDiscoverableTools(allDefinitions, listResolvableSiteConfigs()),
293
+ list: discoverableTools,
257
294
  call: callTool,
258
295
  },
259
- resources: { definitions: RESOURCES, read: readResource },
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
- // #141: fail closed when HTTPS is network-facing without a bearer token.
353
- // Loopback binds and explicit MCP_ALLOW_UNAUTHENTICATED=1 remain for local/proxy setups.
354
- if (!authToken && !isLoopbackBind && !allowUnauth) {
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] FATAL: MCP_AUTH_TOKEN is required when binding beyond loopback.\n" +
357
- " Set MCP_AUTH_TOKEN, bind to 127.0.0.1 (default without MCP_BIND_HOST), or set\n" +
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
  // ---------------------------------------------------------------------------
@@ -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
- // Tools with no site context skip per-site checks
70
- if (toolName === "drupal_list_sites") return handler(args);
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
- const site = getSiteConfig(args?.site);
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, args), args?.id ?? "?");
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" && args?.query) {
89
- assertGraphqlMutationAllowed(sec, args.query);
113
+ } else if (op === "graphql" && nextArgs?.query) {
114
+ assertGraphqlMutationAllowed(sec, nextArgs.query);
90
115
  }
91
116
 
92
- return handler(args);
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;