drupal-mcp-connector 2.9.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.10.1] - 2026-08-28
11
+
12
+ ### Fixed
13
+ - **Node reads include the numeric Drupal node ID (#237).** JSON:API-backed
14
+ `drupal_get_node` and `drupal_entity_get` results retain
15
+ `fields.drupal_internal__nid`, matching schema discovery while preserving
16
+ entity allowlists and configured field redaction. The canonical top-level
17
+ `id` remains the UUID.
18
+
19
+ ### Deprecated
20
+ - **Loopback `shared_bearer` inbound auth (#231).** Startup warns when
21
+ `resolveInboundAuthMode` is `shared_bearer`, naming removal in v3.0.0.
22
+ Resource-server mode is silent. `npm run verify` lists the residual.
23
+ This is the deprecation half only; removal waits for the next major.
24
+
25
+ ## [2.10.0] - 2026-08-27
26
+
27
+ ### Security
28
+ - **Lab fan-down strips caller credential headers (#229).** The
29
+ outbound-relay lab harness framed the northbound request's headers down
30
+ the tenant tunnel verbatim, including `Authorization` — invisible in the
31
+ lab (the stub is in-process and the lab northbound carries no bearer),
32
+ but the wrong pattern for anyone wiring the README's optional live hop.
33
+ `Authorization`, `Cookie`, and `Proxy-Authorization` are now stripped,
34
+ with hop-by-hop headers, before framing; a test asserts marker
35
+ credentials never appear in any frame crossing the tunnel. The lab
36
+ README now states plainly that the lab northbound does not authenticate
37
+ the caller (the lab credential authenticates the agent channel) — the
38
+ product edge's northbound OAuth is DEV-294 work, not this harness.
39
+
40
+ ### Added
41
+ - **Deployment documentation for the relay edge and tenant-agent entry
42
+ points (#232).** `docs/deployment.md` gains the outbound-path section:
43
+ startup requirements, credential-header stripping, channel credentials,
44
+ and the issuer claim requirements.
45
+ - **Relay edge and tenant-agent entry points (#232).** `drupal-mcp-edge`
46
+ terminates northbound MCP on the inbound OAuth resource server — the only
47
+ authentication arm on this entry point; a missing issuer/audience is fatal
48
+ at any bind host, including loopback — and fans requests down an outbound
49
+ tenant channel. The edge requires a non-empty `auth.grants` table and an
50
+ agent channel credential store to start, resolves the authoritative target
51
+ per request before fan-down, strips caller credential and
52
+ identity-assertion headers before framing (the frame carries the validated
53
+ identity object only), and holds no site credentials — a catalog entry
54
+ carrying credential material refuses startup. `drupal-mcp-agent` dials out
55
+ (it never listens), authenticates the channel with its own issued,
56
+ revocable credential, and serves the real connector tool surface, so site
57
+ credentials exist only in the tenant process. Northbound is stateless MCP
58
+ 2026-07-28 with no session ids in either direction; revocation of both the
59
+ northbound principal and the agent channel is per-request with no grace
60
+ window. The frame codec lives at `src/lib/relay/frames.js` with hard size,
61
+ teardown, and timeout bounds. These are entry points and libraries only;
62
+ nothing here is a hosted service.
63
+ - **Lab-only outbound-relay harness (DEV-293).** Isolated under
64
+ `lab/outbound-relay/` with tests in `tests/lab/`. A tenant agent dials out
65
+ to a loopback relay; one MCP 2026-07-28 Streamable-HTTP request (stateless,
66
+ no `Mcp-Session-Id`) reaches an in-process stub private Drupal; reconnect
67
+ keeps the same tunnel identity; revocation is checked per request (next
68
+ request denied, no grace window). This is not a public surface, not a
69
+ hosted-service claim, and not DEV-294. Hosted MCP is unstarted.
70
+
10
71
  ## [2.9.0] - 2026-08-26
11
72
 
12
73
  ### Fixed
@@ -1268,6 +1329,7 @@ The connector is now **dual-protocol**: every tool runs against an abstract back
1268
1329
  - User tools gained explicit PII-access assertions.
1269
1330
  - Whole tree lint-clean (`npm run lint`) with object-injection sinks rewritten to safe lookups.
1270
1331
 
1332
+ [2.10.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v2.10.1
1271
1333
  [1.0.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v1.0.0
1272
1334
  [0.10.0]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.10.0
1273
1335
  [0.9.1]: https://github.com/Wilkes-Liberty/drupal-mcp-connector/releases/tag/v0.9.1
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * drupal-mcp-agent — relay tenant agent entry point (#232).
4
+ *
5
+ * Dials OUT to the relay edge's agent channel and serves the real connector
6
+ * server (the full governed tool surface, dispatched through the same
7
+ * middleware as every other transport) over the framed tunnel. This process
8
+ * never listens: southbound site credentials exist only here, tenant-side.
9
+ * The channel is authenticated with the agent's own issued, revocable
10
+ * credential — never a northbound token, never a site credential.
11
+ *
12
+ * Environment variables:
13
+ * MCP_EDGE_HOST Relay edge host. Required.
14
+ * MCP_EDGE_AGENT_PORT Relay edge agent-channel port. Required.
15
+ * MCP_CHANNEL_TOKEN Issued channel credential (or MCP_CHANNEL_TOKEN_FILE).
16
+ * Required. The edge stores only its SHA-256 digest.
17
+ * MCP_EDGE_ALLOW_TCP "1" permits a plain TCP channel to loopback only
18
+ * (dev). The default channel is TLS.
19
+ *
20
+ * On a lost channel the process exits non-zero — fail loudly and let the
21
+ * supervisor restart it rather than idling disconnected.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { connect as netConnect } from "node:net";
26
+ import { connect as tlsConnect } from "node:tls";
27
+ import process from "node:process";
28
+ import { CLIENT_VERSION, listSiteNames, loadConfig } from "../src/lib/config.js";
29
+ import { callTool, listResolvableSiteConfigs } from "../src/lib/dispatch.js";
30
+ import { filterDiscoverableTools } from "../src/lib/governance.js";
31
+ import {
32
+ loadLocalSecrets,
33
+ secretLoadFatalMessage,
34
+ secretTableMismatchMessage,
35
+ } from "../src/lib/load-secrets.js";
36
+ import {
37
+ filterPromptsByPrincipal,
38
+ filterResourcesByPrincipal,
39
+ filterToolsByPrincipal,
40
+ getRequestIdentity,
41
+ visibleSiteTargets,
42
+ } from "../src/lib/principal.js";
43
+ import { createRelayAgent } from "../src/lib/relay/agent.js";
44
+ import { buildToolPrompts, getToolPromptMessages } from "../src/lib/tool-prompts.js";
45
+ import { allDefinitions, definitionsByName } from "../src/tools/index.js";
46
+
47
+ function fatal(message) {
48
+ console.error(`[drupal-mcp-agent] FATAL: ${message}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const host = process.env.MCP_EDGE_HOST || "";
53
+ const port = Number(process.env.MCP_EDGE_AGENT_PORT || 0);
54
+ if (!host || !port) {
55
+ fatal("Set MCP_EDGE_HOST and MCP_EDGE_AGENT_PORT to the relay edge's agent channel.");
56
+ }
57
+
58
+ const tokenFile = process.env.MCP_CHANNEL_TOKEN_FILE || "";
59
+ const token = process.env.MCP_CHANNEL_TOKEN
60
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- credential path comes from the operator's environment, not user input
61
+ || (tokenFile ? readFileSync(tokenFile, "utf8").trim() : "");
62
+ if (!token) {
63
+ fatal(
64
+ "The tenant agent requires its issued channel credential: set "
65
+ + "MCP_CHANNEL_TOKEN or MCP_CHANNEL_TOKEN_FILE.",
66
+ );
67
+ }
68
+
69
+ // Apply config/secrets.map before any site resolution, exactly like the
70
+ // primary entry point: this process is where site credentials live.
71
+ const secretLoad = loadLocalSecrets();
72
+ const secretFatal = secretLoadFatalMessage(secretLoad);
73
+ if (secretFatal) fatal(secretFatal);
74
+ const secretMismatch = secretTableMismatchMessage(secretLoad);
75
+ if (secretMismatch) {
76
+ console.error(`[drupal-mcp-agent] WARNING: ${secretMismatch}`);
77
+ } else if (secretLoad.unset.length) {
78
+ console.error(
79
+ "[drupal-mcp-agent] WARNING: config.json names secret env vars that are unset: "
80
+ + `${secretLoad.unset.join(", ")}. Those sites will fail closed.`,
81
+ );
82
+ }
83
+
84
+ try {
85
+ loadConfig();
86
+ } catch (error) {
87
+ fatal(error instanceof Error ? error.message : "configuration load failed");
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Connector surface — the real governed tool surface. Workflow prompts and
92
+ // the templated resources stay on the primary entry point; the governed
93
+ // surface here is tools (full), the sites resource, and per-tool prompts.
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const RESOURCES = [{
97
+ uri: "drupal://sites",
98
+ name: "Configured Drupal Sites",
99
+ description: "All named Drupal site profiles (no credentials).",
100
+ mimeType: "application/json",
101
+ }];
102
+
103
+ const TOOL_PROMPTS = buildToolPrompts(allDefinitions);
104
+
105
+ async function discoverableTools() {
106
+ const sites = listResolvableSiteConfigs();
107
+ const identity = getRequestIdentity();
108
+ const governed = await filterDiscoverableTools(allDefinitions, sites);
109
+ return filterToolsByPrincipal(governed, sites, identity);
110
+ }
111
+
112
+ const surface = {
113
+ serverInfo: { name: "drupal-mcp-connector", version: CLIENT_VERSION },
114
+ tools: {
115
+ definitions: allDefinitions,
116
+ list: discoverableTools,
117
+ call: callTool,
118
+ },
119
+ resources: {
120
+ definitions: RESOURCES,
121
+ list: async () => {
122
+ const sites = listResolvableSiteConfigs();
123
+ return filterResourcesByPrincipal(RESOURCES, getRequestIdentity(), sites);
124
+ },
125
+ read: async (uri) => {
126
+ if (uri === "drupal://sites") {
127
+ return visibleSiteTargets(
128
+ getRequestIdentity(),
129
+ listResolvableSiteConfigs(),
130
+ listSiteNames(),
131
+ );
132
+ }
133
+ throw new Error(`Unknown resource URI: ${uri}`);
134
+ },
135
+ },
136
+ prompts: {
137
+ definitions: TOOL_PROMPTS,
138
+ list: async () => {
139
+ const identity = getRequestIdentity();
140
+ const tools = await discoverableTools();
141
+ return filterPromptsByPrincipal(TOOL_PROMPTS, identity, tools);
142
+ },
143
+ get: (name, args) => getToolPromptMessages(name, args, definitionsByName),
144
+ },
145
+ };
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Channel — TLS by default; plain TCP only to loopback, by explicit opt-in.
149
+ // ---------------------------------------------------------------------------
150
+
151
+ const allowTcp = process.env.MCP_EDGE_ALLOW_TCP === "1";
152
+ const isLoopbackEdge = host === "127.0.0.1" || host === "::1" || host === "localhost";
153
+ let connectFn;
154
+ if (allowTcp) {
155
+ if (!isLoopbackEdge) {
156
+ fatal("MCP_EDGE_ALLOW_TCP permits a plain channel to loopback only. Use TLS.");
157
+ }
158
+ connectFn = netConnect;
159
+ } else {
160
+ connectFn = (options, onConnect) =>
161
+ tlsConnect({ ...options, servername: host }, onConnect);
162
+ }
163
+
164
+ const agent = createRelayAgent({
165
+ host,
166
+ port,
167
+ token,
168
+ surface,
169
+ connectFn,
170
+ onChannelClose: () => {
171
+ fatal("The channel to the relay edge was lost. Exiting for a supervised restart.");
172
+ },
173
+ });
174
+
175
+ let hello;
176
+ try {
177
+ hello = await agent.dial();
178
+ } catch (error) {
179
+ fatal(`Could not dial the relay edge at ${host}:${port}: `
180
+ + `${error instanceof Error ? error.message : "unknown error"}`);
181
+ }
182
+ if (!hello.ok) {
183
+ fatal(`The relay edge denied the channel credential (${hello.reason}).`);
184
+ }
185
+
186
+ console.error(
187
+ `[drupal-mcp-agent v${CLIENT_VERSION}] Outbound channel to ${host}:${port} `
188
+ + `established as "${hello.agent?.agentId ?? "unknown"}" · `
189
+ + `${allDefinitions.length} tools · ${RESOURCES.length} resources · `
190
+ + `${TOOL_PROMPTS.length} prompts`,
191
+ );
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * drupal-mcp-edge — relay northbound edge entry point (#232).
4
+ *
5
+ * Terminates northbound MCP on the inbound OAuth resource server and fans
6
+ * requests down the outbound tenant-agent channel (see src/lib/relay/edge.js).
7
+ * There is no shared-bearer and no unauthenticated mode on this entry point:
8
+ * MCP_AUTH_TOKEN and MCP_ALLOW_UNAUTHENTICATED are ignored, and a missing
9
+ * issuer/audience is fatal at every bind host including loopback. The edge
10
+ * holds no site credentials; its site catalog is names and base URLs only.
11
+ *
12
+ * Environment variables:
13
+ * MCP_RESOURCE_ISSUER / MCP_RESOURCE_AUDIENCE / MCP_RESOURCE
14
+ * Inbound OAuth resource server (or config auth.issuer /
15
+ * auth.audience / auth.resource). Required.
16
+ * MCP_CHANNEL_CREDENTIALS_FILE
17
+ * Agent channel credential store (or config
18
+ * relay.channelCredentialsFile). Required. JSON:
19
+ * {"agents": {"<id>": {"tokenSha256": "<hex>"}}}
20
+ * MCP_EDGE_PORT Northbound port (default: MCP_PORT / config tls.port).
21
+ * MCP_EDGE_AGENT_PORT
22
+ * Agent channel port (default: northbound port + 1).
23
+ * MCP_BIND_HOST Northbound bind when TLS is present (default 0.0.0.0).
24
+ * MCP_EDGE_AGENT_BIND_HOST
25
+ * Agent channel bind (default: the northbound bind).
26
+ * TLS_CERT_PATH / TLS_KEY_PATH
27
+ * TLS material for both listeners.
28
+ * MCP_ALLOW_HTTP "1" permits plain loopback listeners (dev only).
29
+ * MCP_RATE_LIMIT / MCP_RATE_WINDOW_SEC
30
+ * Northbound /mcp rate limit (same defaults as the
31
+ * primary entry point).
32
+ *
33
+ * Config: auth.grants (client id -> [site names]) is mandatory; the edge
34
+ * refuses to start without it.
35
+ */
36
+
37
+ import { readFileSync } from "node:fs";
38
+ import process from "node:process";
39
+ import { getInboundGrants, getTlsConfig, loadConfig } from "../src/lib/config.js";
40
+ import { resolveInboundAuthConfig } from "../src/lib/http-auth.js";
41
+ import { createRateLimiter } from "../src/lib/rate-limit.js";
42
+ import {
43
+ createChannelCredentialStore,
44
+ startEdge,
45
+ } from "../src/lib/relay/edge.js";
46
+
47
+ function fatal(message) {
48
+ console.error(`[drupal-mcp-edge] FATAL: ${message}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ let config;
53
+ try {
54
+ config = loadConfig();
55
+ } catch (error) {
56
+ fatal(error instanceof Error ? error.message : "configuration load failed");
57
+ }
58
+
59
+ const inboundCfg = resolveInboundAuthConfig(config);
60
+ if (!inboundCfg.issuer || !inboundCfg.audience) {
61
+ fatal(
62
+ "The relay edge requires an inbound OAuth resource server: set auth.issuer and "
63
+ + "auth.audience (or MCP_RESOURCE_ISSUER / MCP_RESOURCE_AUDIENCE). There is no "
64
+ + "shared-bearer or unauthenticated mode on this entry point, at any bind host "
65
+ + "including loopback.",
66
+ );
67
+ }
68
+
69
+ const grants = getInboundGrants();
70
+ if (!grants) {
71
+ fatal(
72
+ "The relay edge refuses to start without a non-empty auth.grants table "
73
+ + "(client id -> [site names]).",
74
+ );
75
+ }
76
+
77
+ const channelFile = process.env.MCP_CHANNEL_CREDENTIALS_FILE
78
+ || config.relay?.channelCredentialsFile
79
+ || "";
80
+ if (!channelFile) {
81
+ fatal(
82
+ "The relay edge requires an agent channel credential store: set "
83
+ + "MCP_CHANNEL_CREDENTIALS_FILE (or relay.channelCredentialsFile).",
84
+ );
85
+ }
86
+
87
+ // The catalog is passed as configured; startEdge refuses any entry carrying
88
+ // credential material, so a tenant config deployed to an edge host fails
89
+ // closed instead of quietly holding site secrets.
90
+ const sites = Object.entries(config.sites ?? {})
91
+ .map(([name, site]) => ({ _name: name, ...site }));
92
+
93
+ const tlsCfg = getTlsConfig();
94
+ let tls = null;
95
+ if (tlsCfg.certPath && tlsCfg.keyPath) {
96
+ tls = {
97
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- TLS cert/key path comes from operator-controlled config, not user input
98
+ cert: readFileSync(tlsCfg.certPath),
99
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- TLS cert/key path comes from operator-controlled config, not user input
100
+ key: readFileSync(tlsCfg.keyPath),
101
+ };
102
+ }
103
+ const allowHttp = process.env.MCP_ALLOW_HTTP === "1";
104
+ const bindHost = tls ? (process.env.MCP_BIND_HOST || "0.0.0.0") : "127.0.0.1";
105
+ const agentBindHost = process.env.MCP_EDGE_AGENT_BIND_HOST || bindHost;
106
+ const isLoopbackBind = bindHost === "127.0.0.1" || bindHost === "::1" || bindHost === "localhost";
107
+
108
+ const port = Number(process.env.MCP_EDGE_PORT || tlsCfg.port);
109
+ const agentPort = Number(process.env.MCP_EDGE_AGENT_PORT || port + 1);
110
+
111
+ // Northbound rate limiting, same defaults as the primary entry point (#141).
112
+ const rateWindowSec = Number(process.env.MCP_RATE_WINDOW_SEC || 60);
113
+ const rateLimitEnv = process.env.MCP_RATE_LIMIT;
114
+ const rateLimitDefault = (tls && !isLoopbackBind) ? 120 : 0;
115
+ const rateLimit = rateLimitEnv === undefined || rateLimitEnv === ""
116
+ ? rateLimitDefault
117
+ : Number(rateLimitEnv);
118
+
119
+ let edge;
120
+ try {
121
+ edge = await startEdge({
122
+ auth: inboundCfg,
123
+ grants,
124
+ sites,
125
+ defaultSite: config.defaultSite,
126
+ channelCredentials: createChannelCredentialStore({ filePath: channelFile }),
127
+ bindHost,
128
+ agentBindHost,
129
+ port,
130
+ agentPort,
131
+ tls,
132
+ allowHttpLoopback: allowHttp,
133
+ rateLimiter: rateLimit > 0
134
+ ? createRateLimiter({ limit: rateLimit, windowMs: rateWindowSec * 1000 })
135
+ : null,
136
+ });
137
+ } catch (error) {
138
+ fatal(error instanceof Error ? error.message : "edge startup failed");
139
+ }
140
+
141
+ console.error(
142
+ `[drupal-mcp-edge] Northbound ${edge.northboundUrl} · `
143
+ + `agent channel ${agentBindHost}:${edge.agentPort} · `
144
+ + `issuer ${inboundCfg.issuer}`,
145
+ );
146
+ if (rateLimit > 0) {
147
+ console.error(
148
+ `[drupal-mcp-edge] Rate limiting: ${rateLimit} req / ${rateWindowSec}s per client IP on /mcp.`,
149
+ );
150
+ }
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "drupal-mcp-connector",
3
- "version": "2.9.0",
3
+ "version": "2.10.1",
4
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": {
8
8
  "drupal-mcp-connector": "src/index.js",
9
- "drupal-mcp-verify": "bin/drupal-mcp-verify.js"
9
+ "drupal-mcp-verify": "bin/drupal-mcp-verify.js",
10
+ "drupal-mcp-edge": "bin/drupal-mcp-edge.js",
11
+ "drupal-mcp-agent": "bin/drupal-mcp-agent.js"
10
12
  },
11
13
  "files": [
12
14
  "src/",
package/src/index.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  makeBearerCheck,
40
40
  resolveInboundAuthConfig,
41
41
  resolveInboundAuthMode,
42
+ inboundAuthDeprecationWarning,
42
43
  createInboundHttpsAuth,
43
44
  } from "./lib/http-auth.js";
44
45
  import { createLegacySessionHandler, createMcpRequestHandler } from "./lib/http-handler.js";
@@ -404,6 +405,8 @@ if (transport === "stdio") {
404
405
  console.error(`[drupal-mcp-connector] FATAL: ${inboundMode.reason}`);
405
406
  process.exit(1);
406
407
  }
408
+ const deprecation = inboundAuthDeprecationWarning(inboundMode.mode);
409
+ if (deprecation) console.error(deprecation);
407
410
 
408
411
  let checkAuth = makeBearerCheck(inboundMode.mode === "shared_bearer" ? authToken : "");
409
412
  let authenticate = null;
@@ -17,8 +17,9 @@ import {
17
17
  BASE_ATTRIBUTE_FIELDS,
18
18
  } from "../canonical.js";
19
19
 
20
- // Drupal exposes internal numeric ids under drupal_internal__* attributes;
21
- // these are dropped from canonical `fields` (the canonical id is the UUID).
20
+ // Drupal exposes internal identifiers under drupal_internal__* attributes.
21
+ // They are dropped from canonical `fields` except for the identifiers that
22
+ // governed read/write workflows explicitly need.
22
23
  const INTERNAL_ATTR_RE = /^drupal_internal__/;
23
24
 
24
25
  // countEntities() pagination. Drupal core JSON:API returns no total in `meta`,
@@ -194,8 +195,9 @@ export class JsonApiBackend extends Backend {
194
195
 
195
196
  /**
196
197
  * Convert a JSON:API resource object into a CanonicalEntity. Base attributes
197
- * (title/status/...) are promoted; drupal_internal__* and base fields are
198
- * stripped from `fields`; relationships are normalized to canonical refs.
198
+ * (title/status/...) are promoted; base fields and unneeded
199
+ * drupal_internal__* attributes are stripped from `fields`; relationships
200
+ * are normalized to canonical refs.
199
201
  * @param {object} resource A JSON:API resource object.
200
202
  * @returns {import("../canonical.js").CanonicalEntity}
201
203
  */
@@ -208,10 +210,13 @@ export class JsonApiBackend extends Backend {
208
210
  Object.entries(attrs).filter(([k]) => {
209
211
  if (BASE_ATTRIBUTE_FIELDS.includes(k)) return false;
210
212
  if (INTERNAL_ATTR_RE.test(k)) {
213
+ // Node reads expose the numeric nid advertised by schema discovery
214
+ // while the UUID remains the canonical top-level id (#237).
211
215
  // Paragraph ERR attach needs the current revision id (#192).
212
216
  // Node / revisionable writes need the working vs live vid (#166).
213
217
  // Other drupal_internal__* attributes stay stripped.
214
- return k === "drupal_internal__vid"
218
+ return (entityType === "node" && k === "drupal_internal__nid")
219
+ || k === "drupal_internal__vid"
215
220
  || (entityType === "paragraph" && k === "drupal_internal__revision_id");
216
221
  }
217
222
  return true;
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { timingSafeEqual } from "crypto";
13
13
  import { readFileSync, statSync } from "fs";
14
- import { createRemoteJWKSet, jwtVerify } from "jose";
14
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
15
15
 
16
16
  /** Header names that must never become identity. */
17
17
  export const SPOOFABLE_IDENTITY_HEADERS = Object.freeze([
@@ -479,6 +479,26 @@ export function resolveInboundAuthMode({
479
479
  };
480
480
  }
481
481
 
482
+ /**
483
+ * Startup deprecation warning for inbound auth mode `shared_bearer` (#231).
484
+ *
485
+ * Removal is the next major (v3.0.0). Resource-server and unauthenticated
486
+ * modes are silent. The warning names the issue and the removal version so
487
+ * a loopback install cannot miss the kill criterion.
488
+ *
489
+ * @param {string} mode
490
+ * @returns {?string} Warning text, or null when this mode is not deprecated.
491
+ */
492
+ export function inboundAuthDeprecationWarning(mode) {
493
+ if (mode !== "shared_bearer") return null;
494
+ return (
495
+ "[drupal-mcp-connector] WARNING: inbound auth mode shared_bearer is deprecated " +
496
+ "and will be removed in v3.0.0 (#231). Configure an OAuth resource server " +
497
+ "(auth.issuer + auth.audience) or run unauthenticated loopback. " +
498
+ "MCP_ALLOW_UNAUTHENTICATED remains the explicit trusted-proxy opt-in."
499
+ );
500
+ }
501
+
482
502
  /**
483
503
  * Merge config.auth with environment overrides.
484
504
  * @param {object} [cfg]
@@ -524,7 +544,9 @@ export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
524
544
  const issuer = normalizeIssuer(inboundCfg.issuer);
525
545
  const asMeta = await discoverAuthorizationServer(issuer, fetchFn);
526
546
  const advertisedIssuer = asMeta.issuer || issuer;
527
- const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri));
547
+ // JWKS retrieval goes through the same injectable fetch as issuer
548
+ // discovery; with the default global fetch the behavior is unchanged.
549
+ const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri), { [customFetch]: fetchFn });
528
550
  const resourceMetadataUrl = resourceMetadataUrlFor(resource);
529
551
  const revocationStore = inboundCfg.revocationFile
530
552
  ? createRevocationStore({ filePath: inboundCfg.revocationFile })