drupal-mcp-connector 2.9.0 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/bin/drupal-mcp-agent.js +191 -0
- package/bin/drupal-mcp-edge.js +150 -0
- package/package.json +4 -2
- package/src/index.js +3 -0
- package/src/lib/backends/jsonapi.js +10 -5
- package/src/lib/http-auth.js +24 -2
- package/src/lib/relay/agent.js +164 -0
- package/src/lib/relay/edge.js +470 -0
- package/src/lib/relay/frames.js +198 -0
- package/src/lib/verify.js +10 -0
|
@@ -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
|
+
}
|