@gethelio/proxy 0.11.0 → 0.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.
- package/README.md +75 -45
- package/dist/cli.js +2351 -959
- package/dist/dashboard-assets/assets/index-BBYXsIig.css +1 -0
- package/dist/dashboard-assets/assets/index-uJng9NyO.js +128 -0
- package/dist/dashboard-assets/index.html +2 -2
- package/dist/index.d.ts +257 -20
- package/dist/index.js +2205 -874
- package/package.json +7 -7
- package/dist/dashboard-assets/assets/index-CPoQ6xns.css +0 -1
- package/dist/dashboard-assets/assets/index-D19fYKEH.js +0 -128
package/dist/cli.js
CHANGED
|
@@ -39,10 +39,25 @@ function parseDuration(duration) {
|
|
|
39
39
|
}
|
|
40
40
|
return value * multiplier;
|
|
41
41
|
}
|
|
42
|
+
var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
|
|
43
|
+
"mcp-session-id",
|
|
44
|
+
"mcp-protocol-version",
|
|
45
|
+
"content-type",
|
|
46
|
+
"content-length",
|
|
47
|
+
"host",
|
|
48
|
+
// Modern (2026-07-28) transport headers Helio owns on the wire for every
|
|
49
|
+
// Streamable HTTP POST it sends upstream — relayed client traffic and
|
|
50
|
+
// proxy-initiated requests (era probe, revalidation) alike — see
|
|
51
|
+
// upstream-session-manager.ts and streamable-http-forwarder.ts.
|
|
52
|
+
"mcp-method",
|
|
53
|
+
"mcp-name"
|
|
54
|
+
]);
|
|
42
55
|
var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
|
|
56
|
+
var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
|
|
43
57
|
var upstreamSchema = z.object({
|
|
44
58
|
url: z.string(),
|
|
45
59
|
transport: transportSchema.default("streamable-http"),
|
|
60
|
+
protocol_version: protocolVersionSchema.default("auto"),
|
|
46
61
|
command: z.string().optional(),
|
|
47
62
|
args: z.array(z.string()).optional(),
|
|
48
63
|
connect_timeout: durationSchema.default("10s"),
|
|
@@ -53,6 +68,13 @@ var upstreamSchema = z.object({
|
|
|
53
68
|
message: '"command" is required when transport is "stdio"',
|
|
54
69
|
path: ["command"]
|
|
55
70
|
}).superRefine((data, ctx) => {
|
|
71
|
+
if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: "custom",
|
|
74
|
+
path: ["protocol_version"],
|
|
75
|
+
message: data.transport === "stdio" ? 'protocol_version "2026-07-28" requires transport "streamable-http" \u2014 stdio modern-era support is tracked in #256.' : 'protocol_version "2026-07-28" requires transport "streamable-http" \u2014 the SSE upstream transport is the deprecated legacy transport.'
|
|
76
|
+
});
|
|
77
|
+
}
|
|
56
78
|
for (const [index, header] of data.forward_headers.entries()) {
|
|
57
79
|
if (!header.toLowerCase().startsWith("x-")) {
|
|
58
80
|
ctx.addIssue({
|
|
@@ -62,15 +84,8 @@ var upstreamSchema = z.object({
|
|
|
62
84
|
});
|
|
63
85
|
}
|
|
64
86
|
}
|
|
65
|
-
const reserved = /* @__PURE__ */ new Set([
|
|
66
|
-
"mcp-session-id",
|
|
67
|
-
"mcp-protocol-version",
|
|
68
|
-
"content-type",
|
|
69
|
-
"content-length",
|
|
70
|
-
"host"
|
|
71
|
-
]);
|
|
72
87
|
for (const name of Object.keys(data.headers)) {
|
|
73
|
-
if (
|
|
88
|
+
if (RESERVED_TRANSPORT_HEADERS.has(name.toLowerCase())) {
|
|
74
89
|
ctx.addIssue({
|
|
75
90
|
code: "custom",
|
|
76
91
|
path: ["headers", name],
|
|
@@ -81,8 +96,63 @@ var upstreamSchema = z.object({
|
|
|
81
96
|
});
|
|
82
97
|
var listenSchema = z.object({
|
|
83
98
|
port: z.number().int().min(1).max(65535).default(3e3),
|
|
84
|
-
host: z.string().default("127.0.0.1")
|
|
85
|
-
|
|
99
|
+
host: z.string().default("127.0.0.1"),
|
|
100
|
+
/**
|
|
101
|
+
* Origin allowlist for the MCP transports (issue #213). Requests to /mcp
|
|
102
|
+
* or /sse carrying an Origin header not in this list are refused with 403.
|
|
103
|
+
* Empty (the default) means every Origin is refused — MCP clients are
|
|
104
|
+
* non-browser processes and never send one. This is NOT CORS support: the
|
|
105
|
+
* proxy emits no CORS response headers, so a browser still cannot read
|
|
106
|
+
* responses. The list exists for deployments where a fronting proxy or
|
|
107
|
+
* embedding host injects an Origin the operator needs to name.
|
|
108
|
+
*/
|
|
109
|
+
allowed_origins: z.array(z.string().min(1)).default([])
|
|
110
|
+
}).strict().superRefine((data, ctx) => {
|
|
111
|
+
for (const [index, entry] of data.allowed_origins.entries()) {
|
|
112
|
+
if (entry === "*") {
|
|
113
|
+
ctx.addIssue({
|
|
114
|
+
code: "custom",
|
|
115
|
+
path: ["allowed_origins", index],
|
|
116
|
+
message: "listen.allowed_origins does not support wildcards \u2014 list each origin exactly."
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (entry === "null") {
|
|
121
|
+
ctx.addIssue({
|
|
122
|
+
code: "custom",
|
|
123
|
+
path: ["allowed_origins", index],
|
|
124
|
+
message: 'The literal "null" cannot be allowlisted: it is the opaque origin sent by sandboxed frames, data: documents, and file:// pages, so allowing it would admit all of them.'
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = new URL(entry);
|
|
131
|
+
} catch {
|
|
132
|
+
ctx.addIssue({
|
|
133
|
+
code: "custom",
|
|
134
|
+
path: ["allowed_origins", index],
|
|
135
|
+
message: `"${entry}" is not a serialized origin. Use scheme://host[:port], e.g. "http://localhost:5173".`
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
140
|
+
ctx.addIssue({
|
|
141
|
+
code: "custom",
|
|
142
|
+
path: ["allowed_origins", index],
|
|
143
|
+
message: `"${entry}" is not an http(s) origin. Allowlist entries must be serialized http(s) origins, e.g. "http://localhost:5173".`
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (parsed.origin !== entry) {
|
|
148
|
+
ctx.addIssue({
|
|
149
|
+
code: "custom",
|
|
150
|
+
path: ["allowed_origins", index],
|
|
151
|
+
message: `"${entry}" is not in serialized origin form and would never match a browser-sent Origin \u2014 did you mean "${parsed.origin}"?`
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
86
156
|
function isLoopbackHost(host) {
|
|
87
157
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
88
158
|
}
|
|
@@ -97,6 +167,56 @@ var dashboardSchema = z.object({
|
|
|
97
167
|
allow_open_mode: z.boolean().default(false),
|
|
98
168
|
sse_heartbeat_interval: durationSchema.default("30s")
|
|
99
169
|
}).strict();
|
|
170
|
+
var sessionHeaderSourceSchema = z.object({
|
|
171
|
+
source: z.literal("header"),
|
|
172
|
+
/** Lowercased on parse — HTTP header names are case-insensitive. */
|
|
173
|
+
name: z.string().min(1).default("x-helio-session-id").transform((name) => name.toLowerCase())
|
|
174
|
+
}).strict();
|
|
175
|
+
var sessionMetaSourceSchema = z.object({
|
|
176
|
+
source: z.literal("meta")
|
|
177
|
+
}).strict();
|
|
178
|
+
var sessionLegacyHeaderSourceSchema = z.object({
|
|
179
|
+
source: z.literal("legacy_header")
|
|
180
|
+
}).strict();
|
|
181
|
+
var sessionIdentitySourceSchema = z.discriminatedUnion("source", [
|
|
182
|
+
sessionHeaderSourceSchema,
|
|
183
|
+
sessionMetaSourceSchema,
|
|
184
|
+
sessionLegacyHeaderSourceSchema
|
|
185
|
+
]);
|
|
186
|
+
var sessionSchema = z.object({
|
|
187
|
+
/** Ordered identity sources; the first source that yields a value wins. */
|
|
188
|
+
identity: z.array(sessionIdentitySourceSchema).min(1, {
|
|
189
|
+
message: "session.identity cannot be empty \u2014 an empty chain would leave every request unresolved. Omit the section to use the defaults, or list at least one source."
|
|
190
|
+
}).default([{ source: "header", name: "x-helio-session-id" }, { source: "legacy_header" }]),
|
|
191
|
+
on_unresolved: z.enum(["deny", "anonymous"]).default("deny")
|
|
192
|
+
}).strict().superRefine((session, ctx) => {
|
|
193
|
+
const seen = /* @__PURE__ */ new Set();
|
|
194
|
+
for (const [index, entry] of session.identity.entries()) {
|
|
195
|
+
if (entry.source !== "header") continue;
|
|
196
|
+
if (!entry.name.startsWith("x-")) {
|
|
197
|
+
ctx.addIssue({
|
|
198
|
+
code: "custom",
|
|
199
|
+
path: ["identity", index, "name"],
|
|
200
|
+
message: 'Session identity header names must start with "x-" (for example "x-helio-session-id")'
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (RESERVED_TRANSPORT_HEADERS.has(entry.name)) {
|
|
204
|
+
ctx.addIssue({
|
|
205
|
+
code: "custom",
|
|
206
|
+
path: ["identity", index, "name"],
|
|
207
|
+
message: `session.identity must not read reserved transport header "${entry.name}" \u2014 the proxy owns it on the wire. Use source: legacy_header for Mcp-Session-Id, or a custom x- header.`
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (seen.has(entry.name)) {
|
|
211
|
+
ctx.addIssue({
|
|
212
|
+
code: "custom",
|
|
213
|
+
path: ["identity", index, "name"],
|
|
214
|
+
message: `Duplicate session identity header "${entry.name}" \u2014 the first entry always wins, so the duplicate is dead config. Remove it.`
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
seen.add(entry.name);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
100
220
|
var inputConditionSchema = z.object({
|
|
101
221
|
eq: z.unknown().optional(),
|
|
102
222
|
neq: z.unknown().optional(),
|
|
@@ -195,6 +315,21 @@ var installSchema = z.object({
|
|
|
195
315
|
default: z.enum(["allow", "deny"]).default("allow"),
|
|
196
316
|
rules: z.array(installRuleSchema).default([])
|
|
197
317
|
}).strict();
|
|
318
|
+
var toolRevalidationSchema = z.object({
|
|
319
|
+
enabled: z.boolean().default(true),
|
|
320
|
+
interval: durationSchema.default("5m"),
|
|
321
|
+
// Default: `interval`, applied at compile time (undefined here means
|
|
322
|
+
// "same as interval" — see PoliciesConfig compilation).
|
|
323
|
+
max_advertised_ttl: durationSchema.optional()
|
|
324
|
+
}).strict().superRefine((data, ctx) => {
|
|
325
|
+
if (parseDuration(data.interval) < 1e4) {
|
|
326
|
+
ctx.addIssue({
|
|
327
|
+
code: "custom",
|
|
328
|
+
path: ["interval"],
|
|
329
|
+
message: "tool_revalidation.interval must be at least 10s"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
});
|
|
198
333
|
var policiesSchema = z.object({
|
|
199
334
|
default: z.enum(["allow", "deny"]).default("allow"),
|
|
200
335
|
flag_destructive: z.enum(["log", "require_approval"]).optional(),
|
|
@@ -215,6 +350,13 @@ var policiesSchema = z.object({
|
|
|
215
350
|
* don't need the field; undefined is treated as "block".
|
|
216
351
|
*/
|
|
217
352
|
on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
|
|
353
|
+
/**
|
|
354
|
+
* Proxy-scheduled `tools/list` revalidation and `ttlMs` clamping (issue
|
|
355
|
+
* #221). Optional; absent ⇒ compiled defaults (enabled: true, interval:
|
|
356
|
+
* "5m") in `CompiledPolicy`, except in literal `CompiledPolicy` fixtures,
|
|
357
|
+
* which treat an absent field as disabled — see `compilePolicies`.
|
|
358
|
+
*/
|
|
359
|
+
tool_revalidation: toolRevalidationSchema.optional(),
|
|
218
360
|
/**
|
|
219
361
|
* Whether `helio start` should watch the config file for changes and
|
|
220
362
|
* reconcile policy state on every save. Defaults to `true` when omitted.
|
|
@@ -349,6 +491,10 @@ var helioConfigBaseSchema = z.object({
|
|
|
349
491
|
upstream: upstreamSchema,
|
|
350
492
|
listen: listenSchema.prefault({}),
|
|
351
493
|
environment: z.string().optional(),
|
|
494
|
+
// Session precedes policies deliberately: upstream/listen/environment say
|
|
495
|
+
// where and as-what Helio runs, session says who is calling, and
|
|
496
|
+
// policies/budgets then govern those calls (issue #218).
|
|
497
|
+
session: sessionSchema.prefault({}),
|
|
352
498
|
policies: policiesSchema.prefault({}),
|
|
353
499
|
// Budgets sit beside policies deliberately: they are the second half of the
|
|
354
500
|
// governance declaration (policy decision → budget gate), not plumbing.
|
|
@@ -681,6 +827,9 @@ function diffReloadBoundary(previous, next) {
|
|
|
681
827
|
if (!isDeepStrictEqual(previous.environment, next.environment)) {
|
|
682
828
|
restartRequiredPaths.push("environment");
|
|
683
829
|
}
|
|
830
|
+
if (!isDeepStrictEqual(previous.session, next.session)) {
|
|
831
|
+
restartRequiredPaths.push("session");
|
|
832
|
+
}
|
|
684
833
|
if (!isDeepStrictEqual(previous.approval, next.approval)) {
|
|
685
834
|
restartRequiredPaths.push("approval");
|
|
686
835
|
}
|
|
@@ -790,11 +939,18 @@ var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
|
|
|
790
939
|
function compilePolicies(config) {
|
|
791
940
|
const warnings = [];
|
|
792
941
|
const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
|
|
942
|
+
const rv = config.tool_revalidation;
|
|
943
|
+
const toolRevalidation = {
|
|
944
|
+
enabled: rv?.enabled ?? true,
|
|
945
|
+
intervalMs: parseDuration(rv?.interval ?? "5m"),
|
|
946
|
+
maxAdvertisedTtlMs: parseDuration(rv?.max_advertised_ttl ?? rv?.interval ?? "5m")
|
|
947
|
+
};
|
|
793
948
|
const policy = {
|
|
794
949
|
defaultAction: config.default,
|
|
795
950
|
flagDestructive: config.flag_destructive,
|
|
796
951
|
...config.dry_run && { dryRun: true },
|
|
797
952
|
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
953
|
+
toolRevalidation,
|
|
798
954
|
rules,
|
|
799
955
|
...config.install && { install: compileInstallPolicy(config.install) }
|
|
800
956
|
};
|
|
@@ -1151,10 +1307,17 @@ var PARSE_ERROR = -32700;
|
|
|
1151
1307
|
var INVALID_REQUEST = -32600;
|
|
1152
1308
|
var INVALID_PARAMS = -32602;
|
|
1153
1309
|
var INTERNAL_ERROR = -32603;
|
|
1310
|
+
var HEADER_MISMATCH = -32020;
|
|
1154
1311
|
function makeJsonRpcError(id, code, message) {
|
|
1155
1312
|
return {
|
|
1156
1313
|
jsonrpc: "2.0",
|
|
1157
|
-
id
|
|
1314
|
+
id,
|
|
1315
|
+
error: { code, message }
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
function makeJsonRpcErrorWithoutId(code, message) {
|
|
1319
|
+
return {
|
|
1320
|
+
jsonrpc: "2.0",
|
|
1158
1321
|
error: { code, message }
|
|
1159
1322
|
};
|
|
1160
1323
|
}
|
|
@@ -1223,6 +1386,79 @@ function parseJsonRpcRequest(body) {
|
|
|
1223
1386
|
};
|
|
1224
1387
|
}
|
|
1225
1388
|
|
|
1389
|
+
// src/mcp/session-resolver.ts
|
|
1390
|
+
var MAX_SESSION_ID_LENGTH = 256;
|
|
1391
|
+
var CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo";
|
|
1392
|
+
function compileSessionIdentity(config) {
|
|
1393
|
+
const strategySummary = config.identity.map((entry) => entry.source === "header" ? `header "${entry.name}"` : entry.source).join(", ");
|
|
1394
|
+
return {
|
|
1395
|
+
sources: config.identity,
|
|
1396
|
+
onUnresolved: config.on_unresolved,
|
|
1397
|
+
strategySummary
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
var DEFAULT_SESSION_IDENTITY = compileSessionIdentity({
|
|
1401
|
+
identity: [{ source: "header", name: "x-helio-session-id" }, { source: "legacy_header" }],
|
|
1402
|
+
on_unresolved: "deny"
|
|
1403
|
+
});
|
|
1404
|
+
function paramsMeta(params) {
|
|
1405
|
+
if (params === null || typeof params !== "object" || Array.isArray(params)) return void 0;
|
|
1406
|
+
return params["_meta"];
|
|
1407
|
+
}
|
|
1408
|
+
var malformedValueWarned = false;
|
|
1409
|
+
function sanitizeCandidate(value, origin) {
|
|
1410
|
+
if (value === void 0) return void 0;
|
|
1411
|
+
if (value.trim() === "" || value.length > MAX_SESSION_ID_LENGTH) {
|
|
1412
|
+
if (!malformedValueWarned) {
|
|
1413
|
+
malformedValueWarned = true;
|
|
1414
|
+
console.error(
|
|
1415
|
+
`[helio] Warning: session identity value from ${origin} was skipped (empty or longer than ${String(MAX_SESSION_ID_LENGTH)} chars); continuing down the identity chain. Further skips will not be logged.`
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
return void 0;
|
|
1419
|
+
}
|
|
1420
|
+
return value;
|
|
1421
|
+
}
|
|
1422
|
+
function clientInfoId(meta) {
|
|
1423
|
+
if (meta === null || typeof meta !== "object") return void 0;
|
|
1424
|
+
const clientInfo = meta[CLIENT_INFO_META_KEY];
|
|
1425
|
+
if (clientInfo === null || typeof clientInfo !== "object") return void 0;
|
|
1426
|
+
const { name, version } = clientInfo;
|
|
1427
|
+
if (typeof name !== "string" || name.trim() === "") return void 0;
|
|
1428
|
+
return `clientinfo:${name}@${typeof version === "string" ? version : "unknown"}`;
|
|
1429
|
+
}
|
|
1430
|
+
function resolveSession(input, identity) {
|
|
1431
|
+
for (const strategy of identity.sources) {
|
|
1432
|
+
switch (strategy.source) {
|
|
1433
|
+
case "header": {
|
|
1434
|
+
const value = sanitizeCandidate(input.headers[strategy.name], `header "${strategy.name}"`);
|
|
1435
|
+
if (value !== void 0) return { id: value, source: "header" };
|
|
1436
|
+
break;
|
|
1437
|
+
}
|
|
1438
|
+
case "meta": {
|
|
1439
|
+
const id = sanitizeCandidate(clientInfoId(input.meta), "meta clientInfo");
|
|
1440
|
+
if (id !== void 0) return { id, source: "meta" };
|
|
1441
|
+
break;
|
|
1442
|
+
}
|
|
1443
|
+
case "legacy_header": {
|
|
1444
|
+
const value = sanitizeCandidate(input.transportSessionId, "legacy_header");
|
|
1445
|
+
if (value !== void 0) return { id: value, source: "legacy_header" };
|
|
1446
|
+
break;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (input.transportMintedId !== void 0) {
|
|
1451
|
+
return { id: input.transportMintedId, source: "transport" };
|
|
1452
|
+
}
|
|
1453
|
+
return void 0;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/transport/content-type.ts
|
|
1457
|
+
function isJsonContentType(header) {
|
|
1458
|
+
const [essence = ""] = (header ?? "").split(";");
|
|
1459
|
+
return essence.trim().toLowerCase() === "application/json";
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1226
1462
|
// src/transport/forward-headers.ts
|
|
1227
1463
|
function buildForwardHeaders(requestHeaders, allowlist) {
|
|
1228
1464
|
const forwardHeaders = {};
|
|
@@ -1240,806 +1476,1419 @@ function buildForwardHeaders(requestHeaders, allowlist) {
|
|
|
1240
1476
|
return Object.keys(forwardHeaders).length > 0 ? forwardHeaders : void 0;
|
|
1241
1477
|
}
|
|
1242
1478
|
|
|
1243
|
-
// src/
|
|
1244
|
-
|
|
1245
|
-
|
|
1479
|
+
// src/mcp/protocol-version.ts
|
|
1480
|
+
var HELIO_MCP_LEGACY_PROTOCOL_VERSION = "2025-06-18";
|
|
1481
|
+
var HELIO_MCP_MODERN_PROTOCOL_VERSION = "2026-07-28";
|
|
1482
|
+
function isModernProtocolClaim(rawValue) {
|
|
1483
|
+
if (rawValue === void 0) return false;
|
|
1484
|
+
const tokens = rawValue.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
1485
|
+
return tokens.length > 0 && tokens.every((token) => token === HELIO_MCP_MODERN_PROTOCOL_VERSION);
|
|
1246
1486
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1487
|
+
|
|
1488
|
+
// src/upstream/standard-headers.ts
|
|
1489
|
+
var SENTINEL_PREFIX = "=?base64?";
|
|
1490
|
+
var SENTINEL_SUFFIX = "?=";
|
|
1491
|
+
var MCP_NAME_MAX_BYTES = 8192;
|
|
1492
|
+
var NAME_SOURCE_FIELD = /* @__PURE__ */ new Map([
|
|
1493
|
+
["tools/call", "name"],
|
|
1494
|
+
["prompts/get", "name"],
|
|
1495
|
+
["resources/read", "uri"]
|
|
1496
|
+
]);
|
|
1497
|
+
function needsSentinelEncoding(value) {
|
|
1498
|
+
const hasUnsafeChar = /[^\t\x20-\x7E]/.test(value);
|
|
1499
|
+
const hasEdgeWhitespace = /^[ \t]/.test(value) || /[ \t]$/.test(value);
|
|
1500
|
+
const looksLikeSentinel = value.startsWith(SENTINEL_PREFIX) && value.endsWith(SENTINEL_SUFFIX);
|
|
1501
|
+
return hasUnsafeChar || hasEdgeWhitespace || looksLikeSentinel;
|
|
1502
|
+
}
|
|
1503
|
+
function encodeSentinelValue(value) {
|
|
1504
|
+
return `${SENTINEL_PREFIX}${Buffer.from(value, "utf8").toString("base64")}${SENTINEL_SUFFIX}`;
|
|
1505
|
+
}
|
|
1506
|
+
var SENTINEL_DECODE_PATTERN = /^=\?base64\?([A-Za-z0-9+/]*={0,2})\?=$/;
|
|
1507
|
+
function decodeSentinelValue(value) {
|
|
1508
|
+
const match = SENTINEL_DECODE_PATTERN.exec(value);
|
|
1509
|
+
return match ? Buffer.from(match[1] ?? "", "base64").toString("utf8") : value;
|
|
1510
|
+
}
|
|
1511
|
+
function nameBearingField(method) {
|
|
1512
|
+
return NAME_SOURCE_FIELD.get(method);
|
|
1513
|
+
}
|
|
1514
|
+
function extractName(method, params) {
|
|
1515
|
+
const field = NAME_SOURCE_FIELD.get(method);
|
|
1516
|
+
if (!field || typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
1517
|
+
return void 0;
|
|
1518
|
+
}
|
|
1519
|
+
let source = params;
|
|
1520
|
+
const maybeToJSON = params.toJSON;
|
|
1521
|
+
if (typeof maybeToJSON === "function") {
|
|
1522
|
+
try {
|
|
1523
|
+
source = maybeToJSON.call(params, "params");
|
|
1524
|
+
} catch {
|
|
1525
|
+
return void 0;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) {
|
|
1529
|
+
return void 0;
|
|
1530
|
+
}
|
|
1531
|
+
if (!Object.prototype.propertyIsEnumerable.call(source, field)) {
|
|
1532
|
+
return void 0;
|
|
1533
|
+
}
|
|
1534
|
+
const raw = source[field];
|
|
1535
|
+
return typeof raw === "string" ? raw : void 0;
|
|
1249
1536
|
}
|
|
1250
|
-
function
|
|
1251
|
-
|
|
1252
|
-
const id = value["id"];
|
|
1253
|
-
return isValidJsonRpcId(id) ? id : void 0;
|
|
1537
|
+
function isHeaderSafeMethod(method) {
|
|
1538
|
+
return /^[\x21-\x7E]+$/.test(method);
|
|
1254
1539
|
}
|
|
1255
|
-
function
|
|
1256
|
-
|
|
1257
|
-
|
|
1540
|
+
function encodedNameValue(method, params) {
|
|
1541
|
+
const name = extractName(method, params);
|
|
1542
|
+
if (name === void 0) return void 0;
|
|
1543
|
+
return needsSentinelEncoding(name) ? encodeSentinelValue(name) : name;
|
|
1258
1544
|
}
|
|
1259
|
-
function
|
|
1260
|
-
if (!
|
|
1261
|
-
|
|
1262
|
-
if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
|
|
1263
|
-
return false;
|
|
1545
|
+
function buildStandardRequestHeaders(method, params) {
|
|
1546
|
+
if (!isHeaderSafeMethod(method)) {
|
|
1547
|
+
return {};
|
|
1264
1548
|
}
|
|
1265
|
-
const
|
|
1266
|
-
const
|
|
1267
|
-
if (
|
|
1268
|
-
|
|
1269
|
-
|
|
1549
|
+
const headers = { "mcp-method": method };
|
|
1550
|
+
const value = encodedNameValue(method, params);
|
|
1551
|
+
if (value !== void 0 && Buffer.byteLength(value) <= MCP_NAME_MAX_BYTES) {
|
|
1552
|
+
headers["mcp-name"] = value;
|
|
1553
|
+
}
|
|
1554
|
+
return headers;
|
|
1270
1555
|
}
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
data
|
|
1556
|
+
|
|
1557
|
+
// src/upstream/merge-headers.ts
|
|
1558
|
+
function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
|
|
1559
|
+
const out = {};
|
|
1560
|
+
const apply = (headers) => {
|
|
1561
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1562
|
+
out[name.toLowerCase()] = value;
|
|
1279
1563
|
}
|
|
1280
1564
|
};
|
|
1565
|
+
apply(base);
|
|
1566
|
+
apply(forwarded);
|
|
1567
|
+
apply(staticHeaders);
|
|
1568
|
+
return out;
|
|
1281
1569
|
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
const upstream = args.upstreamResponse;
|
|
1304
|
-
const upstreamContentType = upstream.headers["content-type"] ?? null;
|
|
1305
|
-
if (!isValidJsonRpcResponse(upstream.body)) {
|
|
1306
|
-
return {
|
|
1307
|
-
httpStatus: 200,
|
|
1308
|
-
wrapped: true,
|
|
1309
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1310
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1311
|
-
upstream_http_status: upstream.status,
|
|
1312
|
-
upstream_content_type: upstreamContentType,
|
|
1313
|
-
upstream_body_type: typeof upstream.body
|
|
1314
|
-
})
|
|
1315
|
-
};
|
|
1316
|
-
}
|
|
1317
|
-
if (args.requestId !== void 0) {
|
|
1318
|
-
const upstreamId = getJsonRpcId(upstream.body);
|
|
1319
|
-
if (upstreamId === void 0) {
|
|
1320
|
-
return {
|
|
1321
|
-
httpStatus: 200,
|
|
1322
|
-
wrapped: true,
|
|
1323
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1324
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1325
|
-
upstream_http_status: upstream.status,
|
|
1326
|
-
upstream_content_type: upstreamContentType,
|
|
1327
|
-
upstream_body_type: typeof upstream.body,
|
|
1328
|
-
invalid_reason: "missing_response_id"
|
|
1329
|
-
})
|
|
1330
|
-
};
|
|
1331
|
-
}
|
|
1332
|
-
const expectedId = args.requestId ?? null;
|
|
1333
|
-
if (upstreamId !== expectedId) {
|
|
1334
|
-
return {
|
|
1335
|
-
httpStatus: 200,
|
|
1336
|
-
wrapped: true,
|
|
1337
|
-
body: makeWrappedError(args.requestId, "upstream response id mismatch", {
|
|
1338
|
-
failure_class: "upstream_id_mismatch",
|
|
1339
|
-
expected_request_id: expectedId,
|
|
1340
|
-
upstream_response_id: upstreamId
|
|
1341
|
-
})
|
|
1342
|
-
};
|
|
1570
|
+
|
|
1571
|
+
// src/upstream/connection-error.ts
|
|
1572
|
+
var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
|
|
1573
|
+
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
1574
|
+
"ECONNREFUSED",
|
|
1575
|
+
"ENOTFOUND",
|
|
1576
|
+
"EAI_AGAIN",
|
|
1577
|
+
"ECONNRESET",
|
|
1578
|
+
"EHOSTUNREACH",
|
|
1579
|
+
"ENETUNREACH",
|
|
1580
|
+
"ETIMEDOUT",
|
|
1581
|
+
"EPIPE",
|
|
1582
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
1583
|
+
"UND_ERR_SOCKET"
|
|
1584
|
+
]);
|
|
1585
|
+
function extractErrorCode(error) {
|
|
1586
|
+
let current = error;
|
|
1587
|
+
for (let depth = 0; depth < 5 && current != null; depth += 1) {
|
|
1588
|
+
if (typeof current === "object" && "code" in current) {
|
|
1589
|
+
const code = current.code;
|
|
1590
|
+
if (typeof code === "string") return code;
|
|
1343
1591
|
}
|
|
1592
|
+
current = current.cause;
|
|
1344
1593
|
}
|
|
1345
|
-
return
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1594
|
+
return void 0;
|
|
1595
|
+
}
|
|
1596
|
+
function describeUnreachableUpstream(error, url) {
|
|
1597
|
+
const code = extractErrorCode(error);
|
|
1598
|
+
const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
|
|
1599
|
+
if (code !== void 0) {
|
|
1600
|
+
if (!UNREACHABLE_CODES.has(code)) return null;
|
|
1601
|
+
} else if (!isGenericFetchFailure) {
|
|
1602
|
+
return null;
|
|
1603
|
+
}
|
|
1604
|
+
const codeSuffix = code ? ` (${code})` : "";
|
|
1605
|
+
return new Error(
|
|
1606
|
+
`Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
|
|
1607
|
+
);
|
|
1350
1608
|
}
|
|
1351
1609
|
|
|
1352
|
-
// src/
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
const
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
const
|
|
1360
|
-
if (
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1610
|
+
// src/upstream/sse-parse.ts
|
|
1611
|
+
function parseSseChunk(chunk, state, onEvent) {
|
|
1612
|
+
let { event, data, remainder } = state;
|
|
1613
|
+
const text = remainder + chunk;
|
|
1614
|
+
const lines = text.split("\n");
|
|
1615
|
+
remainder = lines.pop() ?? "";
|
|
1616
|
+
for (const rawLine of lines) {
|
|
1617
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1618
|
+
if (line === "") {
|
|
1619
|
+
if (event || data) {
|
|
1620
|
+
onEvent(event, data);
|
|
1621
|
+
event = "";
|
|
1622
|
+
data = "";
|
|
1623
|
+
}
|
|
1624
|
+
} else if (line.startsWith("event:")) {
|
|
1625
|
+
const value = line.slice(6).replace(/^ /, "");
|
|
1626
|
+
event = value;
|
|
1627
|
+
} else if (line.startsWith("data:")) {
|
|
1628
|
+
const value = line.slice(5).replace(/^ /, "");
|
|
1629
|
+
data = data ? data + "\n" + value : value;
|
|
1365
1630
|
}
|
|
1366
|
-
|
|
1631
|
+
}
|
|
1632
|
+
return { event, data, remainder };
|
|
1633
|
+
}
|
|
1634
|
+
async function readSseJsonRpcResponse(res, requestId) {
|
|
1635
|
+
if (!res.body) {
|
|
1636
|
+
throw new Error("upstream SSE response had no body");
|
|
1637
|
+
}
|
|
1638
|
+
const reader = res.body.getReader();
|
|
1639
|
+
const decoder = new TextDecoder();
|
|
1640
|
+
let state = { event: "", data: "", remainder: "" };
|
|
1641
|
+
let found;
|
|
1642
|
+
const onEvent = (event, data) => {
|
|
1643
|
+
if (event && event !== "message") return;
|
|
1644
|
+
let parsed;
|
|
1367
1645
|
try {
|
|
1368
|
-
|
|
1646
|
+
parsed = JSON.parse(data);
|
|
1369
1647
|
} catch {
|
|
1370
|
-
return
|
|
1371
|
-
}
|
|
1372
|
-
const parsedRequest = parseJsonRpcRequest(body);
|
|
1373
|
-
if (!parsedRequest.success) {
|
|
1374
|
-
return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
|
|
1648
|
+
return;
|
|
1375
1649
|
}
|
|
1376
|
-
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
1381
|
-
const mcpRequest = {
|
|
1382
|
-
jsonrpc: "2.0",
|
|
1383
|
-
id,
|
|
1384
|
-
method,
|
|
1385
|
-
params,
|
|
1386
|
-
sessionId,
|
|
1387
|
-
headers: forwardHeaders,
|
|
1388
|
-
signal: c.req.raw.signal
|
|
1389
|
-
};
|
|
1390
|
-
if (id === void 0) {
|
|
1391
|
-
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
1392
|
-
void forwarder.forward(notificationRequest).catch((err) => {
|
|
1393
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1394
|
-
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
1395
|
-
});
|
|
1396
|
-
return c.body(null, 202);
|
|
1650
|
+
if (parsed === null || typeof parsed !== "object") return;
|
|
1651
|
+
const id = parsed["id"];
|
|
1652
|
+
if (id === requestId) {
|
|
1653
|
+
found = parsed;
|
|
1397
1654
|
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1655
|
+
};
|
|
1656
|
+
const processChunk = (chunk) => {
|
|
1657
|
+
state = parseSseChunk(chunk, state, onEvent);
|
|
1658
|
+
};
|
|
1659
|
+
for (; ; ) {
|
|
1660
|
+
const result = await reader.read();
|
|
1661
|
+
if (result.value !== void 0) {
|
|
1662
|
+
const chunk = result.value;
|
|
1663
|
+
processChunk(decoder.decode(chunk, { stream: true }));
|
|
1664
|
+
if (found) {
|
|
1665
|
+
await reader.cancel().catch(() => void 0);
|
|
1666
|
+
return found;
|
|
1667
|
+
}
|
|
1406
1668
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
if (
|
|
1410
|
-
|
|
1669
|
+
if (result.done) {
|
|
1670
|
+
const tail = decoder.decode();
|
|
1671
|
+
if (tail) {
|
|
1672
|
+
processChunk(tail);
|
|
1673
|
+
if (found) return found;
|
|
1411
1674
|
}
|
|
1675
|
+
break;
|
|
1412
1676
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1677
|
+
}
|
|
1678
|
+
throw new Error(
|
|
1679
|
+
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1680
|
+
);
|
|
1417
1681
|
}
|
|
1418
1682
|
|
|
1419
|
-
// src/
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
var
|
|
1424
|
-
var
|
|
1425
|
-
var
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1683
|
+
// src/upstream/upstream-session-manager.ts
|
|
1684
|
+
var ERA_PROBE_BACKOFF_MS = 3e4;
|
|
1685
|
+
var MAX_SSE_SCAN_BYTES = 256 * 1024;
|
|
1686
|
+
var ERA_PROBE_REQUEST_ID = "helio-era-probe";
|
|
1687
|
+
var MCP_MISSING_CLIENT_CAPABILITY_CODE = -32021;
|
|
1688
|
+
var MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022;
|
|
1689
|
+
var MCP_MODERN_ONLY_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
1690
|
+
HEADER_MISMATCH,
|
|
1691
|
+
MCP_MISSING_CLIENT_CAPABILITY_CODE,
|
|
1692
|
+
MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE
|
|
1693
|
+
]);
|
|
1694
|
+
var MCP_META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
|
|
1695
|
+
function buildInternalMeta() {
|
|
1696
|
+
return {
|
|
1697
|
+
[MCP_META_PROTOCOL_VERSION_KEY]: HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
1698
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
1699
|
+
"io.modelcontextprotocol/clientInfo": { name: "helio-proxy", version: "0" }
|
|
1700
|
+
};
|
|
1434
1701
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1702
|
+
var UpstreamSessionManager = class {
|
|
1703
|
+
url;
|
|
1704
|
+
staticHeaders;
|
|
1705
|
+
requestTimeoutMs;
|
|
1706
|
+
pin;
|
|
1707
|
+
internal;
|
|
1708
|
+
era;
|
|
1709
|
+
capture;
|
|
1710
|
+
inflight;
|
|
1711
|
+
inflightProbe;
|
|
1712
|
+
probeBackoffUntil = 0;
|
|
1713
|
+
constructor(options) {
|
|
1714
|
+
this.url = options.url;
|
|
1715
|
+
this.staticHeaders = options.staticHeaders;
|
|
1716
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1717
|
+
this.pin = options.protocolVersion ?? "auto";
|
|
1718
|
+
}
|
|
1719
|
+
/** Return the internal session, establishing it once if needed. */
|
|
1720
|
+
ensureInternalSession() {
|
|
1721
|
+
if (this.internal) return Promise.resolve(this.internal);
|
|
1722
|
+
this.inflight ??= this.establish().then((session) => {
|
|
1723
|
+
this.internal = session;
|
|
1724
|
+
return session;
|
|
1725
|
+
}).finally(() => {
|
|
1726
|
+
this.inflight = void 0;
|
|
1447
1727
|
});
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1728
|
+
return this.inflight;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Drop the cached internal session and era so the next call re-probes.
|
|
1732
|
+
* Does not cancel any in-flight establishment. Every era wipe drops the
|
|
1733
|
+
* probe-time DiscoverResult capture with it; invalidation is session
|
|
1734
|
+
* lifecycle, not falsification, so it must NOT arm the probe backoff.
|
|
1735
|
+
*/
|
|
1736
|
+
invalidateInternalSession() {
|
|
1737
|
+
this.internal = void 0;
|
|
1738
|
+
this.era = void 0;
|
|
1739
|
+
this.capture = void 0;
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Resolve the era a relayed request should be sent under. Evaluated in a
|
|
1743
|
+
* strict total order: pin, cached era, join an in-flight probe, backoff
|
|
1744
|
+
* presumption, start a probe. A probe failure never fails the relay — the
|
|
1745
|
+
* request proceeds under a per-request legacy presumption, preserving
|
|
1746
|
+
* today's behavior for deployments the probe cannot classify (for example
|
|
1747
|
+
* per-client Authorization pass-through, where the probe is refused
|
|
1748
|
+
* forever while relays carry the client's own credentials and succeed).
|
|
1749
|
+
*/
|
|
1750
|
+
async resolveRelayEra() {
|
|
1751
|
+
const pinned = this.pinnedEra();
|
|
1752
|
+
if (pinned) return pinned;
|
|
1753
|
+
if (this.era) return this.era;
|
|
1754
|
+
const joined = this.inflightProbe;
|
|
1755
|
+
if (joined) {
|
|
1756
|
+
try {
|
|
1757
|
+
const outcome = await joined;
|
|
1758
|
+
this.cacheRelayClassification(outcome);
|
|
1759
|
+
return outcome.era;
|
|
1760
|
+
} catch {
|
|
1761
|
+
return "legacy";
|
|
1456
1762
|
}
|
|
1457
1763
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
|
|
1466
|
-
writeSessionEvent(sessionId, endpointData);
|
|
1467
|
-
c.req.raw.signal.addEventListener("abort", () => {
|
|
1468
|
-
sessions.delete(sessionId);
|
|
1469
|
-
void writer.close().catch(() => {
|
|
1470
|
-
});
|
|
1471
|
-
});
|
|
1472
|
-
return new Response(readable, {
|
|
1473
|
-
headers: {
|
|
1474
|
-
"content-type": "text/event-stream",
|
|
1475
|
-
"cache-control": "no-cache",
|
|
1476
|
-
connection: "keep-alive"
|
|
1477
|
-
}
|
|
1478
|
-
});
|
|
1479
|
-
});
|
|
1480
|
-
app.post("/", async (c) => {
|
|
1481
|
-
const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
|
|
1482
|
-
if (!parsedQuery.success) {
|
|
1483
|
-
return c.json(
|
|
1484
|
-
makeJsonRpcError(null, INVALID_REQUEST, "missing sessionId query parameter"),
|
|
1485
|
-
400
|
|
1486
|
-
);
|
|
1764
|
+
if (Date.now() < this.probeBackoffUntil) return "legacy";
|
|
1765
|
+
try {
|
|
1766
|
+
const outcome = await this.sharedProbe();
|
|
1767
|
+
this.cacheRelayClassification(outcome);
|
|
1768
|
+
return outcome.era;
|
|
1769
|
+
} catch {
|
|
1770
|
+
return "legacy";
|
|
1487
1771
|
}
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* The falsification door, shared by both sides: a signal just
|
|
1775
|
+
* contradicted a cached LEGACY era — a relayed response only a modern
|
|
1776
|
+
* server gives (a modern-only JSON-RPC code on any method, a 404/-32601
|
|
1777
|
+
* answer to a relayed initialize), or the internal initialize failing
|
|
1778
|
+
* against the classification that promised it would work. No-ops unless
|
|
1779
|
+
* the cached era is 'legacy': a cached modern era is never cleared
|
|
1780
|
+
* automatically — no reliable legacy-rejection signal exists, recovery
|
|
1781
|
+
* from an upstream downgrade is pin-or-restart, and during the
|
|
1782
|
+
* probe-to-initialize window a fresher relay probe may already have
|
|
1783
|
+
* re-classified the upstream as modern, in which case an initialize
|
|
1784
|
+
* failure is evidence against the STALE legacy classification, not
|
|
1785
|
+
* against that newer conclusion.
|
|
1786
|
+
*/
|
|
1787
|
+
clearFalsifiedLegacyEra(door) {
|
|
1788
|
+
if (this.era !== "legacy") return;
|
|
1789
|
+
this.clearEraAndArmBackoff(door);
|
|
1790
|
+
}
|
|
1791
|
+
/** Probe-captured DiscoverResult fields for the relay initialize synthesis. */
|
|
1792
|
+
getDiscoverCapture() {
|
|
1793
|
+
return this.capture;
|
|
1794
|
+
}
|
|
1795
|
+
/** The era a non-auto pin dictates; undefined in auto mode. */
|
|
1796
|
+
pinnedEra() {
|
|
1797
|
+
if (this.pin === HELIO_MCP_MODERN_PROTOCOL_VERSION) return "modern";
|
|
1798
|
+
if (this.pin === HELIO_MCP_LEGACY_PROTOCOL_VERSION) return "legacy";
|
|
1799
|
+
return void 0;
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* One in-flight `server/discover` per manager, shared by `establish()` and
|
|
1803
|
+
* `resolveRelayEra()` — whichever asks first starts it, later callers
|
|
1804
|
+
* join. A failure notes its time, arming the relay-path backoff, then
|
|
1805
|
+
* rethrows for the consumer's own handling.
|
|
1806
|
+
*/
|
|
1807
|
+
sharedProbe() {
|
|
1808
|
+
this.inflightProbe ??= this.probeEra().catch((error) => {
|
|
1809
|
+
this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
|
|
1810
|
+
throw error;
|
|
1811
|
+
}).finally(() => {
|
|
1812
|
+
this.inflightProbe = void 0;
|
|
1813
|
+
});
|
|
1814
|
+
return this.inflightProbe;
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1817
|
+
* Relay-path caching: the probe classification alone settles the era.
|
|
1818
|
+
* Caching 'legacy' without a proven initialize is what makes
|
|
1819
|
+
* `establish()`'s legacy fast path live; the two-sided re-probe rule
|
|
1820
|
+
* (`clearFalsifiedLegacyEra()` and the internal initialize catch) heals a
|
|
1821
|
+
* wrong conclusion.
|
|
1822
|
+
*/
|
|
1823
|
+
cacheRelayClassification(outcome) {
|
|
1824
|
+
if (outcome.era === "modern") {
|
|
1825
|
+
this.cacheModernClassification(outcome);
|
|
1826
|
+
return;
|
|
1492
1827
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1828
|
+
this.setEra("legacy");
|
|
1829
|
+
}
|
|
1830
|
+
/** Every modern classification re-captures from its own fresh DiscoverResult. */
|
|
1831
|
+
cacheModernClassification(outcome) {
|
|
1832
|
+
this.capture = { capabilities: outcome.capabilities, instructions: outcome.instructions };
|
|
1833
|
+
this.setEra("modern");
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Falsified-classification clear: any cleared era means the classification
|
|
1837
|
+
* was just contradicted, so re-probing is throttled no matter which door
|
|
1838
|
+
* noticed. No-ops under a pin and on uncached eras; drops the probe-time
|
|
1839
|
+
* DiscoverResult capture with the era it came from; emits exactly one
|
|
1840
|
+
* operator line per clear.
|
|
1841
|
+
*/
|
|
1842
|
+
clearEraAndArmBackoff(door) {
|
|
1843
|
+
if (this.pinnedEra()) return;
|
|
1844
|
+
if (this.era === void 0) return;
|
|
1845
|
+
this.era = void 0;
|
|
1846
|
+
this.capture = void 0;
|
|
1847
|
+
this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
|
|
1848
|
+
console.error(
|
|
1849
|
+
`[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
/** Convert a fetch failure into an actionable error for the given step. */
|
|
1853
|
+
describeFetchFailure(error, step) {
|
|
1854
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
1855
|
+
return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
|
|
1499
1856
|
}
|
|
1500
|
-
|
|
1857
|
+
return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
|
|
1858
|
+
}
|
|
1859
|
+
async establish() {
|
|
1860
|
+
const pinned = this.pinnedEra();
|
|
1861
|
+
if (pinned === "modern") return this.modernSession();
|
|
1862
|
+
if (pinned === "legacy") return this.legacyInitialize();
|
|
1863
|
+
if (this.era === "modern") return this.modernSession();
|
|
1864
|
+
if (this.era === "legacy") return this.legacyInitializeCachingEra(void 0);
|
|
1865
|
+
const probe = await this.sharedProbe();
|
|
1866
|
+
if (probe.era === "modern") {
|
|
1867
|
+
this.cacheModernClassification(probe);
|
|
1868
|
+
return this.modernSession();
|
|
1869
|
+
}
|
|
1870
|
+
return this.legacyInitializeCachingEra(probe.unsupportedModernVersions);
|
|
1871
|
+
}
|
|
1872
|
+
/** The only `initialize` call site: one handshake attempt per establishment. */
|
|
1873
|
+
async legacyInitializeCachingEra(unsupportedModernVersions) {
|
|
1501
1874
|
try {
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
return
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1875
|
+
const session = await this.legacyInitialize();
|
|
1876
|
+
this.setEra("legacy");
|
|
1877
|
+
return session;
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
this.clearFalsifiedLegacyEra("internal initialize failed against the cached legacy era");
|
|
1880
|
+
if (unsupportedModernVersions) {
|
|
1881
|
+
throw new Error(
|
|
1882
|
+
`upstream is a modern MCP server supporting [${unsupportedModernVersions.join(", ")}] (helio speaks ${HELIO_MCP_MODERN_PROTOCOL_VERSION} or legacy initialize); legacy fallback also failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
throw error;
|
|
1509
1886
|
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1887
|
+
}
|
|
1888
|
+
/** Single owner of era assignment and of the era-detected log line. */
|
|
1889
|
+
setEra(era) {
|
|
1890
|
+
if (this.era === era) return;
|
|
1891
|
+
this.era = era;
|
|
1892
|
+
console.error(
|
|
1893
|
+
era === "modern" ? `[helio] Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : "[helio] Upstream MCP era detected: legacy (initialize handshake)"
|
|
1894
|
+
);
|
|
1895
|
+
}
|
|
1896
|
+
/** A modern upstream neither mints nor echoes session ids — nothing to hold. */
|
|
1897
|
+
modernSession() {
|
|
1898
|
+
return {
|
|
1899
|
+
sessionId: void 0,
|
|
1900
|
+
protocolVersion: HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
1901
|
+
era: "modern"
|
|
1522
1902
|
};
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* Classify the upstream's era with one `server/discover` request.
|
|
1906
|
+
*
|
|
1907
|
+
* A pure classifier: it performs no handshake, so the dual-era salvage cannot
|
|
1908
|
+
* double-initialize. It throws when no era conclusion is possible — a
|
|
1909
|
+
* transport failure, a status that says nothing about the era (401/403/5xx),
|
|
1910
|
+
* or a known-modern server refusing Helio's own probe — leaving the era
|
|
1911
|
+
* uncached so the next attempt re-probes.
|
|
1912
|
+
*/
|
|
1913
|
+
async probeEra() {
|
|
1914
|
+
const headers = mergeUpstreamHeaders(
|
|
1915
|
+
{
|
|
1916
|
+
"content-type": "application/json",
|
|
1917
|
+
accept: "application/json, text/event-stream",
|
|
1918
|
+
"mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
1919
|
+
"mcp-method": "server/discover"
|
|
1920
|
+
},
|
|
1921
|
+
{},
|
|
1922
|
+
this.staticHeaders
|
|
1923
|
+
);
|
|
1924
|
+
headers["mcp-method"] = "server/discover";
|
|
1925
|
+
delete headers["mcp-name"];
|
|
1926
|
+
const probeBody = {
|
|
1927
|
+
jsonrpc: "2.0",
|
|
1928
|
+
id: ERA_PROBE_REQUEST_ID,
|
|
1929
|
+
method: "server/discover",
|
|
1930
|
+
params: { _meta: buildInternalMeta() }
|
|
1931
|
+
};
|
|
1932
|
+
let res;
|
|
1933
|
+
try {
|
|
1934
|
+
res = await fetch(this.url, {
|
|
1935
|
+
method: "POST",
|
|
1936
|
+
headers,
|
|
1937
|
+
body: JSON.stringify(probeBody),
|
|
1938
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1528
1939
|
});
|
|
1529
|
-
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
throw this.describeFetchFailure(error, "server/discover probe");
|
|
1530
1942
|
}
|
|
1531
|
-
|
|
1943
|
+
if (!isClassifiableProbeStatus(res.status)) {
|
|
1944
|
+
throw new Error(`upstream server/discover probe failed: HTTP ${String(res.status)}`);
|
|
1945
|
+
}
|
|
1946
|
+
const body = await this.readProbeBody(res);
|
|
1947
|
+
if (body.kind === "stalled") {
|
|
1948
|
+
throw new Error(`upstream server/discover probe ${body.reason}`);
|
|
1949
|
+
}
|
|
1950
|
+
if (body.kind === "unparseable") {
|
|
1951
|
+
return { era: "legacy" };
|
|
1952
|
+
}
|
|
1953
|
+
if (body.kind === "error") {
|
|
1954
|
+
return classifyProbeError(body.envelope);
|
|
1955
|
+
}
|
|
1956
|
+
return classifyProbeResult(body.envelope);
|
|
1957
|
+
}
|
|
1958
|
+
async readProbeBody(res) {
|
|
1959
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1960
|
+
if (contentType.includes("text/event-stream")) {
|
|
1961
|
+
if (!res.body) return { kind: "unparseable" };
|
|
1962
|
+
const scan = await this.scanSseEvents(
|
|
1963
|
+
res.body,
|
|
1964
|
+
(payload) => payload["id"] === ERA_PROBE_REQUEST_ID ? payload : void 0
|
|
1965
|
+
);
|
|
1966
|
+
switch (scan.outcome) {
|
|
1967
|
+
case "found":
|
|
1968
|
+
return classifyEnvelopeShape(scan.value);
|
|
1969
|
+
case "closed":
|
|
1970
|
+
return { kind: "unparseable" };
|
|
1971
|
+
case "timed-out":
|
|
1972
|
+
return {
|
|
1973
|
+
kind: "stalled",
|
|
1974
|
+
reason: `SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1975
|
+
};
|
|
1976
|
+
case "too-large":
|
|
1977
|
+
return {
|
|
1978
|
+
kind: "stalled",
|
|
1979
|
+
reason: `SSE response exceeded ${String(MAX_SSE_SCAN_BYTES)} bytes`
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
const raw = await res.text();
|
|
1984
|
+
if (!raw.trim()) return { kind: "unparseable" };
|
|
1985
|
+
let parsed;
|
|
1532
1986
|
try {
|
|
1533
|
-
|
|
1534
|
-
} catch
|
|
1535
|
-
|
|
1536
|
-
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
1537
|
-
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
1538
|
-
const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
|
|
1539
|
-
writeSessionEvent(sessionId, errorEvent);
|
|
1540
|
-
return c.body(null, 202);
|
|
1987
|
+
parsed = JSON.parse(raw);
|
|
1988
|
+
} catch {
|
|
1989
|
+
return { kind: "unparseable" };
|
|
1541
1990
|
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1991
|
+
if (typeof parsed !== "object" || parsed === null) return { kind: "unparseable" };
|
|
1992
|
+
return classifyEnvelopeShape(parsed);
|
|
1993
|
+
}
|
|
1994
|
+
async legacyInitialize() {
|
|
1995
|
+
const headers = mergeUpstreamHeaders(
|
|
1996
|
+
{
|
|
1997
|
+
"content-type": "application/json",
|
|
1998
|
+
accept: "application/json, text/event-stream"
|
|
1999
|
+
},
|
|
2000
|
+
{},
|
|
2001
|
+
this.staticHeaders
|
|
2002
|
+
);
|
|
2003
|
+
delete headers["mcp-method"];
|
|
2004
|
+
delete headers["mcp-name"];
|
|
2005
|
+
const initBody = {
|
|
2006
|
+
jsonrpc: "2.0",
|
|
2007
|
+
id: 0,
|
|
2008
|
+
method: "initialize",
|
|
2009
|
+
params: {
|
|
2010
|
+
protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION,
|
|
2011
|
+
capabilities: {},
|
|
2012
|
+
clientInfo: { name: "helio-proxy", version: "0" }
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
let res;
|
|
2016
|
+
try {
|
|
2017
|
+
res = await fetch(this.url, {
|
|
2018
|
+
method: "POST",
|
|
2019
|
+
headers,
|
|
2020
|
+
body: JSON.stringify(initBody),
|
|
2021
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
2022
|
+
});
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
throw this.describeFetchFailure(error, "initialize");
|
|
2025
|
+
}
|
|
2026
|
+
if (!res.ok) {
|
|
2027
|
+
throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
|
|
2028
|
+
}
|
|
2029
|
+
const sessionId = res.headers.get("mcp-session-id") ?? void 0;
|
|
2030
|
+
const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
|
|
2031
|
+
res,
|
|
2032
|
+
initBody.id,
|
|
2033
|
+
"initialize"
|
|
2034
|
+
);
|
|
2035
|
+
const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
|
|
2036
|
+
if (initializeError) {
|
|
2037
|
+
throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
|
|
2038
|
+
}
|
|
2039
|
+
const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
|
|
2040
|
+
const notifyHeaders = { ...headers };
|
|
2041
|
+
if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
|
|
2042
|
+
notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
2043
|
+
const notifyRes = await fetch(this.url, {
|
|
2044
|
+
method: "POST",
|
|
2045
|
+
headers: notifyHeaders,
|
|
2046
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
2047
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
2048
|
+
}).catch((error) => {
|
|
2049
|
+
throw this.describeFetchFailure(error, "notifications/initialized");
|
|
1566
2050
|
});
|
|
1567
|
-
|
|
1568
|
-
|
|
2051
|
+
if (!notifyRes.ok) {
|
|
2052
|
+
throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
|
|
2053
|
+
}
|
|
2054
|
+
const notifyError = await this.readOptionalJsonRpcError(notifyRes);
|
|
2055
|
+
if (notifyError) {
|
|
2056
|
+
throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
|
|
2057
|
+
}
|
|
2058
|
+
return { sessionId, protocolVersion: negotiatedProtocolVersion, era: "legacy" };
|
|
2059
|
+
}
|
|
2060
|
+
async readRequiredJsonRpcEnvelope(res, requestId, step) {
|
|
2061
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
2062
|
+
if (contentType.includes("text/event-stream")) {
|
|
2063
|
+
const payload = await readSseJsonRpcResponse(res, requestId);
|
|
2064
|
+
return payload;
|
|
2065
|
+
}
|
|
2066
|
+
const raw = await res.text();
|
|
2067
|
+
if (!raw.trim()) {
|
|
2068
|
+
throw new Error(`upstream ${step} returned an empty body`);
|
|
2069
|
+
}
|
|
2070
|
+
let parsed;
|
|
1569
2071
|
try {
|
|
1570
|
-
|
|
2072
|
+
parsed = JSON.parse(raw);
|
|
1571
2073
|
} catch {
|
|
2074
|
+
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1572
2075
|
}
|
|
1573
|
-
if (
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
2076
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
2077
|
+
throw new Error(`upstream ${step} returned non-object JSON`);
|
|
2078
|
+
}
|
|
2079
|
+
return parsed;
|
|
2080
|
+
}
|
|
2081
|
+
async readOptionalJsonRpcError(res) {
|
|
2082
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
2083
|
+
if (contentType.includes("text/event-stream")) {
|
|
2084
|
+
if (!res.body) return void 0;
|
|
2085
|
+
const scan = await this.scanSseEvents(
|
|
2086
|
+
res.body,
|
|
2087
|
+
(payload) => extractJsonRpcErrorMessage(payload)
|
|
2088
|
+
);
|
|
2089
|
+
switch (scan.outcome) {
|
|
2090
|
+
case "found":
|
|
2091
|
+
return scan.value;
|
|
2092
|
+
case "closed":
|
|
2093
|
+
return void 0;
|
|
2094
|
+
case "timed-out":
|
|
2095
|
+
throw new Error(
|
|
2096
|
+
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
2097
|
+
);
|
|
2098
|
+
case "too-large":
|
|
2099
|
+
throw new Error(
|
|
2100
|
+
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_SCAN_BYTES)} bytes`
|
|
2101
|
+
);
|
|
1577
2102
|
}
|
|
1578
|
-
return;
|
|
1579
2103
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
2104
|
+
const raw = await res.text();
|
|
2105
|
+
if (!raw.trim()) return void 0;
|
|
2106
|
+
let parsed;
|
|
2107
|
+
try {
|
|
2108
|
+
parsed = JSON.parse(raw);
|
|
2109
|
+
} catch {
|
|
2110
|
+
return void 0;
|
|
1582
2111
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
2112
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
2113
|
+
return extractJsonRpcErrorMessage(parsed);
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Read an SSE POST response body under an explicit read deadline and byte
|
|
2117
|
+
* cap, returning the first `message` payload `select` accepts. The
|
|
2118
|
+
* fetch-level `AbortSignal.timeout` would eventually abort a stalled body
|
|
2119
|
+
* read; these bounds are the belt to its braces.
|
|
2120
|
+
*/
|
|
2121
|
+
async scanSseEvents(body, select) {
|
|
2122
|
+
const reader = body.getReader();
|
|
2123
|
+
const decoder = new TextDecoder();
|
|
2124
|
+
let state = { event: "", data: "", remainder: "" };
|
|
2125
|
+
let found;
|
|
2126
|
+
let scannedBytes = 0;
|
|
2127
|
+
const deadline = Date.now() + this.requestTimeoutMs;
|
|
2128
|
+
const onEvent = (event, data) => {
|
|
2129
|
+
if (found !== void 0) return;
|
|
2130
|
+
if (event && event !== "message") return;
|
|
2131
|
+
let parsed;
|
|
1602
2132
|
try {
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
settle(err);
|
|
1606
|
-
return;
|
|
1607
|
-
}
|
|
1608
|
-
settle();
|
|
1609
|
-
});
|
|
1610
|
-
} catch (error) {
|
|
1611
|
-
settle(normalizeError(error));
|
|
2133
|
+
parsed = JSON.parse(data);
|
|
2134
|
+
} catch {
|
|
1612
2135
|
return;
|
|
1613
2136
|
}
|
|
2137
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
2138
|
+
found = select(parsed);
|
|
2139
|
+
};
|
|
2140
|
+
for (; ; ) {
|
|
2141
|
+
const remainingMs = deadline - Date.now();
|
|
2142
|
+
if (remainingMs <= 0) {
|
|
2143
|
+
await reader.cancel().catch(() => void 0);
|
|
2144
|
+
return { outcome: "timed-out" };
|
|
2145
|
+
}
|
|
2146
|
+
let chunk;
|
|
1614
2147
|
try {
|
|
1615
|
-
|
|
2148
|
+
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1616
2149
|
} catch {
|
|
2150
|
+
await reader.cancel().catch(() => void 0);
|
|
2151
|
+
return { outcome: "timed-out" };
|
|
1617
2152
|
}
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
2153
|
+
const { done, value } = chunk;
|
|
2154
|
+
if (value !== void 0) {
|
|
2155
|
+
scannedBytes += value.byteLength;
|
|
2156
|
+
if (scannedBytes > MAX_SSE_SCAN_BYTES) {
|
|
2157
|
+
await reader.cancel().catch(() => void 0);
|
|
2158
|
+
return { outcome: "too-large" };
|
|
2159
|
+
}
|
|
2160
|
+
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
2161
|
+
if (found !== void 0) {
|
|
2162
|
+
await reader.cancel().catch(() => void 0);
|
|
2163
|
+
return { outcome: "found", value: found };
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
if (done) {
|
|
2167
|
+
const tail = decoder.decode();
|
|
2168
|
+
if (tail) {
|
|
2169
|
+
state = parseSseChunk(tail, state, onEvent);
|
|
2170
|
+
}
|
|
2171
|
+
return found !== void 0 ? { outcome: "found", value: found } : { outcome: "closed" };
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
};
|
|
2176
|
+
async function readSseChunkWithTimeout(reader, timeoutMs) {
|
|
2177
|
+
let timeoutHandle;
|
|
2178
|
+
try {
|
|
2179
|
+
const result = await Promise.race([
|
|
2180
|
+
reader.read(),
|
|
2181
|
+
new Promise((_, reject) => {
|
|
2182
|
+
timeoutHandle = setTimeout(() => {
|
|
2183
|
+
reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
|
|
2184
|
+
}, timeoutMs);
|
|
2185
|
+
})
|
|
2186
|
+
]);
|
|
2187
|
+
if (!isSseReadChunk(result)) {
|
|
2188
|
+
throw new Error("upstream SSE response returned invalid chunk");
|
|
2189
|
+
}
|
|
2190
|
+
return result;
|
|
2191
|
+
} finally {
|
|
2192
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1633
2193
|
}
|
|
1634
|
-
return app;
|
|
1635
2194
|
}
|
|
1636
|
-
function
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
return createServerHandle(server);
|
|
2195
|
+
function isSseReadChunk(value) {
|
|
2196
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2197
|
+
const candidate = value;
|
|
2198
|
+
if (typeof candidate.done !== "boolean") return false;
|
|
2199
|
+
if (candidate.value === void 0) return true;
|
|
2200
|
+
return candidate.value instanceof Uint8Array;
|
|
1643
2201
|
}
|
|
1644
|
-
function
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
port,
|
|
1648
|
-
hostname: host
|
|
1649
|
-
});
|
|
1650
|
-
return createServerHandle(server);
|
|
2202
|
+
function isClassifiableProbeStatus(status) {
|
|
2203
|
+
if (status >= 200 && status < 300) return true;
|
|
2204
|
+
return status === 400 || status === 404 || status === 405;
|
|
1651
2205
|
}
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
const
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
}
|
|
1668
|
-
} else {
|
|
1669
|
-
body = await res.text();
|
|
2206
|
+
function classifyEnvelopeShape(envelope) {
|
|
2207
|
+
if (envelope["error"] !== void 0) return { kind: "error", envelope };
|
|
2208
|
+
if (envelope["result"] !== void 0) return { kind: "result", envelope };
|
|
2209
|
+
return { kind: "unparseable" };
|
|
2210
|
+
}
|
|
2211
|
+
function classifyProbeError(envelope) {
|
|
2212
|
+
const error = envelope["error"];
|
|
2213
|
+
const code = typeof error === "object" && error !== null ? error["code"] : void 0;
|
|
2214
|
+
if (code === MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE) {
|
|
2215
|
+
const data = error["data"];
|
|
2216
|
+
return {
|
|
2217
|
+
era: "legacy",
|
|
2218
|
+
unsupportedModernVersions: readStringArray(
|
|
2219
|
+
typeof data === "object" && data !== null ? data["supported"] : void 0
|
|
2220
|
+
)
|
|
2221
|
+
};
|
|
1670
2222
|
}
|
|
1671
|
-
|
|
2223
|
+
if (code === HEADER_MISMATCH || code === MCP_MISSING_CLIENT_CAPABILITY_CODE) {
|
|
2224
|
+
throw new Error(
|
|
2225
|
+
`upstream refused Helio's server/discover probe with modern MCP error ${String(code)}: ${extractJsonRpcErrorMessage(envelope) ?? "unknown JSON-RPC error"} (the upstream is a modern MCP server, so Helio does not fall back to initialize)`
|
|
2226
|
+
);
|
|
2227
|
+
}
|
|
2228
|
+
return { era: "legacy" };
|
|
1672
2229
|
}
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
remainder = lines.pop() ?? "";
|
|
1680
|
-
for (const rawLine of lines) {
|
|
1681
|
-
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1682
|
-
if (line === "") {
|
|
1683
|
-
if (event || data) {
|
|
1684
|
-
onEvent(event, data);
|
|
1685
|
-
event = "";
|
|
1686
|
-
data = "";
|
|
1687
|
-
}
|
|
1688
|
-
} else if (line.startsWith("event:")) {
|
|
1689
|
-
const value = line.slice(6).replace(/^ /, "");
|
|
1690
|
-
event = value;
|
|
1691
|
-
} else if (line.startsWith("data:")) {
|
|
1692
|
-
const value = line.slice(5).replace(/^ /, "");
|
|
1693
|
-
data = data ? data + "\n" + value : value;
|
|
1694
|
-
}
|
|
2230
|
+
function classifyProbeResult(envelope) {
|
|
2231
|
+
const result = envelope["result"];
|
|
2232
|
+
if (typeof result !== "object" || result === null) return { era: "legacy" };
|
|
2233
|
+
const supportedVersions = result["supportedVersions"];
|
|
2234
|
+
if (!Array.isArray(supportedVersions)) {
|
|
2235
|
+
return { era: "legacy" };
|
|
1695
2236
|
}
|
|
1696
|
-
|
|
2237
|
+
const versions = readStringArray(supportedVersions);
|
|
2238
|
+
if (versions.includes(HELIO_MCP_MODERN_PROTOCOL_VERSION)) {
|
|
2239
|
+
const record = result;
|
|
2240
|
+
const capabilities = record["capabilities"];
|
|
2241
|
+
const instructions = record["instructions"];
|
|
2242
|
+
return {
|
|
2243
|
+
era: "modern",
|
|
2244
|
+
capabilities: typeof capabilities === "object" && capabilities !== null && !Array.isArray(capabilities) ? capabilities : void 0,
|
|
2245
|
+
instructions: typeof instructions === "string" ? instructions : void 0
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
return { era: "legacy", unsupportedModernVersions: versions };
|
|
1697
2249
|
}
|
|
1698
|
-
|
|
1699
|
-
if (!
|
|
1700
|
-
|
|
2250
|
+
function readStringArray(value) {
|
|
2251
|
+
if (!Array.isArray(value)) return [];
|
|
2252
|
+
return value.filter((entry) => typeof entry === "string");
|
|
2253
|
+
}
|
|
2254
|
+
function extractJsonRpcErrorMessage(payload) {
|
|
2255
|
+
const error = payload["error"];
|
|
2256
|
+
if (typeof error === "string") return error;
|
|
2257
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
2258
|
+
const message = error["message"];
|
|
2259
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
2260
|
+
return "unknown JSON-RPC error";
|
|
2261
|
+
}
|
|
2262
|
+
function extractNegotiatedProtocolVersion(payload) {
|
|
2263
|
+
const result = payload["result"];
|
|
2264
|
+
if (typeof result !== "object" || result === null) {
|
|
2265
|
+
return HELIO_MCP_LEGACY_PROTOCOL_VERSION;
|
|
1701
2266
|
}
|
|
1702
|
-
const
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2267
|
+
const protocolVersion = result["protocolVersion"];
|
|
2268
|
+
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_LEGACY_PROTOCOL_VERSION;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// src/transport/header-body-agreement.ts
|
|
2272
|
+
var DISPLAY_CAP_CHARS = 256;
|
|
2273
|
+
function displayCap(value) {
|
|
2274
|
+
if (value.length <= DISPLAY_CAP_CHARS) return value;
|
|
2275
|
+
return `${value.slice(0, DISPLAY_CAP_CHARS)}\u2026 (truncated)`;
|
|
2276
|
+
}
|
|
2277
|
+
function readOwnField(source, field) {
|
|
2278
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) return void 0;
|
|
2279
|
+
if (!Object.prototype.hasOwnProperty.call(source, field)) return void 0;
|
|
2280
|
+
return source[field];
|
|
2281
|
+
}
|
|
2282
|
+
function validateHeaderBodyAgreement(input) {
|
|
2283
|
+
const { method, params } = input;
|
|
2284
|
+
const headerMethod = input.headers["mcp-method"];
|
|
2285
|
+
const headerName = input.headers["mcp-name"];
|
|
2286
|
+
const rawVersionClaim = input.headers["mcp-protocol-version"];
|
|
2287
|
+
const modern = isModernProtocolClaim(rawVersionClaim);
|
|
2288
|
+
const isNotification = input.id === void 0;
|
|
2289
|
+
const requiresPresence = modern && !isNotification;
|
|
2290
|
+
const field = nameBearingField(method);
|
|
2291
|
+
const rawFieldValue = field === void 0 ? void 0 : readOwnField(params, field);
|
|
2292
|
+
const bodyName = typeof rawFieldValue === "string" ? rawFieldValue : void 0;
|
|
2293
|
+
const presentHeaders = {};
|
|
2294
|
+
if (headerMethod !== void 0) presentHeaders["mcp-method"] = headerMethod;
|
|
2295
|
+
if (headerName !== void 0) presentHeaders["mcp-name"] = headerName;
|
|
2296
|
+
if (rawVersionClaim !== void 0) presentHeaders["mcp-protocol-version"] = rawVersionClaim;
|
|
2297
|
+
const reject = (reason) => ({
|
|
2298
|
+
ok: false,
|
|
2299
|
+
reason,
|
|
2300
|
+
evidence: {
|
|
2301
|
+
headers: presentHeaders,
|
|
2302
|
+
...bodyName !== void 0 && { bodyName }
|
|
1713
2303
|
}
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
if (
|
|
1717
|
-
|
|
2304
|
+
});
|
|
2305
|
+
if (headerMethod === void 0) {
|
|
2306
|
+
if (requiresPresence) {
|
|
2307
|
+
return reject(`missing mcp-method header (expected ${displayCap(method)})`);
|
|
1718
2308
|
}
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
if (
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
2309
|
+
} else if (headerMethod !== method) {
|
|
2310
|
+
return reject(
|
|
2311
|
+
`mismatched mcp-method header (expected ${displayCap(method)}, got ${displayCap(headerMethod)})`
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
if (bodyName !== void 0) {
|
|
2315
|
+
if (headerName === void 0) {
|
|
2316
|
+
if (requiresPresence) {
|
|
2317
|
+
return reject(`missing mcp-name header (expected ${displayCap(bodyName)})`);
|
|
2318
|
+
}
|
|
2319
|
+
} else {
|
|
2320
|
+
const decoded = decodeSentinelValue(headerName);
|
|
2321
|
+
if (decoded !== bodyName) {
|
|
2322
|
+
return reject(
|
|
2323
|
+
`mismatched mcp-name header (expected ${displayCap(bodyName)}, got ${displayCap(decoded)})`
|
|
2324
|
+
);
|
|
1731
2325
|
}
|
|
1732
2326
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
2327
|
+
}
|
|
2328
|
+
if (modern) {
|
|
2329
|
+
const mirror = readOwnField(readOwnField(params, "_meta"), MCP_META_PROTOCOL_VERSION_KEY);
|
|
2330
|
+
if (mirror === void 0) {
|
|
2331
|
+
if (!isNotification) {
|
|
2332
|
+
return reject(
|
|
2333
|
+
`missing params._meta["${MCP_META_PROTOCOL_VERSION_KEY}"] mirror (expected ${HELIO_MCP_MODERN_PROTOCOL_VERSION})`
|
|
2334
|
+
);
|
|
1738
2335
|
}
|
|
1739
|
-
|
|
2336
|
+
} else if (mirror !== HELIO_MCP_MODERN_PROTOCOL_VERSION) {
|
|
2337
|
+
const display = typeof mirror === "string" ? mirror : JSON.stringify(mirror);
|
|
2338
|
+
return reject(
|
|
2339
|
+
`mismatched params._meta["${MCP_META_PROTOCOL_VERSION_KEY}"] mirror (expected ${HELIO_MCP_MODERN_PROTOCOL_VERSION}, got ${displayCap(display)})`
|
|
2340
|
+
);
|
|
1740
2341
|
}
|
|
1741
2342
|
}
|
|
1742
|
-
|
|
1743
|
-
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1744
|
-
);
|
|
2343
|
+
return { ok: true };
|
|
1745
2344
|
}
|
|
1746
2345
|
|
|
1747
|
-
// src/
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2346
|
+
// src/transport/origin-guard.ts
|
|
2347
|
+
var MAX_UNIQUE_ORIGIN_WARNINGS = 20;
|
|
2348
|
+
var SUPPRESSED_WARNING_SUMMARY_INTERVAL = 50;
|
|
2349
|
+
var LOGGED_ORIGIN_MAX_LENGTH = 256;
|
|
2350
|
+
function createOriginGuard(allowedOrigins) {
|
|
2351
|
+
const allowed = new Set(allowedOrigins);
|
|
2352
|
+
const warnedOrigins = /* @__PURE__ */ new Set();
|
|
2353
|
+
let suppressedWarningCount = 0;
|
|
2354
|
+
const logRejection = (origin) => {
|
|
2355
|
+
const displayOrigin = origin.length > LOGGED_ORIGIN_MAX_LENGTH ? `${origin.slice(0, LOGGED_ORIGIN_MAX_LENGTH)}\u2026 (truncated)` : origin;
|
|
2356
|
+
if (warnedOrigins.has(displayOrigin)) return;
|
|
2357
|
+
if (warnedOrigins.size < MAX_UNIQUE_ORIGIN_WARNINGS) {
|
|
2358
|
+
warnedOrigins.add(displayOrigin);
|
|
2359
|
+
console.error(`[helio] Rejected request with disallowed Origin: ${displayOrigin}`);
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
suppressedWarningCount += 1;
|
|
2363
|
+
if (suppressedWarningCount === 1 || suppressedWarningCount % SUPPRESSED_WARNING_SUMMARY_INTERVAL === 0) {
|
|
2364
|
+
console.error(
|
|
2365
|
+
`[helio] Origin rejection warnings: logged ${String(MAX_UNIQUE_ORIGIN_WARNINGS)} distinct origins and are suppressing the rest (${String(suppressedWarningCount)} further rejections so far).`
|
|
2366
|
+
);
|
|
1753
2367
|
}
|
|
1754
2368
|
};
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
2369
|
+
return async (c, next) => {
|
|
2370
|
+
const origin = c.req.header("origin");
|
|
2371
|
+
if (origin !== void 0 && !allowed.has(origin)) {
|
|
2372
|
+
logRejection(origin);
|
|
2373
|
+
return c.json(makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Origin not allowed"), 403);
|
|
2374
|
+
}
|
|
2375
|
+
await next();
|
|
2376
|
+
};
|
|
1759
2377
|
}
|
|
1760
2378
|
|
|
1761
|
-
// src/
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
"ECONNREFUSED",
|
|
1765
|
-
"ENOTFOUND",
|
|
1766
|
-
"EAI_AGAIN",
|
|
1767
|
-
"ECONNRESET",
|
|
1768
|
-
"EHOSTUNREACH",
|
|
1769
|
-
"ENETUNREACH",
|
|
1770
|
-
"ETIMEDOUT",
|
|
1771
|
-
"EPIPE",
|
|
1772
|
-
"UND_ERR_CONNECT_TIMEOUT",
|
|
1773
|
-
"UND_ERR_SOCKET"
|
|
1774
|
-
]);
|
|
1775
|
-
function extractErrorCode(error) {
|
|
1776
|
-
let current = error;
|
|
1777
|
-
for (let depth = 0; depth < 5 && current != null; depth += 1) {
|
|
1778
|
-
if (typeof current === "object" && "code" in current) {
|
|
1779
|
-
const code = current.code;
|
|
1780
|
-
if (typeof code === "string") return code;
|
|
1781
|
-
}
|
|
1782
|
-
current = current.cause;
|
|
1783
|
-
}
|
|
1784
|
-
return void 0;
|
|
2379
|
+
// src/transport/response-normalizer.ts
|
|
2380
|
+
function isObject(value) {
|
|
2381
|
+
return value !== null && typeof value === "object";
|
|
1785
2382
|
}
|
|
1786
|
-
function
|
|
1787
|
-
|
|
1788
|
-
const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
|
|
1789
|
-
if (code !== void 0) {
|
|
1790
|
-
if (!UNREACHABLE_CODES.has(code)) return null;
|
|
1791
|
-
} else if (!isGenericFetchFailure) {
|
|
1792
|
-
return null;
|
|
1793
|
-
}
|
|
1794
|
-
const codeSuffix = code ? ` (${code})` : "";
|
|
1795
|
-
return new Error(
|
|
1796
|
-
`Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
|
|
1797
|
-
);
|
|
2383
|
+
function isValidJsonRpcId(value) {
|
|
2384
|
+
return value === null || typeof value === "string" || typeof value === "number";
|
|
1798
2385
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
}
|
|
1814
|
-
/** Return the internal session, performing the handshake once if needed. */
|
|
1815
|
-
ensureInternalSession() {
|
|
1816
|
-
if (this.internal) return Promise.resolve(this.internal);
|
|
1817
|
-
this.inflight ??= this.initialize().then((session) => {
|
|
1818
|
-
this.internal = session;
|
|
1819
|
-
return session;
|
|
1820
|
-
}).finally(() => {
|
|
1821
|
-
this.inflight = void 0;
|
|
1822
|
-
});
|
|
1823
|
-
return this.inflight;
|
|
1824
|
-
}
|
|
1825
|
-
/**
|
|
1826
|
-
* Drop the cached internal session so the next call re-initializes.
|
|
1827
|
-
* Does not cancel any in-flight initialize.
|
|
1828
|
-
*/
|
|
1829
|
-
invalidateInternalSession() {
|
|
1830
|
-
this.internal = void 0;
|
|
2386
|
+
function getJsonRpcId(value) {
|
|
2387
|
+
if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
|
|
2388
|
+
const id = value["id"];
|
|
2389
|
+
return isValidJsonRpcId(id) ? id : void 0;
|
|
2390
|
+
}
|
|
2391
|
+
function isValidJsonRpcError(value) {
|
|
2392
|
+
if (!isObject(value)) return false;
|
|
2393
|
+
return typeof value["code"] === "number" && typeof value["message"] === "string";
|
|
2394
|
+
}
|
|
2395
|
+
function isValidJsonRpcResponse(value) {
|
|
2396
|
+
if (!isObject(value)) return false;
|
|
2397
|
+
if (value["jsonrpc"] !== "2.0") return false;
|
|
2398
|
+
if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
|
|
2399
|
+
return false;
|
|
1831
2400
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
2401
|
+
const hasResult = Object.prototype.hasOwnProperty.call(value, "result");
|
|
2402
|
+
const hasError = Object.prototype.hasOwnProperty.call(value, "error");
|
|
2403
|
+
if (hasResult && hasError || !hasResult && !hasError) return false;
|
|
2404
|
+
if (hasError && !isValidJsonRpcError(value["error"])) return false;
|
|
2405
|
+
return true;
|
|
2406
|
+
}
|
|
2407
|
+
function makeWrappedError(requestId, message, data) {
|
|
2408
|
+
return {
|
|
2409
|
+
jsonrpc: "2.0",
|
|
2410
|
+
id: requestId ?? null,
|
|
2411
|
+
error: {
|
|
2412
|
+
code: INTERNAL_ERROR,
|
|
2413
|
+
message,
|
|
2414
|
+
data
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
function normalizeUpstreamOutcome(args) {
|
|
2419
|
+
if (args.forwardingError) {
|
|
2420
|
+
return {
|
|
2421
|
+
httpStatus: 200,
|
|
2422
|
+
wrapped: true,
|
|
2423
|
+
body: makeWrappedError(args.requestId, "upstream forwarding failed", {
|
|
2424
|
+
failure_class: "upstream_forward_error",
|
|
2425
|
+
failure_reason: args.forwardingError.message
|
|
2426
|
+
})
|
|
2427
|
+
};
|
|
1838
2428
|
}
|
|
1839
|
-
|
|
1840
|
-
|
|
2429
|
+
if (!args.upstreamResponse) {
|
|
2430
|
+
return {
|
|
2431
|
+
httpStatus: 200,
|
|
2432
|
+
wrapped: true,
|
|
2433
|
+
body: makeWrappedError(args.requestId, "upstream forwarding failed", {
|
|
2434
|
+
failure_class: "upstream_forward_error",
|
|
2435
|
+
failure_reason: "missing upstream response"
|
|
2436
|
+
})
|
|
2437
|
+
};
|
|
2438
|
+
}
|
|
2439
|
+
const upstream = args.upstreamResponse;
|
|
2440
|
+
const upstreamContentType = upstream.headers["content-type"] ?? null;
|
|
2441
|
+
if (!isValidJsonRpcResponse(upstream.body)) {
|
|
2442
|
+
return {
|
|
2443
|
+
httpStatus: 200,
|
|
2444
|
+
wrapped: true,
|
|
2445
|
+
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
2446
|
+
failure_class: "upstream_invalid_jsonrpc",
|
|
2447
|
+
upstream_http_status: upstream.status,
|
|
2448
|
+
upstream_content_type: upstreamContentType,
|
|
2449
|
+
upstream_body_type: typeof upstream.body
|
|
2450
|
+
})
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
if (args.requestId !== void 0) {
|
|
2454
|
+
const upstreamId = getJsonRpcId(upstream.body);
|
|
2455
|
+
if (upstreamId === void 0) {
|
|
2456
|
+
return {
|
|
2457
|
+
httpStatus: 200,
|
|
2458
|
+
wrapped: true,
|
|
2459
|
+
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
2460
|
+
failure_class: "upstream_invalid_jsonrpc",
|
|
2461
|
+
upstream_http_status: upstream.status,
|
|
2462
|
+
upstream_content_type: upstreamContentType,
|
|
2463
|
+
upstream_body_type: typeof upstream.body,
|
|
2464
|
+
invalid_reason: "missing_response_id"
|
|
2465
|
+
})
|
|
2466
|
+
};
|
|
2467
|
+
}
|
|
2468
|
+
const expectedId = args.requestId ?? null;
|
|
2469
|
+
if (upstreamId !== expectedId) {
|
|
2470
|
+
return {
|
|
2471
|
+
httpStatus: 200,
|
|
2472
|
+
wrapped: true,
|
|
2473
|
+
body: makeWrappedError(args.requestId, "upstream response id mismatch", {
|
|
2474
|
+
failure_class: "upstream_id_mismatch",
|
|
2475
|
+
expected_request_id: expectedId,
|
|
2476
|
+
upstream_response_id: upstreamId
|
|
2477
|
+
})
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
return {
|
|
2482
|
+
httpStatus: 200,
|
|
2483
|
+
wrapped: false,
|
|
2484
|
+
body: upstream.body
|
|
2485
|
+
};
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// src/transport/streamable-http.ts
|
|
2489
|
+
var MCP_SESSION_HEADER = "mcp-session-id";
|
|
2490
|
+
var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
|
|
2491
|
+
function createStreamableHttpRoute(forwarder, options = {}) {
|
|
2492
|
+
const app = new Hono();
|
|
2493
|
+
const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
|
|
2494
|
+
const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
2495
|
+
app.use("*", createOriginGuard(options.allowedOrigins ?? []));
|
|
2496
|
+
app.post("/", async (c) => {
|
|
2497
|
+
const handlerStart = performance.now();
|
|
2498
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
2499
|
+
return c.json(
|
|
2500
|
+
makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Content-Type must be application/json"),
|
|
2501
|
+
415
|
|
2502
|
+
);
|
|
2503
|
+
}
|
|
2504
|
+
let body;
|
|
2505
|
+
try {
|
|
2506
|
+
body = await c.req.json();
|
|
2507
|
+
} catch {
|
|
2508
|
+
return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
|
|
2509
|
+
}
|
|
2510
|
+
const parsedRequest = parseJsonRpcRequest(body);
|
|
2511
|
+
if (!parsedRequest.success) {
|
|
2512
|
+
const errorBody = parsedRequest.id === null ? makeJsonRpcErrorWithoutId(INVALID_REQUEST, parsedRequest.message) : makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message);
|
|
2513
|
+
return c.json(errorBody, 400);
|
|
2514
|
+
}
|
|
2515
|
+
const id = parsedRequest.request.id;
|
|
2516
|
+
const method = parsedRequest.request.method;
|
|
2517
|
+
const params = parsedRequest.request.params;
|
|
2518
|
+
const transportSessionId = c.req.header(MCP_SESSION_HEADER);
|
|
2519
|
+
const session = resolveSession(
|
|
1841
2520
|
{
|
|
1842
|
-
|
|
1843
|
-
|
|
2521
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
2522
|
+
meta: paramsMeta(params),
|
|
2523
|
+
transportSessionId
|
|
1844
2524
|
},
|
|
1845
|
-
|
|
1846
|
-
this.staticHeaders
|
|
2525
|
+
sessionIdentity
|
|
1847
2526
|
);
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
params
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2527
|
+
const protocolVersion = c.req.header("mcp-protocol-version");
|
|
2528
|
+
const agreement = validateHeaderBodyAgreement({
|
|
2529
|
+
method,
|
|
2530
|
+
id,
|
|
2531
|
+
params,
|
|
2532
|
+
headers: {
|
|
2533
|
+
"mcp-method": c.req.header("mcp-method"),
|
|
2534
|
+
"mcp-name": c.req.header("mcp-name"),
|
|
2535
|
+
"mcp-protocol-version": protocolVersion
|
|
1856
2536
|
}
|
|
2537
|
+
});
|
|
2538
|
+
if (!agreement.ok) {
|
|
2539
|
+
options.onHeaderMismatch?.({
|
|
2540
|
+
reason: agreement.reason,
|
|
2541
|
+
method,
|
|
2542
|
+
params,
|
|
2543
|
+
...agreement.evidence.bodyName !== void 0 && { bodyName: agreement.evidence.bodyName },
|
|
2544
|
+
...protocolVersion !== void 0 && { protocolVersion },
|
|
2545
|
+
headers: agreement.evidence.headers,
|
|
2546
|
+
...session !== void 0 && { session },
|
|
2547
|
+
durationMs: performance.now() - handlerStart
|
|
2548
|
+
});
|
|
2549
|
+
const errorBody = id === void 0 || id === null ? makeJsonRpcErrorWithoutId(HEADER_MISMATCH, agreement.reason) : makeJsonRpcError(id, HEADER_MISMATCH, agreement.reason);
|
|
2550
|
+
return c.json(errorBody, 400);
|
|
2551
|
+
}
|
|
2552
|
+
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
2553
|
+
const mcpRequest = {
|
|
2554
|
+
jsonrpc: "2.0",
|
|
2555
|
+
id,
|
|
2556
|
+
method,
|
|
2557
|
+
params,
|
|
2558
|
+
session,
|
|
2559
|
+
transportSessionId,
|
|
2560
|
+
protocolVersion,
|
|
2561
|
+
headers: forwardHeaders,
|
|
2562
|
+
signal: c.req.raw.signal
|
|
1857
2563
|
};
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
body: JSON.stringify(initBody),
|
|
1864
|
-
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
2564
|
+
if (id === void 0) {
|
|
2565
|
+
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
2566
|
+
void forwarder.forward(notificationRequest).catch((err) => {
|
|
2567
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2568
|
+
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
1865
2569
|
});
|
|
1866
|
-
|
|
1867
|
-
throw this.describeFetchFailure(error, "initialize");
|
|
2570
|
+
return c.body(null, 202);
|
|
1868
2571
|
}
|
|
1869
|
-
|
|
1870
|
-
|
|
2572
|
+
let result;
|
|
2573
|
+
try {
|
|
2574
|
+
result = await forwarder.forward(mcpRequest);
|
|
2575
|
+
} catch (err) {
|
|
2576
|
+
const forwardingError = err instanceof Error ? err : new Error(String(err));
|
|
2577
|
+
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
2578
|
+
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
2579
|
+
return c.json(normalized2.body, normalized2.httpStatus);
|
|
1871
2580
|
}
|
|
1872
|
-
const
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
);
|
|
1878
|
-
const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
|
|
1879
|
-
if (initializeError) {
|
|
1880
|
-
throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
|
|
2581
|
+
const { response } = result;
|
|
2582
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
2583
|
+
if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
|
2584
|
+
c.header(key, value);
|
|
2585
|
+
}
|
|
1881
2586
|
}
|
|
1882
|
-
const
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
2587
|
+
const normalized = normalizeUpstreamOutcome({ requestId: id, upstreamResponse: response });
|
|
2588
|
+
return c.json(normalized.body, normalized.httpStatus);
|
|
2589
|
+
});
|
|
2590
|
+
return app;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/transport/sse.ts
|
|
2594
|
+
import { randomUUID } from "crypto";
|
|
2595
|
+
import { Hono as Hono2 } from "hono";
|
|
2596
|
+
import { z as z3 } from "zod";
|
|
2597
|
+
var encoder = new TextEncoder();
|
|
2598
|
+
var MCP_SESSION_HEADER2 = "mcp-session-id";
|
|
2599
|
+
var STALE_THRESHOLD_MS = 9e4;
|
|
2600
|
+
var SWEEP_INTERVAL_MS = 6e4;
|
|
2601
|
+
var MAX_CONCURRENT_SESSIONS = 1024;
|
|
2602
|
+
var REFUSAL_LOG_WINDOW_MS = 1e4;
|
|
2603
|
+
var ssePostQuerySchema = z3.object({
|
|
2604
|
+
sessionId: z3.string().min(1)
|
|
2605
|
+
});
|
|
2606
|
+
function sseEvent(event, data) {
|
|
2607
|
+
return `event: ${event}
|
|
2608
|
+
data: ${data}
|
|
2609
|
+
|
|
2610
|
+
`;
|
|
2611
|
+
}
|
|
2612
|
+
function createSseRoute(forwarder, options = {}) {
|
|
2613
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2614
|
+
const app = new Hono2();
|
|
2615
|
+
const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
|
|
2616
|
+
const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
2617
|
+
const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
|
|
2618
|
+
let refusalCount = 0;
|
|
2619
|
+
let lastRefusalLogAt = null;
|
|
2620
|
+
const logRefusal = () => {
|
|
2621
|
+
refusalCount += 1;
|
|
2622
|
+
const now = Date.now();
|
|
2623
|
+
if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
|
|
2624
|
+
lastRefusalLogAt = now;
|
|
2625
|
+
console.error(
|
|
2626
|
+
`[helio] /sse at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
|
|
2627
|
+
);
|
|
2628
|
+
};
|
|
2629
|
+
app.use("*", createOriginGuard(options.allowedOrigins ?? []));
|
|
2630
|
+
const writeSessionEvent = (sessionId, eventPayload) => {
|
|
2631
|
+
const session = sessions.get(sessionId);
|
|
2632
|
+
if (!session) return;
|
|
2633
|
+
session.lastActivity = Date.now();
|
|
2634
|
+
void session.writer.write(encoder.encode(eventPayload)).catch(() => {
|
|
2635
|
+
sessions.delete(sessionId);
|
|
2636
|
+
void session.writer.close().catch(() => {
|
|
2637
|
+
});
|
|
1893
2638
|
});
|
|
1894
|
-
|
|
1895
|
-
|
|
2639
|
+
};
|
|
2640
|
+
const sweepInterval = setInterval(() => {
|
|
2641
|
+
const now = Date.now();
|
|
2642
|
+
for (const [id, session] of sessions) {
|
|
2643
|
+
if (now - session.lastActivity > STALE_THRESHOLD_MS) {
|
|
2644
|
+
sessions.delete(id);
|
|
2645
|
+
void session.writer.close().catch(() => {
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
1896
2648
|
}
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
2649
|
+
}, SWEEP_INTERVAL_MS);
|
|
2650
|
+
sweepInterval.unref();
|
|
2651
|
+
app.get("/", (c) => {
|
|
2652
|
+
if (sessions.size >= maxConcurrentSessions) {
|
|
2653
|
+
logRefusal();
|
|
2654
|
+
return c.json({ error: "session capacity reached" }, 503);
|
|
1900
2655
|
}
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
2656
|
+
const sessionId = randomUUID();
|
|
2657
|
+
const { readable, writable } = new TransformStream();
|
|
2658
|
+
const writer = writable.getWriter();
|
|
2659
|
+
sessions.set(sessionId, { writer, lastActivity: Date.now() });
|
|
2660
|
+
const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
|
|
2661
|
+
writeSessionEvent(sessionId, endpointData);
|
|
2662
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
2663
|
+
sessions.delete(sessionId);
|
|
2664
|
+
void writer.close().catch(() => {
|
|
2665
|
+
});
|
|
2666
|
+
});
|
|
2667
|
+
return new Response(readable, {
|
|
2668
|
+
headers: {
|
|
2669
|
+
"content-type": "text/event-stream",
|
|
2670
|
+
"cache-control": "no-cache",
|
|
2671
|
+
connection: "keep-alive"
|
|
2672
|
+
}
|
|
2673
|
+
});
|
|
2674
|
+
});
|
|
2675
|
+
app.post("/", async (c) => {
|
|
2676
|
+
const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
|
|
2677
|
+
if (!parsedQuery.success) {
|
|
2678
|
+
return c.json(
|
|
2679
|
+
makeJsonRpcErrorWithoutId(INVALID_REQUEST, "missing sessionId query parameter"),
|
|
2680
|
+
400
|
|
2681
|
+
);
|
|
1908
2682
|
}
|
|
1909
|
-
const
|
|
1910
|
-
|
|
1911
|
-
|
|
2683
|
+
const sessionId = parsedQuery.data.sessionId;
|
|
2684
|
+
const session = sessions.get(sessionId);
|
|
2685
|
+
if (!session) {
|
|
2686
|
+
return c.json(makeJsonRpcErrorWithoutId(INVALID_REQUEST, "unknown session"), 404);
|
|
1912
2687
|
}
|
|
1913
|
-
|
|
2688
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
2689
|
+
return c.json(
|
|
2690
|
+
makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Content-Type must be application/json"),
|
|
2691
|
+
415
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2694
|
+
let body;
|
|
1914
2695
|
try {
|
|
1915
|
-
|
|
2696
|
+
body = await c.req.json();
|
|
1916
2697
|
} catch {
|
|
1917
|
-
|
|
2698
|
+
return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
|
|
1918
2699
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
2700
|
+
const parsedRequest = parseJsonRpcRequest(body);
|
|
2701
|
+
if (!parsedRequest.success) {
|
|
2702
|
+
const errorBody = parsedRequest.id === null ? makeJsonRpcErrorWithoutId(INVALID_REQUEST, parsedRequest.message) : makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message);
|
|
2703
|
+
return c.json(errorBody, 400);
|
|
1921
2704
|
}
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
const
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
2705
|
+
const id = parsedRequest.request.id;
|
|
2706
|
+
const method = parsedRequest.request.method;
|
|
2707
|
+
const params = parsedRequest.request.params;
|
|
2708
|
+
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
2709
|
+
const transportSessionId = c.req.header(MCP_SESSION_HEADER2);
|
|
2710
|
+
const resolvedSession = resolveSession(
|
|
2711
|
+
{
|
|
2712
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
2713
|
+
meta: paramsMeta(params),
|
|
2714
|
+
transportSessionId,
|
|
2715
|
+
transportMintedId: sessionId
|
|
2716
|
+
},
|
|
2717
|
+
sessionIdentity
|
|
2718
|
+
);
|
|
2719
|
+
const mcpRequest = {
|
|
2720
|
+
jsonrpc: "2.0",
|
|
2721
|
+
id,
|
|
2722
|
+
method,
|
|
2723
|
+
params,
|
|
2724
|
+
session: resolvedSession,
|
|
2725
|
+
transportSessionId,
|
|
2726
|
+
headers: forwardHeaders,
|
|
2727
|
+
signal: c.req.raw.signal
|
|
2728
|
+
};
|
|
2729
|
+
if (id === void 0) {
|
|
2730
|
+
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
2731
|
+
void forwarder.forward(notificationRequest).catch((err) => {
|
|
2732
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2733
|
+
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
2734
|
+
});
|
|
2735
|
+
return c.body(null, 202);
|
|
2736
|
+
}
|
|
2737
|
+
let result;
|
|
2738
|
+
try {
|
|
2739
|
+
result = await forwarder.forward(mcpRequest);
|
|
2740
|
+
} catch (err) {
|
|
2741
|
+
const forwardingError = err instanceof Error ? err : new Error(String(err));
|
|
2742
|
+
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
2743
|
+
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
2744
|
+
const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
|
|
2745
|
+
writeSessionEvent(sessionId, errorEvent);
|
|
2746
|
+
return c.body(null, 202);
|
|
2747
|
+
}
|
|
2748
|
+
const normalized = normalizeUpstreamOutcome({
|
|
2749
|
+
requestId: id,
|
|
2750
|
+
upstreamResponse: result.response
|
|
2751
|
+
});
|
|
2752
|
+
const messageEvent = sseEvent("message", JSON.stringify(normalized.body));
|
|
2753
|
+
writeSessionEvent(sessionId, messageEvent);
|
|
2754
|
+
return c.body(null, 202);
|
|
2755
|
+
});
|
|
2756
|
+
return app;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// src/server.ts
|
|
2760
|
+
var FORCE_CONNECTION_CLOSE_GRACE_MS = 1500;
|
|
2761
|
+
function normalizeError(error) {
|
|
2762
|
+
if (error instanceof Error) return error;
|
|
2763
|
+
return new Error(String(error));
|
|
2764
|
+
}
|
|
2765
|
+
function createServerHandle(server) {
|
|
2766
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
2767
|
+
const nodeServer = server;
|
|
2768
|
+
nodeServer.on("connection", (socket) => {
|
|
2769
|
+
sockets.add(socket);
|
|
2770
|
+
socket.on("close", () => {
|
|
2771
|
+
sockets.delete(socket);
|
|
2772
|
+
});
|
|
2773
|
+
});
|
|
2774
|
+
const forceCloseConnections = () => {
|
|
2775
|
+
try {
|
|
2776
|
+
nodeServer.closeIdleConnections?.();
|
|
2777
|
+
} catch {
|
|
2778
|
+
}
|
|
2779
|
+
if (nodeServer.closeAllConnections) {
|
|
2780
|
+
try {
|
|
2781
|
+
nodeServer.closeAllConnections();
|
|
2782
|
+
} catch {
|
|
2783
|
+
}
|
|
2784
|
+
return;
|
|
2785
|
+
}
|
|
2786
|
+
for (const socket of sockets) {
|
|
2787
|
+
socket.destroy();
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
return {
|
|
2791
|
+
server,
|
|
2792
|
+
close: () => new Promise((resolve2, reject) => {
|
|
2793
|
+
let settled = false;
|
|
2794
|
+
let forceTimer;
|
|
2795
|
+
const settle = (err) => {
|
|
2796
|
+
if (settled) return;
|
|
2797
|
+
settled = true;
|
|
2798
|
+
if (forceTimer) {
|
|
2799
|
+
clearTimeout(forceTimer);
|
|
2800
|
+
forceTimer = void 0;
|
|
2801
|
+
}
|
|
2802
|
+
if (err) {
|
|
2803
|
+
reject(err);
|
|
1941
2804
|
return;
|
|
1942
2805
|
}
|
|
1943
|
-
|
|
1944
|
-
errorMessage = extractJsonRpcErrorMessage(parsed2);
|
|
2806
|
+
resolve2();
|
|
1945
2807
|
};
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1952
|
-
);
|
|
1953
|
-
}
|
|
1954
|
-
let chunk;
|
|
1955
|
-
try {
|
|
1956
|
-
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1957
|
-
} catch {
|
|
1958
|
-
await reader.cancel().catch(() => void 0);
|
|
1959
|
-
throw new Error(
|
|
1960
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1961
|
-
);
|
|
1962
|
-
}
|
|
1963
|
-
const { done, value } = chunk;
|
|
1964
|
-
if (value !== void 0) {
|
|
1965
|
-
scannedBytes += value.byteLength;
|
|
1966
|
-
if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
|
|
1967
|
-
await reader.cancel().catch(() => void 0);
|
|
1968
|
-
throw new Error(
|
|
1969
|
-
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
|
|
1970
|
-
);
|
|
1971
|
-
}
|
|
1972
|
-
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
1973
|
-
if (errorMessage) {
|
|
1974
|
-
await reader.cancel().catch(() => void 0);
|
|
1975
|
-
return errorMessage;
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
if (done) {
|
|
1979
|
-
const tail = decoder.decode();
|
|
1980
|
-
if (tail) {
|
|
1981
|
-
state = parseSseChunk(tail, state, onEvent);
|
|
2808
|
+
try {
|
|
2809
|
+
nodeServer.close((err) => {
|
|
2810
|
+
if (err) {
|
|
2811
|
+
settle(err);
|
|
2812
|
+
return;
|
|
1982
2813
|
}
|
|
1983
|
-
|
|
1984
|
-
}
|
|
2814
|
+
settle();
|
|
2815
|
+
});
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
settle(normalizeError(error));
|
|
2818
|
+
return;
|
|
1985
2819
|
}
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
2820
|
+
try {
|
|
2821
|
+
nodeServer.closeIdleConnections?.();
|
|
2822
|
+
} catch {
|
|
2823
|
+
}
|
|
2824
|
+
forceTimer = setTimeout(() => {
|
|
2825
|
+
forceCloseConnections();
|
|
2826
|
+
}, FORCE_CONNECTION_CLOSE_GRACE_MS);
|
|
2827
|
+
forceTimer.unref();
|
|
2828
|
+
})
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
function createApp(config, forwarder, options) {
|
|
2832
|
+
const app = new Hono3();
|
|
2833
|
+
const forwardHeadersAllowlist = config.upstream.forward_headers;
|
|
2834
|
+
const allowedOrigins = config.listen.allowed_origins;
|
|
2835
|
+
const session = compileSessionIdentity(config.session);
|
|
2836
|
+
app.get("/healthz", (c) => c.json({ status: "ok" }));
|
|
2837
|
+
app.route(
|
|
2838
|
+
"/mcp",
|
|
2839
|
+
createStreamableHttpRoute(forwarder, {
|
|
2840
|
+
forwardHeadersAllowlist,
|
|
2841
|
+
allowedOrigins,
|
|
2842
|
+
session,
|
|
2843
|
+
onHeaderMismatch: options?.onHeaderMismatch
|
|
2844
|
+
})
|
|
2845
|
+
);
|
|
2846
|
+
app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist, allowedOrigins, session }));
|
|
2847
|
+
if (options?.slackActionApp) {
|
|
2848
|
+
app.route("/slack/actions", options.slackActionApp);
|
|
2016
2849
|
}
|
|
2850
|
+
return app;
|
|
2017
2851
|
}
|
|
2018
|
-
function
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2852
|
+
function startServer(app, config) {
|
|
2853
|
+
const server = serve({
|
|
2854
|
+
fetch: app.fetch,
|
|
2855
|
+
port: config.listen.port,
|
|
2856
|
+
hostname: config.listen.host
|
|
2857
|
+
});
|
|
2858
|
+
return createServerHandle(server);
|
|
2024
2859
|
}
|
|
2025
|
-
function
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
return
|
|
2860
|
+
function startSidebandServer(app, port, host = "127.0.0.1") {
|
|
2861
|
+
const server = serve({
|
|
2862
|
+
fetch: app.fetch,
|
|
2863
|
+
port,
|
|
2864
|
+
hostname: host
|
|
2865
|
+
});
|
|
2866
|
+
return createServerHandle(server);
|
|
2032
2867
|
}
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2868
|
+
|
|
2869
|
+
// src/upstream/response.ts
|
|
2870
|
+
async function parseUpstreamResponse(res) {
|
|
2871
|
+
const headers = {};
|
|
2872
|
+
res.headers.forEach((value, key) => {
|
|
2873
|
+
headers[key] = value;
|
|
2874
|
+
});
|
|
2875
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
2876
|
+
let body;
|
|
2877
|
+
if (contentType.includes("application/json")) {
|
|
2878
|
+
const text = await res.text();
|
|
2879
|
+
try {
|
|
2880
|
+
body = JSON.parse(text);
|
|
2881
|
+
} catch {
|
|
2882
|
+
body = text;
|
|
2883
|
+
}
|
|
2884
|
+
} else {
|
|
2885
|
+
body = await res.text();
|
|
2037
2886
|
}
|
|
2038
|
-
|
|
2039
|
-
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
|
|
2887
|
+
return { status: res.status, headers, body };
|
|
2040
2888
|
}
|
|
2041
2889
|
|
|
2042
2890
|
// src/upstream/streamable-http-forwarder.ts
|
|
2891
|
+
var JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
2043
2892
|
var StreamableHttpForwarder = class {
|
|
2044
2893
|
url;
|
|
2045
2894
|
staticHeaders;
|
|
@@ -2052,7 +2901,8 @@ var StreamableHttpForwarder = class {
|
|
|
2052
2901
|
this.sessions = new UpstreamSessionManager({
|
|
2053
2902
|
url: this.url,
|
|
2054
2903
|
staticHeaders: this.staticHeaders,
|
|
2055
|
-
requestTimeoutMs: this.requestTimeoutMs
|
|
2904
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
2905
|
+
protocolVersion: options.protocolVersion
|
|
2056
2906
|
});
|
|
2057
2907
|
}
|
|
2058
2908
|
/** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
|
|
@@ -2065,15 +2915,91 @@ var StreamableHttpForwarder = class {
|
|
|
2065
2915
|
return Promise.resolve();
|
|
2066
2916
|
}
|
|
2067
2917
|
async forward(request) {
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
request
|
|
2072
|
-
|
|
2073
|
-
|
|
2918
|
+
const era = await this.sessions.resolveRelayEra();
|
|
2919
|
+
if (era === "modern") {
|
|
2920
|
+
if (request.method === "initialize") {
|
|
2921
|
+
return this.synthesizeInitializeResult(request);
|
|
2922
|
+
}
|
|
2923
|
+
if (request.method === "notifications/initialized") {
|
|
2924
|
+
return this.swallowInitializedNotification();
|
|
2925
|
+
}
|
|
2926
|
+
return this.send(request, {
|
|
2927
|
+
sessionId: void 0,
|
|
2928
|
+
protocolVersion: void 0,
|
|
2929
|
+
era: "modern"
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
const result = request.method === "initialize" ? await this.send(request, {
|
|
2933
|
+
sessionId: request.transportSessionId,
|
|
2934
|
+
protocolVersion: void 0
|
|
2935
|
+
}) : (
|
|
2936
|
+
// Downstream-driven and external sessionless callers alike are
|
|
2937
|
+
// transparent passthrough: forward whatever session the caller did
|
|
2938
|
+
// (or did not) supply.
|
|
2939
|
+
await this.send(request, {
|
|
2940
|
+
sessionId: request.transportSessionId,
|
|
2941
|
+
protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION
|
|
2942
|
+
})
|
|
2943
|
+
);
|
|
2944
|
+
this.inspectLegacyRelayOutcome(request, result.response);
|
|
2945
|
+
return result;
|
|
2946
|
+
}
|
|
2947
|
+
/**
|
|
2948
|
+
* The dual-era bridge (relay leg, modern era): a modern-only server
|
|
2949
|
+
* answers the retired `initialize` handshake with 404/-32601, so Helio
|
|
2950
|
+
* synthesizes the legacy InitializeResult locally from the upstream's own
|
|
2951
|
+
* probe-time DiscoverResult. No `mcp-session-id` response header — the
|
|
2952
|
+
* legacy spec permits sessionless servers, and the downstream stays
|
|
2953
|
+
* sessionless. The synthesized protocolVersion is always the current
|
|
2954
|
+
* legacy revision, even for a client that offered an older one.
|
|
2955
|
+
*/
|
|
2956
|
+
synthesizeInitializeResult(request) {
|
|
2957
|
+
const capture = this.sessions.getDiscoverCapture();
|
|
2958
|
+
const result = {
|
|
2959
|
+
protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION,
|
|
2960
|
+
capabilities: capture?.capabilities ?? { tools: {} },
|
|
2961
|
+
// NOT copied from upstream: the 2026-07-28 DiscoverResult has no
|
|
2962
|
+
// serverInfo field at all, and the bridge is Helio's own construct —
|
|
2963
|
+
// matching buildInternalMeta()'s identity.
|
|
2964
|
+
serverInfo: { name: "helio-proxy", version: "0" }
|
|
2965
|
+
};
|
|
2966
|
+
if (capture?.instructions !== void 0) {
|
|
2967
|
+
result["instructions"] = capture.instructions;
|
|
2968
|
+
}
|
|
2969
|
+
const response = {
|
|
2970
|
+
status: 200,
|
|
2971
|
+
headers: { "content-type": "application/json" },
|
|
2972
|
+
body: { jsonrpc: "2.0", id: request.id ?? null, result }
|
|
2973
|
+
};
|
|
2974
|
+
return { response, durationMs: 0 };
|
|
2975
|
+
}
|
|
2976
|
+
/**
|
|
2977
|
+
* The modern upstream removed `notifications/initialized`; answer the
|
|
2978
|
+
* same minimal success envelope the SSE-notification path returns.
|
|
2979
|
+
*/
|
|
2980
|
+
swallowInitializedNotification() {
|
|
2981
|
+
const response = { status: 200, headers: {}, body: { jsonrpc: "2.0" } };
|
|
2982
|
+
return { response, durationMs: 0 };
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* The relay-side era falsification door (issue #219): a legacy-leg relay whose answer only a
|
|
2986
|
+
* modern server gives clears the cached legacy era (the manager no-ops on
|
|
2987
|
+
* pins, uncached eras, and cached modern). The response still flows to the
|
|
2988
|
+
* client unchanged — no in-place retry.
|
|
2989
|
+
*/
|
|
2990
|
+
inspectLegacyRelayOutcome(request, response) {
|
|
2991
|
+
const errorCode = readJsonRpcErrorCode(response.body);
|
|
2992
|
+
if (errorCode !== void 0 && MCP_MODERN_ONLY_ERROR_CODES.has(errorCode)) {
|
|
2993
|
+
this.sessions.clearFalsifiedLegacyEra(
|
|
2994
|
+
`a relayed response carried the modern-only JSON-RPC error ${String(errorCode)}`
|
|
2995
|
+
);
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
if (request.method === "initialize" && (response.status === 404 || errorCode === JSON_RPC_METHOD_NOT_FOUND)) {
|
|
2999
|
+
this.sessions.clearFalsifiedLegacyEra(
|
|
3000
|
+
response.status === 404 ? "a relayed initialize was answered with HTTP 404" : "a relayed initialize was answered with JSON-RPC -32601"
|
|
2074
3001
|
);
|
|
2075
3002
|
}
|
|
2076
|
-
return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
|
|
2077
3003
|
}
|
|
2078
3004
|
/**
|
|
2079
3005
|
* Helio-internal execution path (startup prime / internal maintenance) that
|
|
@@ -2084,8 +3010,7 @@ var StreamableHttpForwarder = class {
|
|
|
2084
3010
|
try {
|
|
2085
3011
|
return await this.send(
|
|
2086
3012
|
request,
|
|
2087
|
-
session
|
|
2088
|
-
session.protocolVersion,
|
|
3013
|
+
session,
|
|
2089
3014
|
/* internalManaged */
|
|
2090
3015
|
true
|
|
2091
3016
|
);
|
|
@@ -2095,8 +3020,7 @@ var StreamableHttpForwarder = class {
|
|
|
2095
3020
|
const fresh = await this.sessions.ensureInternalSession();
|
|
2096
3021
|
return this.send(
|
|
2097
3022
|
request,
|
|
2098
|
-
fresh
|
|
2099
|
-
fresh.protocolVersion,
|
|
3023
|
+
fresh,
|
|
2100
3024
|
/* internalManaged */
|
|
2101
3025
|
true
|
|
2102
3026
|
);
|
|
@@ -2104,7 +3028,50 @@ var StreamableHttpForwarder = class {
|
|
|
2104
3028
|
throw error;
|
|
2105
3029
|
}
|
|
2106
3030
|
}
|
|
2107
|
-
|
|
3031
|
+
/** Drop the managed internal session AND the cached era; next internal call re-probes. */
|
|
3032
|
+
resetInternalSession() {
|
|
3033
|
+
this.sessions.invalidateInternalSession();
|
|
3034
|
+
}
|
|
3035
|
+
async send(request, session, internalManaged = false) {
|
|
3036
|
+
const modern = session.era === "modern";
|
|
3037
|
+
let outboundParams;
|
|
3038
|
+
if (modern) {
|
|
3039
|
+
if (request.params !== void 0 && !isPlainObject(request.params)) {
|
|
3040
|
+
throw new Error(
|
|
3041
|
+
"helio refused to forward: MCP 2026-07-28 requires the _meta mirror inside params, which cannot be attached to array or primitive params; send object params or none"
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
const params = request.params ?? {};
|
|
3045
|
+
const rawMeta = params["_meta"];
|
|
3046
|
+
const existingMeta = isPlainObject(rawMeta) ? rawMeta : {};
|
|
3047
|
+
const internalMeta = buildInternalMeta();
|
|
3048
|
+
const clientCapabilities = existingMeta["io.modelcontextprotocol/clientCapabilities"];
|
|
3049
|
+
const clientInfo = existingMeta["io.modelcontextprotocol/clientInfo"];
|
|
3050
|
+
outboundParams = {
|
|
3051
|
+
...params,
|
|
3052
|
+
_meta: {
|
|
3053
|
+
...existingMeta,
|
|
3054
|
+
[MCP_META_PROTOCOL_VERSION_KEY]: HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
3055
|
+
"io.modelcontextprotocol/clientCapabilities": clientCapabilities !== void 0 ? clientCapabilities : internalMeta["io.modelcontextprotocol/clientCapabilities"],
|
|
3056
|
+
"io.modelcontextprotocol/clientInfo": clientInfo !== void 0 ? clientInfo : internalMeta["io.modelcontextprotocol/clientInfo"]
|
|
3057
|
+
}
|
|
3058
|
+
};
|
|
3059
|
+
} else {
|
|
3060
|
+
outboundParams = request.params;
|
|
3061
|
+
}
|
|
3062
|
+
if (modern) {
|
|
3063
|
+
if (!isHeaderSafeMethod(request.method)) {
|
|
3064
|
+
throw new Error(
|
|
3065
|
+
"helio refused to forward: the request method cannot be carried in the Mcp-Method header that MCP 2026-07-28 requires (it contains characters outside the visible-ASCII token set), so the upstream is guaranteed to reject the request"
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
const nameValue = encodedNameValue(request.method, outboundParams);
|
|
3069
|
+
if (nameValue !== void 0 && Buffer.byteLength(nameValue) > MCP_NAME_MAX_BYTES) {
|
|
3070
|
+
throw new Error(
|
|
3071
|
+
`helio refused to forward: params.name/uri exceeds the ${String(MCP_NAME_MAX_BYTES)}-byte Mcp-Name header cap after encoding, and MCP 2026-07-28 requires the header on this method; shorten the name or uri`
|
|
3072
|
+
);
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
2108
3075
|
const headers = mergeUpstreamHeaders(
|
|
2109
3076
|
{
|
|
2110
3077
|
"content-type": "application/json",
|
|
@@ -2113,16 +3080,26 @@ var StreamableHttpForwarder = class {
|
|
|
2113
3080
|
request.headers ?? {},
|
|
2114
3081
|
this.staticHeaders
|
|
2115
3082
|
);
|
|
2116
|
-
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
2117
|
-
if (
|
|
2118
|
-
headers["mcp-
|
|
2119
|
-
|
|
3083
|
+
if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
|
|
3084
|
+
if (modern) {
|
|
3085
|
+
delete headers["mcp-session-id"];
|
|
3086
|
+
headers["mcp-protocol-version"] = HELIO_MCP_MODERN_PROTOCOL_VERSION;
|
|
3087
|
+
} else if (session.protocolVersion && headers["mcp-protocol-version"] === void 0) {
|
|
3088
|
+
headers["mcp-protocol-version"] = session.protocolVersion;
|
|
3089
|
+
}
|
|
3090
|
+
delete headers["mcp-method"];
|
|
3091
|
+
delete headers["mcp-name"];
|
|
3092
|
+
Object.assign(headers, buildStandardRequestHeaders(request.method, outboundParams));
|
|
2120
3093
|
const body = {
|
|
2121
3094
|
jsonrpc: request.jsonrpc,
|
|
2122
3095
|
method: request.method
|
|
2123
3096
|
};
|
|
2124
3097
|
if (request.id !== void 0) body["id"] = request.id;
|
|
2125
|
-
if (
|
|
3098
|
+
if (modern) {
|
|
3099
|
+
body["params"] = outboundParams;
|
|
3100
|
+
} else if (request.params !== void 0) {
|
|
3101
|
+
body["params"] = outboundParams;
|
|
3102
|
+
}
|
|
2126
3103
|
const start = performance.now();
|
|
2127
3104
|
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
2128
3105
|
const requestSignal = request.signal;
|
|
@@ -2138,7 +3115,7 @@ var StreamableHttpForwarder = class {
|
|
|
2138
3115
|
}
|
|
2139
3116
|
throw describeUnreachableUpstream(error, this.url) ?? error;
|
|
2140
3117
|
}
|
|
2141
|
-
if (internalManaged && res.status === 404 && sessionId) {
|
|
3118
|
+
if (internalManaged && res.status === 404 && session.sessionId) {
|
|
2142
3119
|
await res.text().catch(() => void 0);
|
|
2143
3120
|
throw new UpstreamSessionExpiredError();
|
|
2144
3121
|
}
|
|
@@ -2148,6 +3125,7 @@ var StreamableHttpForwarder = class {
|
|
|
2148
3125
|
res.headers.forEach((value, key) => {
|
|
2149
3126
|
responseHeaders[key] = value;
|
|
2150
3127
|
});
|
|
3128
|
+
if (modern) delete responseHeaders["mcp-session-id"];
|
|
2151
3129
|
if (request.id === void 0) {
|
|
2152
3130
|
await res.body?.cancel().catch(() => void 0);
|
|
2153
3131
|
const response3 = {
|
|
@@ -2162,6 +3140,7 @@ var StreamableHttpForwarder = class {
|
|
|
2162
3140
|
return { response: response2, durationMs: performance.now() - start };
|
|
2163
3141
|
}
|
|
2164
3142
|
const response = await parseUpstreamResponse(res);
|
|
3143
|
+
if (modern) delete response.headers["mcp-session-id"];
|
|
2165
3144
|
return { response, durationMs: performance.now() - start };
|
|
2166
3145
|
}
|
|
2167
3146
|
};
|
|
@@ -2171,6 +3150,16 @@ var UpstreamSessionExpiredError = class extends Error {
|
|
|
2171
3150
|
this.name = "UpstreamSessionExpiredError";
|
|
2172
3151
|
}
|
|
2173
3152
|
};
|
|
3153
|
+
function isPlainObject(value) {
|
|
3154
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3155
|
+
}
|
|
3156
|
+
function readJsonRpcErrorCode(body) {
|
|
3157
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
3158
|
+
const error = body["error"];
|
|
3159
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
3160
|
+
const code = error["code"];
|
|
3161
|
+
return typeof code === "number" ? code : void 0;
|
|
3162
|
+
}
|
|
2174
3163
|
|
|
2175
3164
|
// src/mcp/pending-requests.ts
|
|
2176
3165
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -2319,8 +3308,11 @@ var SseUpstreamForwarder = class {
|
|
|
2319
3308
|
request.headers ?? {},
|
|
2320
3309
|
this.staticHeaders
|
|
2321
3310
|
);
|
|
2322
|
-
|
|
2323
|
-
|
|
3311
|
+
delete headers["mcp-method"];
|
|
3312
|
+
delete headers["mcp-name"];
|
|
3313
|
+
delete headers["mcp-session-id"];
|
|
3314
|
+
if (request.transportSessionId) {
|
|
3315
|
+
headers["mcp-session-id"] = request.transportSessionId;
|
|
2324
3316
|
}
|
|
2325
3317
|
const start = performance.now();
|
|
2326
3318
|
const signal = buildRequestSignal(request, this.requestTimeoutMs);
|
|
@@ -2637,8 +3629,14 @@ async function createForwarderFromConfig(config) {
|
|
|
2637
3629
|
const http = new StreamableHttpForwarder({
|
|
2638
3630
|
url: config.upstream.url,
|
|
2639
3631
|
headers: config.upstream.headers,
|
|
2640
|
-
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
3632
|
+
requestTimeoutMs: parseDuration(config.upstream.request_timeout),
|
|
3633
|
+
protocolVersion: config.upstream.protocol_version
|
|
2641
3634
|
});
|
|
3635
|
+
if (config.upstream.protocol_version !== "auto") {
|
|
3636
|
+
console.error(
|
|
3637
|
+
`[helio] Upstream MCP protocol version pinned: ${config.upstream.protocol_version} (upstream.protocol_version)`
|
|
3638
|
+
);
|
|
3639
|
+
}
|
|
2642
3640
|
await http.connect();
|
|
2643
3641
|
return { forwarder: http, close: () => http.close() };
|
|
2644
3642
|
}
|
|
@@ -2783,6 +3781,79 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2783
3781
|
// src/policy/governed-forwarder.ts
|
|
2784
3782
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2785
3783
|
|
|
3784
|
+
// src/policy/session-gate.ts
|
|
3785
|
+
function isWellFormedSessionId(id) {
|
|
3786
|
+
return id != null && id.trim() !== "";
|
|
3787
|
+
}
|
|
3788
|
+
function gateSession(sessionId, onUnresolved) {
|
|
3789
|
+
if (isWellFormedSessionId(sessionId)) {
|
|
3790
|
+
return { ok: true, session: sessionId, anonymous: false };
|
|
3791
|
+
}
|
|
3792
|
+
if (onUnresolved === "anonymous") {
|
|
3793
|
+
return { ok: true, session: "unknown", anonymous: true };
|
|
3794
|
+
}
|
|
3795
|
+
return { ok: false };
|
|
3796
|
+
}
|
|
3797
|
+
function sessionLimitKey(session) {
|
|
3798
|
+
return `session:${session}`;
|
|
3799
|
+
}
|
|
3800
|
+
function gateBudgetCharges(resolved, gate) {
|
|
3801
|
+
const sessionEngaged = resolved.charges.some((charge) => charge.budget.key === "session") || resolved.failures.some((failure) => failure.budget.key === "session");
|
|
3802
|
+
if (!gate.ok) {
|
|
3803
|
+
if (sessionEngaged) return { ok: false, unresolvedEngaged: true };
|
|
3804
|
+
return { ok: true, charges: resolved.charges };
|
|
3805
|
+
}
|
|
3806
|
+
if (gate.anonymous && sessionEngaged) warnAnonymousPoolingOnce();
|
|
3807
|
+
return { ok: true, charges: resolved.charges };
|
|
3808
|
+
}
|
|
3809
|
+
function freezeGatedPlans(charges, breached) {
|
|
3810
|
+
if (breached.length !== charges.length) {
|
|
3811
|
+
throw new Error(
|
|
3812
|
+
`freezeGatedPlans: breach markers must pair positionally with charges (${String(breached.length)} markers for ${String(charges.length)} charges)`
|
|
3813
|
+
);
|
|
3814
|
+
}
|
|
3815
|
+
return charges.map(
|
|
3816
|
+
(charge, index) => ({
|
|
3817
|
+
kind: "budget",
|
|
3818
|
+
budget: charge.budget,
|
|
3819
|
+
bucketKey: charge.bucketKey,
|
|
3820
|
+
amount: charge.amount,
|
|
3821
|
+
generation: charge.generation,
|
|
3822
|
+
breached: breached[index] === true
|
|
3823
|
+
})
|
|
3824
|
+
);
|
|
3825
|
+
}
|
|
3826
|
+
function remintDeferredCharges(frozen, actualAmount) {
|
|
3827
|
+
return frozen.map((plan) => ({
|
|
3828
|
+
budget: plan.budget,
|
|
3829
|
+
bucketKey: plan.bucketKey,
|
|
3830
|
+
amount: actualAmount ?? plan.amount,
|
|
3831
|
+
generation: plan.generation
|
|
3832
|
+
}));
|
|
3833
|
+
}
|
|
3834
|
+
function sessionUnresolvedControlMessage(tried) {
|
|
3835
|
+
return `No session identity resolved (tried: ${tried}) \u2014 a session-keyed limit or budget requires one. See session.identity in helio.yaml.`;
|
|
3836
|
+
}
|
|
3837
|
+
function sessionRequiredForGroundingMessage(tried) {
|
|
3838
|
+
return `No session identity resolved (tried: ${tried}) \u2014 rules using evidence.requires or requires need one. See session.identity in helio.yaml.`;
|
|
3839
|
+
}
|
|
3840
|
+
var unresolvedEngagementWarned = false;
|
|
3841
|
+
var anonymousPoolingWarned = false;
|
|
3842
|
+
function warnSessionUnresolvedEngagementOnce(tried) {
|
|
3843
|
+
if (unresolvedEngagementWarned) return;
|
|
3844
|
+
unresolvedEngagementWarned = true;
|
|
3845
|
+
console.error(
|
|
3846
|
+
`[helio] Warning: a session-keyed control was engaged with no resolved session identity (tried: ${tried}); session.on_unresolved: deny denies such requests (dry-run reports them). Send an identity the chain can read (e.g. the x-helio-session-id header), or set session.on_unresolved: anonymous to restore pre-0.12 shared pooling.`
|
|
3847
|
+
);
|
|
3848
|
+
}
|
|
3849
|
+
function warnAnonymousPoolingOnce() {
|
|
3850
|
+
if (anonymousPoolingWarned) return;
|
|
3851
|
+
anonymousPoolingWarned = true;
|
|
3852
|
+
console.error(
|
|
3853
|
+
'[helio] Warning: session identity unresolved; session-keyed limits and budgets are pooling into the shared "unknown" bucket (session.on_unresolved: anonymous). Have callers send session identity to isolate them from each other.'
|
|
3854
|
+
);
|
|
3855
|
+
}
|
|
3856
|
+
|
|
2786
3857
|
// src/evidence/grounding.ts
|
|
2787
3858
|
function checkEvidence(store, sessionId, requirements) {
|
|
2788
3859
|
if (requirements.length === 0) {
|
|
@@ -2828,7 +3899,8 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
|
2828
3899
|
|
|
2829
3900
|
// src/policy/decision-pipeline.ts
|
|
2830
3901
|
function decide(input) {
|
|
2831
|
-
const { toolName, toolArguments,
|
|
3902
|
+
const { toolName, toolArguments, policy, environment, evidenceStore } = input;
|
|
3903
|
+
const sessionId = isWellFormedSessionId(input.sessionId) ? input.sessionId : void 0;
|
|
2832
3904
|
const annotations = input.baselineAnnotations;
|
|
2833
3905
|
const driftEvent = input.driftEvent;
|
|
2834
3906
|
const driftMode = policy.onToolDrift ?? "block";
|
|
@@ -2887,7 +3959,9 @@ function decide(input) {
|
|
|
2887
3959
|
decision = {
|
|
2888
3960
|
action: "deny",
|
|
2889
3961
|
matchedRule: decision.matchedRule,
|
|
2890
|
-
reason:
|
|
3962
|
+
reason: sessionRequiredForGroundingMessage(
|
|
3963
|
+
input.sessionStrategySummary ?? "the configured session.identity chain"
|
|
3964
|
+
)
|
|
2891
3965
|
};
|
|
2892
3966
|
}
|
|
2893
3967
|
if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
|
|
@@ -3338,6 +4412,17 @@ function buildToolDriftFeedback(drift, action) {
|
|
|
3338
4412
|
retry_allowed: false
|
|
3339
4413
|
};
|
|
3340
4414
|
}
|
|
4415
|
+
function buildSessionUnresolvedFeedback(decision, control, tried) {
|
|
4416
|
+
return {
|
|
4417
|
+
blocked: true,
|
|
4418
|
+
reason: "session_unresolved",
|
|
4419
|
+
...ruleInfo(decision.matchedRule),
|
|
4420
|
+
control,
|
|
4421
|
+
tried,
|
|
4422
|
+
suggestion: `No session identity resolved (tried: ${tried}). Send an identity the chain can read \u2014 for example set the x-helio-session-id header once per agent run \u2014 or set session.on_unresolved: anonymous to restore shared pooling.`,
|
|
4423
|
+
retry_allowed: true
|
|
4424
|
+
};
|
|
4425
|
+
}
|
|
3341
4426
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
3342
4427
|
const info = ruleInfo(decision.matchedRule);
|
|
3343
4428
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
@@ -3795,6 +4880,7 @@ var GovernedForwarder = class {
|
|
|
3795
4880
|
inner;
|
|
3796
4881
|
policy;
|
|
3797
4882
|
environment;
|
|
4883
|
+
session;
|
|
3798
4884
|
auditWriter;
|
|
3799
4885
|
evidenceStore;
|
|
3800
4886
|
approvalRouter;
|
|
@@ -3814,6 +4900,7 @@ var GovernedForwarder = class {
|
|
|
3814
4900
|
this.rateLimiter = options?.rateLimiter;
|
|
3815
4901
|
this.spendLimiter = options?.spendLimiter;
|
|
3816
4902
|
this.budgetEngine = options?.budgetEngine;
|
|
4903
|
+
this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
|
|
3817
4904
|
if (this.evidenceStore) {
|
|
3818
4905
|
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
3819
4906
|
}
|
|
@@ -3887,6 +4974,7 @@ var GovernedForwarder = class {
|
|
|
3887
4974
|
try {
|
|
3888
4975
|
const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
|
|
3889
4976
|
if (result.response.status >= 400) {
|
|
4977
|
+
internal.resetInternalSession?.();
|
|
3890
4978
|
return {
|
|
3891
4979
|
success: false,
|
|
3892
4980
|
toolsCached: this.annotationCache.size,
|
|
@@ -3895,6 +4983,7 @@ var GovernedForwarder = class {
|
|
|
3895
4983
|
}
|
|
3896
4984
|
const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
|
|
3897
4985
|
if (!update.updated) {
|
|
4986
|
+
internal.resetInternalSession?.();
|
|
3898
4987
|
return {
|
|
3899
4988
|
success: false,
|
|
3900
4989
|
toolsCached: this.annotationCache.size,
|
|
@@ -3903,6 +4992,7 @@ var GovernedForwarder = class {
|
|
|
3903
4992
|
}
|
|
3904
4993
|
return { success: true, toolsCached: this.annotationCache.size };
|
|
3905
4994
|
} catch (error) {
|
|
4995
|
+
internal.resetInternalSession?.();
|
|
3906
4996
|
return {
|
|
3907
4997
|
success: false,
|
|
3908
4998
|
toolsCached: this.annotationCache.size,
|
|
@@ -3916,17 +5006,42 @@ var GovernedForwarder = class {
|
|
|
3916
5006
|
}
|
|
3917
5007
|
const result = await this.inner.forward(request);
|
|
3918
5008
|
if (request.method === "tools/list") {
|
|
3919
|
-
this.applyToolDefinitionUpdate(result.response.body, request.
|
|
5009
|
+
this.applyToolDefinitionUpdate(result.response.body, request.session);
|
|
5010
|
+
this.clampCacheHints(result.response.body);
|
|
3920
5011
|
}
|
|
3921
5012
|
return result;
|
|
3922
5013
|
}
|
|
5014
|
+
/**
|
|
5015
|
+
* Clamp an over-long `result.ttlMs` on a `tools/list` response to
|
|
5016
|
+
* `policies.tool_revalidation.max_advertised_ttl` (issue #221, D7).
|
|
5017
|
+
*
|
|
5018
|
+
* Downward-only: a `ttlMs` at or below the cap is left untouched, and a
|
|
5019
|
+
* response with no `ttlMs` never gains one — Helio does not manufacture a
|
|
5020
|
+
* cache hint the upstream never advertised. Non-numeric values are left
|
|
5021
|
+
* alone rather than coerced. `cacheScope` passes through untouched: Helio
|
|
5022
|
+
* baselines and vouches for tool *definitions* only, and its own
|
|
5023
|
+
* `tools/list` view is not caller-varying, so it has no basis to alter a
|
|
5024
|
+
* scope hint the upstream set. No-op when tool revalidation is disabled
|
|
5025
|
+
* (including hand-built `CompiledPolicy` fixtures that omit the field).
|
|
5026
|
+
*/
|
|
5027
|
+
clampCacheHints(responseBody) {
|
|
5028
|
+
const rv = this.policy.toolRevalidation;
|
|
5029
|
+
if (!rv?.enabled) return;
|
|
5030
|
+
if (typeof responseBody !== "object" || responseBody === null) return;
|
|
5031
|
+
const result = responseBody["result"];
|
|
5032
|
+
if (typeof result !== "object" || result === null) return;
|
|
5033
|
+
const r = result;
|
|
5034
|
+
if (typeof r["ttlMs"] === "number" && r["ttlMs"] > rv.maxAdvertisedTtlMs) {
|
|
5035
|
+
r["ttlMs"] = rv.maxAdvertisedTtlMs;
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
3923
5038
|
/**
|
|
3924
5039
|
* Apply a tools/list response to the definition cache and surface any
|
|
3925
5040
|
* drift: console warning + immediate audit record per event. Single entry
|
|
3926
5041
|
* point for both runtime tools/list responses and startup priming, so the
|
|
3927
5042
|
* cache is updated exactly once per response.
|
|
3928
5043
|
*/
|
|
3929
|
-
applyToolDefinitionUpdate(responseBody,
|
|
5044
|
+
applyToolDefinitionUpdate(responseBody, session) {
|
|
3930
5045
|
const update = this.annotationCache.update(responseBody);
|
|
3931
5046
|
if (!update.updated) return update;
|
|
3932
5047
|
for (const drift of update.drifted) {
|
|
@@ -3934,22 +5049,23 @@ var GovernedForwarder = class {
|
|
|
3934
5049
|
console.error(
|
|
3935
5050
|
`[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
|
|
3936
5051
|
);
|
|
3937
|
-
this.writeDriftAuditRecord(drift,
|
|
5052
|
+
this.writeDriftAuditRecord(drift, session, "tool_drift");
|
|
3938
5053
|
}
|
|
3939
5054
|
for (const toolName of update.reverted) {
|
|
3940
5055
|
console.error(
|
|
3941
5056
|
`[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
|
|
3942
5057
|
);
|
|
3943
|
-
this.writeDriftAuditRecord({ toolName, changes: [] },
|
|
5058
|
+
this.writeDriftAuditRecord({ toolName, changes: [] }, session, "tool_drift_reverted");
|
|
3944
5059
|
}
|
|
3945
5060
|
return update;
|
|
3946
5061
|
}
|
|
3947
5062
|
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
3948
|
-
writeDriftAuditRecord(drift,
|
|
5063
|
+
writeDriftAuditRecord(drift, session, decision) {
|
|
3949
5064
|
if (!this.auditWriter) return;
|
|
3950
5065
|
this.auditWriter.pushImmediate({
|
|
3951
5066
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3952
|
-
session_id:
|
|
5067
|
+
session_id: session?.id ?? null,
|
|
5068
|
+
session_source: session?.source ?? null,
|
|
3953
5069
|
agent_id: null,
|
|
3954
5070
|
environment: this.environment ?? null,
|
|
3955
5071
|
tool_name: drift.toolName,
|
|
@@ -3972,7 +5088,9 @@ var GovernedForwarder = class {
|
|
|
3972
5088
|
dry_run: false,
|
|
3973
5089
|
record_kind: "drift_event",
|
|
3974
5090
|
origin: "mcp",
|
|
3975
|
-
metadata: null
|
|
5091
|
+
metadata: null,
|
|
5092
|
+
// Drift is a cache event, not a request: no protocol claim exists.
|
|
5093
|
+
protocol_version: null
|
|
3976
5094
|
});
|
|
3977
5095
|
}
|
|
3978
5096
|
async handleToolsCall(original) {
|
|
@@ -4009,7 +5127,8 @@ var GovernedForwarder = class {
|
|
|
4009
5127
|
} = decide({
|
|
4010
5128
|
toolName,
|
|
4011
5129
|
toolArguments,
|
|
4012
|
-
sessionId: request.
|
|
5130
|
+
sessionId: request.session?.id,
|
|
5131
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
4013
5132
|
policy: this.policy,
|
|
4014
5133
|
environment: this.environment,
|
|
4015
5134
|
evidenceStore: this.evidenceStore,
|
|
@@ -4102,9 +5221,10 @@ var GovernedForwarder = class {
|
|
|
4102
5221
|
});
|
|
4103
5222
|
}
|
|
4104
5223
|
try {
|
|
4105
|
-
|
|
5224
|
+
const dependencySessionId = request.session?.id;
|
|
5225
|
+
if (forwarded && !isDryRun && this.evidenceStore && isWellFormedSessionId(dependencySessionId) && toolName) {
|
|
4106
5226
|
const succeeded = !hasJsonRpcError(result);
|
|
4107
|
-
this.evidenceStore.recordToolCall(
|
|
5227
|
+
this.evidenceStore.recordToolCall(dependencySessionId, toolName, succeeded);
|
|
4108
5228
|
}
|
|
4109
5229
|
} catch (err) {
|
|
4110
5230
|
console.error("[helio] dependency tracking failed after forward:", err);
|
|
@@ -4125,6 +5245,7 @@ var GovernedForwarder = class {
|
|
|
4125
5245
|
evidenceResult,
|
|
4126
5246
|
dependencyResult,
|
|
4127
5247
|
evidenceBlocked,
|
|
5248
|
+
sessionBlocked,
|
|
4128
5249
|
approvalOutcome,
|
|
4129
5250
|
approvalContext,
|
|
4130
5251
|
rateLimitResult,
|
|
@@ -4155,7 +5276,7 @@ var GovernedForwarder = class {
|
|
|
4155
5276
|
tool_name: toolName,
|
|
4156
5277
|
tool_input: toolArguments ?? {},
|
|
4157
5278
|
matched_rule: decision.matchedRule,
|
|
4158
|
-
session_id: request.
|
|
5279
|
+
session_id: request.session?.id ?? null,
|
|
4159
5280
|
breached_budgets: gate.breachContexts,
|
|
4160
5281
|
approval: gate.approval
|
|
4161
5282
|
},
|
|
@@ -4298,15 +5419,25 @@ var GovernedForwarder = class {
|
|
|
4298
5419
|
gateBudgets(request, decision, toolName, toolArguments) {
|
|
4299
5420
|
const engine = this.budgetEngine;
|
|
4300
5421
|
if (!engine) return { kind: "proceed" };
|
|
5422
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4301
5423
|
const { charges, failures } = engine.resolveCharges({
|
|
4302
5424
|
toolName,
|
|
4303
5425
|
toolArguments,
|
|
4304
|
-
sessionId:
|
|
5426
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4305
5427
|
senderId: null
|
|
4306
5428
|
// adapter context; absent on the MCP path
|
|
4307
5429
|
});
|
|
4308
5430
|
if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
|
|
4309
|
-
const
|
|
5431
|
+
const gated = gateBudgetCharges({ charges, failures }, sessionGate);
|
|
5432
|
+
if (!gated.ok) {
|
|
5433
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
5434
|
+
return {
|
|
5435
|
+
kind: "blocked",
|
|
5436
|
+
result: this.makeSessionUnresolvedResult(request, decision, "budget"),
|
|
5437
|
+
chain: []
|
|
5438
|
+
};
|
|
5439
|
+
}
|
|
5440
|
+
const peek = charges.length > 0 ? engine.peekAll(gated.charges) : { allowed: true, entries: [] };
|
|
4310
5441
|
const breaches = peek.entries.filter((entry) => !entry.allowed);
|
|
4311
5442
|
const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
|
|
4312
5443
|
if (breaches.length > 0) engine.reportBreaches(breaches);
|
|
@@ -4339,7 +5470,7 @@ var GovernedForwarder = class {
|
|
|
4339
5470
|
const peekBlockByName = new Map(
|
|
4340
5471
|
peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
|
|
4341
5472
|
);
|
|
4342
|
-
const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
|
|
5473
|
+
const commit = (auditRecordId, kinds) => engine.recordAll(gated.charges, {
|
|
4343
5474
|
kind: "spend",
|
|
4344
5475
|
...kinds ? { kinds } : {},
|
|
4345
5476
|
auditRecordId,
|
|
@@ -4415,7 +5546,8 @@ var GovernedForwarder = class {
|
|
|
4415
5546
|
const toolInput = { raw_params: params ?? null };
|
|
4416
5547
|
this.auditWriter.pushImmediate({
|
|
4417
5548
|
timestamp,
|
|
4418
|
-
session_id: request.
|
|
5549
|
+
session_id: request.session?.id ?? null,
|
|
5550
|
+
session_source: request.session?.source ?? null,
|
|
4419
5551
|
agent_id: null,
|
|
4420
5552
|
environment: this.environment ?? null,
|
|
4421
5553
|
tool_name: "<nameless>",
|
|
@@ -4438,7 +5570,8 @@ var GovernedForwarder = class {
|
|
|
4438
5570
|
dry_run: false,
|
|
4439
5571
|
record_kind: "tool_call",
|
|
4440
5572
|
origin: "mcp",
|
|
4441
|
-
metadata: null
|
|
5573
|
+
metadata: null,
|
|
5574
|
+
protocol_version: request.protocolVersion ?? null
|
|
4442
5575
|
});
|
|
4443
5576
|
}
|
|
4444
5577
|
return result;
|
|
@@ -4451,7 +5584,7 @@ var GovernedForwarder = class {
|
|
|
4451
5584
|
tool_name: toolName,
|
|
4452
5585
|
tool_input: toolArguments ?? {},
|
|
4453
5586
|
matched_rule: decision.matchedRule,
|
|
4454
|
-
session_id: request.
|
|
5587
|
+
session_id: request.session?.id ?? null
|
|
4455
5588
|
},
|
|
4456
5589
|
request.signal
|
|
4457
5590
|
);
|
|
@@ -4547,7 +5680,20 @@ var GovernedForwarder = class {
|
|
|
4547
5680
|
rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4548
5681
|
};
|
|
4549
5682
|
}
|
|
4550
|
-
|
|
5683
|
+
let key;
|
|
5684
|
+
if (limits.key === "session") {
|
|
5685
|
+
const sessionKey = this.gateSessionLimitKey(request);
|
|
5686
|
+
if (sessionKey === null) {
|
|
5687
|
+
return {
|
|
5688
|
+
proceed: false,
|
|
5689
|
+
result: this.makeSessionUnresolvedResult(request, decision, "rate_limit"),
|
|
5690
|
+
approvalWaitMs: 0
|
|
5691
|
+
};
|
|
5692
|
+
}
|
|
5693
|
+
key = sessionKey;
|
|
5694
|
+
} else {
|
|
5695
|
+
key = this.buildLimitKey(limits.key, toolName);
|
|
5696
|
+
}
|
|
4551
5697
|
const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
|
|
4552
5698
|
const rateLimitResult = limiter.peek(params);
|
|
4553
5699
|
if (!rateLimitResult.allowed) {
|
|
@@ -4585,7 +5731,21 @@ var GovernedForwarder = class {
|
|
|
4585
5731
|
spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4586
5732
|
};
|
|
4587
5733
|
}
|
|
4588
|
-
|
|
5734
|
+
let baseKey;
|
|
5735
|
+
if (maxSpend.key === "session") {
|
|
5736
|
+
const sessionKey = this.gateSessionLimitKey(request);
|
|
5737
|
+
if (sessionKey === null) {
|
|
5738
|
+
return {
|
|
5739
|
+
proceed: false,
|
|
5740
|
+
result: this.makeSessionUnresolvedResult(request, decision, "spend_limit"),
|
|
5741
|
+
approvalWaitMs: 0
|
|
5742
|
+
};
|
|
5743
|
+
}
|
|
5744
|
+
baseKey = sessionKey;
|
|
5745
|
+
} else {
|
|
5746
|
+
baseKey = this.buildLimitKey(maxSpend.key, toolName);
|
|
5747
|
+
}
|
|
5748
|
+
const key = spendBucketKey(baseKey, decision.matchedRule.index);
|
|
4589
5749
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
4590
5750
|
if (typeof rawAmount !== "number") {
|
|
4591
5751
|
console.error(
|
|
@@ -4649,6 +5809,7 @@ var GovernedForwarder = class {
|
|
|
4649
5809
|
const evidenceSatisfied = !evidenceBlocked;
|
|
4650
5810
|
let wouldForward = false;
|
|
4651
5811
|
let limitsOk = true;
|
|
5812
|
+
let sessionUnresolved = false;
|
|
4652
5813
|
if (!evidenceBlocked) {
|
|
4653
5814
|
switch (decision.action) {
|
|
4654
5815
|
case "allow":
|
|
@@ -4656,14 +5817,21 @@ var GovernedForwarder = class {
|
|
|
4656
5817
|
break;
|
|
4657
5818
|
case "rate_limit":
|
|
4658
5819
|
if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
|
|
4659
|
-
const
|
|
4660
|
-
const
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
5820
|
+
const limits = decision.matchedRule.limits;
|
|
5821
|
+
const key = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
|
|
5822
|
+
if (key === null) {
|
|
5823
|
+
wouldForward = false;
|
|
5824
|
+
limitsOk = false;
|
|
5825
|
+
sessionUnresolved = true;
|
|
5826
|
+
} else {
|
|
5827
|
+
const peekResult = this.rateLimiter.peek({
|
|
5828
|
+
key,
|
|
5829
|
+
maxCalls: decision.matchedRule.limits.maxCalls,
|
|
5830
|
+
windowMs: decision.matchedRule.limits.windowMs
|
|
5831
|
+
});
|
|
5832
|
+
wouldForward = peekResult.allowed;
|
|
5833
|
+
limitsOk = peekResult.allowed;
|
|
5834
|
+
}
|
|
4667
5835
|
}
|
|
4668
5836
|
break;
|
|
4669
5837
|
case "spend_limit":
|
|
@@ -4683,20 +5851,21 @@ var GovernedForwarder = class {
|
|
|
4683
5851
|
wouldForward = false;
|
|
4684
5852
|
limitsOk = false;
|
|
4685
5853
|
} else {
|
|
4686
|
-
const
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
5854
|
+
const baseKey = maxSpend.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(maxSpend.key, toolName);
|
|
5855
|
+
if (baseKey === null) {
|
|
5856
|
+
wouldForward = false;
|
|
5857
|
+
limitsOk = false;
|
|
5858
|
+
sessionUnresolved = true;
|
|
5859
|
+
} else {
|
|
5860
|
+
const peekResult = this.spendLimiter.peek({
|
|
5861
|
+
key: spendBucketKey(baseKey, decision.matchedRule.index),
|
|
5862
|
+
amount: rawAmount,
|
|
5863
|
+
limit: maxSpend.limit,
|
|
5864
|
+
windowMs: maxSpend.windowMs
|
|
5865
|
+
});
|
|
5866
|
+
wouldForward = peekResult.allowed;
|
|
5867
|
+
limitsOk = peekResult.allowed;
|
|
5868
|
+
}
|
|
4700
5869
|
}
|
|
4701
5870
|
}
|
|
4702
5871
|
break;
|
|
@@ -4704,30 +5873,39 @@ var GovernedForwarder = class {
|
|
|
4704
5873
|
}
|
|
4705
5874
|
let budgets;
|
|
4706
5875
|
if (wouldForward && this.budgetEngine) {
|
|
5876
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4707
5877
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
4708
5878
|
toolName,
|
|
4709
5879
|
toolArguments,
|
|
4710
|
-
sessionId:
|
|
5880
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4711
5881
|
senderId: null
|
|
4712
5882
|
});
|
|
4713
5883
|
if (failures.length > 0 || charges.length > 0) {
|
|
4714
|
-
const
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
5884
|
+
const gated = gateBudgetCharges({ charges, failures }, sessionGate);
|
|
5885
|
+
if (!gated.ok) {
|
|
5886
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
5887
|
+
wouldForward = false;
|
|
5888
|
+
limitsOk = false;
|
|
5889
|
+
sessionUnresolved = true;
|
|
5890
|
+
} else {
|
|
5891
|
+
const peek = charges.length > 0 ? this.budgetEngine.peekAll(gated.charges) : { allowed: true, entries: [] };
|
|
5892
|
+
const ok = failures.length === 0 && peek.allowed;
|
|
5893
|
+
wouldForward &&= ok;
|
|
5894
|
+
limitsOk &&= ok;
|
|
5895
|
+
budgets = [
|
|
5896
|
+
...peek.entries.map((entry) => budgetChainBlock(entry)),
|
|
5897
|
+
...failures.map((failure) => ({
|
|
5898
|
+
name: failure.budget.name,
|
|
5899
|
+
bucket_key: failure.bucketKey,
|
|
5900
|
+
allowed: false,
|
|
5901
|
+
reason: failure.reason,
|
|
5902
|
+
spent: failure.spent,
|
|
5903
|
+
limit: failure.budget.limit,
|
|
5904
|
+
remaining: failure.remaining,
|
|
5905
|
+
currency: failure.budget.currency
|
|
5906
|
+
}))
|
|
5907
|
+
];
|
|
5908
|
+
}
|
|
4731
5909
|
}
|
|
4732
5910
|
}
|
|
4733
5911
|
return this.makeDryRunResult(
|
|
@@ -4736,14 +5914,18 @@ var GovernedForwarder = class {
|
|
|
4736
5914
|
wouldForward,
|
|
4737
5915
|
evidenceSatisfied,
|
|
4738
5916
|
limitsOk,
|
|
4739
|
-
budgets
|
|
5917
|
+
budgets,
|
|
5918
|
+
sessionUnresolved
|
|
4740
5919
|
);
|
|
4741
5920
|
}
|
|
4742
|
-
/**
|
|
4743
|
-
|
|
5921
|
+
/**
|
|
5922
|
+
* Construct a non-session limit bucket key. Session keys are deliberately
|
|
5923
|
+
* NOT built here: they come only from the gate module's `sessionLimitKey`,
|
|
5924
|
+
* whose `GatedSession` parameter makes skipping the identity gate a
|
|
5925
|
+
* compile error (issue #218) — call sites branch on `key === 'session'`.
|
|
5926
|
+
*/
|
|
5927
|
+
buildLimitKey(keyType, toolName) {
|
|
4744
5928
|
switch (keyType) {
|
|
4745
|
-
case "session":
|
|
4746
|
-
return `session:${request.sessionId ?? "unknown"}`;
|
|
4747
5929
|
case "agent":
|
|
4748
5930
|
if (!this.agentKeyWarned) {
|
|
4749
5931
|
this.agentKeyWarned = true;
|
|
@@ -4766,14 +5948,29 @@ var GovernedForwarder = class {
|
|
|
4766
5948
|
}
|
|
4767
5949
|
}
|
|
4768
5950
|
/**
|
|
4769
|
-
*
|
|
4770
|
-
*
|
|
4771
|
-
*
|
|
5951
|
+
* Gate a session-keyed limit at its key-build site (issue #218). Returns
|
|
5952
|
+
* the bucket key, or null when identity is unresolved under deny mode —
|
|
5953
|
+
* the caller denies (enforce) or reports the marker (dry-run).
|
|
4772
5954
|
*/
|
|
4773
|
-
|
|
4774
|
-
|
|
5955
|
+
gateSessionLimitKey(request) {
|
|
5956
|
+
const gate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
5957
|
+
if (!gate.ok) {
|
|
5958
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
5959
|
+
return null;
|
|
5960
|
+
}
|
|
5961
|
+
if (gate.anonymous) warnAnonymousPoolingOnce();
|
|
5962
|
+
return sessionLimitKey(gate.session);
|
|
5963
|
+
}
|
|
5964
|
+
makeSessionUnresolvedResult(request, decision, control) {
|
|
5965
|
+
const feedback = buildSessionUnresolvedFeedback(decision, control, this.session.strategySummary);
|
|
5966
|
+
return makeErrorResult(
|
|
5967
|
+
request,
|
|
5968
|
+
POLICY_DENIED,
|
|
5969
|
+
sessionUnresolvedControlMessage(this.session.strategySummary),
|
|
5970
|
+
{ ...feedback }
|
|
5971
|
+
);
|
|
4775
5972
|
}
|
|
4776
|
-
writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
|
|
5973
|
+
writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, sessionBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
|
|
4777
5974
|
if (!this.auditWriter) return;
|
|
4778
5975
|
const actuallyForwarded = forwarded && !isDryRun;
|
|
4779
5976
|
const hadForwardingError = forwardingError !== void 0;
|
|
@@ -4866,9 +6063,16 @@ var GovernedForwarder = class {
|
|
|
4866
6063
|
};
|
|
4867
6064
|
}
|
|
4868
6065
|
const blockReason = extractBlockReason(result);
|
|
6066
|
+
if (sessionBlocked || blockReason === "session_unresolved") {
|
|
6067
|
+
evidenceChain = {
|
|
6068
|
+
...evidenceChain ?? {},
|
|
6069
|
+
session: { unresolved: true, tried: this.session.strategySummary }
|
|
6070
|
+
};
|
|
6071
|
+
}
|
|
4869
6072
|
const record = {
|
|
4870
6073
|
timestamp,
|
|
4871
|
-
session_id: request.
|
|
6074
|
+
session_id: request.session?.id ?? null,
|
|
6075
|
+
session_source: request.session?.source ?? null,
|
|
4872
6076
|
agent_id: null,
|
|
4873
6077
|
environment: this.environment ?? null,
|
|
4874
6078
|
tool_name: toolName,
|
|
@@ -4894,7 +6098,8 @@ var GovernedForwarder = class {
|
|
|
4894
6098
|
dry_run: isDryRun ?? false,
|
|
4895
6099
|
record_kind: "tool_call",
|
|
4896
6100
|
origin: "mcp",
|
|
4897
|
-
metadata: null
|
|
6101
|
+
metadata: null,
|
|
6102
|
+
protocol_version: request.protocolVersion ?? null
|
|
4898
6103
|
};
|
|
4899
6104
|
const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
|
|
4900
6105
|
if (isEnforcementDecision) {
|
|
@@ -4937,7 +6142,7 @@ var GovernedForwarder = class {
|
|
|
4937
6142
|
unsupported: true
|
|
4938
6143
|
});
|
|
4939
6144
|
}
|
|
4940
|
-
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
|
|
6145
|
+
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets, sessionUnresolved) {
|
|
4941
6146
|
const payload = {
|
|
4942
6147
|
dry_run: true,
|
|
4943
6148
|
would_forward: wouldForward,
|
|
@@ -4945,13 +6150,19 @@ var GovernedForwarder = class {
|
|
|
4945
6150
|
matched_rule: decision.matchedRule?.name ?? null,
|
|
4946
6151
|
evidence_satisfied: evidenceSatisfied,
|
|
4947
6152
|
limits_ok: limitsOk,
|
|
4948
|
-
...budgets ? { budgets } : {}
|
|
6153
|
+
...budgets ? { budgets } : {},
|
|
6154
|
+
...sessionUnresolved ? { session_unresolved: true } : {}
|
|
4949
6155
|
};
|
|
4950
6156
|
const body = {
|
|
4951
6157
|
jsonrpc: "2.0",
|
|
4952
6158
|
id: request.id ?? null,
|
|
4953
6159
|
result: {
|
|
4954
|
-
content: [{ type: "text", text: JSON.stringify(payload) }]
|
|
6160
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
6161
|
+
// `resultType` is REQUIRED on every 2026-07-28 result; earlier
|
|
6162
|
+
// revisions never defined it, so the field rides on the client's
|
|
6163
|
+
// validated wire claim — the same tokenizer the #226 door uses for
|
|
6164
|
+
// its tier decision, keeping the two verdicts in agreement.
|
|
6165
|
+
...isModernProtocolClaim(request.protocolVersion) ? { resultType: "complete" } : {}
|
|
4955
6166
|
}
|
|
4956
6167
|
};
|
|
4957
6168
|
const response = {
|
|
@@ -4974,15 +6185,10 @@ var GovernedForwarder = class {
|
|
|
4974
6185
|
}
|
|
4975
6186
|
makeSessionRequiredBlockResult(request, decision) {
|
|
4976
6187
|
const feedback = buildPolicyDeniedFeedback(decision);
|
|
4977
|
-
return makeErrorResult(
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
{
|
|
4982
|
-
...feedback,
|
|
4983
|
-
retry_allowed: true
|
|
4984
|
-
}
|
|
4985
|
-
);
|
|
6188
|
+
return makeErrorResult(request, POLICY_DENIED, decision.reason, {
|
|
6189
|
+
...feedback,
|
|
6190
|
+
retry_allowed: true
|
|
6191
|
+
});
|
|
4986
6192
|
}
|
|
4987
6193
|
makeClientDisconnectedBlockResult(request, decision) {
|
|
4988
6194
|
const feedback = buildClientDisconnectedFeedback(decision);
|
|
@@ -5315,6 +6521,127 @@ var RateLimiter = class {
|
|
|
5315
6521
|
}
|
|
5316
6522
|
};
|
|
5317
6523
|
|
|
6524
|
+
// src/policy/annotation-prime-loop.ts
|
|
6525
|
+
var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
|
|
6526
|
+
var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
|
|
6527
|
+
var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
|
|
6528
|
+
var ANNOTATION_PRIME_RETRY_JITTER_MS = 250;
|
|
6529
|
+
function computePrimeRetryDelayMs(attempt) {
|
|
6530
|
+
const exponent = Math.max(0, attempt - 1);
|
|
6531
|
+
const baseDelay = Math.min(
|
|
6532
|
+
ANNOTATION_PRIME_RETRY_MAX_MS,
|
|
6533
|
+
ANNOTATION_PRIME_RETRY_BASE_MS * 2 ** exponent
|
|
6534
|
+
);
|
|
6535
|
+
const jitter = Math.floor(Math.random() * ANNOTATION_PRIME_RETRY_JITTER_MS);
|
|
6536
|
+
return Math.min(ANNOTATION_PRIME_RETRY_MAX_MS, baseDelay + jitter);
|
|
6537
|
+
}
|
|
6538
|
+
async function startAnnotationPrimeLoop(forwarder, revalidation) {
|
|
6539
|
+
let stopped = false;
|
|
6540
|
+
let primed = false;
|
|
6541
|
+
let retryAttempt = 0;
|
|
6542
|
+
let retryTimer;
|
|
6543
|
+
let current = revalidation;
|
|
6544
|
+
let revalidateTimer;
|
|
6545
|
+
let revalidateEpoch = 0;
|
|
6546
|
+
const clearRetryTimer = () => {
|
|
6547
|
+
if (!retryTimer) return;
|
|
6548
|
+
clearTimeout(retryTimer);
|
|
6549
|
+
retryTimer = void 0;
|
|
6550
|
+
};
|
|
6551
|
+
const clearRevalidateTimer = () => {
|
|
6552
|
+
if (!revalidateTimer) return;
|
|
6553
|
+
clearTimeout(revalidateTimer);
|
|
6554
|
+
revalidateTimer = void 0;
|
|
6555
|
+
};
|
|
6556
|
+
const scheduleRevalidation = () => {
|
|
6557
|
+
const rv = current;
|
|
6558
|
+
if (stopped || !primed || !rv?.enabled || revalidateTimer) return;
|
|
6559
|
+
const epoch = revalidateEpoch;
|
|
6560
|
+
revalidateTimer = setTimeout(() => {
|
|
6561
|
+
revalidateTimer = void 0;
|
|
6562
|
+
void forwarder.primeAnnotationCache().then((result) => {
|
|
6563
|
+
if (epoch !== revalidateEpoch) return;
|
|
6564
|
+
if (!result.success) {
|
|
6565
|
+
console.error(
|
|
6566
|
+
`[helio] Tool revalidation failed: ${result.reason ?? "unknown reason"} \u2014 keeping the last baselines; next attempt in ${String(rv.intervalMs)}ms`
|
|
6567
|
+
);
|
|
6568
|
+
}
|
|
6569
|
+
scheduleRevalidation();
|
|
6570
|
+
});
|
|
6571
|
+
}, rv.intervalMs);
|
|
6572
|
+
revalidateTimer.unref();
|
|
6573
|
+
};
|
|
6574
|
+
const stop = () => {
|
|
6575
|
+
stopped = true;
|
|
6576
|
+
revalidateEpoch += 1;
|
|
6577
|
+
clearRetryTimer();
|
|
6578
|
+
clearRevalidateTimer();
|
|
6579
|
+
};
|
|
6580
|
+
const reconfigure = (next) => {
|
|
6581
|
+
current = next;
|
|
6582
|
+
revalidateEpoch += 1;
|
|
6583
|
+
clearRevalidateTimer();
|
|
6584
|
+
scheduleRevalidation();
|
|
6585
|
+
};
|
|
6586
|
+
const scheduleRetry = () => {
|
|
6587
|
+
if (stopped || primed || retryTimer) return;
|
|
6588
|
+
retryAttempt += 1;
|
|
6589
|
+
const delayMs = computePrimeRetryDelayMs(retryAttempt);
|
|
6590
|
+
console.error(
|
|
6591
|
+
`[helio] Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
|
|
6592
|
+
);
|
|
6593
|
+
retryTimer = setTimeout(() => {
|
|
6594
|
+
retryTimer = void 0;
|
|
6595
|
+
void runPrimeAttempt("retry");
|
|
6596
|
+
}, delayMs);
|
|
6597
|
+
retryTimer.unref();
|
|
6598
|
+
};
|
|
6599
|
+
const handlePrimeResult = (phase, result) => {
|
|
6600
|
+
if (stopped || primed) return;
|
|
6601
|
+
if (result.success) {
|
|
6602
|
+
primed = true;
|
|
6603
|
+
clearRetryTimer();
|
|
6604
|
+
const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
|
|
6605
|
+
console.error(
|
|
6606
|
+
`${prefix}: ${String(result.toolsCached)} tool definitions baselined for drift detection (baselines are per-process; a restart re-baselines \u2014 review tool_drift audit records before restarting)`
|
|
6607
|
+
);
|
|
6608
|
+
scheduleRevalidation();
|
|
6609
|
+
return;
|
|
6610
|
+
}
|
|
6611
|
+
const reason = result.reason ?? "unknown reason";
|
|
6612
|
+
if (phase === "initial") {
|
|
6613
|
+
console.error(
|
|
6614
|
+
`[helio] Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
|
|
6615
|
+
);
|
|
6616
|
+
} else {
|
|
6617
|
+
console.error(
|
|
6618
|
+
`[helio] Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
|
|
6619
|
+
);
|
|
6620
|
+
}
|
|
6621
|
+
scheduleRetry();
|
|
6622
|
+
};
|
|
6623
|
+
const runPrimeAttempt = async (phase) => {
|
|
6624
|
+
const result = await forwarder.primeAnnotationCache();
|
|
6625
|
+
handlePrimeResult(phase, result);
|
|
6626
|
+
};
|
|
6627
|
+
const initialAttempt = runPrimeAttempt("initial");
|
|
6628
|
+
const initialOutcome = await Promise.race([
|
|
6629
|
+
initialAttempt.then(() => "completed"),
|
|
6630
|
+
new Promise((resolve2) => {
|
|
6631
|
+
setTimeout(() => {
|
|
6632
|
+
resolve2("timeout");
|
|
6633
|
+
}, ANNOTATION_PRIME_INITIAL_WAIT_MS).unref();
|
|
6634
|
+
})
|
|
6635
|
+
]);
|
|
6636
|
+
if (initialOutcome === "timeout") {
|
|
6637
|
+
console.error(
|
|
6638
|
+
`[helio] Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
|
|
6639
|
+
);
|
|
6640
|
+
scheduleRetry();
|
|
6641
|
+
}
|
|
6642
|
+
return { stop, reconfigure };
|
|
6643
|
+
}
|
|
6644
|
+
|
|
5318
6645
|
// src/audit/store.ts
|
|
5319
6646
|
import Database from "better-sqlite3";
|
|
5320
6647
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
@@ -5389,6 +6716,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5389
6716
|
id TEXT PRIMARY KEY,
|
|
5390
6717
|
timestamp TEXT NOT NULL,
|
|
5391
6718
|
session_id TEXT,
|
|
6719
|
+
session_source TEXT,
|
|
5392
6720
|
agent_id TEXT,
|
|
5393
6721
|
environment TEXT,
|
|
5394
6722
|
tool_name TEXT NOT NULL,
|
|
@@ -5412,6 +6740,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5412
6740
|
record_kind TEXT NOT NULL DEFAULT 'tool_call',
|
|
5413
6741
|
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
5414
6742
|
metadata TEXT,
|
|
6743
|
+
protocol_version TEXT,
|
|
5415
6744
|
created_at TEXT NOT NULL
|
|
5416
6745
|
);
|
|
5417
6746
|
`;
|
|
@@ -5427,19 +6756,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
|
5427
6756
|
`;
|
|
5428
6757
|
var INSERT_SQL = `
|
|
5429
6758
|
INSERT INTO audit_records (
|
|
5430
|
-
id, timestamp, session_id, agent_id, environment, tool_name, tool_input,
|
|
6759
|
+
id, timestamp, session_id, session_source, agent_id, environment, tool_name, tool_input,
|
|
5431
6760
|
policy_decision, block_reason, matched_rule, matched_rule_index, evidence_chain, approval_status,
|
|
5432
6761
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
5433
6762
|
upstream_http_status,
|
|
5434
6763
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
5435
|
-
flagged_destructive, dry_run, record_kind, origin, metadata, created_at
|
|
6764
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
|
|
5436
6765
|
) VALUES (
|
|
5437
|
-
@id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
|
|
6766
|
+
@id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
|
|
5438
6767
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
5439
6768
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
5440
6769
|
@upstream_http_status,
|
|
5441
6770
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
5442
|
-
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
|
|
6771
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
|
|
5443
6772
|
)
|
|
5444
6773
|
`;
|
|
5445
6774
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -5452,13 +6781,20 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
5452
6781
|
"upstream_http_status",
|
|
5453
6782
|
"record_kind",
|
|
5454
6783
|
"origin",
|
|
5455
|
-
"metadata"
|
|
6784
|
+
"metadata",
|
|
6785
|
+
// Deliberately listed (issue #218): pre-0.12 local DBs fail fast with the
|
|
6786
|
+
// documented delete-these-files message — the pre-1.0 clean-break policy.
|
|
6787
|
+
"session_source",
|
|
6788
|
+
// Same clean break, same unreleased cycle (issue #219): released users see
|
|
6789
|
+
// ONE break, at v0.12.0.
|
|
6790
|
+
"protocol_version"
|
|
5456
6791
|
];
|
|
5457
6792
|
function deserializeRow(row) {
|
|
5458
6793
|
return {
|
|
5459
6794
|
id: row.id,
|
|
5460
6795
|
timestamp: row.timestamp,
|
|
5461
6796
|
session_id: row.session_id,
|
|
6797
|
+
session_source: row.session_source,
|
|
5462
6798
|
agent_id: row.agent_id,
|
|
5463
6799
|
environment: row.environment,
|
|
5464
6800
|
tool_name: row.tool_name,
|
|
@@ -5482,6 +6818,7 @@ function deserializeRow(row) {
|
|
|
5482
6818
|
record_kind: row.record_kind,
|
|
5483
6819
|
origin: row.origin,
|
|
5484
6820
|
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
6821
|
+
protocol_version: row.protocol_version,
|
|
5485
6822
|
created_at: row.created_at
|
|
5486
6823
|
};
|
|
5487
6824
|
}
|
|
@@ -5665,6 +7002,7 @@ var AuditStore = class {
|
|
|
5665
7002
|
id: resolvedId,
|
|
5666
7003
|
timestamp: record.timestamp,
|
|
5667
7004
|
session_id: record.session_id,
|
|
7005
|
+
session_source: record.session_source,
|
|
5668
7006
|
agent_id: record.agent_id,
|
|
5669
7007
|
environment: record.environment,
|
|
5670
7008
|
tool_name: record.tool_name,
|
|
@@ -5688,6 +7026,7 @@ var AuditStore = class {
|
|
|
5688
7026
|
record_kind: record.record_kind,
|
|
5689
7027
|
origin: record.origin,
|
|
5690
7028
|
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
7029
|
+
protocol_version: record.protocol_version,
|
|
5691
7030
|
created_at: now
|
|
5692
7031
|
});
|
|
5693
7032
|
return resolvedId;
|
|
@@ -5956,6 +7295,48 @@ var AuditWriter = class {
|
|
|
5956
7295
|
}
|
|
5957
7296
|
};
|
|
5958
7297
|
|
|
7298
|
+
// src/audit/header-mismatch.ts
|
|
7299
|
+
function buildHeaderMismatchAuditRecord(rejection, environment) {
|
|
7300
|
+
return {
|
|
7301
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7302
|
+
session_id: rejection.session?.id ?? null,
|
|
7303
|
+
session_source: rejection.session?.source ?? null,
|
|
7304
|
+
agent_id: null,
|
|
7305
|
+
environment: environment ?? null,
|
|
7306
|
+
tool_name: rejection.bodyName ?? "<header_mismatch>",
|
|
7307
|
+
// Wrap parity with the nameless precedent: the wire params always nest
|
|
7308
|
+
// under `raw_params`, so a wrapped scalar can never be confused with an
|
|
7309
|
+
// object that happens to contain a `raw_params` key. Headers are the
|
|
7310
|
+
// present markers only, verbatim as received.
|
|
7311
|
+
tool_input: {
|
|
7312
|
+
raw_params: rejection.params ?? null,
|
|
7313
|
+
body_method: rejection.method,
|
|
7314
|
+
mismatch_reason: rejection.reason,
|
|
7315
|
+
headers: { ...rejection.headers }
|
|
7316
|
+
},
|
|
7317
|
+
policy_decision: "rejected",
|
|
7318
|
+
block_reason: "header_mismatch",
|
|
7319
|
+
matched_rule: null,
|
|
7320
|
+
matched_rule_index: null,
|
|
7321
|
+
evidence_chain: null,
|
|
7322
|
+
approval_status: null,
|
|
7323
|
+
approved_by: null,
|
|
7324
|
+
upstream_response: null,
|
|
7325
|
+
upstream_error: null,
|
|
7326
|
+
upstream_http_status: null,
|
|
7327
|
+
upstream_latency_ms: null,
|
|
7328
|
+
total_duration_ms: rejection.durationMs,
|
|
7329
|
+
approval_wait_ms: 0,
|
|
7330
|
+
proxy_compute_ms: rejection.durationMs,
|
|
7331
|
+
flagged_destructive: false,
|
|
7332
|
+
dry_run: false,
|
|
7333
|
+
record_kind: "tool_call",
|
|
7334
|
+
origin: "mcp",
|
|
7335
|
+
metadata: null,
|
|
7336
|
+
protocol_version: rejection.protocolVersion ?? null
|
|
7337
|
+
};
|
|
7338
|
+
}
|
|
7339
|
+
|
|
5959
7340
|
// src/evidence/store.ts
|
|
5960
7341
|
var EvidenceStore = class _EvidenceStore {
|
|
5961
7342
|
static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
|
|
@@ -6438,15 +7819,18 @@ function asStatus(status) {
|
|
|
6438
7819
|
|
|
6439
7820
|
// src/evidence/api.ts
|
|
6440
7821
|
var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
7822
|
+
var sessionIdSchema = z5.string().min(1).refine((value) => value.trim() !== "", {
|
|
7823
|
+
message: "session_id must not be whitespace-only"
|
|
7824
|
+
});
|
|
6441
7825
|
var postEvidenceBody = z5.object({
|
|
6442
|
-
session_id:
|
|
7826
|
+
session_id: sessionIdSchema,
|
|
6443
7827
|
tool_name: z5.string().min(1),
|
|
6444
7828
|
evidence_key: z5.string().min(1),
|
|
6445
7829
|
evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
6446
7830
|
ttl_seconds: z5.number().int().positive().optional()
|
|
6447
7831
|
});
|
|
6448
7832
|
var postContextBody = z5.object({
|
|
6449
|
-
session_id:
|
|
7833
|
+
session_id: sessionIdSchema,
|
|
6450
7834
|
key: z5.string().min(1),
|
|
6451
7835
|
value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
|
|
6452
7836
|
});
|
|
@@ -6576,6 +7960,7 @@ var SWEEP_INTERVAL_MS2 = 3e4;
|
|
|
6576
7960
|
var GovernanceService = class {
|
|
6577
7961
|
policy;
|
|
6578
7962
|
environment;
|
|
7963
|
+
session;
|
|
6579
7964
|
evidenceStore;
|
|
6580
7965
|
approvalRouter;
|
|
6581
7966
|
rateLimiter;
|
|
@@ -6609,6 +7994,7 @@ var GovernanceService = class {
|
|
|
6609
7994
|
constructor(options) {
|
|
6610
7995
|
this.policy = options.policy;
|
|
6611
7996
|
this.environment = options.environment;
|
|
7997
|
+
this.session = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
6612
7998
|
this.evidenceStore = options.evidenceStore;
|
|
6613
7999
|
this.approvalRouter = options.approvalRouter;
|
|
6614
8000
|
this.rateLimiter = options.rateLimiter;
|
|
@@ -6643,6 +8029,7 @@ var GovernanceService = class {
|
|
|
6643
8029
|
if (reserved) {
|
|
6644
8030
|
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
6645
8031
|
}
|
|
8032
|
+
const sessionId = isWellFormedSessionId(req.session_id) ? req.session_id : null;
|
|
6646
8033
|
const inputBytes = byteLength(req.arguments ?? {});
|
|
6647
8034
|
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
6648
8035
|
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
@@ -6650,7 +8037,7 @@ var GovernanceService = class {
|
|
|
6650
8037
|
const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
|
|
6651
8038
|
tool: req.tool.name,
|
|
6652
8039
|
agent_id: req.agent_id,
|
|
6653
|
-
session_id:
|
|
8040
|
+
session_id: sessionId,
|
|
6654
8041
|
origin: req.origin
|
|
6655
8042
|
});
|
|
6656
8043
|
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
@@ -6672,7 +8059,8 @@ var GovernanceService = class {
|
|
|
6672
8059
|
const pipeline = decide({
|
|
6673
8060
|
toolName,
|
|
6674
8061
|
toolArguments: req.arguments,
|
|
6675
|
-
sessionId:
|
|
8062
|
+
sessionId: sessionId ?? void 0,
|
|
8063
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
6676
8064
|
policy: this.policy,
|
|
6677
8065
|
environment: this.environment,
|
|
6678
8066
|
evidenceStore: this.evidenceStore,
|
|
@@ -6689,6 +8077,8 @@ var GovernanceService = class {
|
|
|
6689
8077
|
const plans = [];
|
|
6690
8078
|
let limitsBlock;
|
|
6691
8079
|
let ruleLimitOk = true;
|
|
8080
|
+
let sessionUnresolvedDeny = false;
|
|
8081
|
+
let dryRunSessionUnresolved = false;
|
|
6692
8082
|
const reservedThisCall = [];
|
|
6693
8083
|
const reserve = (key) => {
|
|
6694
8084
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -6703,12 +8093,14 @@ var GovernanceService = class {
|
|
|
6703
8093
|
if (pipeline.isDryRun) {
|
|
6704
8094
|
wire = "dry_run";
|
|
6705
8095
|
if (decision.action === "rate_limit") {
|
|
6706
|
-
const planned = this.planRate(decision, toolName,
|
|
8096
|
+
const planned = this.planRate(decision, toolName, sessionId, senderId);
|
|
6707
8097
|
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
8098
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
6708
8099
|
ruleLimitOk = planned?.allowed ?? true;
|
|
6709
8100
|
} else if (decision.action === "spend_limit") {
|
|
6710
|
-
const planned = this.planSpend(decision, toolName,
|
|
8101
|
+
const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
|
|
6711
8102
|
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
8103
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
6712
8104
|
ruleLimitOk = planned?.allowed ?? true;
|
|
6713
8105
|
}
|
|
6714
8106
|
} else if (decision.action === "deny") {
|
|
@@ -6716,21 +8108,31 @@ var GovernanceService = class {
|
|
|
6716
8108
|
} else if (decision.action === "require_approval") {
|
|
6717
8109
|
wire = "require_approval";
|
|
6718
8110
|
} else if (decision.action === "rate_limit") {
|
|
6719
|
-
const planned = this.planRate(decision, toolName,
|
|
6720
|
-
if (planned?.
|
|
6721
|
-
|
|
8111
|
+
const planned = this.planRate(decision, toolName, sessionId, senderId);
|
|
8112
|
+
if (planned?.sessionUnresolved) {
|
|
8113
|
+
wire = "deny";
|
|
8114
|
+
sessionUnresolvedDeny = true;
|
|
8115
|
+
} else {
|
|
8116
|
+
if (planned?.plan && !reserve(planned.plan.key)) {
|
|
8117
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
8118
|
+
}
|
|
8119
|
+
if (planned?.plan) plans.push(planned.plan);
|
|
8120
|
+
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
8121
|
+
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
6722
8122
|
}
|
|
6723
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
6724
|
-
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
6725
|
-
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
6726
8123
|
} else if (decision.action === "spend_limit") {
|
|
6727
|
-
const planned = this.planSpend(decision, toolName,
|
|
6728
|
-
if (planned?.
|
|
6729
|
-
|
|
8124
|
+
const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
|
|
8125
|
+
if (planned?.sessionUnresolved) {
|
|
8126
|
+
wire = "deny";
|
|
8127
|
+
sessionUnresolvedDeny = true;
|
|
8128
|
+
} else {
|
|
8129
|
+
if (planned?.plan && !reserve(planned.plan.key)) {
|
|
8130
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
8131
|
+
}
|
|
8132
|
+
if (planned?.plan) plans.push(planned.plan);
|
|
8133
|
+
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
8134
|
+
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
6730
8135
|
}
|
|
6731
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
6732
|
-
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
6733
|
-
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
6734
8136
|
} else {
|
|
6735
8137
|
wire = "allow";
|
|
6736
8138
|
}
|
|
@@ -6742,14 +8144,27 @@ var GovernanceService = class {
|
|
|
6742
8144
|
let budgetTicketTimeoutMs;
|
|
6743
8145
|
let budgetTriggeredApproval = false;
|
|
6744
8146
|
if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
|
|
8147
|
+
const budgetSessionGate = gateSession(sessionId, this.session.onUnresolved);
|
|
6745
8148
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
6746
8149
|
toolName,
|
|
6747
8150
|
toolArguments: req.arguments,
|
|
6748
|
-
sessionId:
|
|
8151
|
+
sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
|
|
6749
8152
|
senderId
|
|
6750
8153
|
});
|
|
6751
|
-
|
|
6752
|
-
|
|
8154
|
+
const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
|
|
8155
|
+
if (gatedCharges && !gatedCharges.ok) {
|
|
8156
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
8157
|
+
if (wire === "dry_run") {
|
|
8158
|
+
budgetDryRunOk = false;
|
|
8159
|
+
dryRunSessionUnresolved = true;
|
|
8160
|
+
} else {
|
|
8161
|
+
releaseReservations();
|
|
8162
|
+
plans.length = 0;
|
|
8163
|
+
wire = "deny";
|
|
8164
|
+
sessionUnresolvedDeny = true;
|
|
8165
|
+
}
|
|
8166
|
+
} else if (gatedCharges) {
|
|
8167
|
+
const peek = charges.length > 0 ? this.budgetEngine.peekAll(gatedCharges.charges) : { allowed: true, entries: [] };
|
|
6753
8168
|
budgetsBlock = [
|
|
6754
8169
|
...peek.entries.map((entry2) => budgetWireBlock(entry2)),
|
|
6755
8170
|
...failures.map((failure) => budgetFailureBlock(failure))
|
|
@@ -6771,19 +8186,16 @@ var GovernanceService = class {
|
|
|
6771
8186
|
} else {
|
|
6772
8187
|
if (breaches.length > 0) budgetDryRunOk = false;
|
|
6773
8188
|
if (wire !== "dry_run") {
|
|
6774
|
-
|
|
6775
|
-
|
|
8189
|
+
const frozen = freezeGatedPlans(
|
|
8190
|
+
gatedCharges.charges,
|
|
8191
|
+
charges.map((_, index) => peek.entries[index]?.allowed === false)
|
|
8192
|
+
);
|
|
8193
|
+
for (const plan of frozen) {
|
|
8194
|
+
if (!reserve(plan.bucketKey)) {
|
|
6776
8195
|
releaseReservations();
|
|
6777
8196
|
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
6778
8197
|
}
|
|
6779
|
-
plans.push(
|
|
6780
|
-
kind: "budget",
|
|
6781
|
-
budget: charge.budget,
|
|
6782
|
-
bucketKey: charge.bucketKey,
|
|
6783
|
-
amount: charge.amount,
|
|
6784
|
-
generation: charge.generation,
|
|
6785
|
-
breached: peek.entries[index]?.allowed === false
|
|
6786
|
-
});
|
|
8198
|
+
plans.push(plan);
|
|
6787
8199
|
}
|
|
6788
8200
|
if (breaches.length > 0) {
|
|
6789
8201
|
budgetBreachEntries = breaches;
|
|
@@ -6832,12 +8244,21 @@ var GovernanceService = class {
|
|
|
6832
8244
|
suggestion: budgetDenial.invalid.length > 0 ? "Retry with a non-negative finite amount in the expected field." : "Wait for the window to reset or reduce the amount."
|
|
6833
8245
|
};
|
|
6834
8246
|
}
|
|
8247
|
+
if (sessionUnresolvedDeny) {
|
|
8248
|
+
const message = sessionUnresolvedControlMessage(this.session.strategySummary);
|
|
8249
|
+
responseBody["reason"] = message;
|
|
8250
|
+
responseBody["feedback"] = {
|
|
8251
|
+
message,
|
|
8252
|
+
suggestion: "Send a session_id the identity policy accepts, or set session.on_unresolved: anonymous to restore shared pooling."
|
|
8253
|
+
};
|
|
8254
|
+
}
|
|
6835
8255
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
6836
8256
|
if (wire === "dry_run") {
|
|
6837
8257
|
responseBody["dry_run"] = {
|
|
6838
8258
|
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6839
8259
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
6840
|
-
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
8260
|
+
limits_ok: ruleLimitOk && budgetDryRunOk,
|
|
8261
|
+
...dryRunSessionUnresolved ? { session_unresolved: true } : {}
|
|
6841
8262
|
};
|
|
6842
8263
|
}
|
|
6843
8264
|
if (pipeline.driftEvent) {
|
|
@@ -6848,7 +8269,7 @@ var GovernanceService = class {
|
|
|
6848
8269
|
timestampIso,
|
|
6849
8270
|
origin: req.origin,
|
|
6850
8271
|
agentId: req.agent_id,
|
|
6851
|
-
sessionId
|
|
8272
|
+
sessionId,
|
|
6852
8273
|
toolName,
|
|
6853
8274
|
toolInput: req.arguments ?? {},
|
|
6854
8275
|
metadata: req.metadata,
|
|
@@ -6863,7 +8284,9 @@ var GovernanceService = class {
|
|
|
6863
8284
|
// limitsBlock is also the response's `limits`, and the audit writer
|
|
6864
8285
|
// buffers records by reference until flush — a direct embedder
|
|
6865
8286
|
// editing the returned body must not be able to rewrite evidence.
|
|
6866
|
-
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
8287
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0,
|
|
8288
|
+
sessionUnresolved: sessionUnresolvedDeny,
|
|
8289
|
+
sessionChain: pipeline.sessionBlocked
|
|
6867
8290
|
});
|
|
6868
8291
|
this.tombstones.set(evaluationId, {
|
|
6869
8292
|
auditRecordId: auditId,
|
|
@@ -6896,7 +8319,7 @@ var GovernanceService = class {
|
|
|
6896
8319
|
// rewrite it (same guard as the pending entry's evidence below).
|
|
6897
8320
|
tool_input: structuredClone(req.arguments ?? {}),
|
|
6898
8321
|
matched_rule: decision.matchedRule,
|
|
6899
|
-
session_id:
|
|
8322
|
+
session_id: sessionId,
|
|
6900
8323
|
origin: req.origin,
|
|
6901
8324
|
timeout_ms: timeoutMs,
|
|
6902
8325
|
breached_budgets: budgetBreachContexts
|
|
@@ -6914,7 +8337,7 @@ var GovernanceService = class {
|
|
|
6914
8337
|
evaluationId,
|
|
6915
8338
|
origin: req.origin,
|
|
6916
8339
|
agentId: req.agent_id,
|
|
6917
|
-
sessionId
|
|
8340
|
+
sessionId,
|
|
6918
8341
|
toolName,
|
|
6919
8342
|
// Cloned: direct embedders share these references and could otherwise
|
|
6920
8343
|
// mutate the audit evidence (and desync the byte accounting) after
|
|
@@ -7133,7 +8556,9 @@ var GovernanceService = class {
|
|
|
7133
8556
|
timestampIso: new Date(this.now()).toISOString(),
|
|
7134
8557
|
origin: req.origin,
|
|
7135
8558
|
agentId: req.agent_id,
|
|
7136
|
-
|
|
8559
|
+
// Same trim-empty normalization as /evaluate: a whitespace-only id
|
|
8560
|
+
// must not land in the audit row as attributed sideband identity.
|
|
8561
|
+
sessionId: isWellFormedSessionId(req.session_id) ? req.session_id : null,
|
|
7137
8562
|
toolName,
|
|
7138
8563
|
toolInput: { ...req.package },
|
|
7139
8564
|
metadata: req.metadata,
|
|
@@ -7450,7 +8875,18 @@ var GovernanceService = class {
|
|
|
7450
8875
|
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
7451
8876
|
return { allowed: true };
|
|
7452
8877
|
}
|
|
7453
|
-
|
|
8878
|
+
let key;
|
|
8879
|
+
if (limits.key === "session") {
|
|
8880
|
+
const gate = gateSession(sessionId, this.session.onUnresolved);
|
|
8881
|
+
if (!gate.ok) {
|
|
8882
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
8883
|
+
return { allowed: false, sessionUnresolved: true };
|
|
8884
|
+
}
|
|
8885
|
+
if (gate.anonymous) warnAnonymousPoolingOnce();
|
|
8886
|
+
key = sessionLimitKey(gate.session);
|
|
8887
|
+
} else {
|
|
8888
|
+
key = buildLimitKey(limits.key, toolName, senderId);
|
|
8889
|
+
}
|
|
7454
8890
|
const peek = this.rateLimiter.peek({
|
|
7455
8891
|
key,
|
|
7456
8892
|
maxCalls: limits.maxCalls,
|
|
@@ -7470,10 +8906,19 @@ var GovernanceService = class {
|
|
|
7470
8906
|
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
7471
8907
|
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
7472
8908
|
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
7473
|
-
|
|
7474
|
-
|
|
7475
|
-
|
|
7476
|
-
|
|
8909
|
+
let baseKey;
|
|
8910
|
+
if (maxSpend.key === "session") {
|
|
8911
|
+
const gate = gateSession(sessionId, this.session.onUnresolved);
|
|
8912
|
+
if (!gate.ok) {
|
|
8913
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
8914
|
+
return { allowed: false, sessionUnresolved: true };
|
|
8915
|
+
}
|
|
8916
|
+
if (gate.anonymous) warnAnonymousPoolingOnce();
|
|
8917
|
+
baseKey = sessionLimitKey(gate.session);
|
|
8918
|
+
} else {
|
|
8919
|
+
baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
|
|
8920
|
+
}
|
|
8921
|
+
const key = spendBucketKey(baseKey, decision.matchedRule.index);
|
|
7477
8922
|
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
7478
8923
|
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
7479
8924
|
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
@@ -7505,18 +8950,15 @@ var GovernanceService = class {
|
|
|
7505
8950
|
/** Commit every plan of one call at /audit time; returns the chain blocks. */
|
|
7506
8951
|
commitPlans(entry, actualAmount, auditId, approvalStatus) {
|
|
7507
8952
|
let chain;
|
|
7508
|
-
const budgetPlans = entry.plans.filter(
|
|
8953
|
+
const budgetPlans = entry.plans.filter(
|
|
8954
|
+
(plan) => plan.kind === "budget"
|
|
8955
|
+
);
|
|
7509
8956
|
if (budgetPlans.length > 0 && this.budgetEngine) {
|
|
7510
8957
|
const kinds = new Map(
|
|
7511
8958
|
budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
|
|
7512
8959
|
);
|
|
7513
8960
|
const snapshots = this.budgetEngine.recordAll(
|
|
7514
|
-
budgetPlans
|
|
7515
|
-
budget: plan.budget,
|
|
7516
|
-
bucketKey: plan.bucketKey,
|
|
7517
|
-
amount: actualAmount ?? plan.amount,
|
|
7518
|
-
generation: plan.generation
|
|
7519
|
-
})),
|
|
8961
|
+
remintDeferredCharges(budgetPlans, actualAmount),
|
|
7520
8962
|
{
|
|
7521
8963
|
kind: "spend",
|
|
7522
8964
|
...kinds.size > 0 ? { kinds } : {},
|
|
@@ -7587,6 +9029,12 @@ var GovernanceService = class {
|
|
|
7587
9029
|
if (!this.auditWriter) return id;
|
|
7588
9030
|
const blockReason = deriveBlockReason(args);
|
|
7589
9031
|
let evidenceChain = args.limitsChain ?? null;
|
|
9032
|
+
if (args.sessionUnresolved || args.sessionChain) {
|
|
9033
|
+
evidenceChain = {
|
|
9034
|
+
...evidenceChain ?? {},
|
|
9035
|
+
session: { unresolved: true, tried: this.session.strategySummary }
|
|
9036
|
+
};
|
|
9037
|
+
}
|
|
7590
9038
|
if (args.sidebandUnreported) {
|
|
7591
9039
|
evidenceChain = {
|
|
7592
9040
|
...evidenceChain ?? {},
|
|
@@ -7602,6 +9050,9 @@ var GovernanceService = class {
|
|
|
7602
9050
|
const record = {
|
|
7603
9051
|
timestamp: args.timestampIso,
|
|
7604
9052
|
session_id: args.sessionId,
|
|
9053
|
+
// Adapter-supplied ids are attributed to the sideband door itself —
|
|
9054
|
+
// the MCP resolver's source vocabulary does not apply here.
|
|
9055
|
+
session_source: args.sessionId != null ? "sideband" : null,
|
|
7605
9056
|
agent_id: args.agentId,
|
|
7606
9057
|
environment: this.environment ?? null,
|
|
7607
9058
|
tool_name: args.toolName,
|
|
@@ -7624,7 +9075,9 @@ var GovernanceService = class {
|
|
|
7624
9075
|
dry_run: args.dryRun,
|
|
7625
9076
|
record_kind: args.recordKind,
|
|
7626
9077
|
origin: args.origin,
|
|
7627
|
-
metadata: args.metadata
|
|
9078
|
+
metadata: args.metadata,
|
|
9079
|
+
// The sideband has no MCP wire, so no protocol claim exists.
|
|
9080
|
+
protocol_version: null
|
|
7628
9081
|
};
|
|
7629
9082
|
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
7630
9083
|
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
@@ -7643,6 +9096,7 @@ function deriveBlockReason(args) {
|
|
|
7643
9096
|
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
7644
9097
|
if (args.dryRun) return null;
|
|
7645
9098
|
if (args.budgetBreachBlocked) return "budget_exceeded";
|
|
9099
|
+
if (args.sessionUnresolved) return "session_unresolved";
|
|
7646
9100
|
if (args.approvalStatus === "denied") return "approval_denied";
|
|
7647
9101
|
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
7648
9102
|
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
@@ -7681,10 +9135,8 @@ function policyCanRequireApproval(policy) {
|
|
|
7681
9135
|
}
|
|
7682
9136
|
return policy.rules.some((rule) => rule.action === "require_approval");
|
|
7683
9137
|
}
|
|
7684
|
-
function buildLimitKey(keyType, toolName,
|
|
9138
|
+
function buildLimitKey(keyType, toolName, senderId) {
|
|
7685
9139
|
switch (keyType) {
|
|
7686
|
-
case "session":
|
|
7687
|
-
return `session:${sessionId ?? "unknown"}`;
|
|
7688
9140
|
case "sender_id":
|
|
7689
9141
|
return `sender:${senderId ?? "unknown"}`;
|
|
7690
9142
|
case "agent":
|
|
@@ -8894,7 +10346,11 @@ var BudgetEngine = class {
|
|
|
8894
10346
|
}
|
|
8895
10347
|
return { charges, failures };
|
|
8896
10348
|
}
|
|
8897
|
-
/**
|
|
10349
|
+
/**
|
|
10350
|
+
* Check every charge without mutating. All-or-nothing: one deny flips
|
|
10351
|
+
* `allowed`. Accepts only gate-branded charges (issue #218) — a caller
|
|
10352
|
+
* cannot peek budget state without having run the session engagement check.
|
|
10353
|
+
*/
|
|
8898
10354
|
peekAll(charges) {
|
|
8899
10355
|
const entries = charges.map((charge) => this.snapshot(charge));
|
|
8900
10356
|
return { allowed: entries.every((entry) => entry.allowed), entries };
|
|
@@ -9664,7 +11120,11 @@ var CSV_HEADERS = [
|
|
|
9664
11120
|
"matched_rule_index",
|
|
9665
11121
|
"record_kind",
|
|
9666
11122
|
"origin",
|
|
9667
|
-
"metadata"
|
|
11123
|
+
"metadata",
|
|
11124
|
+
// Appended LAST (issues #218, #219): positional consumers of the existing
|
|
11125
|
+
// columns keep working — new columns always go at the end.
|
|
11126
|
+
"session_source",
|
|
11127
|
+
"protocol_version"
|
|
9668
11128
|
];
|
|
9669
11129
|
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
9670
11130
|
function csvEscape(value) {
|
|
@@ -10541,6 +12001,13 @@ upstream:
|
|
|
10541
12001
|
|
|
10542
12002
|
# environment: production
|
|
10543
12003
|
|
|
12004
|
+
# session:
|
|
12005
|
+
# identity: # ordered; first match wins
|
|
12006
|
+
# - source: header
|
|
12007
|
+
# name: x-helio-session-id
|
|
12008
|
+
# - source: legacy_header # verbatim Mcp-Session-Id (deprecation window)
|
|
12009
|
+
# on_unresolved: deny # deny | anonymous
|
|
12010
|
+
|
|
10544
12011
|
# policies:
|
|
10545
12012
|
# default: allow
|
|
10546
12013
|
# dry_run: false
|
|
@@ -10600,90 +12067,6 @@ function printConfigErrorDetails(error, prefix = "") {
|
|
|
10600
12067
|
console.error(`${prefix} ${detail.path}: ${detail.message}`);
|
|
10601
12068
|
}
|
|
10602
12069
|
}
|
|
10603
|
-
var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
|
|
10604
|
-
var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
|
|
10605
|
-
var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
|
|
10606
|
-
var ANNOTATION_PRIME_RETRY_JITTER_MS = 250;
|
|
10607
|
-
function computePrimeRetryDelayMs(attempt) {
|
|
10608
|
-
const exponent = Math.max(0, attempt - 1);
|
|
10609
|
-
const baseDelay = Math.min(
|
|
10610
|
-
ANNOTATION_PRIME_RETRY_MAX_MS,
|
|
10611
|
-
ANNOTATION_PRIME_RETRY_BASE_MS * 2 ** exponent
|
|
10612
|
-
);
|
|
10613
|
-
const jitter = Math.floor(Math.random() * ANNOTATION_PRIME_RETRY_JITTER_MS);
|
|
10614
|
-
return Math.min(ANNOTATION_PRIME_RETRY_MAX_MS, baseDelay + jitter);
|
|
10615
|
-
}
|
|
10616
|
-
async function startAnnotationPrimeLoop(governedForwarder) {
|
|
10617
|
-
let stopped = false;
|
|
10618
|
-
let primed = false;
|
|
10619
|
-
let retryAttempt = 0;
|
|
10620
|
-
let retryTimer;
|
|
10621
|
-
const clearRetryTimer = () => {
|
|
10622
|
-
if (!retryTimer) return;
|
|
10623
|
-
clearTimeout(retryTimer);
|
|
10624
|
-
retryTimer = void 0;
|
|
10625
|
-
};
|
|
10626
|
-
const stop = () => {
|
|
10627
|
-
stopped = true;
|
|
10628
|
-
clearRetryTimer();
|
|
10629
|
-
};
|
|
10630
|
-
const scheduleRetry = () => {
|
|
10631
|
-
if (stopped || primed || retryTimer) return;
|
|
10632
|
-
retryAttempt += 1;
|
|
10633
|
-
const delayMs = computePrimeRetryDelayMs(retryAttempt);
|
|
10634
|
-
console.error(
|
|
10635
|
-
`[helio] Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
|
|
10636
|
-
);
|
|
10637
|
-
retryTimer = setTimeout(() => {
|
|
10638
|
-
retryTimer = void 0;
|
|
10639
|
-
void runPrimeAttempt("retry");
|
|
10640
|
-
}, delayMs);
|
|
10641
|
-
retryTimer.unref();
|
|
10642
|
-
};
|
|
10643
|
-
const handlePrimeResult = (phase, result) => {
|
|
10644
|
-
if (stopped || primed) return;
|
|
10645
|
-
if (result.success) {
|
|
10646
|
-
primed = true;
|
|
10647
|
-
clearRetryTimer();
|
|
10648
|
-
const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
|
|
10649
|
-
console.error(
|
|
10650
|
-
`${prefix}: ${String(result.toolsCached)} tool definitions baselined for drift detection (baselines are per-process; a restart re-baselines \u2014 review tool_drift audit records before restarting)`
|
|
10651
|
-
);
|
|
10652
|
-
return;
|
|
10653
|
-
}
|
|
10654
|
-
const reason = result.reason ?? "unknown reason";
|
|
10655
|
-
if (phase === "initial") {
|
|
10656
|
-
console.error(
|
|
10657
|
-
`[helio] Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
|
|
10658
|
-
);
|
|
10659
|
-
} else {
|
|
10660
|
-
console.error(
|
|
10661
|
-
`[helio] Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
|
|
10662
|
-
);
|
|
10663
|
-
}
|
|
10664
|
-
scheduleRetry();
|
|
10665
|
-
};
|
|
10666
|
-
const runPrimeAttempt = async (phase) => {
|
|
10667
|
-
const result = await governedForwarder.primeAnnotationCache();
|
|
10668
|
-
handlePrimeResult(phase, result);
|
|
10669
|
-
};
|
|
10670
|
-
const initialAttempt = runPrimeAttempt("initial");
|
|
10671
|
-
const initialOutcome = await Promise.race([
|
|
10672
|
-
initialAttempt.then(() => "completed"),
|
|
10673
|
-
new Promise((resolve2) => {
|
|
10674
|
-
setTimeout(() => {
|
|
10675
|
-
resolve2("timeout");
|
|
10676
|
-
}, ANNOTATION_PRIME_INITIAL_WAIT_MS).unref();
|
|
10677
|
-
})
|
|
10678
|
-
]);
|
|
10679
|
-
if (initialOutcome === "timeout") {
|
|
10680
|
-
console.error(
|
|
10681
|
-
`[helio] Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
|
|
10682
|
-
);
|
|
10683
|
-
scheduleRetry();
|
|
10684
|
-
}
|
|
10685
|
-
return { stop };
|
|
10686
|
-
}
|
|
10687
12070
|
async function startCommand(configPath, options) {
|
|
10688
12071
|
let config;
|
|
10689
12072
|
try {
|
|
@@ -10731,6 +12114,8 @@ async function startCommand(configPath, options) {
|
|
|
10731
12114
|
block_reason: record.block_reason,
|
|
10732
12115
|
approval_status: record.approval_status,
|
|
10733
12116
|
session_id: record.session_id,
|
|
12117
|
+
session_source: record.session_source,
|
|
12118
|
+
protocol_version: record.protocol_version,
|
|
10734
12119
|
agent_id: record.agent_id,
|
|
10735
12120
|
environment: record.environment,
|
|
10736
12121
|
timestamp: record.timestamp,
|
|
@@ -10815,6 +12200,7 @@ async function startCommand(configPath, options) {
|
|
|
10815
12200
|
}
|
|
10816
12201
|
});
|
|
10817
12202
|
budgetEngine.hydrate();
|
|
12203
|
+
const session = compileSessionIdentity(config.session);
|
|
10818
12204
|
const governedForwarder = new GovernedForwarder(forwarder, policy, {
|
|
10819
12205
|
environment: config.environment,
|
|
10820
12206
|
auditWriter,
|
|
@@ -10822,13 +12208,17 @@ async function startCommand(configPath, options) {
|
|
|
10822
12208
|
approvalRouter,
|
|
10823
12209
|
rateLimiter,
|
|
10824
12210
|
spendLimiter,
|
|
10825
|
-
budgetEngine
|
|
12211
|
+
budgetEngine,
|
|
12212
|
+
session
|
|
10826
12213
|
});
|
|
10827
|
-
const annotationPrime = await startAnnotationPrimeLoop(governedForwarder);
|
|
12214
|
+
const annotationPrime = await startAnnotationPrimeLoop(governedForwarder, policy.toolRevalidation);
|
|
10828
12215
|
const hasSlackChannels = [...channels.values()].some((ch) => ch.type === "slack");
|
|
10829
12216
|
const slackActionApp = hasSlackChannels ? createSlackActionApp({ router: approvalRouter, channels }) : void 0;
|
|
10830
12217
|
const app = createApp(config, governedForwarder, {
|
|
10831
|
-
slackActionApp
|
|
12218
|
+
slackActionApp,
|
|
12219
|
+
onHeaderMismatch: (rejection) => {
|
|
12220
|
+
auditWriter.pushImmediate(buildHeaderMismatchAuditRecord(rejection, config.environment));
|
|
12221
|
+
}
|
|
10832
12222
|
});
|
|
10833
12223
|
const handle = startServer(app, config);
|
|
10834
12224
|
let sidebandHandle;
|
|
@@ -10862,6 +12252,7 @@ async function startCommand(configPath, options) {
|
|
|
10862
12252
|
rateLimiter,
|
|
10863
12253
|
spendLimiter,
|
|
10864
12254
|
budgetEngine,
|
|
12255
|
+
session,
|
|
10865
12256
|
auditWriter,
|
|
10866
12257
|
approvalTimeoutMs: parseDuration(config.approval.timeout),
|
|
10867
12258
|
ttlMs: parseDuration(config.sdk.evaluation_ttl)
|
|
@@ -10983,6 +12374,7 @@ async function startCommand(configPath, options) {
|
|
|
10983
12374
|
}
|
|
10984
12375
|
budgetEngine.reconcile(newBudgets);
|
|
10985
12376
|
governedForwarder.updatePolicy(newPolicy);
|
|
12377
|
+
annotationPrime.reconfigure(newPolicy.toolRevalidation);
|
|
10986
12378
|
governanceService?.updatePolicy(newPolicy);
|
|
10987
12379
|
const budgetTotal = newBudgets.length;
|
|
10988
12380
|
console.error(
|