drupal-mcp-connector 2.11.0 → 2.13.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.
@@ -16,6 +16,7 @@
16
16
  import { connect as netConnect } from "node:net";
17
17
  import { createMcpHandler } from "@modelcontextprotocol/server";
18
18
  import { createConnectorServerFactory } from "../mcp-server.js";
19
+ import { POLICY_DIGEST } from "../policy-promotion.js";
19
20
  import { runWithIdentity } from "../principal.js";
20
21
  import { attachFramer, forwardHeaders, writeFrame } from "./frames.js";
21
22
 
@@ -35,6 +36,9 @@ import { attachFramer, forwardHeaders, writeFrame } from "./frames.js";
35
36
  * @param {?() => void} [options.onChannelClose] Called when an established
36
37
  * channel is lost (not on deliberate `drop`/`close`), so an entry point
37
38
  * can fail loudly instead of idling disconnected.
39
+ * @param {?{activate: Function}} [options.policyEnforcement] Local Sentinel
40
+ * hook. `activate(document)` verifies, activates, and attests. Missing
41
+ * hook fail-closes a `policy-bundle` frame (no mint on this process).
38
42
  * @param {?{recordConnect: Function}} [options.ledger]
39
43
  * @returns {object}
40
44
  */
@@ -45,6 +49,7 @@ export function createRelayAgent({
45
49
  surface,
46
50
  connectFn = netConnect,
47
51
  onChannelClose = null,
52
+ policyEnforcement = null,
48
53
  ledger = null,
49
54
  }) {
50
55
  if (!host || !port) {
@@ -63,6 +68,41 @@ export function createRelayAgent({
63
68
  let socket = null;
64
69
  let agentInfo = null;
65
70
 
71
+ async function presentBundle(activeSocket, frame) {
72
+ let ok = false;
73
+ let digest = "";
74
+ let reason = "no_enforcement";
75
+ try {
76
+ if (policyEnforcement && typeof policyEnforcement.activate === "function") {
77
+ const result = await policyEnforcement.activate(frame.document);
78
+ const claimed = typeof result?.digest === "string"
79
+ ? result.digest.trim().toLowerCase()
80
+ : "";
81
+ ok = result?.ok === true && POLICY_DIGEST.test(claimed);
82
+ digest = ok ? claimed : "";
83
+ reason = ok
84
+ ? ""
85
+ : (typeof result?.reason === "string" && result.reason.trim()
86
+ ? result.reason.trim().slice(0, 64)
87
+ : "unverified");
88
+ }
89
+ } catch {
90
+ ok = false;
91
+ digest = "";
92
+ reason = "activate_failed";
93
+ }
94
+ try {
95
+ writeFrame(activeSocket, {
96
+ type: "policy-bundle-ack",
97
+ ok,
98
+ digest,
99
+ ...(ok ? {} : { reason }),
100
+ });
101
+ } catch {
102
+ activeSocket.destroy();
103
+ }
104
+ }
105
+
66
106
  async function serveFrame(activeSocket, frame) {
67
107
  if (frame.type !== "mcp-request") return;
68
108
  if (!frame.identity || typeof frame.identity !== "object" || Array.isArray(frame.identity)) {
@@ -138,6 +178,10 @@ export function createRelayAgent({
138
178
  resolve({ ok: false, reason: frame.reason });
139
179
  return;
140
180
  }
181
+ if (frame.type === "policy-bundle") {
182
+ void presentBundle(next, frame);
183
+ return;
184
+ }
141
185
  void serveFrame(next, frame);
142
186
  });
143
187
  next.on("error", reject);
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Relay northbound edge (#232, #242, #244, #247, #250) — DEV-294 AC4, DEV-122
3
- * isolation, DEV-124 tenant routing, DEV-123 actor mapping, DEV-125 policy digest.
2
+ * Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) — DEV-294
3
+ * AC4, DEV-122 isolation, DEV-124 tenant routing, DEV-123 actor mapping,
4
+ * DEV-125 policy digest and W&L-operated bundle promotion, DEV-126
5
+ * attributable usage, quotas, and abuse signals.
4
6
  *
5
7
  * Terminates northbound MCP over the OAuth resource server and fans requests
6
8
  * down outbound tenant-agent channels. The edge proposes; the tenant-side
@@ -26,6 +28,11 @@
26
28
  * no `Mcp-Session-Id` crosses in either direction.
27
29
  * - Revocation is per-request with no grace window, for both credential
28
30
  * kinds (northbound principal, agent channel).
31
+ * - Metering is optional and fail-closed. With a `usage` ledger every
32
+ * decision and receipt is recorded against the grant-resolved tenant and
33
+ * the validated principal; with `auth.quotas` a tenant or principal
34
+ * without a row, an exhausted window, or a locked principal is refused
35
+ * with zero frames. `GET /usage` serves one tenant partition only.
29
36
  */
30
37
 
31
38
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
@@ -35,10 +42,27 @@ import { createServer as createHttpsServer } from "node:https";
35
42
  import { createServer as createNetServer } from "node:net";
36
43
  import { createServer as createTlsServer } from "node:tls";
37
44
  import { createLocalRelay } from "../contracts/relay.js";
38
- import { createInboundHttpsAuth, SPOOFABLE_IDENTITY_HEADERS } from "../http-auth.js";
45
+ import {
46
+ createInboundHttpsAuth,
47
+ formatWwwAuthenticate,
48
+ SPOOFABLE_IDENTITY_HEADERS,
49
+ } from "../http-auth.js";
39
50
  import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
40
51
  import { isWriteLikeCall } from "../operations.js";
52
+ import {
53
+ eligiblePromotions,
54
+ POLICY_DIGEST,
55
+ promotionsRequired,
56
+ resolveEligiblePromotion,
57
+ } from "../policy-promotion.js";
41
58
  import { DIAGNOSTIC_TOOLS, resolveActor, resolveGrantedSites, resolvePolicy } from "../principal.js";
59
+ import {
60
+ attributedTenant,
61
+ createQuotaGate,
62
+ readUsage,
63
+ reconcileUsage,
64
+ usagePrincipalKey,
65
+ } from "../usage.js";
42
66
  import {
43
67
  attachFramer,
44
68
  createRequestBroker,
@@ -86,6 +110,16 @@ const SITE_CREDENTIAL_KEYS = Object.freeze([
86
110
 
87
111
  const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
88
112
 
113
+ /** Post-authentication refusals that count toward a principal's abuse lock. */
114
+ const ABUSE_SIGNAL_ERRORS = new Set(["not_entitled", "quota_exceeded"]);
115
+
116
+ /**
117
+ * Refusal scopes that describe something other than the caller's own
118
+ * behaviour — a shared tenant window, or a table the operator misconfigured.
119
+ * They never feed a principal's abuse lock.
120
+ */
121
+ const SHARED_SCOPES = new Set(["tenant", "config"]);
122
+
89
123
  /**
90
124
  * Normalize a channel-record `sites` list. Empty / missing means unscoped
91
125
  * (legal only as the sole connected agent — the DEV-294 compatibility path).
@@ -451,11 +485,16 @@ function assertBindAllowed(role, host, hasTls) {
451
485
  }
452
486
  }
453
487
 
454
- function jsonResponse(res, status, body) {
455
- res.writeHead(status, { "content-type": "application/json" })
488
+ function jsonResponse(res, status, body, headers = {}) {
489
+ res.writeHead(status, { ...headers, "content-type": "application/json" })
456
490
  .end(JSON.stringify(body));
457
491
  }
458
492
 
493
+ function byteLength(value) {
494
+ if (value === null || value === undefined) return 0;
495
+ return Buffer.byteLength(typeof value === "string" ? value : JSON.stringify(value), "utf8");
496
+ }
497
+
459
498
  /**
460
499
  * Start the relay edge: an authenticated northbound MCP listener and the
461
500
  * agent channel listener the tenant dials into.
@@ -469,6 +508,10 @@ function jsonResponse(res, status, body) {
469
508
  * @param {object|null} [options.actors] Optional principal → Drupal actor
470
509
  * table (`sub` / `azp` → `{ uuid, delegators? }`). When present, write-like
471
510
  * tools/call require a mapping.
511
+ * @param {object|null} [options.policies] Optional principal → SHA-256 digest.
512
+ * @param {object|null} [options.promotions] Optional W&L-operated dual-control
513
+ * ledger (digest → sealed document + two operator ids). When present,
514
+ * non-diagnostic tools/call require a matching agent attestation.
472
515
  * @param {Array<{_name: string}>} options.sites Credential-free catalog.
473
516
  * @param {string} [options.defaultSite]
474
517
  * @param {{lookup: Function}} options.channelCredentials Agent channel store.
@@ -478,6 +521,14 @@ function jsonResponse(res, status, body) {
478
521
  * @param {number} [options.agentPort] Agent channel port (0 = ephemeral).
479
522
  * @param {{cert: string|Buffer, key: string|Buffer}} [options.tls]
480
523
  * @param {boolean} [options.allowHttpLoopback] Permit plain listeners on loopback.
524
+ * @param {object|null} [options.quotas] Optional `auth.quotas` (tenant /
525
+ * principal windows plus an abuse lock). When present, a tenant or
526
+ * principal without a row is refused; exhausted windows and locked
527
+ * principals are refused with `Retry-After`. Zero frames on any refusal.
528
+ * @param {?object} [options.usage] Optional usage ledger (usage.js). When
529
+ * present, every decision and receipt is recorded and `GET /usage` serves
530
+ * the caller's tenant partition. Omit to record nothing (404 on /usage).
531
+ * @param {() => number} [options.now] Clock for quotas and cost signals.
481
532
  * @param {?object} [options.rateLimiter] Optional rate limiter (rate-limit.js).
482
533
  * @param {number} [options.fanDownTimeoutMs]
483
534
  * @param {typeof fetch} [options.fetchFn] Issuer discovery/JWKS fetch.
@@ -490,6 +541,7 @@ export async function startEdge({
490
541
  tenantGrants = null,
491
542
  actors = null,
492
543
  policies = null,
544
+ promotions = null,
493
545
  sites,
494
546
  defaultSite,
495
547
  channelCredentials,
@@ -499,6 +551,9 @@ export async function startEdge({
499
551
  agentPort = 0,
500
552
  tls = null,
501
553
  allowHttpLoopback = false,
554
+ quotas = null,
555
+ usage = null,
556
+ now = () => Date.now(),
502
557
  rateLimiter = null,
503
558
  fanDownTimeoutMs = DEFAULT_FAN_DOWN_TIMEOUT_MS,
504
559
  fetchFn = fetch,
@@ -525,6 +580,43 @@ export async function startEdge({
525
580
  const policyTable = policies && typeof policies === "object" && !Array.isArray(policies)
526
581
  ? policies
527
582
  : null;
583
+ const promotionTable = promotions && typeof promotions === "object" && !Array.isArray(promotions)
584
+ ? promotions
585
+ : null;
586
+ const promoRequired = promotionsRequired(promotionTable);
587
+ const quotaGate = createQuotaGate({ quotas, now });
588
+ if (quotaGate.invalid) {
589
+ throw new EdgeStartupError(
590
+ `Relay edge refuses to start: auth.quotas is not readable at "${quotaGate.reason}". `
591
+ + "A quota table that cannot be read authorizes nobody; fix the row or remove the table.",
592
+ );
593
+ }
594
+ const usageLedger = usage === null || usage === undefined ? null : usage;
595
+ if (usageLedger !== null && (
596
+ typeof usageLedger !== "object"
597
+ || typeof usageLedger.record !== "function"
598
+ || typeof usageLedger.query !== "function"
599
+ || typeof usageLedger.stats !== "function"
600
+ )) {
601
+ throw new EdgeStartupError(
602
+ "Relay edge usage ledger must expose record(), query(), and stats() "
603
+ + "(see createUsageLedger in usage.js), or be omitted.",
604
+ );
605
+ }
606
+
607
+ /**
608
+ * Record without ever changing the verdict: a metering failure is logged
609
+ * (without the error detail) and the request proceeds as decided.
610
+ */
611
+ function meter(entry) {
612
+ if (!usageLedger) return null;
613
+ try {
614
+ return usageLedger.record(entry) ?? null;
615
+ } catch {
616
+ console.error("[drupal-mcp-edge] usage record failed; the decision stands and is unmetered.");
617
+ return null;
618
+ }
619
+ }
528
620
  if (typeof channelCredentials?.lookup !== "function") {
529
621
  throw new EdgeStartupError(
530
622
  "Relay edge requires an agent channel credential store; without one no "
@@ -550,7 +642,7 @@ export async function startEdge({
550
642
  const targetRelay = createLocalRelay({ sites: catalog, grants: grantTable, defaultSite });
551
643
  const broker = createRequestBroker({ timeoutMs: fanDownTimeoutMs });
552
644
 
553
- /** @type {Map<string, {socket: object, token: string, agentId: string, sites: string[]|null}>} */
645
+ /** @type {Map<string, {socket: object, token: string, agentId: string, sites: string[]|null, attestedDigests: Set<string>, offeredDigests: Set<string>}>} */
554
646
  const sessions = new Map();
555
647
  const catalogNames = catalog.map((site) => site._name);
556
648
 
@@ -580,21 +672,65 @@ export async function startEdge({
580
672
  const existing = sessions.get(record.agentId);
581
673
  if (existing && existing.socket !== socket) existing.socket.destroy();
582
674
  agentId = record.agentId;
675
+ const offeredDigests = new Set();
583
676
  sessions.set(agentId, {
584
677
  socket,
585
678
  token: frame.token,
586
679
  agentId,
587
680
  sites: decision.sites,
681
+ attestedDigests: new Set(),
682
+ offeredDigests,
588
683
  });
589
- writeFrame(socket, { type: "hello-ok", agent: { agentId } });
684
+ try {
685
+ for (const row of eligiblePromotions(promotionTable)) {
686
+ const wrote = writeFrame(socket, { type: "policy-bundle", document: row.document });
687
+ if (!wrote) throw new Error("policy-bundle write failed");
688
+ offeredDigests.add(row.digest);
689
+ }
690
+ writeFrame(socket, { type: "hello-ok", agent: { agentId } });
691
+ } catch {
692
+ sessions.delete(agentId);
693
+ agentId = null;
694
+ socket.destroy();
695
+ }
696
+ return;
697
+ }
698
+ if (!agentId) {
699
+ socket.destroy();
700
+ return;
701
+ }
702
+ if (frame.type === "policy-bundle-ack") {
703
+ const session = sessions.get(agentId);
704
+ const digest = typeof frame.digest === "string"
705
+ ? frame.digest.trim().toLowerCase()
706
+ : "";
707
+ if (
708
+ session && frame.ok === true && POLICY_DIGEST.test(digest)
709
+ && session.offeredDigests.has(digest)
710
+ ) {
711
+ session.attestedDigests.add(digest);
712
+ }
590
713
  return;
591
714
  }
592
715
  if (frame.type === "mcp-response") {
593
- if (!agentId) {
594
- socket.destroy();
595
- return;
716
+ const settled = broker.settle(frame, { owner: agentId });
717
+ if (!settled && usageLedger) {
718
+ // A response nobody is waiting for: late, repeated, fabricated, or
719
+ // injected from another tenant's tunnel. Recorded against the
720
+ // sending tunnel so reconciliation and abuse signals can see it.
721
+ meter({
722
+ phase: "receipt",
723
+ requestId: typeof frame.id === "string" && frame.id ? frame.id : null,
724
+ decisionId: null,
725
+ tenant: agentId,
726
+ principalKey: null,
727
+ outcome: "unknown",
728
+ reason: "unmatched_receipt",
729
+ status: Number.isInteger(frame.status) ? frame.status : null,
730
+ bytesOut: byteLength(frame.body),
731
+ durationMs: null,
732
+ });
596
733
  }
597
- broker.settle(frame, { owner: agentId });
598
734
  return;
599
735
  }
600
736
  // Any other frame type on the agent channel is a protocol violation.
@@ -618,11 +754,57 @@ export async function startEdge({
618
754
  return;
619
755
  }
620
756
 
757
+ // Attribution context (#256): who this is, which tenant the grant names,
758
+ // and what it cost. Stamped on every decision, allow or deny. Caller
759
+ // fields never reach it — the identity object and grant tables do.
760
+ const startedAt = now();
761
+ const isCall = body?.method === "tools/call";
762
+ const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
763
+ ? body.params.name.trim()
764
+ : null;
765
+ const principalKey = usagePrincipalKey(identity);
766
+ const principal = Object.freeze({
767
+ clientId: identity.clientId ?? null,
768
+ sub: identity.sub ?? null,
769
+ });
770
+ const bytesIn = byteLength(body);
771
+ let usageTenant = attributedTenant(identity, tenantGrantTable);
772
+ let policyDigest = null;
773
+
774
+ function refuse(status, error, {
775
+ scope = null, exposeScope = true, retryAfterSec = 0, extra = {},
776
+ } = {}) {
777
+ if (ABUSE_SIGNAL_ERRORS.has(error) && !SHARED_SCOPES.has(scope)) {
778
+ quotaGate.noteDenial(principalKey);
779
+ }
780
+ meter({
781
+ phase: "decision",
782
+ decision: "deny",
783
+ reason: error,
784
+ scope,
785
+ requestId: null,
786
+ tenant: usageTenant,
787
+ principal,
788
+ principalKey,
789
+ method: body?.method ?? null,
790
+ tool: toolName,
791
+ policyDigest,
792
+ units: 1,
793
+ bytesIn,
794
+ });
795
+ jsonResponse(
796
+ res,
797
+ status,
798
+ { error, ...(scope && exposeScope ? { scope } : {}), ...extra },
799
+ retryAfterSec > 0 ? { "Retry-After": String(retryAfterSec) } : {},
800
+ );
801
+ }
802
+
621
803
  // Entitlement at the seam, before anything about the tenant is revealed:
622
804
  // an unlisted client learns nothing, not even whether an agent exists.
623
805
  const granted = resolveGrantedSites(identity, catalog, grantTable);
624
806
  if (!granted.length) {
625
- jsonResponse(res, 403, { error: "not_entitled" });
807
+ refuse(403, "not_entitled");
626
808
  return;
627
809
  }
628
810
  const args = body?.params?.arguments ?? {};
@@ -630,30 +812,31 @@ export async function startEdge({
630
812
  const siteArgs = { ...args };
631
813
  delete siteArgs.tenant;
632
814
  let targetName = null;
633
- if (body?.method === "tools/call") {
815
+ if (isCall) {
634
816
  try {
635
817
  targetName = targetRelay.resolve(identity, siteArgs).name;
636
818
  } catch {
637
- jsonResponse(res, 403, { error: "not_entitled" });
819
+ refuse(403, "not_entitled");
638
820
  return;
639
821
  }
640
822
  }
641
823
 
642
824
  const mapped = resolveActor({ identity, actors: actorTable });
643
825
  const boundPolicy = resolvePolicy({ identity, policies: policyTable });
644
- const isCall = body?.method === "tools/call";
645
- const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
646
- ? body.params.name.trim()
647
- : null;
826
+ policyDigest = boundPolicy.policy ?? null;
648
827
  if (mapped.required && mapped.reason && isCall && (!toolName || isWriteLikeCall(toolName, args))) {
649
- jsonResponse(res, 403, { error: "not_entitled" });
828
+ refuse(403, "not_entitled");
650
829
  return;
651
830
  }
831
+ const policyCall = isCall && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName));
652
832
  if (
653
- boundPolicy.required && boundPolicy.reason && isCall
654
- && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName))
833
+ boundPolicy.required && boundPolicy.reason && policyCall
655
834
  ) {
656
- jsonResponse(res, 403, { error: "not_entitled" });
835
+ refuse(403, "not_entitled");
836
+ return;
837
+ }
838
+ if (promoRequired && policyCall && !boundPolicy.policy) {
839
+ refuse(403, "not_entitled");
657
840
  return;
658
841
  }
659
842
 
@@ -665,21 +848,53 @@ export async function startEdge({
665
848
  targetName,
666
849
  sessions: [...sessions.values()],
667
850
  });
668
- if (!selected.session) {
669
- const entitled = selected.reason === "not_entitled";
670
- jsonResponse(res, entitled ? 403 : 503, {
671
- error: entitled ? "not_entitled" : "no_agent",
851
+ if (selected.tenant) usageTenant = selected.tenant;
852
+ if (!selected.session && selected.reason === "not_entitled") {
853
+ refuse(403, "not_entitled");
854
+ return;
855
+ }
856
+ if (!selected.session && !selected.tenant) {
857
+ // Site-derived path with no agent: there is no tenant to meter against,
858
+ // so this is an outage answer, not a quota or abuse signal.
859
+ refuse(503, "no_agent");
860
+ return;
861
+ }
862
+ // Quota boundary (#256): the tenant is now grant-resolved. Every request
863
+ // reaching this line counts; a tenant or principal without a row, an
864
+ // exhausted window, or a locked principal is refused with zero frames.
865
+ const verdict = quotaGate.check({ tenant: selected.tenant, principalKey });
866
+ if (!verdict.allowed) {
867
+ const unassigned = verdict.reason === "not_entitled";
868
+ refuse(unassigned ? 403 : 429, verdict.reason, {
869
+ scope: verdict.scope,
870
+ exposeScope: !unassigned,
871
+ retryAfterSec: verdict.retryAfterSec,
672
872
  });
673
873
  return;
674
874
  }
875
+ if (promoRequired && policyCall) {
876
+ const promo = resolveEligiblePromotion({
877
+ digest: boundPolicy.policy,
878
+ promotions: promotionTable,
879
+ });
880
+ const attested = selected.session?.attestedDigests;
881
+ if (!promo.eligible || !attested || !attested.has(boundPolicy.policy)) {
882
+ refuse(403, "not_entitled");
883
+ return;
884
+ }
885
+ }
886
+ if (!selected.session) {
887
+ refuse(503, "no_agent");
888
+ return;
889
+ }
675
890
  const record = channelCredentials.lookup(selected.session.token);
676
891
  if (!record || record.revoked) {
677
- jsonResponse(res, 403, { error: "revoked", bound: EDGE_REVOCATION_BOUND.name });
892
+ refuse(403, "revoked", { extra: { bound: EDGE_REVOCATION_BOUND.name } });
678
893
  return;
679
894
  }
680
895
  if (siteBindingKey(record.sites) !== siteBindingKey(selected.session.sites)) {
681
896
  selected.session.socket.destroy();
682
- jsonResponse(res, 503, { error: "no_agent" });
897
+ refuse(503, "no_agent");
683
898
  return;
684
899
  }
685
900
 
@@ -711,14 +926,55 @@ export async function startEdge({
711
926
  });
712
927
  if (!wrote) {
713
928
  broker.settle({ id, status: 503 }, { owner: selected.session.agentId });
714
- jsonResponse(res, 503, { error: "no_agent" });
929
+ refuse(503, "no_agent");
715
930
  return;
716
931
  }
932
+ const decisionRecord = meter({
933
+ phase: "decision",
934
+ decision: "allow",
935
+ reason: null,
936
+ requestId: id,
937
+ tenant: selected.tenant,
938
+ principal,
939
+ principalKey,
940
+ method: body?.method ?? null,
941
+ tool: toolName,
942
+ target: selected.target ?? null,
943
+ actor: mapped.actor ?? null,
944
+ policyDigest,
945
+ units: 1,
946
+ bytesIn,
947
+ });
948
+
949
+ function receipt(fields) {
950
+ meter({
951
+ phase: "receipt",
952
+ requestId: id,
953
+ decisionId: decisionRecord?.decisionId ?? null,
954
+ tenant: selected.tenant,
955
+ principalKey,
956
+ durationMs: Math.max(0, now() - startedAt),
957
+ ...fields,
958
+ });
959
+ }
717
960
 
718
961
  let result;
719
962
  try {
720
963
  result = await waited;
721
964
  } catch {
965
+ // The frame crossed; no settled response came back. The tenant may
966
+ // have executed it — reconciliation names this chain uncertain.
967
+ receipt({ outcome: "unknown", reason: "fan_down_failed", status: null, bytesOut: 0 });
968
+ jsonResponse(res, 502, { error: "fan_down_failed" });
969
+ return;
970
+ }
971
+ // The agent frame is unvalidated input. A status the northbound
972
+ // listener cannot emit, or a header it refuses, is a failed receipt and
973
+ // a 502 — never an "ok" receipt for a response nobody received.
974
+ const status = result.status;
975
+ const bytesOut = byteLength(result.body);
976
+ if (!Number.isInteger(status) || status < 100 || status > 999) {
977
+ receipt({ outcome: "failed", reason: "invalid_status", status: null, bytesOut });
722
978
  jsonResponse(res, 502, { error: "fan_down_failed" });
723
979
  return;
724
980
  }
@@ -726,8 +982,86 @@ export async function startEdge({
726
982
  Object.entries(forwardHeaders(result.headers ?? {}))
727
983
  .filter(([name]) => String(name).toLowerCase() !== "mcp-session-id"),
728
984
  );
729
- res.writeHead(result.status || 200, headers);
985
+ try {
986
+ res.writeHead(status, headers);
987
+ } catch {
988
+ receipt({ outcome: "failed", reason: "relay_write_failed", status, bytesOut });
989
+ if (!res.headersSent) {
990
+ for (const name of res.getHeaderNames()) res.removeHeader(name);
991
+ jsonResponse(res, 502, { error: "fan_down_failed" });
992
+ } else {
993
+ res.destroy();
994
+ }
995
+ return;
996
+ }
730
997
  res.end(result.body ?? "");
998
+ receipt({ outcome: status >= 500 ? "failed" : "ok", reason: null, status, bytesOut });
999
+ }
1000
+
1001
+ /**
1002
+ * `GET /usage` — the caller's own tenant partition (#256). Authenticated
1003
+ * on the same resource server as `/mcp`; the tenant comes from
1004
+ * `auth.tenantGrants`, a `tenant` query value is a confirming hint, and
1005
+ * any other tenant is `not_entitled` with no records. 404 without a ledger.
1006
+ */
1007
+ async function serveUsage(req, res) {
1008
+ if (!usageLedger) {
1009
+ res.writeHead(404).end("Not found");
1010
+ return;
1011
+ }
1012
+ if (req.method !== "GET") {
1013
+ res.writeHead(405, { Allow: "GET" }).end("Method Not Allowed");
1014
+ return;
1015
+ }
1016
+ if (rateLimiter) {
1017
+ const verdict = rateLimiter.check(req.socket?.remoteAddress || "unknown");
1018
+ if (!verdict.allowed) {
1019
+ res.writeHead(429, { "Retry-After": String(verdict.retryAfterSec) }).end("Too Many Requests");
1020
+ return;
1021
+ }
1022
+ }
1023
+ let auth;
1024
+ try {
1025
+ auth = await inbound.authenticate(req);
1026
+ } catch {
1027
+ res.writeHead(401, {
1028
+ "WWW-Authenticate": formatWwwAuthenticate({
1029
+ error: "invalid_token",
1030
+ errorDescription: "Token validation failed",
1031
+ }),
1032
+ }).end("Unauthorized");
1033
+ return;
1034
+ }
1035
+ if (!auth.ok) {
1036
+ res.writeHead(auth.status, auth.headers).end(auth.body);
1037
+ return;
1038
+ }
1039
+ try {
1040
+ const query = new URL(String(req.url || "/usage"), "http://edge.invalid").searchParams;
1041
+ const read = readUsage({
1042
+ identity: auth.identity,
1043
+ tenantGrants: tenantGrantTable,
1044
+ tenant: query.get("tenant"),
1045
+ principalKey: query.get("principal"),
1046
+ ledger: usageLedger,
1047
+ });
1048
+ if (!read.ok) {
1049
+ jsonResponse(res, 403, { error: "not_entitled" });
1050
+ return;
1051
+ }
1052
+ jsonResponse(res, 200, {
1053
+ tenant: read.tenant,
1054
+ records: read.records,
1055
+ reconciliation: reconcileUsage(read.records, { dropped: usageLedger.stats().dropped }),
1056
+ });
1057
+ } catch {
1058
+ console.error("[drupal-mcp-edge] usage read failed.");
1059
+ if (res.headersSent) {
1060
+ res.destroy();
1061
+ return;
1062
+ }
1063
+ res.writeHead(500).end("Internal Server Error");
1064
+ }
731
1065
  }
732
1066
 
733
1067
  const requestHandler = createMcpRequestHandler({
@@ -746,9 +1080,16 @@ export async function startEdge({
746
1080
  rateLimiter,
747
1081
  });
748
1082
 
1083
+ function northbound(req, res) {
1084
+ if (String(req.url || "").split("?")[0] === "/usage") {
1085
+ void serveUsage(req, res);
1086
+ return;
1087
+ }
1088
+ void requestHandler(req, res);
1089
+ }
749
1090
  const northServer = hasTls
750
- ? createHttpsServer(tls, (req, res) => { void requestHandler(req, res); })
751
- : createHttpServer((req, res) => { void requestHandler(req, res); });
1091
+ ? createHttpsServer(tls, northbound)
1092
+ : createHttpServer(northbound);
752
1093
 
753
1094
  const channelAddr = await listen(channelServer, agentBindHost, agentPort, "edge-agent-channel");
754
1095
  const northAddr = await listen(northServer, bindHost, port, "edge-northbound");
@@ -21,6 +21,8 @@ export const FRAME_TYPES = Object.freeze([
21
21
  "denied",
22
22
  "mcp-request",
23
23
  "mcp-response",
24
+ "policy-bundle",
25
+ "policy-bundle-ack",
24
26
  ]);
25
27
 
26
28
  const FRAME_TYPE_SET = new Set(FRAME_TYPES);