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.
- package/CHANGELOG.md +40 -0
- package/bin/drupal-mcp-edge.js +58 -1
- package/config/config.example.json +6 -0
- package/package.json +1 -1
- package/src/lib/config.js +40 -0
- package/src/lib/policy-enforcement.js +268 -0
- package/src/lib/policy-promotion.js +130 -0
- package/src/lib/relay/agent.js +44 -0
- package/src/lib/relay/edge.js +373 -32
- package/src/lib/relay/frames.js +2 -0
- package/src/lib/usage.js +611 -0
package/src/lib/relay/agent.js
CHANGED
|
@@ -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);
|
package/src/lib/relay/edge.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Relay northbound edge (#232, #242, #244, #247, #250) — DEV-294
|
|
3
|
-
* isolation, DEV-124 tenant routing, DEV-123 actor mapping,
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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 (
|
|
815
|
+
if (isCall) {
|
|
634
816
|
try {
|
|
635
817
|
targetName = targetRelay.resolve(identity, siteArgs).name;
|
|
636
818
|
} catch {
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
654
|
-
&& (!toolName || !DIAGNOSTIC_TOOLS.has(toolName))
|
|
833
|
+
boundPolicy.required && boundPolicy.reason && policyCall
|
|
655
834
|
) {
|
|
656
|
-
|
|
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 (
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
751
|
-
: createHttpServer(
|
|
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");
|