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.
@@ -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,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` (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.
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; 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.0",
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,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 { makeBearerCheck } from "./lib/http-auth.js";
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 { sites: listSiteNames() };
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: () => filterDiscoverableTools(allDefinitions, listResolvableSiteConfigs()),
293
+ list: discoverableTools,
240
294
  call: callTool,
241
295
  },
242
- 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
+ },
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
- // #141: fail closed when HTTPS is network-facing without a bearer token.
336
- // Loopback binds and explicit MCP_ALLOW_UNAUTHENTICATED=1 remain for local/proxy setups.
337
- 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) {
338
425
  console.error(
339
- "[drupal-mcp-connector] FATAL: MCP_AUTH_TOKEN is required when binding beyond loopback.\n" +
340
- " Set MCP_AUTH_TOKEN, bind to 127.0.0.1 (default without MCP_BIND_HOST), or set\n" +
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 "${site._name}": requireSecureAuth is set but no Bearer apiToken or OAuth2 client ` +
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
  // ---------------------------------------------------------------------------
@@ -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;