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 +62 -0
- package/bin/drupal-mcp-agent.js +191 -0
- package/bin/drupal-mcp-edge.js +150 -0
- package/package.json +4 -2
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +10 -5
- package/src/lib/http-auth.js +24 -2
- package/src/lib/relay/agent.js +164 -0
- package/src/lib/relay/edge.js +470 -0
- package/src/lib/relay/frames.js +198 -0
- package/src/lib/verify.js +10 -0
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.
|
|
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
|
|
21
|
-
//
|
|
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;
|
|
198
|
-
* stripped from `fields`; relationships
|
|
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 === "
|
|
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;
|
package/src/lib/http-auth.js
CHANGED
|
@@ -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
|
-
|
|
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 })
|