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.
@@ -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
+ }