drupal-mcp-connector 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/bin/drupal-mcp-agent.js +191 -0
- package/bin/drupal-mcp-edge.js +150 -0
- package/package.json +4 -2
- package/src/lib/http-auth.js +4 -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/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.10.0] - 2026-08-27
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
- **Lab fan-down strips caller credential headers (#229).** The
|
|
14
|
+
outbound-relay lab harness framed the northbound request's headers down
|
|
15
|
+
the tenant tunnel verbatim, including `Authorization` — invisible in the
|
|
16
|
+
lab (the stub is in-process and the lab northbound carries no bearer),
|
|
17
|
+
but the wrong pattern for anyone wiring the README's optional live hop.
|
|
18
|
+
`Authorization`, `Cookie`, and `Proxy-Authorization` are now stripped,
|
|
19
|
+
with hop-by-hop headers, before framing; a test asserts marker
|
|
20
|
+
credentials never appear in any frame crossing the tunnel. The lab
|
|
21
|
+
README now states plainly that the lab northbound does not authenticate
|
|
22
|
+
the caller (the lab credential authenticates the agent channel) — the
|
|
23
|
+
product edge's northbound OAuth is DEV-294 work, not this harness.
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **Deployment documentation for the relay edge and tenant-agent entry
|
|
27
|
+
points (#232).** `docs/deployment.md` gains the outbound-path section:
|
|
28
|
+
startup requirements, credential-header stripping, channel credentials,
|
|
29
|
+
and the issuer claim requirements.
|
|
30
|
+
- **Relay edge and tenant-agent entry points (#232).** `drupal-mcp-edge`
|
|
31
|
+
terminates northbound MCP on the inbound OAuth resource server — the only
|
|
32
|
+
authentication arm on this entry point; a missing issuer/audience is fatal
|
|
33
|
+
at any bind host, including loopback — and fans requests down an outbound
|
|
34
|
+
tenant channel. The edge requires a non-empty `auth.grants` table and an
|
|
35
|
+
agent channel credential store to start, resolves the authoritative target
|
|
36
|
+
per request before fan-down, strips caller credential and
|
|
37
|
+
identity-assertion headers before framing (the frame carries the validated
|
|
38
|
+
identity object only), and holds no site credentials — a catalog entry
|
|
39
|
+
carrying credential material refuses startup. `drupal-mcp-agent` dials out
|
|
40
|
+
(it never listens), authenticates the channel with its own issued,
|
|
41
|
+
revocable credential, and serves the real connector tool surface, so site
|
|
42
|
+
credentials exist only in the tenant process. Northbound is stateless MCP
|
|
43
|
+
2026-07-28 with no session ids in either direction; revocation of both the
|
|
44
|
+
northbound principal and the agent channel is per-request with no grace
|
|
45
|
+
window. The frame codec lives at `src/lib/relay/frames.js` with hard size,
|
|
46
|
+
teardown, and timeout bounds. These are entry points and libraries only;
|
|
47
|
+
nothing here is a hosted service.
|
|
48
|
+
- **Lab-only outbound-relay harness (DEV-293).** Isolated under
|
|
49
|
+
`lab/outbound-relay/` with tests in `tests/lab/`. A tenant agent dials out
|
|
50
|
+
to a loopback relay; one MCP 2026-07-28 Streamable-HTTP request (stateless,
|
|
51
|
+
no `Mcp-Session-Id`) reaches an in-process stub private Drupal; reconnect
|
|
52
|
+
keeps the same tunnel identity; revocation is checked per request (next
|
|
53
|
+
request denied, no grace window). This is not a public surface, not a
|
|
54
|
+
hosted-service claim, and not DEV-294. Hosted MCP is unstarted.
|
|
55
|
+
|
|
10
56
|
## [2.9.0] - 2026-08-26
|
|
11
57
|
|
|
12
58
|
### Fixed
|
|
@@ -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.0",
|
|
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/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([
|
|
@@ -524,7 +524,9 @@ export async function createInboundHttpsAuth({ inboundCfg, fetchFn = fetch }) {
|
|
|
524
524
|
const issuer = normalizeIssuer(inboundCfg.issuer);
|
|
525
525
|
const asMeta = await discoverAuthorizationServer(issuer, fetchFn);
|
|
526
526
|
const advertisedIssuer = asMeta.issuer || issuer;
|
|
527
|
-
|
|
527
|
+
// JWKS retrieval goes through the same injectable fetch as issuer
|
|
528
|
+
// discovery; with the default global fetch the behavior is unchanged.
|
|
529
|
+
const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri), { [customFetch]: fetchFn });
|
|
528
530
|
const resourceMetadataUrl = resourceMetadataUrlFor(resource);
|
|
529
531
|
const revocationStore = inboundCfg.revocationFile
|
|
530
532
|
? createRevocationStore({ filePath: inboundCfg.revocationFile })
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay tenant agent (#232) — the DEV-294 AC4 slice, tenant side.
|
|
3
|
+
*
|
|
4
|
+
* Dials OUT to the edge's agent channel and serves the real connector server
|
|
5
|
+
* (`createConnectorServerFactory`) over the framed tunnel. The agent never
|
|
6
|
+
* listens: nothing tenant-side accepts an inbound connection, so southbound
|
|
7
|
+
* site credentials exist only in this process.
|
|
8
|
+
*
|
|
9
|
+
* The channel is authenticated with the agent's own issued, revocable
|
|
10
|
+
* credential — distinct from every northbound principal token and from every
|
|
11
|
+
* site credential. Request frames carry the identity the edge validated;
|
|
12
|
+
* a frame without one is refused rather than dispatched, because a null
|
|
13
|
+
* principal downstream would mean "local operator".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { connect as netConnect } from "node:net";
|
|
17
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
18
|
+
import { createConnectorServerFactory } from "../mcp-server.js";
|
|
19
|
+
import { runWithIdentity } from "../principal.js";
|
|
20
|
+
import { attachFramer, forwardHeaders, writeFrame } from "./frames.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create the tenant agent.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} options
|
|
26
|
+
* @param {string} options.host Edge agent-channel host.
|
|
27
|
+
* @param {number} options.port Edge agent-channel port.
|
|
28
|
+
* @param {string} options.token Issued channel credential (raw; the edge
|
|
29
|
+
* stores only its digest).
|
|
30
|
+
* @param {object} options.surface Connector surface for
|
|
31
|
+
* `createConnectorServerFactory` — the real tool/resource/prompt surface,
|
|
32
|
+
* holding the site config and credentials tenant-side.
|
|
33
|
+
* @param {typeof netConnect} [options.connectFn] Injectable dialer (e.g. a
|
|
34
|
+
* TLS connect wrapper). The agent only ever dials; it never listens.
|
|
35
|
+
* @param {?() => void} [options.onChannelClose] Called when an established
|
|
36
|
+
* channel is lost (not on deliberate `drop`/`close`), so an entry point
|
|
37
|
+
* can fail loudly instead of idling disconnected.
|
|
38
|
+
* @param {?{recordConnect: Function}} [options.ledger]
|
|
39
|
+
* @returns {object}
|
|
40
|
+
*/
|
|
41
|
+
export function createRelayAgent({
|
|
42
|
+
host,
|
|
43
|
+
port,
|
|
44
|
+
token,
|
|
45
|
+
surface,
|
|
46
|
+
connectFn = netConnect,
|
|
47
|
+
onChannelClose = null,
|
|
48
|
+
ledger = null,
|
|
49
|
+
}) {
|
|
50
|
+
if (!host || !port) {
|
|
51
|
+
throw new Error("createRelayAgent requires the edge agent-channel host and port.");
|
|
52
|
+
}
|
|
53
|
+
if (!token) {
|
|
54
|
+
throw new Error("createRelayAgent requires an issued channel credential.");
|
|
55
|
+
}
|
|
56
|
+
if (!surface) {
|
|
57
|
+
throw new Error("createRelayAgent requires the connector surface.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const handler = createMcpHandler(createConnectorServerFactory(surface), {
|
|
61
|
+
legacy: "reject",
|
|
62
|
+
});
|
|
63
|
+
let socket = null;
|
|
64
|
+
let agentInfo = null;
|
|
65
|
+
|
|
66
|
+
async function serveFrame(activeSocket, frame) {
|
|
67
|
+
if (frame.type !== "mcp-request") return;
|
|
68
|
+
if (!frame.identity || typeof frame.identity !== "object" || Array.isArray(frame.identity)) {
|
|
69
|
+
// Fail closed: without the validated identity there is no principal to
|
|
70
|
+
// entitle, and dispatching as null would grant local-operator trust.
|
|
71
|
+
writeFrame(activeSocket, {
|
|
72
|
+
type: "mcp-response",
|
|
73
|
+
id: frame.id,
|
|
74
|
+
status: 403,
|
|
75
|
+
headers: { "content-type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ error: "missing_identity" }),
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
const headers = forwardHeaders(frame.headers);
|
|
84
|
+
const body = frame.body === null || frame.body === undefined
|
|
85
|
+
? undefined
|
|
86
|
+
: (typeof frame.body === "string" ? frame.body : JSON.stringify(frame.body));
|
|
87
|
+
const request = new Request("http://127.0.0.1/mcp", {
|
|
88
|
+
method: frame.method || "POST",
|
|
89
|
+
headers,
|
|
90
|
+
...(body !== undefined ? { body } : {}),
|
|
91
|
+
});
|
|
92
|
+
response = await runWithIdentity(frame.identity, () => handler.fetch(request));
|
|
93
|
+
} catch {
|
|
94
|
+
writeFrame(activeSocket, {
|
|
95
|
+
type: "mcp-response",
|
|
96
|
+
id: frame.id,
|
|
97
|
+
status: 500,
|
|
98
|
+
headers: {},
|
|
99
|
+
body: "",
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
writeFrame(activeSocket, {
|
|
104
|
+
type: "mcp-response",
|
|
105
|
+
id: frame.id,
|
|
106
|
+
status: response.status,
|
|
107
|
+
headers: Object.fromEntries(response.headers),
|
|
108
|
+
body: await response.text(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
get agent() {
|
|
114
|
+
return agentInfo;
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Dial out to the edge. The edge never connects here.
|
|
119
|
+
* @returns {Promise<{ok: boolean, reason?: string, agent?: object}>}
|
|
120
|
+
*/
|
|
121
|
+
dial() {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
ledger?.recordConnect("agent", { host, port });
|
|
124
|
+
const next = connectFn({ host, port }, () => {
|
|
125
|
+
writeFrame(next, { type: "hello", token });
|
|
126
|
+
});
|
|
127
|
+
socket = next;
|
|
128
|
+
let established = false;
|
|
129
|
+
attachFramer(next, (frame) => {
|
|
130
|
+
if (frame.type === "hello-ok") {
|
|
131
|
+
agentInfo = frame.agent ?? null;
|
|
132
|
+
established = true;
|
|
133
|
+
resolve({ ok: true, agent: agentInfo });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (frame.type === "denied") {
|
|
137
|
+
next.end();
|
|
138
|
+
resolve({ ok: false, reason: frame.reason });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
void serveFrame(next, frame);
|
|
142
|
+
});
|
|
143
|
+
next.on("error", reject);
|
|
144
|
+
next.on("close", () => {
|
|
145
|
+
if (socket === next) {
|
|
146
|
+
socket = null;
|
|
147
|
+
if (established) onChannelClose?.();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/** Drop the outbound channel without destroying the agent. */
|
|
154
|
+
drop() {
|
|
155
|
+
socket?.destroy();
|
|
156
|
+
socket = null;
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async close() {
|
|
160
|
+
this.drop();
|
|
161
|
+
await handler.close();
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay northbound edge (#232) — the DEV-294 AC4 slice.
|
|
3
|
+
*
|
|
4
|
+
* Terminates northbound MCP over the OAuth resource server and fans requests
|
|
5
|
+
* down an outbound tenant-agent channel. The edge proposes; the tenant-side
|
|
6
|
+
* connector disposes. Policy at this seam, fail-closed from birth:
|
|
7
|
+
*
|
|
8
|
+
* - `createInboundHttpsAuth` is the ONLY authentication arm. There is no
|
|
9
|
+
* shared-bearer and no unauthenticated mode in this module; a missing
|
|
10
|
+
* issuer/audience is fatal at every bind host, loopback included.
|
|
11
|
+
* - A non-empty `auth.grants` table is mandatory. The library's all-sites
|
|
12
|
+
* fallback (principal.js) is untouched for existing installs; this entry
|
|
13
|
+
* point refuses to exist without explicit grants.
|
|
14
|
+
* - Caller credential headers (authorization, cookie, proxy-authorization —
|
|
15
|
+
* the #229 strip set) and caller identity-assertion headers are stripped
|
|
16
|
+
* before framing. The frame carries the validated identity object only.
|
|
17
|
+
* - The edge holds no site credentials: a catalog entry carrying credential
|
|
18
|
+
* material refuses startup. It cannot leak what it does not hold.
|
|
19
|
+
* - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
|
|
20
|
+
* no `Mcp-Session-Id` crosses in either direction.
|
|
21
|
+
* - Revocation is per-request with no grace window, for both credential
|
|
22
|
+
* kinds (northbound principal, agent channel).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
26
|
+
import { readFileSync, statSync } from "node:fs";
|
|
27
|
+
import { createServer as createHttpServer } from "node:http";
|
|
28
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
29
|
+
import { createServer as createNetServer } from "node:net";
|
|
30
|
+
import { createServer as createTlsServer } from "node:tls";
|
|
31
|
+
import { createLocalRelay } from "../contracts/relay.js";
|
|
32
|
+
import { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
|
|
33
|
+
import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
|
|
34
|
+
import { resolveGrantedSites } from "../principal.js";
|
|
35
|
+
import {
|
|
36
|
+
attachFramer,
|
|
37
|
+
createRequestBroker,
|
|
38
|
+
forwardHeaders,
|
|
39
|
+
writeFrame,
|
|
40
|
+
} from "./frames.js";
|
|
41
|
+
|
|
42
|
+
/** Northbound protocol pin. Stateless; no session ids in either direction. */
|
|
43
|
+
export const EDGE_MCP_PROTOCOL = "2026-07-28";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Revocation bound restated from the DEV-293 lab for both credential kinds.
|
|
47
|
+
* The next request after revoke is denied; an in-flight request may finish.
|
|
48
|
+
*/
|
|
49
|
+
export const EDGE_REVOCATION_BOUND = Object.freeze({
|
|
50
|
+
name: "per-request",
|
|
51
|
+
graceMs: 0,
|
|
52
|
+
appliesTo: Object.freeze(["northbound-principal", "agent-channel"]),
|
|
53
|
+
description:
|
|
54
|
+
"Revocation is checked at the start of each northbound request for the "
|
|
55
|
+
+ "principal token and the agent channel credential. The next request "
|
|
56
|
+
+ "after revoke is denied. An in-flight request may finish.",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Caller credential headers (#229 parity). The northbound caller's
|
|
61
|
+
* credentials authenticate the caller to the edge; they must never cross the
|
|
62
|
+
* tunnel toward the tenant.
|
|
63
|
+
*/
|
|
64
|
+
export const CALLER_CREDENTIAL_HEADERS = new Set([
|
|
65
|
+
"authorization",
|
|
66
|
+
"cookie",
|
|
67
|
+
"proxy-authorization",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** Site config keys that mean "this catalog entry carries a credential". */
|
|
71
|
+
const SITE_CREDENTIAL_KEYS = Object.freeze([
|
|
72
|
+
"apiToken",
|
|
73
|
+
"username",
|
|
74
|
+
"password",
|
|
75
|
+
"oauth",
|
|
76
|
+
"basicAuth",
|
|
77
|
+
"drushSsh",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
|
|
81
|
+
|
|
82
|
+
/** Startup refusals: configuration that must not become a listener. */
|
|
83
|
+
export class EdgeStartupError extends Error {
|
|
84
|
+
constructor(message) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "EdgeStartupError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Headers a northbound request may carry down the tunnel: hop-by-hop,
|
|
92
|
+
* caller-credential, and caller identity-assertion headers are stripped
|
|
93
|
+
* before framing.
|
|
94
|
+
*
|
|
95
|
+
* @param {Record<string, string|string[]>} [headers]
|
|
96
|
+
* @returns {Record<string, string>}
|
|
97
|
+
*/
|
|
98
|
+
export function fanDownHeaders(headers = {}) {
|
|
99
|
+
const entries = [];
|
|
100
|
+
for (const [name, value] of Object.entries(forwardHeaders(headers))) {
|
|
101
|
+
const key = String(name).toLowerCase();
|
|
102
|
+
if (CALLER_CREDENTIAL_HEADERS.has(key)) continue;
|
|
103
|
+
if (SPOOFABLE_IDENTITY_HEADERS.includes(key)) continue;
|
|
104
|
+
entries.push([name, value]);
|
|
105
|
+
}
|
|
106
|
+
return Object.fromEntries(entries);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Hot-reloaded agent channel credential store.
|
|
111
|
+
*
|
|
112
|
+
* File shape: `{ "agents": { "<agentId>": { "tokenSha256": "<hex>",
|
|
113
|
+
* "revoked": false } } }`. The edge stores only SHA-256 digests — never a raw
|
|
114
|
+
* channel token. A missing or unreadable file denies every lookup (a
|
|
115
|
+
* credential table that cannot be read authorizes nobody), and the file is
|
|
116
|
+
* re-read when its mtime changes so a revoke needs no restart.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} options
|
|
119
|
+
* @param {string} options.filePath
|
|
120
|
+
* @returns {{lookup: (token: string) => {agentId: string, revoked: boolean}|null}}
|
|
121
|
+
*/
|
|
122
|
+
export function createChannelCredentialStore({
|
|
123
|
+
filePath,
|
|
124
|
+
readFile = readFileSync,
|
|
125
|
+
stat = statSync,
|
|
126
|
+
} = {}) {
|
|
127
|
+
let cache = { mtimeMs: Number.NaN, agents: [], denyAll: true };
|
|
128
|
+
|
|
129
|
+
function load() {
|
|
130
|
+
if (!filePath) return cache;
|
|
131
|
+
let info;
|
|
132
|
+
try {
|
|
133
|
+
info = stat(filePath);
|
|
134
|
+
} catch {
|
|
135
|
+
cache = { mtimeMs: Number.NaN, agents: [], denyAll: true };
|
|
136
|
+
return cache;
|
|
137
|
+
}
|
|
138
|
+
if (info.mtimeMs === cache.mtimeMs) return cache;
|
|
139
|
+
try {
|
|
140
|
+
const raw = JSON.parse(readFile(filePath, "utf8"));
|
|
141
|
+
const agents = Object.entries(raw.agents ?? {})
|
|
142
|
+
.filter(([agentId, entry]) => !agentId.startsWith("_")
|
|
143
|
+
&& entry && typeof entry === "object"
|
|
144
|
+
&& typeof entry.tokenSha256 === "string")
|
|
145
|
+
.map(([agentId, entry]) => ({
|
|
146
|
+
agentId,
|
|
147
|
+
tokenSha256: entry.tokenSha256.toLowerCase(),
|
|
148
|
+
revoked: entry.revoked === true,
|
|
149
|
+
}));
|
|
150
|
+
cache = { mtimeMs: info.mtimeMs, agents, denyAll: false };
|
|
151
|
+
} catch {
|
|
152
|
+
cache = { mtimeMs: info.mtimeMs, agents: [], denyAll: true };
|
|
153
|
+
}
|
|
154
|
+
return cache;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
lookup(token) {
|
|
159
|
+
if (typeof token !== "string" || !token) return null;
|
|
160
|
+
const { agents, denyAll } = load();
|
|
161
|
+
if (denyAll) return null;
|
|
162
|
+
const digest = Buffer.from(
|
|
163
|
+
createHash("sha256").update(token).digest("hex"),
|
|
164
|
+
"utf8",
|
|
165
|
+
);
|
|
166
|
+
for (const agent of agents) {
|
|
167
|
+
const expected = Buffer.from(agent.tokenSha256, "utf8");
|
|
168
|
+
if (digest.length === expected.length && timingSafeEqual(digest, expected)) {
|
|
169
|
+
return { agentId: agent.agentId, revoked: agent.revoked };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isLoopback(host) {
|
|
178
|
+
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeGrants(grants) {
|
|
182
|
+
if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
|
|
183
|
+
const entries = Object.entries(grants)
|
|
184
|
+
.filter(([clientId, sites]) => !clientId.startsWith("_") && Array.isArray(sites))
|
|
185
|
+
.map(([clientId, sites]) => [clientId, sites.map(String)]);
|
|
186
|
+
return entries.length ? Object.fromEntries(entries) : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertNoSiteCredentials(sites) {
|
|
190
|
+
for (const site of sites) {
|
|
191
|
+
for (const key of SITE_CREDENTIAL_KEYS) {
|
|
192
|
+
if (new Map(Object.entries(site)).get(key) !== undefined) {
|
|
193
|
+
throw new EdgeStartupError(
|
|
194
|
+
`Relay edge site "${site._name ?? "?"}" carries credential material (${key}). `
|
|
195
|
+
+ "The edge holds no site credentials; they exist only in the tenant agent.",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assertBindAllowed(role, host, hasTls) {
|
|
203
|
+
if (hasTls) return;
|
|
204
|
+
if (!isLoopback(host)) {
|
|
205
|
+
throw new EdgeStartupError(
|
|
206
|
+
`Relay edge ${role} bind ${host} requires TLS. `
|
|
207
|
+
+ "Plain listeners are permitted on loopback only, and only when explicitly allowed.",
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function jsonResponse(res, status, body) {
|
|
213
|
+
res.writeHead(status, { "content-type": "application/json" })
|
|
214
|
+
.end(JSON.stringify(body));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Start the relay edge: an authenticated northbound MCP listener and the
|
|
219
|
+
* agent channel listener the tenant dials into.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} options
|
|
222
|
+
* @param {{issuer: string, audience: string}} options.auth Inbound
|
|
223
|
+
* resource-server config (`resolveInboundAuthConfig` shape). Mandatory.
|
|
224
|
+
* @param {object} options.grants Non-empty client-id → site-name grant table.
|
|
225
|
+
* @param {Array<{_name: string}>} options.sites Credential-free catalog.
|
|
226
|
+
* @param {string} [options.defaultSite]
|
|
227
|
+
* @param {{lookup: Function}} options.channelCredentials Agent channel store.
|
|
228
|
+
* @param {string} [options.bindHost] Northbound bind (default loopback).
|
|
229
|
+
* @param {string} [options.agentBindHost] Agent channel bind (default loopback).
|
|
230
|
+
* @param {number} [options.port] Northbound port (0 = ephemeral).
|
|
231
|
+
* @param {number} [options.agentPort] Agent channel port (0 = ephemeral).
|
|
232
|
+
* @param {{cert: string|Buffer, key: string|Buffer}} [options.tls]
|
|
233
|
+
* @param {boolean} [options.allowHttpLoopback] Permit plain listeners on loopback.
|
|
234
|
+
* @param {?object} [options.rateLimiter] Optional rate limiter (rate-limit.js).
|
|
235
|
+
* @param {number} [options.fanDownTimeoutMs]
|
|
236
|
+
* @param {typeof fetch} [options.fetchFn] Issuer discovery/JWKS fetch.
|
|
237
|
+
* @param {?{recordListen: Function, recordConnect: Function}} [options.ledger]
|
|
238
|
+
* @returns {Promise<object>}
|
|
239
|
+
*/
|
|
240
|
+
export async function startEdge({
|
|
241
|
+
auth,
|
|
242
|
+
grants,
|
|
243
|
+
sites,
|
|
244
|
+
defaultSite,
|
|
245
|
+
channelCredentials,
|
|
246
|
+
bindHost = "127.0.0.1",
|
|
247
|
+
agentBindHost = "127.0.0.1",
|
|
248
|
+
port = 0,
|
|
249
|
+
agentPort = 0,
|
|
250
|
+
tls = null,
|
|
251
|
+
allowHttpLoopback = false,
|
|
252
|
+
rateLimiter = null,
|
|
253
|
+
fanDownTimeoutMs = DEFAULT_FAN_DOWN_TIMEOUT_MS,
|
|
254
|
+
fetchFn = fetch,
|
|
255
|
+
ledger = null,
|
|
256
|
+
} = {}) {
|
|
257
|
+
if (!auth?.issuer || !auth?.audience) {
|
|
258
|
+
throw new EdgeStartupError(
|
|
259
|
+
"Relay edge requires an inbound OAuth resource server: set auth.issuer and "
|
|
260
|
+
+ "auth.audience. There is no shared-bearer or unauthenticated mode on this "
|
|
261
|
+
+ "entry point, at any bind host including loopback.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const grantTable = normalizeGrants(grants);
|
|
265
|
+
if (!grantTable) {
|
|
266
|
+
throw new EdgeStartupError(
|
|
267
|
+
"Relay edge refuses to start without a non-empty auth.grants table. "
|
|
268
|
+
+ "The library's all-sites fallback does not apply to this entry point.",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
if (typeof channelCredentials?.lookup !== "function") {
|
|
272
|
+
throw new EdgeStartupError(
|
|
273
|
+
"Relay edge requires an agent channel credential store; without one no "
|
|
274
|
+
+ "tenant channel could ever be authorized.",
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
const catalog = Array.isArray(sites) ? sites : [];
|
|
278
|
+
if (!catalog.length) {
|
|
279
|
+
throw new EdgeStartupError("Relay edge requires a non-empty site catalog.");
|
|
280
|
+
}
|
|
281
|
+
assertNoSiteCredentials(catalog);
|
|
282
|
+
const hasTls = Boolean(tls?.cert && tls?.key);
|
|
283
|
+
if (!hasTls && !allowHttpLoopback) {
|
|
284
|
+
throw new EdgeStartupError(
|
|
285
|
+
"Relay edge requires TLS (tls.cert and tls.key), or an explicit "
|
|
286
|
+
+ "loopback-only plain-listener opt-in.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
assertBindAllowed("northbound", bindHost, hasTls);
|
|
290
|
+
assertBindAllowed("agent-channel", agentBindHost, hasTls);
|
|
291
|
+
|
|
292
|
+
const inbound = await createInboundHttpsAuth({ inboundCfg: auth, fetchFn });
|
|
293
|
+
const targetRelay = createLocalRelay({ sites: catalog, grants: grantTable, defaultSite });
|
|
294
|
+
const broker = createRequestBroker({ timeoutMs: fanDownTimeoutMs });
|
|
295
|
+
|
|
296
|
+
let session = null;
|
|
297
|
+
|
|
298
|
+
const channelServer = (hasTls ? createTlsServer(tls) : createNetServer())
|
|
299
|
+
.on("connection", handleChannelSocket)
|
|
300
|
+
.on("secureConnection", handleChannelSocket);
|
|
301
|
+
|
|
302
|
+
function handleChannelSocket(socket) {
|
|
303
|
+
// Plain server emits "connection"; the TLS server emits both "connection"
|
|
304
|
+
// (raw) and "secureConnection" (cleartext). Attach once, post-handshake.
|
|
305
|
+
if (hasTls && !socket.encrypted) return;
|
|
306
|
+
attachFramer(socket, (frame) => {
|
|
307
|
+
if (frame.type === "hello") {
|
|
308
|
+
const record = channelCredentials.lookup(frame.token);
|
|
309
|
+
if (!record) {
|
|
310
|
+
writeFrame(socket, { type: "denied", reason: "unauthenticated" });
|
|
311
|
+
socket.end();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (record.revoked) {
|
|
315
|
+
writeFrame(socket, { type: "denied", reason: "revoked" });
|
|
316
|
+
socket.end();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
if (session && session.socket !== socket) session.socket.destroy();
|
|
320
|
+
session = { socket, token: frame.token, agentId: record.agentId };
|
|
321
|
+
writeFrame(socket, { type: "hello-ok", agent: { agentId: record.agentId } });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (frame.type === "mcp-response") {
|
|
325
|
+
broker.settle(frame);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// Any other frame type on the agent channel is a protocol violation.
|
|
329
|
+
socket.destroy();
|
|
330
|
+
});
|
|
331
|
+
socket.on("close", () => {
|
|
332
|
+
if (session?.socket === socket) {
|
|
333
|
+
session = null;
|
|
334
|
+
broker.rejectAll(new Error("Relay agent channel closed."));
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function fanDown(req, res, body) {
|
|
340
|
+
const identity = req.mcpIdentity ?? null;
|
|
341
|
+
if (!identity) {
|
|
342
|
+
// The authenticate arm always sets an identity; a missing one means
|
|
343
|
+
// this handler was reached outside that arm. Refuse.
|
|
344
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Entitlement at the seam, before anything about the tenant is revealed:
|
|
349
|
+
// an unlisted client learns nothing, not even whether an agent exists.
|
|
350
|
+
const granted = resolveGrantedSites(identity, catalog, grantTable);
|
|
351
|
+
if (!granted.length) {
|
|
352
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (body?.method === "tools/call") {
|
|
356
|
+
try {
|
|
357
|
+
targetRelay.resolve(identity, body?.params?.arguments ?? {});
|
|
358
|
+
} catch {
|
|
359
|
+
jsonResponse(res, 403, { error: "not_entitled" });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!session) {
|
|
365
|
+
jsonResponse(res, 503, { error: "no_agent" });
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const record = channelCredentials.lookup(session.token);
|
|
369
|
+
if (!record || record.revoked) {
|
|
370
|
+
jsonResponse(res, 403, { error: "revoked", bound: EDGE_REVOCATION_BOUND.name });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const id = randomUUID();
|
|
375
|
+
const waited = broker.track(id);
|
|
376
|
+
const wrote = writeFrame(session.socket, {
|
|
377
|
+
type: "mcp-request",
|
|
378
|
+
id,
|
|
379
|
+
method: req.method,
|
|
380
|
+
url: "/mcp",
|
|
381
|
+
headers: fanDownHeaders(req.headers),
|
|
382
|
+
identity,
|
|
383
|
+
body,
|
|
384
|
+
});
|
|
385
|
+
if (!wrote) {
|
|
386
|
+
broker.settle({ id, status: 503 });
|
|
387
|
+
jsonResponse(res, 503, { error: "no_agent" });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let result;
|
|
392
|
+
try {
|
|
393
|
+
result = await waited;
|
|
394
|
+
} catch {
|
|
395
|
+
jsonResponse(res, 502, { error: "fan_down_failed" });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const headers = Object.fromEntries(
|
|
399
|
+
Object.entries(forwardHeaders(result.headers ?? {}))
|
|
400
|
+
.filter(([name]) => String(name).toLowerCase() !== "mcp-session-id"),
|
|
401
|
+
);
|
|
402
|
+
res.writeHead(result.status || 200, headers);
|
|
403
|
+
res.end(result.body ?? "");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const requestHandler = createMcpRequestHandler({
|
|
407
|
+
authenticate: (req) => inbound.authenticate(req),
|
|
408
|
+
protectedResource: inbound.protectedResource,
|
|
409
|
+
modernHandler: fanDown,
|
|
410
|
+
// Northbound is stateless 2026-07-28 only: sessionful legacy traffic is
|
|
411
|
+
// refused, so no Mcp-Session-Id ever exists in either direction.
|
|
412
|
+
legacyHandler: createLegacySessionHandler({
|
|
413
|
+
buildServer: () => {
|
|
414
|
+
throw new Error("unreachable: legacy transport is rejected at the edge");
|
|
415
|
+
},
|
|
416
|
+
mode: "reject",
|
|
417
|
+
}),
|
|
418
|
+
toolCount: 0,
|
|
419
|
+
rateLimiter,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const northServer = hasTls
|
|
423
|
+
? createHttpsServer(tls, (req, res) => { void requestHandler(req, res); })
|
|
424
|
+
: createHttpServer((req, res) => { void requestHandler(req, res); });
|
|
425
|
+
|
|
426
|
+
const channelAddr = await listen(channelServer, agentBindHost, agentPort, "edge-agent-channel");
|
|
427
|
+
const northAddr = await listen(northServer, bindHost, port, "edge-northbound");
|
|
428
|
+
|
|
429
|
+
function listen(server, host, wantedPort, role) {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
server.once("error", reject);
|
|
432
|
+
server.listen(wantedPort, host, () => {
|
|
433
|
+
const address = server.address();
|
|
434
|
+
const bound = { host: address.address, port: address.port };
|
|
435
|
+
ledger?.recordListen(role, bound);
|
|
436
|
+
resolve(bound);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const scheme = hasTls ? "https" : "http";
|
|
442
|
+
let closed = false;
|
|
443
|
+
return {
|
|
444
|
+
northboundUrl: `${scheme}://${northAddr.host}:${northAddr.port}/mcp`,
|
|
445
|
+
port: northAddr.port,
|
|
446
|
+
agentPort: channelAddr.port,
|
|
447
|
+
resourceMetadataUrl: inbound.resourceMetadataUrl,
|
|
448
|
+
get hasAgent() {
|
|
449
|
+
return Boolean(session);
|
|
450
|
+
},
|
|
451
|
+
get agentId() {
|
|
452
|
+
return session?.agentId ?? null;
|
|
453
|
+
},
|
|
454
|
+
async close() {
|
|
455
|
+
if (closed) return;
|
|
456
|
+
closed = true;
|
|
457
|
+
session?.socket.destroy();
|
|
458
|
+
session = null;
|
|
459
|
+
broker.rejectAll(new Error("Relay edge closed."));
|
|
460
|
+
await closeServer(channelServer);
|
|
461
|
+
await closeServer(northServer);
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function closeServer(server) {
|
|
467
|
+
return new Promise((resolve, reject) => {
|
|
468
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
469
|
+
});
|
|
470
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay frame codec (#232).
|
|
3
|
+
*
|
|
4
|
+
* Length-prefixed JSON frames for the edge/agent channel, promoted from the
|
|
5
|
+
* DEV-293 lab harness (`lab/outbound-relay/harness.js`). Mechanism only — no
|
|
6
|
+
* authentication, entitlement, or header policy lives here.
|
|
7
|
+
*
|
|
8
|
+
* Wire format: 4-byte big-endian payload length, then UTF-8 JSON. A frame
|
|
9
|
+
* must be an object whose `type` is one of `FRAME_TYPES`. Anything else —
|
|
10
|
+
* oversize, malformed JSON, unknown type — tears the channel down rather
|
|
11
|
+
* than passing garbage through.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Hard bound on a single frame's JSON payload. */
|
|
15
|
+
export const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** The only frame types that may cross the channel. */
|
|
18
|
+
export const FRAME_TYPES = Object.freeze([
|
|
19
|
+
"hello",
|
|
20
|
+
"hello-ok",
|
|
21
|
+
"denied",
|
|
22
|
+
"mcp-request",
|
|
23
|
+
"mcp-response",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const FRAME_TYPE_SET = new Set(FRAME_TYPES);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RFC 9110 hop-by-hop and transport-scoped headers. They describe one TCP
|
|
30
|
+
* connection and are meaningless (or harmful) across the tunnel.
|
|
31
|
+
*/
|
|
32
|
+
export const HOP_BY_HOP = new Set([
|
|
33
|
+
"connection",
|
|
34
|
+
"keep-alive",
|
|
35
|
+
"proxy-authenticate",
|
|
36
|
+
"proxy-authorization",
|
|
37
|
+
"te",
|
|
38
|
+
"trailers",
|
|
39
|
+
"transfer-encoding",
|
|
40
|
+
"upgrade",
|
|
41
|
+
"content-length",
|
|
42
|
+
"host",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Attach the length-prefixed frame reader to a socket.
|
|
47
|
+
*
|
|
48
|
+
* Oversize declarations, malformed JSON, non-object payloads, and unknown
|
|
49
|
+
* frame types destroy the socket; buffered bytes are dropped and no further
|
|
50
|
+
* frames are delivered.
|
|
51
|
+
*
|
|
52
|
+
* @param {import("node:stream").Duplex} socket
|
|
53
|
+
* @param {(frame: object) => void} onFrame
|
|
54
|
+
*/
|
|
55
|
+
export function attachFramer(socket, onFrame) {
|
|
56
|
+
let buffer = Buffer.alloc(0);
|
|
57
|
+
let dead = false;
|
|
58
|
+
|
|
59
|
+
function teardown() {
|
|
60
|
+
dead = true;
|
|
61
|
+
buffer = Buffer.alloc(0);
|
|
62
|
+
socket.destroy();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
socket.on("data", (chunk) => {
|
|
66
|
+
if (dead) return;
|
|
67
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
68
|
+
while (buffer.length >= 4) {
|
|
69
|
+
const size = buffer.readUInt32BE(0);
|
|
70
|
+
if (size > MAX_FRAME_BYTES) {
|
|
71
|
+
teardown();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (buffer.length < 4 + size) break;
|
|
75
|
+
const json = buffer.subarray(4, 4 + size).toString("utf8");
|
|
76
|
+
buffer = buffer.subarray(4 + size);
|
|
77
|
+
let frame;
|
|
78
|
+
try {
|
|
79
|
+
frame = JSON.parse(json);
|
|
80
|
+
} catch {
|
|
81
|
+
teardown();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!frame || typeof frame !== "object" || Array.isArray(frame)
|
|
85
|
+
|| !FRAME_TYPE_SET.has(frame.type)) {
|
|
86
|
+
teardown();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
onFrame(frame);
|
|
90
|
+
if (dead) return;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Write one frame. Refuses unknown types and oversize payloads by throwing;
|
|
97
|
+
* returns false without writing when the socket is no longer writable.
|
|
98
|
+
*
|
|
99
|
+
* @param {import("node:stream").Duplex} socket
|
|
100
|
+
* @param {object} frame
|
|
101
|
+
* @returns {boolean} True when the frame was written.
|
|
102
|
+
*/
|
|
103
|
+
export function writeFrame(socket, frame) {
|
|
104
|
+
if (!frame || typeof frame !== "object" || !FRAME_TYPE_SET.has(frame.type)) {
|
|
105
|
+
throw new RangeError("Refusing to write an unknown frame type.");
|
|
106
|
+
}
|
|
107
|
+
const payload = Buffer.from(JSON.stringify(frame), "utf8");
|
|
108
|
+
if (payload.length > MAX_FRAME_BYTES) {
|
|
109
|
+
throw new RangeError("Refusing to write a frame beyond the size bound.");
|
|
110
|
+
}
|
|
111
|
+
if (!socket.writable) return false;
|
|
112
|
+
const header = Buffer.alloc(4);
|
|
113
|
+
header.writeUInt32BE(payload.length, 0);
|
|
114
|
+
socket.write(Buffer.concat([header, payload]));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Correlate mcp-request ids with their mcp-response frames.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} [options]
|
|
122
|
+
* @param {number} [options.timeoutMs] Per-request wait bound.
|
|
123
|
+
* @returns {{size: number, track: (id: string) => Promise<object>, settle: (frame: {id?: string}) => boolean, rejectAll: (error: Error) => void}}
|
|
124
|
+
*/
|
|
125
|
+
export function createRequestBroker({ timeoutMs = 10_000 } = {}) {
|
|
126
|
+
const pending = new Map();
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
get size() {
|
|
130
|
+
return pending.size;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* @param {string} id
|
|
135
|
+
* @returns {Promise<object>} Resolves with the matching response frame.
|
|
136
|
+
*/
|
|
137
|
+
track(id) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
pending.delete(id);
|
|
141
|
+
reject(new Error(`Relay fan-down timeout for request ${id}.`));
|
|
142
|
+
}, timeoutMs);
|
|
143
|
+
timer.unref?.();
|
|
144
|
+
pending.set(id, {
|
|
145
|
+
resolve(frame) {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
resolve(frame);
|
|
148
|
+
},
|
|
149
|
+
reject(error) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
reject(error);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {{id?: string}} frame
|
|
159
|
+
* @returns {boolean} True when a tracked request was resolved.
|
|
160
|
+
*/
|
|
161
|
+
settle(frame) {
|
|
162
|
+
if (typeof frame?.id !== "string") return false;
|
|
163
|
+
const waiter = pending.get(frame.id);
|
|
164
|
+
if (!waiter) return false;
|
|
165
|
+
pending.delete(frame.id);
|
|
166
|
+
waiter.resolve(frame);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {Error} error
|
|
172
|
+
*/
|
|
173
|
+
rejectAll(error) {
|
|
174
|
+
for (const [id, waiter] of pending) {
|
|
175
|
+
pending.delete(id);
|
|
176
|
+
waiter.reject(error);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Headers that may cross the tunnel at the transport level: hop-by-hop names
|
|
184
|
+
* are dropped, list values joined. Credential policy (what the edge strips
|
|
185
|
+
* beyond this) lives in edge.js, not here.
|
|
186
|
+
*
|
|
187
|
+
* @param {Record<string, string|string[]|null|undefined>} [headers]
|
|
188
|
+
* @returns {Record<string, string>}
|
|
189
|
+
*/
|
|
190
|
+
export function forwardHeaders(headers = {}) {
|
|
191
|
+
const entries = [];
|
|
192
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
193
|
+
if (HOP_BY_HOP.has(String(name).toLowerCase())) continue;
|
|
194
|
+
if (value === null || value === undefined) continue;
|
|
195
|
+
entries.push([name, Array.isArray(value) ? value.join(", ") : String(value)]);
|
|
196
|
+
}
|
|
197
|
+
return Object.fromEntries(entries);
|
|
198
|
+
}
|