drupal-mcp-connector 2.12.0 → 2.13.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/.agents/commands/drupal-entity-update.md +3 -3
- package/.agents/commands/drupal-update-node.md +3 -3
- package/CHANGELOG.md +52 -12
- package/bin/drupal-mcp-edge.js +55 -2
- package/config/config.example.json +3 -0
- package/package.json +1 -1
- package/src/lib/backends/backend-interface.js +1 -1
- package/src/lib/backends/jsonapi.js +2 -2
- package/src/lib/config.js +24 -0
- package/src/lib/draft-write.js +57 -0
- package/src/lib/patch-preflight.js +18 -7
- package/src/lib/policy-enforcement.js +1 -1
- package/src/lib/policy-promotion.js +1 -1
- package/src/lib/relay/agent.js +1 -1
- package/src/lib/relay/edge.js +315 -32
- package/src/lib/relay/frames.js +1 -1
- package/src/lib/usage.js +610 -0
- package/src/tools/bulk.js +2 -1
- package/src/tools/entities.js +4 -3
- package/src/tools/nodes.js +10 -10
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232, #242, #244, #247, #250, #253) —
|
|
3
|
-
*
|
|
4
|
-
* policy digest and W&L-operated bundle
|
|
2
|
+
* Relay northbound edge (#232, #242, #244, #247, #250, #253, #256) — the
|
|
3
|
+
* hosted-edge slice (#232), tenant isolation (#242), tenant routing (#244),
|
|
4
|
+
* actor mapping (#247), policy digest (#250) and W&L-operated bundle
|
|
5
|
+
* promotion (#253), attributable usage, quotas, and abuse signals (#256).
|
|
5
6
|
*
|
|
6
7
|
* Terminates northbound MCP over the OAuth resource server and fans requests
|
|
7
8
|
* down outbound tenant-agent channels. The edge proposes; the tenant-side
|
|
@@ -22,11 +23,16 @@
|
|
|
22
23
|
* `agentId`; channel records may bind `sites`. A second unscoped agent
|
|
23
24
|
* or overlapping site claim is denied at hello. Fan-down selects the
|
|
24
25
|
* unique bound agent from server-owned grants. Single unscoped agent
|
|
25
|
-
* remains the
|
|
26
|
+
* remains the #232 single-agent compatibility path.
|
|
26
27
|
* - Stateless MCP 2026-07-28 northbound: sessionful traffic is refused and
|
|
27
28
|
* no `Mcp-Session-Id` crosses in either direction.
|
|
28
29
|
* - Revocation is per-request with no grace window, for both credential
|
|
29
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.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
@@ -36,7 +42,11 @@ import { createServer as createHttpsServer } from "node:https";
|
|
|
36
42
|
import { createServer as createNetServer } from "node:net";
|
|
37
43
|
import { createServer as createTlsServer } from "node:tls";
|
|
38
44
|
import { createLocalRelay } from "../contracts/relay.js";
|
|
39
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
createInboundHttpsAuth,
|
|
47
|
+
formatWwwAuthenticate,
|
|
48
|
+
SPOOFABLE_IDENTITY_HEADERS,
|
|
49
|
+
} from "../http-auth.js";
|
|
40
50
|
import { createLegacySessionHandler, createMcpRequestHandler } from "../http-handler.js";
|
|
41
51
|
import { isWriteLikeCall } from "../operations.js";
|
|
42
52
|
import {
|
|
@@ -46,6 +56,13 @@ import {
|
|
|
46
56
|
resolveEligiblePromotion,
|
|
47
57
|
} from "../policy-promotion.js";
|
|
48
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";
|
|
49
66
|
import {
|
|
50
67
|
attachFramer,
|
|
51
68
|
createRequestBroker,
|
|
@@ -57,7 +74,7 @@ import {
|
|
|
57
74
|
export const EDGE_MCP_PROTOCOL = "2026-07-28";
|
|
58
75
|
|
|
59
76
|
/**
|
|
60
|
-
* Revocation bound restated from the
|
|
77
|
+
* Revocation bound restated from the outbound-relay lab for both credential kinds.
|
|
61
78
|
* The next request after revoke is denied; an in-flight request may finish.
|
|
62
79
|
*/
|
|
63
80
|
export const EDGE_REVOCATION_BOUND = Object.freeze({
|
|
@@ -93,9 +110,19 @@ const SITE_CREDENTIAL_KEYS = Object.freeze([
|
|
|
93
110
|
|
|
94
111
|
const DEFAULT_FAN_DOWN_TIMEOUT_MS = 10_000;
|
|
95
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
|
+
|
|
96
123
|
/**
|
|
97
124
|
* Normalize a channel-record `sites` list. Empty / missing means unscoped
|
|
98
|
-
* (legal only as the sole connected agent — the
|
|
125
|
+
* (legal only as the sole connected agent — the #232 compatibility path).
|
|
99
126
|
*
|
|
100
127
|
* @param {unknown} sites
|
|
101
128
|
* @returns {string[]}
|
|
@@ -201,7 +228,7 @@ export function selectTenantSession({
|
|
|
201
228
|
* Resolve tenant and target from server-owned grants. Caller `tenant` is a
|
|
202
229
|
* confirming hint inside the grant, never authority. When `tenantGrants` is
|
|
203
230
|
* omitted, tenant is derived from the unique agent covering the site grant
|
|
204
|
-
* (
|
|
231
|
+
* (#242 / #232 compatibility).
|
|
205
232
|
*
|
|
206
233
|
* @param {object} params
|
|
207
234
|
* @param {object|null} params.identity
|
|
@@ -458,11 +485,16 @@ function assertBindAllowed(role, host, hasTls) {
|
|
|
458
485
|
}
|
|
459
486
|
}
|
|
460
487
|
|
|
461
|
-
function jsonResponse(res, status, body) {
|
|
462
|
-
res.writeHead(status, { "content-type": "application/json" })
|
|
488
|
+
function jsonResponse(res, status, body, headers = {}) {
|
|
489
|
+
res.writeHead(status, { ...headers, "content-type": "application/json" })
|
|
463
490
|
.end(JSON.stringify(body));
|
|
464
491
|
}
|
|
465
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
|
+
|
|
466
498
|
/**
|
|
467
499
|
* Start the relay edge: an authenticated northbound MCP listener and the
|
|
468
500
|
* agent channel listener the tenant dials into.
|
|
@@ -489,6 +521,14 @@ function jsonResponse(res, status, body) {
|
|
|
489
521
|
* @param {number} [options.agentPort] Agent channel port (0 = ephemeral).
|
|
490
522
|
* @param {{cert: string|Buffer, key: string|Buffer}} [options.tls]
|
|
491
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.
|
|
492
532
|
* @param {?object} [options.rateLimiter] Optional rate limiter (rate-limit.js).
|
|
493
533
|
* @param {number} [options.fanDownTimeoutMs]
|
|
494
534
|
* @param {typeof fetch} [options.fetchFn] Issuer discovery/JWKS fetch.
|
|
@@ -511,6 +551,9 @@ export async function startEdge({
|
|
|
511
551
|
agentPort = 0,
|
|
512
552
|
tls = null,
|
|
513
553
|
allowHttpLoopback = false,
|
|
554
|
+
quotas = null,
|
|
555
|
+
usage = null,
|
|
556
|
+
now = () => Date.now(),
|
|
514
557
|
rateLimiter = null,
|
|
515
558
|
fanDownTimeoutMs = DEFAULT_FAN_DOWN_TIMEOUT_MS,
|
|
516
559
|
fetchFn = fetch,
|
|
@@ -541,6 +584,39 @@ export async function startEdge({
|
|
|
541
584
|
? promotions
|
|
542
585
|
: null;
|
|
543
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
|
+
}
|
|
544
620
|
if (typeof channelCredentials?.lookup !== "function") {
|
|
545
621
|
throw new EdgeStartupError(
|
|
546
622
|
"Relay edge requires an agent channel credential store; without one no "
|
|
@@ -637,7 +713,24 @@ export async function startEdge({
|
|
|
637
713
|
return;
|
|
638
714
|
}
|
|
639
715
|
if (frame.type === "mcp-response") {
|
|
640
|
-
broker.settle(frame, { owner: agentId });
|
|
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
|
+
});
|
|
733
|
+
}
|
|
641
734
|
return;
|
|
642
735
|
}
|
|
643
736
|
// Any other frame type on the agent channel is a protocol violation.
|
|
@@ -661,11 +754,57 @@ export async function startEdge({
|
|
|
661
754
|
return;
|
|
662
755
|
}
|
|
663
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
|
+
|
|
664
803
|
// Entitlement at the seam, before anything about the tenant is revealed:
|
|
665
804
|
// an unlisted client learns nothing, not even whether an agent exists.
|
|
666
805
|
const granted = resolveGrantedSites(identity, catalog, grantTable);
|
|
667
806
|
if (!granted.length) {
|
|
668
|
-
|
|
807
|
+
refuse(403, "not_entitled");
|
|
669
808
|
return;
|
|
670
809
|
}
|
|
671
810
|
const args = body?.params?.arguments ?? {};
|
|
@@ -673,34 +812,31 @@ export async function startEdge({
|
|
|
673
812
|
const siteArgs = { ...args };
|
|
674
813
|
delete siteArgs.tenant;
|
|
675
814
|
let targetName = null;
|
|
676
|
-
if (
|
|
815
|
+
if (isCall) {
|
|
677
816
|
try {
|
|
678
817
|
targetName = targetRelay.resolve(identity, siteArgs).name;
|
|
679
818
|
} catch {
|
|
680
|
-
|
|
819
|
+
refuse(403, "not_entitled");
|
|
681
820
|
return;
|
|
682
821
|
}
|
|
683
822
|
}
|
|
684
823
|
|
|
685
824
|
const mapped = resolveActor({ identity, actors: actorTable });
|
|
686
825
|
const boundPolicy = resolvePolicy({ identity, policies: policyTable });
|
|
687
|
-
|
|
688
|
-
const toolName = isCall && typeof body?.params?.name === "string" && body.params.name.trim()
|
|
689
|
-
? body.params.name.trim()
|
|
690
|
-
: null;
|
|
826
|
+
policyDigest = boundPolicy.policy ?? null;
|
|
691
827
|
if (mapped.required && mapped.reason && isCall && (!toolName || isWriteLikeCall(toolName, args))) {
|
|
692
|
-
|
|
828
|
+
refuse(403, "not_entitled");
|
|
693
829
|
return;
|
|
694
830
|
}
|
|
695
831
|
const policyCall = isCall && (!toolName || !DIAGNOSTIC_TOOLS.has(toolName));
|
|
696
832
|
if (
|
|
697
833
|
boundPolicy.required && boundPolicy.reason && policyCall
|
|
698
834
|
) {
|
|
699
|
-
|
|
835
|
+
refuse(403, "not_entitled");
|
|
700
836
|
return;
|
|
701
837
|
}
|
|
702
838
|
if (promoRequired && policyCall && !boundPolicy.policy) {
|
|
703
|
-
|
|
839
|
+
refuse(403, "not_entitled");
|
|
704
840
|
return;
|
|
705
841
|
}
|
|
706
842
|
|
|
@@ -712,6 +848,30 @@ export async function startEdge({
|
|
|
712
848
|
targetName,
|
|
713
849
|
sessions: [...sessions.values()],
|
|
714
850
|
});
|
|
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,
|
|
872
|
+
});
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
715
875
|
if (promoRequired && policyCall) {
|
|
716
876
|
const promo = resolveEligiblePromotion({
|
|
717
877
|
digest: boundPolicy.policy,
|
|
@@ -719,25 +879,22 @@ export async function startEdge({
|
|
|
719
879
|
});
|
|
720
880
|
const attested = selected.session?.attestedDigests;
|
|
721
881
|
if (!promo.eligible || !attested || !attested.has(boundPolicy.policy)) {
|
|
722
|
-
|
|
882
|
+
refuse(403, "not_entitled");
|
|
723
883
|
return;
|
|
724
884
|
}
|
|
725
885
|
}
|
|
726
886
|
if (!selected.session) {
|
|
727
|
-
|
|
728
|
-
jsonResponse(res, entitled ? 403 : 503, {
|
|
729
|
-
error: entitled ? "not_entitled" : "no_agent",
|
|
730
|
-
});
|
|
887
|
+
refuse(503, "no_agent");
|
|
731
888
|
return;
|
|
732
889
|
}
|
|
733
890
|
const record = channelCredentials.lookup(selected.session.token);
|
|
734
891
|
if (!record || record.revoked) {
|
|
735
|
-
|
|
892
|
+
refuse(403, "revoked", { extra: { bound: EDGE_REVOCATION_BOUND.name } });
|
|
736
893
|
return;
|
|
737
894
|
}
|
|
738
895
|
if (siteBindingKey(record.sites) !== siteBindingKey(selected.session.sites)) {
|
|
739
896
|
selected.session.socket.destroy();
|
|
740
|
-
|
|
897
|
+
refuse(503, "no_agent");
|
|
741
898
|
return;
|
|
742
899
|
}
|
|
743
900
|
|
|
@@ -769,14 +926,55 @@ export async function startEdge({
|
|
|
769
926
|
});
|
|
770
927
|
if (!wrote) {
|
|
771
928
|
broker.settle({ id, status: 503 }, { owner: selected.session.agentId });
|
|
772
|
-
|
|
929
|
+
refuse(503, "no_agent");
|
|
773
930
|
return;
|
|
774
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
|
+
}
|
|
775
960
|
|
|
776
961
|
let result;
|
|
777
962
|
try {
|
|
778
963
|
result = await waited;
|
|
779
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 });
|
|
780
978
|
jsonResponse(res, 502, { error: "fan_down_failed" });
|
|
781
979
|
return;
|
|
782
980
|
}
|
|
@@ -784,8 +982,86 @@ export async function startEdge({
|
|
|
784
982
|
Object.entries(forwardHeaders(result.headers ?? {}))
|
|
785
983
|
.filter(([name]) => String(name).toLowerCase() !== "mcp-session-id"),
|
|
786
984
|
);
|
|
787
|
-
|
|
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
|
+
}
|
|
788
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
|
+
}
|
|
789
1065
|
}
|
|
790
1066
|
|
|
791
1067
|
const requestHandler = createMcpRequestHandler({
|
|
@@ -804,9 +1080,16 @@ export async function startEdge({
|
|
|
804
1080
|
rateLimiter,
|
|
805
1081
|
});
|
|
806
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
|
+
}
|
|
807
1090
|
const northServer = hasTls
|
|
808
|
-
? createHttpsServer(tls,
|
|
809
|
-
: createHttpServer(
|
|
1091
|
+
? createHttpsServer(tls, northbound)
|
|
1092
|
+
: createHttpServer(northbound);
|
|
810
1093
|
|
|
811
1094
|
const channelAddr = await listen(channelServer, agentBindHost, agentPort, "edge-agent-channel");
|
|
812
1095
|
const northAddr = await listen(northServer, bindHost, port, "edge-northbound");
|
package/src/lib/relay/frames.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Relay frame codec (#232).
|
|
3
3
|
*
|
|
4
4
|
* Length-prefixed JSON frames for the edge/agent channel, promoted from the
|
|
5
|
-
*
|
|
5
|
+
* outbound-relay lab harness (`lab/outbound-relay/harness.js`). Mechanism only — no
|
|
6
6
|
* authentication, entitlement, or header policy lives here.
|
|
7
7
|
*
|
|
8
8
|
* Wire format: 4-byte big-endian payload length, then UTF-8 JSON. A frame
|