drupal-mcp-connector 2.10.1 → 2.12.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.
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Relay northbound edge (#232) — the DEV-294 AC4 slice.
2
+ * Relay northbound edge (#232, #242, #244, #247, #250, #253) — DEV-294 AC4,
3
+ * DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125
4
+ * policy digest and W&L-operated bundle promotion.
3
5
  *
4
6
  * Terminates northbound MCP over the OAuth resource server and fans requests
5
- * down an outbound tenant-agent channel. The edge proposes; the tenant-side
7
+ * down outbound tenant-agent channels. The edge proposes; the tenant-side
6
8
  * connector disposes. Policy at this seam, fail-closed from birth:
7
9
  *
8
10
  * - `createInboundHttpsAuth` is the ONLY authentication arm. There is no
@@ -16,6 +18,11 @@
16
18
  * before framing. The frame carries the validated identity object only.
17
19
  * - The edge holds no site credentials: a catalog entry carrying credential
18
20
  * material refuses startup. It cannot leak what it does not hold.
21
+ * - Tunnel identity is the tenant boundary. Agent sessions are keyed by
22
+ * `agentId`; channel records may bind `sites`. A second unscoped agent
23
+ * or overlapping site claim is denied at hello. Fan-down selects the
24
+ * unique bound agent from server-owned grants. Single unscoped agent
25
+ * remains the DEV-294 compatibility path.
19
26
  * - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
20
27
  * no `Mcp-Session-Id` crosses in either direction.
21
28
  * - Revocation is per-request with no grace window, for both credential
@@ -31,7 +38,14 @@ import { createServer as createTlsServer } from "node:tls";
31
38
  import { createLocalRelay } from "../contracts/relay.js";
32
39
  import { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
33
40
  import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
34
- import { resolveGrantedSites } from "../principal.js";
41
+ import { isWriteLikeCall } from "../operations.js";
42
+ import {
43
+ eligiblePromotions,
44
+ POLICY_DIGEST,
45
+ promotionsRequired,
46
+ resolveEligiblePromotion,
47
+ } from "../policy-promotion.js";
48
+ import { DIAGNOSTIC_TOOLS, resolveActor, resolveGrantedSites, resolvePolicy } from "../principal.js";
35
49
  import {
36
50
  attachFramer,
37
51
  createRequestBroker,
@@ -79,6 +93,230 @@ const SITE_CREDENTIAL_KEYS = Object.freeze([
79
93
 
80
94
  const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
81
95
 
96
+ /**
97
+ * Normalize a channel-record `sites` list. Empty / missing means unscoped
98
+ * (legal only as the sole connected agent — the DEV-294 compatibility path).
99
+ *
100
+ * @param {unknown} sites
101
+ * @returns {string[]}
102
+ */
103
+ export function boundSiteNames(sites) {
104
+ if (!Array.isArray(sites)) return [];
105
+ return sites.map(String).filter((name) => name && !name.startsWith("_"));
106
+ }
107
+
108
+ /**
109
+ * Decide whether a hello may join the agent-channel session table.
110
+ *
111
+ * @param {object} params
112
+ * @param {{agentId: string, revoked?: boolean, sites?: string[]|null}|null} params.record
113
+ * @param {Array<{agentId: string, sites: string[]|null}>} [params.sessions]
114
+ * Currently connected agents, excluding a reconnect of the same agentId.
115
+ * @param {Iterable<string>} [params.catalogNames]
116
+ * @returns {{ok: true, sites: string[]|null}|{ok: false, reason: string}}
117
+ */
118
+ export function acceptAgentHello({ record, sessions = [], catalogNames = [] } = {}) {
119
+ if (!record || typeof record.agentId !== "string" || !record.agentId) {
120
+ return { ok: false, reason: "unauthenticated" };
121
+ }
122
+ if (record.revoked) return { ok: false, reason: "revoked" };
123
+
124
+ const known = new Set([...catalogNames].map(String));
125
+ let incoming = boundSiteNames(record.sites);
126
+ if (incoming.length && known.size) {
127
+ incoming = incoming.filter((name) => known.has(name));
128
+ if (!incoming.length) return { ok: false, reason: "unbound_tenant" };
129
+ }
130
+ const incomingScoped = incoming.length > 0;
131
+ const others = sessions.filter((session) => session.agentId !== record.agentId);
132
+
133
+ if (others.length === 0) {
134
+ return { ok: true, sites: incomingScoped ? incoming : null };
135
+ }
136
+ if (!incomingScoped) return { ok: false, reason: "unbound_tenant" };
137
+ if (others.some((session) => !boundSiteNames(session.sites).length)) {
138
+ return { ok: false, reason: "unbound_tenant" };
139
+ }
140
+ const claimed = new Set(others.flatMap((session) => boundSiteNames(session.sites)));
141
+ if (incoming.some((name) => claimed.has(name))) {
142
+ return { ok: false, reason: "overlapping_tenant" };
143
+ }
144
+ return { ok: true, sites: incoming };
145
+ }
146
+
147
+ /**
148
+ * Pick the unique tenant agent this principal may use.
149
+ *
150
+ * @param {object} params
151
+ * @param {string[]} params.grantedSiteNames
152
+ * @param {string|null} [params.targetName]
153
+ * @param {Array<{agentId: string, sites: string[]|null}>} params.sessions
154
+ * @returns {{session: object|null, reason: "not_entitled"|"no_agent"|null}}
155
+ */
156
+ export function selectTenantSession({
157
+ grantedSiteNames = [],
158
+ targetName = null,
159
+ sessions = [],
160
+ } = {}) {
161
+ if (!grantedSiteNames.length) return { session: null, reason: "not_entitled" };
162
+ if (targetName && !grantedSiteNames.includes(targetName)) {
163
+ return { session: null, reason: "not_entitled" };
164
+ }
165
+ const needed = targetName ? [targetName] : [...grantedSiteNames];
166
+ if (!sessions.length) return { session: null, reason: "no_agent" };
167
+
168
+ const scoped = [];
169
+ const unscoped = [];
170
+ for (const session of sessions) {
171
+ if (boundSiteNames(session.sites).length) scoped.push(session);
172
+ else unscoped.push(session);
173
+ }
174
+
175
+ if (unscoped.length && scoped.length) {
176
+ return { session: null, reason: "no_agent" };
177
+ }
178
+ if (unscoped.length === 1 && sessions.length === 1) {
179
+ return { session: unscoped[0], reason: null };
180
+ }
181
+ if (unscoped.length > 1) return { session: null, reason: "no_agent" };
182
+
183
+ const matches = scoped.filter((session) => {
184
+ const names = new Set(boundSiteNames(session.sites));
185
+ return needed.every((name) => names.has(name));
186
+ });
187
+ if (matches.length === 1) return { session: matches[0], reason: null };
188
+ if (matches.length > 1) return { session: null, reason: "not_entitled" };
189
+
190
+ const partial = scoped.filter((session) => {
191
+ const names = new Set(boundSiteNames(session.sites));
192
+ return needed.some((name) => names.has(name));
193
+ });
194
+ if (partial.length > 0 && needed.length > 1) {
195
+ return { session: null, reason: "not_entitled" };
196
+ }
197
+ return { session: null, reason: "no_agent" };
198
+ }
199
+
200
+ /**
201
+ * Resolve tenant and target from server-owned grants. Caller `tenant` is a
202
+ * confirming hint inside the grant, never authority. When `tenantGrants` is
203
+ * omitted, tenant is derived from the unique agent covering the site grant
204
+ * (DEV-122 / DEV-294 compatibility).
205
+ *
206
+ * @param {object} params
207
+ * @param {object|null} params.identity
208
+ * @param {string|null} [params.callerTenant]
209
+ * @param {object|null} [params.tenantGrants]
210
+ * @param {string[]} params.grantedSiteNames
211
+ * @param {string|null} [params.targetName]
212
+ * @param {Array<{agentId: string, sites: string[]|null}>} params.sessions
213
+ * @returns {{session: object|null, tenant: string|null, target: string|null, source: string, reason: "not_entitled"|"no_agent"|null}}
214
+ */
215
+ export function resolveTenantRoute({
216
+ identity,
217
+ callerTenant = null,
218
+ tenantGrants = null,
219
+ grantedSiteNames = [],
220
+ targetName = null,
221
+ sessions = [],
222
+ } = {}) {
223
+ if (tenantGrants) {
224
+ const listed = identity?.clientId
225
+ ? new Map(Object.entries(tenantGrants)).get(identity.clientId)
226
+ : undefined;
227
+ const grantedTenants = grantIds(listed);
228
+ if (!grantedTenants.length) {
229
+ return {
230
+ session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
231
+ };
232
+ }
233
+ if (callerTenant && !grantedTenants.includes(callerTenant)) {
234
+ return {
235
+ session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
236
+ };
237
+ }
238
+ if (!callerTenant && grantedTenants.length > 1) {
239
+ return {
240
+ session: null, tenant: null, target: targetName, source: "grant", reason: "not_entitled",
241
+ };
242
+ }
243
+ const tenant = callerTenant || grantedTenants[0];
244
+ const session = sessions.find((entry) => entry.agentId === tenant) ?? null;
245
+ if (!session) {
246
+ return {
247
+ session: null, tenant, target: targetName, source: "grant", reason: "no_agent",
248
+ };
249
+ }
250
+ const tenantSites = boundSiteNames(session.sites);
251
+ const allowed = tenantSites.length
252
+ ? grantedSiteNames.filter((name) => tenantSites.includes(name))
253
+ : grantedSiteNames;
254
+ if (targetName && !allowed.includes(targetName)) {
255
+ return {
256
+ session: null, tenant, target: targetName, source: "grant", reason: "not_entitled",
257
+ };
258
+ }
259
+ if (!targetName && grantedSiteNames.length && allowed.length === 0) {
260
+ return {
261
+ session: null, tenant, target: targetName, source: "grant", reason: "not_entitled",
262
+ };
263
+ }
264
+ return { session, tenant, target: targetName, source: "grant", reason: null };
265
+ }
266
+
267
+ const selected = selectTenantSession({ grantedSiteNames, targetName, sessions });
268
+ return {
269
+ session: selected.session,
270
+ tenant: selected.session?.agentId ?? null,
271
+ target: targetName,
272
+ source: "grant",
273
+ reason: selected.reason,
274
+ };
275
+ }
276
+
277
+ function callerTenantHint(args = {}) {
278
+ const value = new Map(Object.entries(args ?? {})).get("tenant");
279
+ return typeof value === "string" && value.trim() ? value.trim() : null;
280
+ }
281
+
282
+ const CALLER_HINT_KEYS = Object.freeze(["tenant", "actor", "delegator", "policy", "digest"]);
283
+
284
+ function stripCallerHints(body) {
285
+ const args = body?.params?.arguments;
286
+ if (!args || typeof args !== "object" || Array.isArray(args)) return body;
287
+ const bag = new Map(Object.entries(args));
288
+ let changed = false;
289
+ for (const key of CALLER_HINT_KEYS) {
290
+ if (bag.has(key)) {
291
+ bag.delete(key);
292
+ changed = true;
293
+ }
294
+ }
295
+ if (!changed) return body;
296
+ return { ...body, params: { ...body.params, arguments: Object.fromEntries(bag) } };
297
+ }
298
+
299
+ function identityWithGrant(identity, { tenant, actor = null, delegator = null, policy = null }) {
300
+ const bag = new Map(Object.entries(identity ?? {}));
301
+ for (const key of ["actor", "delegator", "policy", "digest"]) bag.delete(key);
302
+ bag.set("tenant", tenant);
303
+ if (actor) bag.set("actor", actor);
304
+ if (delegator) bag.set("delegator", delegator);
305
+ if (policy) bag.set("policy", policy);
306
+ return Object.freeze(Object.fromEntries(bag));
307
+ }
308
+
309
+ /**
310
+ * Canonical form of a tenant site bind. Null and empty both mean unscoped.
311
+ *
312
+ * @param {unknown} sites
313
+ * @returns {string}
314
+ */
315
+ export function siteBindingKey(sites) {
316
+ const names = boundSiteNames(sites);
317
+ return names.length ? names.slice().sort().join("\0") : "";
318
+ }
319
+
82
320
  /** Startup refusals: configuration that must not become a listener. */
83
321
  export class EdgeStartupError extends Error {
84
322
  constructor(message) {
@@ -110,14 +348,17 @@ export function fanDownHeaders(headers = {}) {
110
348
  * Hot-reloaded agent channel credential store.
111
349
  *
112
350
  * 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.
351
+ * "sites": ["site-name"], "revoked": false } } }`. The edge stores only
352
+ * SHA-256 digests — never a raw channel token. `sites` binds the tunnel to
353
+ * catalog names (the tenant boundary). Omitted `sites` is unscoped and is
354
+ * only accepted as the sole connected agent. A missing or unreadable file
355
+ * denies every lookup (a credential table that cannot be read authorizes
356
+ * nobody), and the file is re-read when its mtime changes so a revoke needs
357
+ * no restart.
117
358
  *
118
359
  * @param {object} options
119
360
  * @param {string} options.filePath
120
- * @returns {{lookup: (token: string) => {agentId: string, revoked: boolean}|null}}
361
+ * @returns {{lookup: (token: string) => {agentId: string, revoked: boolean, sites: string[]|null}|null}}
121
362
  */
122
363
  export function createChannelCredentialStore({
123
364
  filePath,
@@ -146,6 +387,7 @@ export function createChannelCredentialStore({
146
387
  agentId,
147
388
  tokenSha256: entry.tokenSha256.toLowerCase(),
148
389
  revoked: entry.revoked === true,
390
+ sites: Array.isArray(entry.sites) ? boundSiteNames(entry.sites) : null,
149
391
  }));
150
392
  cache = { mtimeMs: info.mtimeMs, agents, denyAll: false };
151
393
  } catch {
@@ -166,7 +408,7 @@ export function createChannelCredentialStore({
166
408
  for (const agent of agents) {
167
409
  const expected = Buffer.from(agent.tokenSha256, "utf8");
168
410
  if (digest.length === expected.length && timingSafeEqual(digest, expected)) {
169
- return { agentId: agent.agentId, revoked: agent.revoked };
411
+ return { agentId: agent.agentId, revoked: agent.revoked, sites: agent.sites };
170
412
  }
171
413
  }
172
414
  return null;
@@ -178,11 +420,18 @@ function isLoopback(host) {
178
420
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
179
421
  }
180
422
 
423
+ function grantIds(values) {
424
+ if (!Array.isArray(values)) return [];
425
+ return [...new Set(
426
+ values.map((value) => String(value).trim()).filter((value) => value && !value.startsWith("_")),
427
+ )];
428
+ }
429
+
181
430
  function normalizeGrants(grants) {
182
431
  if (!grants || typeof grants !== "object" || Array.isArray(grants)) return null;
183
432
  const entries = Object.entries(grants)
184
433
  .filter(([clientId, sites]) => !clientId.startsWith("_") && Array.isArray(sites))
185
- .map(([clientId, sites]) => [clientId, sites.map(String)]);
434
+ .map(([clientId, sites]) => [clientId, grantIds(sites)]);
186
435
  return entries.length ? Object.fromEntries(entries) : null;
187
436
  }
188
437
 
@@ -222,6 +471,15 @@ function jsonResponse(res, status, body) {
222
471
  * @param {{issuer: string, audience: string}} options.auth Inbound
223
472
  * resource-server config (`resolveInboundAuthConfig` shape). Mandatory.
224
473
  * @param {object} options.grants Non-empty client-id → site-name grant table.
474
+ * @param {object|null} [options.tenantGrants] Optional client-id → tenant
475
+ * agent-id table. When present, tenant routing is grant-authoritative.
476
+ * @param {object|null} [options.actors] Optional principal → Drupal actor
477
+ * table (`sub` / `azp` → `{ uuid, delegators? }`). When present, write-like
478
+ * tools/call require a mapping.
479
+ * @param {object|null} [options.policies] Optional principal → SHA-256 digest.
480
+ * @param {object|null} [options.promotions] Optional W&L-operated dual-control
481
+ * ledger (digest → sealed document + two operator ids). When present,
482
+ * non-diagnostic tools/call require a matching agent attestation.
225
483
  * @param {Array<{_name: string}>} options.sites Credential-free catalog.
226
484
  * @param {string} [options.defaultSite]
227
485
  * @param {{lookup: Function}} options.channelCredentials Agent channel store.
@@ -240,6 +498,10 @@ function jsonResponse(res, status, body) {
240
498
  export async function startEdge({
241
499
  auth,
242
500
  grants,
501
+ tenantGrants = null,
502
+ actors = null,
503
+ policies = null,
504
+ promotions = null,
243
505
  sites,
244
506
  defaultSite,
245
507
  channelCredentials,
@@ -268,6 +530,17 @@ export async function startEdge({
268
530
  + "The library's all-sites fallback does not apply to this entry point.",
269
531
  );
270
532
  }
533
+ const tenantGrantTable = normalizeGrants(tenantGrants);
534
+ const actorTable = actors && typeof actors === "object" && !Array.isArray(actors)
535
+ ? actors
536
+ : null;
537
+ const policyTable = policies && typeof policies === "object" && !Array.isArray(policies)
538
+ ? policies
539
+ : null;
540
+ const promotionTable = promotions && typeof promotions === "object" && !Array.isArray(promotions)
541
+ ? promotions
542
+ : null;
543
+ const promoRequired = promotionsRequired(promotionTable);
271
544
  if (typeof channelCredentials?.lookup !== "function") {
272
545
  throw new EdgeStartupError(
273
546
  "Relay edge requires an agent channel credential store; without one no "
@@ -293,7 +566,9 @@ export async function startEdge({
293
566
  const targetRelay = createLocalRelay({ sites: catalog, grants: grantTable, defaultSite });
294
567
  const broker = createRequestBroker({ timeoutMs: fanDownTimeoutMs });
295
568
 
296
- let session = null;
569
+ /** @type {Map<string, {socket: object, token: string, agentId: string, sites: string[]|null, attestedDigests: Set<string>, offeredDigests: Set<string>}>} */
570
+ const sessions = new Map();
571
+ const catalogNames = catalog.map((site) => site._name);
297
572
 
298
573
  const channelServer = (hasTls ? createTlsServer(tls) : createNetServer())
299
574
  .on("connection", handleChannelSocket)
@@ -303,35 +578,76 @@ export async function startEdge({
303
578
  // Plain server emits "connection"; the TLS server emits both "connection"
304
579
  // (raw) and "secureConnection" (cleartext). Attach once, post-handshake.
305
580
  if (hasTls && !socket.encrypted) return;
581
+ let agentId = null;
306
582
  attachFramer(socket, (frame) => {
307
583
  if (frame.type === "hello") {
308
584
  const record = channelCredentials.lookup(frame.token);
309
- if (!record) {
310
- writeFrame(socket, { type: "denied", reason: "unauthenticated" });
585
+ const others = [...sessions.values()].filter((entry) => entry.socket !== socket);
586
+ const decision = acceptAgentHello({
587
+ record,
588
+ sessions: others,
589
+ catalogNames,
590
+ });
591
+ if (!decision.ok) {
592
+ writeFrame(socket, { type: "denied", reason: decision.reason });
311
593
  socket.end();
312
594
  return;
313
595
  }
314
- if (record.revoked) {
315
- writeFrame(socket, { type: "denied", reason: "revoked" });
316
- socket.end();
317
- return;
596
+ const existing = sessions.get(record.agentId);
597
+ if (existing && existing.socket !== socket) existing.socket.destroy();
598
+ agentId = record.agentId;
599
+ const offeredDigests = new Set();
600
+ sessions.set(agentId, {
601
+ socket,
602
+ token: frame.token,
603
+ agentId,
604
+ sites: decision.sites,
605
+ attestedDigests: new Set(),
606
+ offeredDigests,
607
+ });
608
+ try {
609
+ for (const row of eligiblePromotions(promotionTable)) {
610
+ const wrote = writeFrame(socket, { type: "policy-bundle", document: row.document });
611
+ if (!wrote) throw new Error("policy-bundle write failed");
612
+ offeredDigests.add(row.digest);
613
+ }
614
+ writeFrame(socket, { type: "hello-ok", agent: { agentId } });
615
+ } catch {
616
+ sessions.delete(agentId);
617
+ agentId = null;
618
+ socket.destroy();
619
+ }
620
+ return;
621
+ }
622
+ if (!agentId) {
623
+ socket.destroy();
624
+ return;
625
+ }
626
+ if (frame.type === "policy-bundle-ack") {
627
+ const session = sessions.get(agentId);
628
+ const digest = typeof frame.digest === "string"
629
+ ? frame.digest.trim().toLowerCase()
630
+ : "";
631
+ if (
632
+ session && frame.ok === true && POLICY_DIGEST.test(digest)
633
+ && session.offeredDigests.has(digest)
634
+ ) {
635
+ session.attestedDigests.add(digest);
318
636
  }
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
637
  return;
323
638
  }
324
639
  if (frame.type === "mcp-response") {
325
- broker.settle(frame);
640
+ broker.settle(frame, { owner: agentId });
326
641
  return;
327
642
  }
328
643
  // Any other frame type on the agent channel is a protocol violation.
329
644
  socket.destroy();
330
645
  });
331
646
  socket.on("close", () => {
332
- if (session?.socket === socket) {
333
- session = null;
334
- broker.rejectAll(new Error("Relay agent channel closed."));
647
+ const current = agentId ? sessions.get(agentId) : null;
648
+ if (current?.socket === socket) {
649
+ sessions.delete(agentId);
650
+ broker.rejectByOwner(agentId, new Error("Relay agent channel closed."));
335
651
  }
336
652
  });
337
653
  }
@@ -352,38 +668,107 @@ export async function startEdge({
352
668
  jsonResponse(res, 403, { error: "not_entitled" });
353
669
  return;
354
670
  }
671
+ const args = body?.params?.arguments ?? {};
672
+ const callerTenant = callerTenantHint(args);
673
+ const siteArgs = { ...args };
674
+ delete siteArgs.tenant;
675
+ let targetName = null;
355
676
  if (body?.method === "tools/call") {
356
677
  try {
357
- targetRelay.resolve(identity, body?.params?.arguments ?? {});
678
+ targetName = targetRelay.resolve(identity, siteArgs).name;
358
679
  } catch {
359
680
  jsonResponse(res, 403, { error: "not_entitled" });
360
681
  return;
361
682
  }
362
683
  }
363
684
 
364
- if (!session) {
365
- jsonResponse(res, 503, { error: "no_agent" });
685
+ const mapped = resolveActor({ identity, actors: actorTable });
686
+ const boundPolicy = resolvePolicy({ identity, policies: policyTable });
687
+ const isCall = body?.method === "tools/call";
688
+ const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
689
+ ? body.params.name.trim()
690
+ : null;
691
+ if (mapped.required && mapped.reason && isCall && (!toolName || isWriteLikeCall(toolName, args))) {
692
+ jsonResponse(res, 403, { error: "not_entitled" });
693
+ return;
694
+ }
695
+ const policyCall = isCall && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName));
696
+ if (
697
+ boundPolicy.required && boundPolicy.reason && policyCall
698
+ ) {
699
+ jsonResponse(res, 403, { error: "not_entitled" });
700
+ return;
701
+ }
702
+ if (promoRequired && policyCall && !boundPolicy.policy) {
703
+ jsonResponse(res, 403, { error: "not_entitled" });
366
704
  return;
367
705
  }
368
- const record = channelCredentials.lookup(session.token);
706
+
707
+ const selected = resolveTenantRoute({
708
+ identity,
709
+ callerTenant,
710
+ tenantGrants: tenantGrantTable,
711
+ grantedSiteNames: granted.map((site) => site._name),
712
+ targetName,
713
+ sessions: [...sessions.values()],
714
+ });
715
+ if (promoRequired && policyCall) {
716
+ const promo = resolveEligiblePromotion({
717
+ digest: boundPolicy.policy,
718
+ promotions: promotionTable,
719
+ });
720
+ const attested = selected.session?.attestedDigests;
721
+ if (!promo.eligible || !attested || !attested.has(boundPolicy.policy)) {
722
+ jsonResponse(res, 403, { error: "not_entitled" });
723
+ return;
724
+ }
725
+ }
726
+ if (!selected.session) {
727
+ const entitled = selected.reason === "not_entitled";
728
+ jsonResponse(res, entitled ? 403 : 503, {
729
+ error: entitled ? "not_entitled" : "no_agent",
730
+ });
731
+ return;
732
+ }
733
+ const record = channelCredentials.lookup(selected.session.token);
369
734
  if (!record || record.revoked) {
370
735
  jsonResponse(res, 403, { error: "revoked", bound: EDGE_REVOCATION_BOUND.name });
371
736
  return;
372
737
  }
738
+ if (siteBindingKey(record.sites) !== siteBindingKey(selected.session.sites)) {
739
+ selected.session.socket.destroy();
740
+ jsonResponse(res, 503, { error: "no_agent" });
741
+ return;
742
+ }
373
743
 
374
744
  const id = randomUUID();
375
- const waited = broker.track(id);
376
- const wrote = writeFrame(session.socket, {
745
+ const waited = broker.track(id, { owner: selected.session.agentId });
746
+ const routedIdentity = identityWithGrant(identity, {
747
+ tenant: selected.tenant,
748
+ actor: mapped.actor,
749
+ delegator: mapped.delegator,
750
+ policy: boundPolicy.policy,
751
+ });
752
+ const wrote = writeFrame(selected.session.socket, {
377
753
  type: "mcp-request",
378
754
  id,
379
755
  method: req.method,
380
756
  url: "/mcp",
381
757
  headers: fanDownHeaders(req.headers),
382
- identity,
383
- body,
758
+ identity: routedIdentity,
759
+ body: stripCallerHints(body),
760
+ correlation: {
761
+ requestId: id,
762
+ tenant: selected.tenant,
763
+ target: selected.target,
764
+ source: selected.source,
765
+ ...(mapped.actor ? { actor: mapped.actor } : {}),
766
+ ...(mapped.delegator ? { delegator: mapped.delegator } : {}),
767
+ ...(boundPolicy.policy ? { policyDigest: boundPolicy.policy } : {}),
768
+ },
384
769
  });
385
770
  if (!wrote) {
386
- broker.settle({ id, status: 503 });
771
+ broker.settle({ id, status: 503 }, { owner: selected.session.agentId });
387
772
  jsonResponse(res, 503, { error: "no_agent" });
388
773
  return;
389
774
  }
@@ -446,16 +831,20 @@ export async function startEdge({
446
831
  agentPort: channelAddr.port,
447
832
  resourceMetadataUrl: inbound.resourceMetadataUrl,
448
833
  get hasAgent() {
449
- return Boolean(session);
834
+ return sessions.size > 0;
450
835
  },
451
836
  get agentId() {
452
- return session?.agentId ?? null;
837
+ if (sessions.size !== 1) return null;
838
+ return sessions.keys().next().value;
839
+ },
840
+ get agentIds() {
841
+ return [...sessions.keys()];
453
842
  },
454
843
  async close() {
455
844
  if (closed) return;
456
845
  closed = true;
457
- session?.socket.destroy();
458
- session = null;
846
+ for (const entry of sessions.values()) entry.socket.destroy();
847
+ sessions.clear();
459
848
  broker.rejectAll(new Error("Relay edge closed."));
460
849
  await closeServer(channelServer);
461
850
  await closeServer(northServer);