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 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.9.0",
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/",
@@ -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
- const jwks = createRemoteJWKSet(new URL(asMeta.jwks_uri));
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
+ }