@gethelio/proxy 0.11.1 → 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 +2345 -957
- 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 +2199 -872
- 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,73 @@ 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
|
+
|
|
1226
1456
|
// src/transport/content-type.ts
|
|
1227
1457
|
function isJsonContentType(header) {
|
|
1228
1458
|
const [essence = ""] = (header ?? "").split(";");
|
|
@@ -1246,804 +1476,1419 @@ function buildForwardHeaders(requestHeaders, allowlist) {
|
|
|
1246
1476
|
return Object.keys(forwardHeaders).length > 0 ? forwardHeaders : void 0;
|
|
1247
1477
|
}
|
|
1248
1478
|
|
|
1249
|
-
// src/
|
|
1250
|
-
|
|
1251
|
-
|
|
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);
|
|
1252
1486
|
}
|
|
1253
|
-
|
|
1254
|
-
|
|
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;
|
|
1255
1536
|
}
|
|
1256
|
-
function
|
|
1257
|
-
|
|
1258
|
-
const id = value["id"];
|
|
1259
|
-
return isValidJsonRpcId(id) ? id : void 0;
|
|
1537
|
+
function isHeaderSafeMethod(method) {
|
|
1538
|
+
return /^[\x21-\x7E]+$/.test(method);
|
|
1260
1539
|
}
|
|
1261
|
-
function
|
|
1262
|
-
|
|
1263
|
-
|
|
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;
|
|
1264
1544
|
}
|
|
1265
|
-
function
|
|
1266
|
-
if (!
|
|
1267
|
-
|
|
1268
|
-
if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
|
|
1269
|
-
return false;
|
|
1545
|
+
function buildStandardRequestHeaders(method, params) {
|
|
1546
|
+
if (!isHeaderSafeMethod(method)) {
|
|
1547
|
+
return {};
|
|
1270
1548
|
}
|
|
1271
|
-
const
|
|
1272
|
-
const
|
|
1273
|
-
if (
|
|
1274
|
-
|
|
1275
|
-
|
|
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;
|
|
1276
1555
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
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;
|
|
1285
1563
|
}
|
|
1286
1564
|
};
|
|
1565
|
+
apply(base);
|
|
1566
|
+
apply(forwarded);
|
|
1567
|
+
apply(staticHeaders);
|
|
1568
|
+
return out;
|
|
1287
1569
|
}
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
const upstream = args.upstreamResponse;
|
|
1310
|
-
const upstreamContentType = upstream.headers["content-type"] ?? null;
|
|
1311
|
-
if (!isValidJsonRpcResponse(upstream.body)) {
|
|
1312
|
-
return {
|
|
1313
|
-
httpStatus: 200,
|
|
1314
|
-
wrapped: true,
|
|
1315
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1316
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1317
|
-
upstream_http_status: upstream.status,
|
|
1318
|
-
upstream_content_type: upstreamContentType,
|
|
1319
|
-
upstream_body_type: typeof upstream.body
|
|
1320
|
-
})
|
|
1321
|
-
};
|
|
1322
|
-
}
|
|
1323
|
-
if (args.requestId !== void 0) {
|
|
1324
|
-
const upstreamId = getJsonRpcId(upstream.body);
|
|
1325
|
-
if (upstreamId === void 0) {
|
|
1326
|
-
return {
|
|
1327
|
-
httpStatus: 200,
|
|
1328
|
-
wrapped: true,
|
|
1329
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1330
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1331
|
-
upstream_http_status: upstream.status,
|
|
1332
|
-
upstream_content_type: upstreamContentType,
|
|
1333
|
-
upstream_body_type: typeof upstream.body,
|
|
1334
|
-
invalid_reason: "missing_response_id"
|
|
1335
|
-
})
|
|
1336
|
-
};
|
|
1337
|
-
}
|
|
1338
|
-
const expectedId = args.requestId ?? null;
|
|
1339
|
-
if (upstreamId !== expectedId) {
|
|
1340
|
-
return {
|
|
1341
|
-
httpStatus: 200,
|
|
1342
|
-
wrapped: true,
|
|
1343
|
-
body: makeWrappedError(args.requestId, "upstream response id mismatch", {
|
|
1344
|
-
failure_class: "upstream_id_mismatch",
|
|
1345
|
-
expected_request_id: expectedId,
|
|
1346
|
-
upstream_response_id: upstreamId
|
|
1347
|
-
})
|
|
1348
|
-
};
|
|
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;
|
|
1349
1591
|
}
|
|
1592
|
+
current = current.cause;
|
|
1350
1593
|
}
|
|
1351
|
-
return
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
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
|
+
);
|
|
1356
1608
|
}
|
|
1357
1609
|
|
|
1358
|
-
// src/
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
const
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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;
|
|
1370
1630
|
}
|
|
1371
|
-
|
|
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;
|
|
1372
1645
|
try {
|
|
1373
|
-
|
|
1646
|
+
parsed = JSON.parse(data);
|
|
1374
1647
|
} catch {
|
|
1375
|
-
return
|
|
1376
|
-
}
|
|
1377
|
-
const parsedRequest = parseJsonRpcRequest(body);
|
|
1378
|
-
if (!parsedRequest.success) {
|
|
1379
|
-
return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
|
|
1648
|
+
return;
|
|
1380
1649
|
}
|
|
1381
|
-
|
|
1382
|
-
const
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
1386
|
-
const mcpRequest = {
|
|
1387
|
-
jsonrpc: "2.0",
|
|
1388
|
-
id,
|
|
1389
|
-
method,
|
|
1390
|
-
params,
|
|
1391
|
-
sessionId,
|
|
1392
|
-
headers: forwardHeaders,
|
|
1393
|
-
signal: c.req.raw.signal
|
|
1394
|
-
};
|
|
1395
|
-
if (id === void 0) {
|
|
1396
|
-
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
1397
|
-
void forwarder.forward(notificationRequest).catch((err) => {
|
|
1398
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1399
|
-
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
1400
|
-
});
|
|
1401
|
-
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;
|
|
1402
1654
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
+
}
|
|
1411
1668
|
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
if (
|
|
1415
|
-
|
|
1669
|
+
if (result.done) {
|
|
1670
|
+
const tail = decoder.decode();
|
|
1671
|
+
if (tail) {
|
|
1672
|
+
processChunk(tail);
|
|
1673
|
+
if (found) return found;
|
|
1416
1674
|
}
|
|
1675
|
+
break;
|
|
1417
1676
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1677
|
+
}
|
|
1678
|
+
throw new Error(
|
|
1679
|
+
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1680
|
+
);
|
|
1422
1681
|
}
|
|
1423
1682
|
|
|
1424
|
-
// src/
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
var
|
|
1429
|
-
var
|
|
1430
|
-
var
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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
|
+
};
|
|
1439
1701
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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;
|
|
1452
1727
|
});
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
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";
|
|
1461
1762
|
}
|
|
1462
1763
|
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
|
|
1471
|
-
writeSessionEvent(sessionId, endpointData);
|
|
1472
|
-
c.req.raw.signal.addEventListener("abort", () => {
|
|
1473
|
-
sessions.delete(sessionId);
|
|
1474
|
-
void writer.close().catch(() => {
|
|
1475
|
-
});
|
|
1476
|
-
});
|
|
1477
|
-
return new Response(readable, {
|
|
1478
|
-
headers: {
|
|
1479
|
-
"content-type": "text/event-stream",
|
|
1480
|
-
"cache-control": "no-cache",
|
|
1481
|
-
connection: "keep-alive"
|
|
1482
|
-
}
|
|
1483
|
-
});
|
|
1484
|
-
});
|
|
1485
|
-
app.post("/", async (c) => {
|
|
1486
|
-
const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
|
|
1487
|
-
if (!parsedQuery.success) {
|
|
1488
|
-
return c.json(
|
|
1489
|
-
makeJsonRpcError(null, INVALID_REQUEST, "missing sessionId query parameter"),
|
|
1490
|
-
400
|
|
1491
|
-
);
|
|
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";
|
|
1492
1771
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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;
|
|
1497
1827
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
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`);
|
|
1503
1856
|
}
|
|
1504
|
-
|
|
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) {
|
|
1505
1874
|
try {
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
return
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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;
|
|
1513
1886
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
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"
|
|
1526
1902
|
};
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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)
|
|
1532
1939
|
});
|
|
1533
|
-
|
|
1940
|
+
} catch (error) {
|
|
1941
|
+
throw this.describeFetchFailure(error, "server/discover probe");
|
|
1534
1942
|
}
|
|
1535
|
-
|
|
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;
|
|
1536
1986
|
try {
|
|
1537
|
-
|
|
1538
|
-
} catch
|
|
1539
|
-
|
|
1540
|
-
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
1541
|
-
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
1542
|
-
const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
|
|
1543
|
-
writeSessionEvent(sessionId, errorEvent);
|
|
1544
|
-
return c.body(null, 202);
|
|
1987
|
+
parsed = JSON.parse(raw);
|
|
1988
|
+
} catch {
|
|
1989
|
+
return { kind: "unparseable" };
|
|
1545
1990
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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");
|
|
1570
2050
|
});
|
|
1571
|
-
|
|
1572
|
-
|
|
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;
|
|
1573
2071
|
try {
|
|
1574
|
-
|
|
2072
|
+
parsed = JSON.parse(raw);
|
|
1575
2073
|
} catch {
|
|
2074
|
+
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1576
2075
|
}
|
|
1577
|
-
if (
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
+
);
|
|
1581
2102
|
}
|
|
1582
|
-
return;
|
|
1583
2103
|
}
|
|
1584
|
-
|
|
1585
|
-
|
|
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;
|
|
1586
2111
|
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
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;
|
|
1606
2132
|
try {
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
settle(err);
|
|
1610
|
-
return;
|
|
1611
|
-
}
|
|
1612
|
-
settle();
|
|
1613
|
-
});
|
|
1614
|
-
} catch (error) {
|
|
1615
|
-
settle(normalizeError(error));
|
|
2133
|
+
parsed = JSON.parse(data);
|
|
2134
|
+
} catch {
|
|
1616
2135
|
return;
|
|
1617
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;
|
|
1618
2147
|
try {
|
|
1619
|
-
|
|
2148
|
+
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1620
2149
|
} catch {
|
|
2150
|
+
await reader.cancel().catch(() => void 0);
|
|
2151
|
+
return { outcome: "timed-out" };
|
|
1621
2152
|
}
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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);
|
|
1637
2193
|
}
|
|
1638
|
-
return app;
|
|
1639
2194
|
}
|
|
1640
|
-
function
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
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;
|
|
1647
2201
|
}
|
|
1648
|
-
function
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
port,
|
|
1652
|
-
hostname: host
|
|
1653
|
-
});
|
|
1654
|
-
return createServerHandle(server);
|
|
2202
|
+
function isClassifiableProbeStatus(status) {
|
|
2203
|
+
if (status >= 200 && status < 300) return true;
|
|
2204
|
+
return status === 400 || status === 404 || status === 405;
|
|
1655
2205
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
const
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
}
|
|
1672
|
-
} else {
|
|
1673
|
-
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
|
+
};
|
|
1674
2222
|
}
|
|
1675
|
-
|
|
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" };
|
|
1676
2229
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
remainder = lines.pop() ?? "";
|
|
1684
|
-
for (const rawLine of lines) {
|
|
1685
|
-
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1686
|
-
if (line === "") {
|
|
1687
|
-
if (event || data) {
|
|
1688
|
-
onEvent(event, data);
|
|
1689
|
-
event = "";
|
|
1690
|
-
data = "";
|
|
1691
|
-
}
|
|
1692
|
-
} else if (line.startsWith("event:")) {
|
|
1693
|
-
const value = line.slice(6).replace(/^ /, "");
|
|
1694
|
-
event = value;
|
|
1695
|
-
} else if (line.startsWith("data:")) {
|
|
1696
|
-
const value = line.slice(5).replace(/^ /, "");
|
|
1697
|
-
data = data ? data + "\n" + value : value;
|
|
1698
|
-
}
|
|
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" };
|
|
1699
2236
|
}
|
|
1700
|
-
|
|
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 };
|
|
1701
2249
|
}
|
|
1702
|
-
|
|
1703
|
-
if (!
|
|
1704
|
-
|
|
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;
|
|
1705
2266
|
}
|
|
1706
|
-
const
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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 }
|
|
1717
2303
|
}
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
if (
|
|
1721
|
-
|
|
2304
|
+
});
|
|
2305
|
+
if (headerMethod === void 0) {
|
|
2306
|
+
if (requiresPresence) {
|
|
2307
|
+
return reject(`missing mcp-method header (expected ${displayCap(method)})`);
|
|
1722
2308
|
}
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
if (
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
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
|
+
);
|
|
1735
2325
|
}
|
|
1736
2326
|
}
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
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
|
+
);
|
|
1742
2335
|
}
|
|
1743
|
-
|
|
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
|
+
);
|
|
1744
2341
|
}
|
|
1745
2342
|
}
|
|
1746
|
-
|
|
1747
|
-
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1748
|
-
);
|
|
2343
|
+
return { ok: true };
|
|
1749
2344
|
}
|
|
1750
2345
|
|
|
1751
|
-
// src/
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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
|
+
);
|
|
1757
2367
|
}
|
|
1758
2368
|
};
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
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
|
+
};
|
|
1763
2377
|
}
|
|
1764
2378
|
|
|
1765
|
-
// src/
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
"ECONNREFUSED",
|
|
1769
|
-
"ENOTFOUND",
|
|
1770
|
-
"EAI_AGAIN",
|
|
1771
|
-
"ECONNRESET",
|
|
1772
|
-
"EHOSTUNREACH",
|
|
1773
|
-
"ENETUNREACH",
|
|
1774
|
-
"ETIMEDOUT",
|
|
1775
|
-
"EPIPE",
|
|
1776
|
-
"UND_ERR_CONNECT_TIMEOUT",
|
|
1777
|
-
"UND_ERR_SOCKET"
|
|
1778
|
-
]);
|
|
1779
|
-
function extractErrorCode(error) {
|
|
1780
|
-
let current = error;
|
|
1781
|
-
for (let depth = 0; depth < 5 && current != null; depth += 1) {
|
|
1782
|
-
if (typeof current === "object" && "code" in current) {
|
|
1783
|
-
const code = current.code;
|
|
1784
|
-
if (typeof code === "string") return code;
|
|
1785
|
-
}
|
|
1786
|
-
current = current.cause;
|
|
1787
|
-
}
|
|
1788
|
-
return void 0;
|
|
2379
|
+
// src/transport/response-normalizer.ts
|
|
2380
|
+
function isObject(value) {
|
|
2381
|
+
return value !== null && typeof value === "object";
|
|
1789
2382
|
}
|
|
1790
|
-
function
|
|
1791
|
-
|
|
1792
|
-
const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
|
|
1793
|
-
if (code !== void 0) {
|
|
1794
|
-
if (!UNREACHABLE_CODES.has(code)) return null;
|
|
1795
|
-
} else if (!isGenericFetchFailure) {
|
|
1796
|
-
return null;
|
|
1797
|
-
}
|
|
1798
|
-
const codeSuffix = code ? ` (${code})` : "";
|
|
1799
|
-
return new Error(
|
|
1800
|
-
`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}`
|
|
1801
|
-
);
|
|
2383
|
+
function isValidJsonRpcId(value) {
|
|
2384
|
+
return value === null || typeof value === "string" || typeof value === "number";
|
|
1802
2385
|
}
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
}
|
|
1818
|
-
/** Return the internal session, performing the handshake once if needed. */
|
|
1819
|
-
ensureInternalSession() {
|
|
1820
|
-
if (this.internal) return Promise.resolve(this.internal);
|
|
1821
|
-
this.inflight ??= this.initialize().then((session) => {
|
|
1822
|
-
this.internal = session;
|
|
1823
|
-
return session;
|
|
1824
|
-
}).finally(() => {
|
|
1825
|
-
this.inflight = void 0;
|
|
1826
|
-
});
|
|
1827
|
-
return this.inflight;
|
|
1828
|
-
}
|
|
1829
|
-
/**
|
|
1830
|
-
* Drop the cached internal session so the next call re-initializes.
|
|
1831
|
-
* Does not cancel any in-flight initialize.
|
|
1832
|
-
*/
|
|
1833
|
-
invalidateInternalSession() {
|
|
1834
|
-
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;
|
|
1835
2400
|
}
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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
|
+
};
|
|
1842
2428
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
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(
|
|
1845
2520
|
{
|
|
1846
|
-
|
|
1847
|
-
|
|
2521
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
2522
|
+
meta: paramsMeta(params),
|
|
2523
|
+
transportSessionId
|
|
1848
2524
|
},
|
|
1849
|
-
|
|
1850
|
-
this.staticHeaders
|
|
2525
|
+
sessionIdentity
|
|
1851
2526
|
);
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
params
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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
|
|
1860
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
|
|
1861
2563
|
};
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
body: JSON.stringify(initBody),
|
|
1868
|
-
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}`);
|
|
1869
2569
|
});
|
|
1870
|
-
|
|
1871
|
-
throw this.describeFetchFailure(error, "initialize");
|
|
2570
|
+
return c.body(null, 202);
|
|
1872
2571
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
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);
|
|
1875
2580
|
}
|
|
1876
|
-
const
|
|
1877
|
-
const
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
);
|
|
1882
|
-
const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
|
|
1883
|
-
if (initializeError) {
|
|
1884
|
-
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
|
+
}
|
|
1885
2586
|
}
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
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
|
+
});
|
|
1897
2638
|
});
|
|
1898
|
-
|
|
1899
|
-
|
|
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
|
+
}
|
|
1900
2648
|
}
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
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);
|
|
1904
2655
|
}
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
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
|
+
);
|
|
1912
2682
|
}
|
|
1913
|
-
const
|
|
1914
|
-
|
|
1915
|
-
|
|
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);
|
|
1916
2687
|
}
|
|
1917
|
-
|
|
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;
|
|
1918
2695
|
try {
|
|
1919
|
-
|
|
2696
|
+
body = await c.req.json();
|
|
1920
2697
|
} catch {
|
|
1921
|
-
|
|
2698
|
+
return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
|
|
1922
2699
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
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);
|
|
1925
2704
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
const
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
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);
|
|
1945
2804
|
return;
|
|
1946
2805
|
}
|
|
1947
|
-
|
|
1948
|
-
errorMessage = extractJsonRpcErrorMessage(parsed2);
|
|
2806
|
+
resolve2();
|
|
1949
2807
|
};
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1956
|
-
);
|
|
1957
|
-
}
|
|
1958
|
-
let chunk;
|
|
1959
|
-
try {
|
|
1960
|
-
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1961
|
-
} catch {
|
|
1962
|
-
await reader.cancel().catch(() => void 0);
|
|
1963
|
-
throw new Error(
|
|
1964
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1965
|
-
);
|
|
1966
|
-
}
|
|
1967
|
-
const { done, value } = chunk;
|
|
1968
|
-
if (value !== void 0) {
|
|
1969
|
-
scannedBytes += value.byteLength;
|
|
1970
|
-
if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
|
|
1971
|
-
await reader.cancel().catch(() => void 0);
|
|
1972
|
-
throw new Error(
|
|
1973
|
-
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
|
|
1974
|
-
);
|
|
1975
|
-
}
|
|
1976
|
-
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
1977
|
-
if (errorMessage) {
|
|
1978
|
-
await reader.cancel().catch(() => void 0);
|
|
1979
|
-
return errorMessage;
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
if (done) {
|
|
1983
|
-
const tail = decoder.decode();
|
|
1984
|
-
if (tail) {
|
|
1985
|
-
state = parseSseChunk(tail, state, onEvent);
|
|
2808
|
+
try {
|
|
2809
|
+
nodeServer.close((err) => {
|
|
2810
|
+
if (err) {
|
|
2811
|
+
settle(err);
|
|
2812
|
+
return;
|
|
1986
2813
|
}
|
|
1987
|
-
|
|
1988
|
-
}
|
|
2814
|
+
settle();
|
|
2815
|
+
});
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
settle(normalizeError(error));
|
|
2818
|
+
return;
|
|
1989
2819
|
}
|
|
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
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
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);
|
|
2020
2849
|
}
|
|
2850
|
+
return app;
|
|
2021
2851
|
}
|
|
2022
|
-
function
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
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);
|
|
2028
2859
|
}
|
|
2029
|
-
function
|
|
2030
|
-
const
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
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);
|
|
2036
2867
|
}
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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();
|
|
2041
2886
|
}
|
|
2042
|
-
|
|
2043
|
-
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
|
|
2887
|
+
return { status: res.status, headers, body };
|
|
2044
2888
|
}
|
|
2045
2889
|
|
|
2046
2890
|
// src/upstream/streamable-http-forwarder.ts
|
|
2891
|
+
var JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
2047
2892
|
var StreamableHttpForwarder = class {
|
|
2048
2893
|
url;
|
|
2049
2894
|
staticHeaders;
|
|
@@ -2056,7 +2901,8 @@ var StreamableHttpForwarder = class {
|
|
|
2056
2901
|
this.sessions = new UpstreamSessionManager({
|
|
2057
2902
|
url: this.url,
|
|
2058
2903
|
staticHeaders: this.staticHeaders,
|
|
2059
|
-
requestTimeoutMs: this.requestTimeoutMs
|
|
2904
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
2905
|
+
protocolVersion: options.protocolVersion
|
|
2060
2906
|
});
|
|
2061
2907
|
}
|
|
2062
2908
|
/** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
|
|
@@ -2069,15 +2915,91 @@ var StreamableHttpForwarder = class {
|
|
|
2069
2915
|
return Promise.resolve();
|
|
2070
2916
|
}
|
|
2071
2917
|
async forward(request) {
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
request
|
|
2076
|
-
|
|
2077
|
-
|
|
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"
|
|
2078
3001
|
);
|
|
2079
3002
|
}
|
|
2080
|
-
return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
|
|
2081
3003
|
}
|
|
2082
3004
|
/**
|
|
2083
3005
|
* Helio-internal execution path (startup prime / internal maintenance) that
|
|
@@ -2088,8 +3010,7 @@ var StreamableHttpForwarder = class {
|
|
|
2088
3010
|
try {
|
|
2089
3011
|
return await this.send(
|
|
2090
3012
|
request,
|
|
2091
|
-
session
|
|
2092
|
-
session.protocolVersion,
|
|
3013
|
+
session,
|
|
2093
3014
|
/* internalManaged */
|
|
2094
3015
|
true
|
|
2095
3016
|
);
|
|
@@ -2099,8 +3020,7 @@ var StreamableHttpForwarder = class {
|
|
|
2099
3020
|
const fresh = await this.sessions.ensureInternalSession();
|
|
2100
3021
|
return this.send(
|
|
2101
3022
|
request,
|
|
2102
|
-
fresh
|
|
2103
|
-
fresh.protocolVersion,
|
|
3023
|
+
fresh,
|
|
2104
3024
|
/* internalManaged */
|
|
2105
3025
|
true
|
|
2106
3026
|
);
|
|
@@ -2108,7 +3028,50 @@ var StreamableHttpForwarder = class {
|
|
|
2108
3028
|
throw error;
|
|
2109
3029
|
}
|
|
2110
3030
|
}
|
|
2111
|
-
|
|
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
|
+
}
|
|
2112
3075
|
const headers = mergeUpstreamHeaders(
|
|
2113
3076
|
{
|
|
2114
3077
|
"content-type": "application/json",
|
|
@@ -2117,16 +3080,26 @@ var StreamableHttpForwarder = class {
|
|
|
2117
3080
|
request.headers ?? {},
|
|
2118
3081
|
this.staticHeaders
|
|
2119
3082
|
);
|
|
2120
|
-
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
2121
|
-
if (
|
|
2122
|
-
headers["mcp-
|
|
2123
|
-
|
|
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));
|
|
2124
3093
|
const body = {
|
|
2125
3094
|
jsonrpc: request.jsonrpc,
|
|
2126
3095
|
method: request.method
|
|
2127
3096
|
};
|
|
2128
3097
|
if (request.id !== void 0) body["id"] = request.id;
|
|
2129
|
-
if (
|
|
3098
|
+
if (modern) {
|
|
3099
|
+
body["params"] = outboundParams;
|
|
3100
|
+
} else if (request.params !== void 0) {
|
|
3101
|
+
body["params"] = outboundParams;
|
|
3102
|
+
}
|
|
2130
3103
|
const start = performance.now();
|
|
2131
3104
|
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
2132
3105
|
const requestSignal = request.signal;
|
|
@@ -2142,7 +3115,7 @@ var StreamableHttpForwarder = class {
|
|
|
2142
3115
|
}
|
|
2143
3116
|
throw describeUnreachableUpstream(error, this.url) ?? error;
|
|
2144
3117
|
}
|
|
2145
|
-
if (internalManaged && res.status === 404 && sessionId) {
|
|
3118
|
+
if (internalManaged && res.status === 404 && session.sessionId) {
|
|
2146
3119
|
await res.text().catch(() => void 0);
|
|
2147
3120
|
throw new UpstreamSessionExpiredError();
|
|
2148
3121
|
}
|
|
@@ -2152,6 +3125,7 @@ var StreamableHttpForwarder = class {
|
|
|
2152
3125
|
res.headers.forEach((value, key) => {
|
|
2153
3126
|
responseHeaders[key] = value;
|
|
2154
3127
|
});
|
|
3128
|
+
if (modern) delete responseHeaders["mcp-session-id"];
|
|
2155
3129
|
if (request.id === void 0) {
|
|
2156
3130
|
await res.body?.cancel().catch(() => void 0);
|
|
2157
3131
|
const response3 = {
|
|
@@ -2166,6 +3140,7 @@ var StreamableHttpForwarder = class {
|
|
|
2166
3140
|
return { response: response2, durationMs: performance.now() - start };
|
|
2167
3141
|
}
|
|
2168
3142
|
const response = await parseUpstreamResponse(res);
|
|
3143
|
+
if (modern) delete response.headers["mcp-session-id"];
|
|
2169
3144
|
return { response, durationMs: performance.now() - start };
|
|
2170
3145
|
}
|
|
2171
3146
|
};
|
|
@@ -2175,6 +3150,16 @@ var UpstreamSessionExpiredError = class extends Error {
|
|
|
2175
3150
|
this.name = "UpstreamSessionExpiredError";
|
|
2176
3151
|
}
|
|
2177
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
|
+
}
|
|
2178
3163
|
|
|
2179
3164
|
// src/mcp/pending-requests.ts
|
|
2180
3165
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
@@ -2323,8 +3308,11 @@ var SseUpstreamForwarder = class {
|
|
|
2323
3308
|
request.headers ?? {},
|
|
2324
3309
|
this.staticHeaders
|
|
2325
3310
|
);
|
|
2326
|
-
|
|
2327
|
-
|
|
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;
|
|
2328
3316
|
}
|
|
2329
3317
|
const start = performance.now();
|
|
2330
3318
|
const signal = buildRequestSignal(request, this.requestTimeoutMs);
|
|
@@ -2641,8 +3629,14 @@ async function createForwarderFromConfig(config) {
|
|
|
2641
3629
|
const http = new StreamableHttpForwarder({
|
|
2642
3630
|
url: config.upstream.url,
|
|
2643
3631
|
headers: config.upstream.headers,
|
|
2644
|
-
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
3632
|
+
requestTimeoutMs: parseDuration(config.upstream.request_timeout),
|
|
3633
|
+
protocolVersion: config.upstream.protocol_version
|
|
2645
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
|
+
}
|
|
2646
3640
|
await http.connect();
|
|
2647
3641
|
return { forwarder: http, close: () => http.close() };
|
|
2648
3642
|
}
|
|
@@ -2787,6 +3781,79 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2787
3781
|
// src/policy/governed-forwarder.ts
|
|
2788
3782
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2789
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
|
+
|
|
2790
3857
|
// src/evidence/grounding.ts
|
|
2791
3858
|
function checkEvidence(store, sessionId, requirements) {
|
|
2792
3859
|
if (requirements.length === 0) {
|
|
@@ -2832,7 +3899,8 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
|
2832
3899
|
|
|
2833
3900
|
// src/policy/decision-pipeline.ts
|
|
2834
3901
|
function decide(input) {
|
|
2835
|
-
const { toolName, toolArguments,
|
|
3902
|
+
const { toolName, toolArguments, policy, environment, evidenceStore } = input;
|
|
3903
|
+
const sessionId = isWellFormedSessionId(input.sessionId) ? input.sessionId : void 0;
|
|
2836
3904
|
const annotations = input.baselineAnnotations;
|
|
2837
3905
|
const driftEvent = input.driftEvent;
|
|
2838
3906
|
const driftMode = policy.onToolDrift ?? "block";
|
|
@@ -2891,7 +3959,9 @@ function decide(input) {
|
|
|
2891
3959
|
decision = {
|
|
2892
3960
|
action: "deny",
|
|
2893
3961
|
matchedRule: decision.matchedRule,
|
|
2894
|
-
reason:
|
|
3962
|
+
reason: sessionRequiredForGroundingMessage(
|
|
3963
|
+
input.sessionStrategySummary ?? "the configured session.identity chain"
|
|
3964
|
+
)
|
|
2895
3965
|
};
|
|
2896
3966
|
}
|
|
2897
3967
|
if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
|
|
@@ -3342,6 +4412,17 @@ function buildToolDriftFeedback(drift, action) {
|
|
|
3342
4412
|
retry_allowed: false
|
|
3343
4413
|
};
|
|
3344
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
|
+
}
|
|
3345
4426
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
3346
4427
|
const info = ruleInfo(decision.matchedRule);
|
|
3347
4428
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
@@ -3799,6 +4880,7 @@ var GovernedForwarder = class {
|
|
|
3799
4880
|
inner;
|
|
3800
4881
|
policy;
|
|
3801
4882
|
environment;
|
|
4883
|
+
session;
|
|
3802
4884
|
auditWriter;
|
|
3803
4885
|
evidenceStore;
|
|
3804
4886
|
approvalRouter;
|
|
@@ -3818,6 +4900,7 @@ var GovernedForwarder = class {
|
|
|
3818
4900
|
this.rateLimiter = options?.rateLimiter;
|
|
3819
4901
|
this.spendLimiter = options?.spendLimiter;
|
|
3820
4902
|
this.budgetEngine = options?.budgetEngine;
|
|
4903
|
+
this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
|
|
3821
4904
|
if (this.evidenceStore) {
|
|
3822
4905
|
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
3823
4906
|
}
|
|
@@ -3891,6 +4974,7 @@ var GovernedForwarder = class {
|
|
|
3891
4974
|
try {
|
|
3892
4975
|
const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
|
|
3893
4976
|
if (result.response.status >= 400) {
|
|
4977
|
+
internal.resetInternalSession?.();
|
|
3894
4978
|
return {
|
|
3895
4979
|
success: false,
|
|
3896
4980
|
toolsCached: this.annotationCache.size,
|
|
@@ -3899,6 +4983,7 @@ var GovernedForwarder = class {
|
|
|
3899
4983
|
}
|
|
3900
4984
|
const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
|
|
3901
4985
|
if (!update.updated) {
|
|
4986
|
+
internal.resetInternalSession?.();
|
|
3902
4987
|
return {
|
|
3903
4988
|
success: false,
|
|
3904
4989
|
toolsCached: this.annotationCache.size,
|
|
@@ -3907,6 +4992,7 @@ var GovernedForwarder = class {
|
|
|
3907
4992
|
}
|
|
3908
4993
|
return { success: true, toolsCached: this.annotationCache.size };
|
|
3909
4994
|
} catch (error) {
|
|
4995
|
+
internal.resetInternalSession?.();
|
|
3910
4996
|
return {
|
|
3911
4997
|
success: false,
|
|
3912
4998
|
toolsCached: this.annotationCache.size,
|
|
@@ -3920,17 +5006,42 @@ var GovernedForwarder = class {
|
|
|
3920
5006
|
}
|
|
3921
5007
|
const result = await this.inner.forward(request);
|
|
3922
5008
|
if (request.method === "tools/list") {
|
|
3923
|
-
this.applyToolDefinitionUpdate(result.response.body, request.
|
|
5009
|
+
this.applyToolDefinitionUpdate(result.response.body, request.session);
|
|
5010
|
+
this.clampCacheHints(result.response.body);
|
|
3924
5011
|
}
|
|
3925
5012
|
return result;
|
|
3926
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
|
+
}
|
|
3927
5038
|
/**
|
|
3928
5039
|
* Apply a tools/list response to the definition cache and surface any
|
|
3929
5040
|
* drift: console warning + immediate audit record per event. Single entry
|
|
3930
5041
|
* point for both runtime tools/list responses and startup priming, so the
|
|
3931
5042
|
* cache is updated exactly once per response.
|
|
3932
5043
|
*/
|
|
3933
|
-
applyToolDefinitionUpdate(responseBody,
|
|
5044
|
+
applyToolDefinitionUpdate(responseBody, session) {
|
|
3934
5045
|
const update = this.annotationCache.update(responseBody);
|
|
3935
5046
|
if (!update.updated) return update;
|
|
3936
5047
|
for (const drift of update.drifted) {
|
|
@@ -3938,22 +5049,23 @@ var GovernedForwarder = class {
|
|
|
3938
5049
|
console.error(
|
|
3939
5050
|
`[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
|
|
3940
5051
|
);
|
|
3941
|
-
this.writeDriftAuditRecord(drift,
|
|
5052
|
+
this.writeDriftAuditRecord(drift, session, "tool_drift");
|
|
3942
5053
|
}
|
|
3943
5054
|
for (const toolName of update.reverted) {
|
|
3944
5055
|
console.error(
|
|
3945
5056
|
`[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
|
|
3946
5057
|
);
|
|
3947
|
-
this.writeDriftAuditRecord({ toolName, changes: [] },
|
|
5058
|
+
this.writeDriftAuditRecord({ toolName, changes: [] }, session, "tool_drift_reverted");
|
|
3948
5059
|
}
|
|
3949
5060
|
return update;
|
|
3950
5061
|
}
|
|
3951
5062
|
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
3952
|
-
writeDriftAuditRecord(drift,
|
|
5063
|
+
writeDriftAuditRecord(drift, session, decision) {
|
|
3953
5064
|
if (!this.auditWriter) return;
|
|
3954
5065
|
this.auditWriter.pushImmediate({
|
|
3955
5066
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3956
|
-
session_id:
|
|
5067
|
+
session_id: session?.id ?? null,
|
|
5068
|
+
session_source: session?.source ?? null,
|
|
3957
5069
|
agent_id: null,
|
|
3958
5070
|
environment: this.environment ?? null,
|
|
3959
5071
|
tool_name: drift.toolName,
|
|
@@ -3976,7 +5088,9 @@ var GovernedForwarder = class {
|
|
|
3976
5088
|
dry_run: false,
|
|
3977
5089
|
record_kind: "drift_event",
|
|
3978
5090
|
origin: "mcp",
|
|
3979
|
-
metadata: null
|
|
5091
|
+
metadata: null,
|
|
5092
|
+
// Drift is a cache event, not a request: no protocol claim exists.
|
|
5093
|
+
protocol_version: null
|
|
3980
5094
|
});
|
|
3981
5095
|
}
|
|
3982
5096
|
async handleToolsCall(original) {
|
|
@@ -4013,7 +5127,8 @@ var GovernedForwarder = class {
|
|
|
4013
5127
|
} = decide({
|
|
4014
5128
|
toolName,
|
|
4015
5129
|
toolArguments,
|
|
4016
|
-
sessionId: request.
|
|
5130
|
+
sessionId: request.session?.id,
|
|
5131
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
4017
5132
|
policy: this.policy,
|
|
4018
5133
|
environment: this.environment,
|
|
4019
5134
|
evidenceStore: this.evidenceStore,
|
|
@@ -4106,9 +5221,10 @@ var GovernedForwarder = class {
|
|
|
4106
5221
|
});
|
|
4107
5222
|
}
|
|
4108
5223
|
try {
|
|
4109
|
-
|
|
5224
|
+
const dependencySessionId = request.session?.id;
|
|
5225
|
+
if (forwarded && !isDryRun && this.evidenceStore && isWellFormedSessionId(dependencySessionId) && toolName) {
|
|
4110
5226
|
const succeeded = !hasJsonRpcError(result);
|
|
4111
|
-
this.evidenceStore.recordToolCall(
|
|
5227
|
+
this.evidenceStore.recordToolCall(dependencySessionId, toolName, succeeded);
|
|
4112
5228
|
}
|
|
4113
5229
|
} catch (err) {
|
|
4114
5230
|
console.error("[helio] dependency tracking failed after forward:", err);
|
|
@@ -4129,6 +5245,7 @@ var GovernedForwarder = class {
|
|
|
4129
5245
|
evidenceResult,
|
|
4130
5246
|
dependencyResult,
|
|
4131
5247
|
evidenceBlocked,
|
|
5248
|
+
sessionBlocked,
|
|
4132
5249
|
approvalOutcome,
|
|
4133
5250
|
approvalContext,
|
|
4134
5251
|
rateLimitResult,
|
|
@@ -4159,7 +5276,7 @@ var GovernedForwarder = class {
|
|
|
4159
5276
|
tool_name: toolName,
|
|
4160
5277
|
tool_input: toolArguments ?? {},
|
|
4161
5278
|
matched_rule: decision.matchedRule,
|
|
4162
|
-
session_id: request.
|
|
5279
|
+
session_id: request.session?.id ?? null,
|
|
4163
5280
|
breached_budgets: gate.breachContexts,
|
|
4164
5281
|
approval: gate.approval
|
|
4165
5282
|
},
|
|
@@ -4302,15 +5419,25 @@ var GovernedForwarder = class {
|
|
|
4302
5419
|
gateBudgets(request, decision, toolName, toolArguments) {
|
|
4303
5420
|
const engine = this.budgetEngine;
|
|
4304
5421
|
if (!engine) return { kind: "proceed" };
|
|
5422
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4305
5423
|
const { charges, failures } = engine.resolveCharges({
|
|
4306
5424
|
toolName,
|
|
4307
5425
|
toolArguments,
|
|
4308
|
-
sessionId:
|
|
5426
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4309
5427
|
senderId: null
|
|
4310
5428
|
// adapter context; absent on the MCP path
|
|
4311
5429
|
});
|
|
4312
5430
|
if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
|
|
4313
|
-
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: [] };
|
|
4314
5441
|
const breaches = peek.entries.filter((entry) => !entry.allowed);
|
|
4315
5442
|
const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
|
|
4316
5443
|
if (breaches.length > 0) engine.reportBreaches(breaches);
|
|
@@ -4343,7 +5470,7 @@ var GovernedForwarder = class {
|
|
|
4343
5470
|
const peekBlockByName = new Map(
|
|
4344
5471
|
peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
|
|
4345
5472
|
);
|
|
4346
|
-
const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
|
|
5473
|
+
const commit = (auditRecordId, kinds) => engine.recordAll(gated.charges, {
|
|
4347
5474
|
kind: "spend",
|
|
4348
5475
|
...kinds ? { kinds } : {},
|
|
4349
5476
|
auditRecordId,
|
|
@@ -4419,7 +5546,8 @@ var GovernedForwarder = class {
|
|
|
4419
5546
|
const toolInput = { raw_params: params ?? null };
|
|
4420
5547
|
this.auditWriter.pushImmediate({
|
|
4421
5548
|
timestamp,
|
|
4422
|
-
session_id: request.
|
|
5549
|
+
session_id: request.session?.id ?? null,
|
|
5550
|
+
session_source: request.session?.source ?? null,
|
|
4423
5551
|
agent_id: null,
|
|
4424
5552
|
environment: this.environment ?? null,
|
|
4425
5553
|
tool_name: "<nameless>",
|
|
@@ -4442,7 +5570,8 @@ var GovernedForwarder = class {
|
|
|
4442
5570
|
dry_run: false,
|
|
4443
5571
|
record_kind: "tool_call",
|
|
4444
5572
|
origin: "mcp",
|
|
4445
|
-
metadata: null
|
|
5573
|
+
metadata: null,
|
|
5574
|
+
protocol_version: request.protocolVersion ?? null
|
|
4446
5575
|
});
|
|
4447
5576
|
}
|
|
4448
5577
|
return result;
|
|
@@ -4455,7 +5584,7 @@ var GovernedForwarder = class {
|
|
|
4455
5584
|
tool_name: toolName,
|
|
4456
5585
|
tool_input: toolArguments ?? {},
|
|
4457
5586
|
matched_rule: decision.matchedRule,
|
|
4458
|
-
session_id: request.
|
|
5587
|
+
session_id: request.session?.id ?? null
|
|
4459
5588
|
},
|
|
4460
5589
|
request.signal
|
|
4461
5590
|
);
|
|
@@ -4551,7 +5680,20 @@ var GovernedForwarder = class {
|
|
|
4551
5680
|
rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4552
5681
|
};
|
|
4553
5682
|
}
|
|
4554
|
-
|
|
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
|
+
}
|
|
4555
5697
|
const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
|
|
4556
5698
|
const rateLimitResult = limiter.peek(params);
|
|
4557
5699
|
if (!rateLimitResult.allowed) {
|
|
@@ -4589,7 +5731,21 @@ var GovernedForwarder = class {
|
|
|
4589
5731
|
spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4590
5732
|
};
|
|
4591
5733
|
}
|
|
4592
|
-
|
|
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);
|
|
4593
5749
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
4594
5750
|
if (typeof rawAmount !== "number") {
|
|
4595
5751
|
console.error(
|
|
@@ -4653,6 +5809,7 @@ var GovernedForwarder = class {
|
|
|
4653
5809
|
const evidenceSatisfied = !evidenceBlocked;
|
|
4654
5810
|
let wouldForward = false;
|
|
4655
5811
|
let limitsOk = true;
|
|
5812
|
+
let sessionUnresolved = false;
|
|
4656
5813
|
if (!evidenceBlocked) {
|
|
4657
5814
|
switch (decision.action) {
|
|
4658
5815
|
case "allow":
|
|
@@ -4660,14 +5817,21 @@ var GovernedForwarder = class {
|
|
|
4660
5817
|
break;
|
|
4661
5818
|
case "rate_limit":
|
|
4662
5819
|
if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
|
|
4663
|
-
const
|
|
4664
|
-
const
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
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
|
+
}
|
|
4671
5835
|
}
|
|
4672
5836
|
break;
|
|
4673
5837
|
case "spend_limit":
|
|
@@ -4687,20 +5851,21 @@ var GovernedForwarder = class {
|
|
|
4687
5851
|
wouldForward = false;
|
|
4688
5852
|
limitsOk = false;
|
|
4689
5853
|
} else {
|
|
4690
|
-
const
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
|
|
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
|
+
}
|
|
4704
5869
|
}
|
|
4705
5870
|
}
|
|
4706
5871
|
break;
|
|
@@ -4708,30 +5873,39 @@ var GovernedForwarder = class {
|
|
|
4708
5873
|
}
|
|
4709
5874
|
let budgets;
|
|
4710
5875
|
if (wouldForward && this.budgetEngine) {
|
|
5876
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4711
5877
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
4712
5878
|
toolName,
|
|
4713
5879
|
toolArguments,
|
|
4714
|
-
sessionId:
|
|
5880
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4715
5881
|
senderId: null
|
|
4716
5882
|
});
|
|
4717
5883
|
if (failures.length > 0 || charges.length > 0) {
|
|
4718
|
-
const
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
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
|
+
}
|
|
4735
5909
|
}
|
|
4736
5910
|
}
|
|
4737
5911
|
return this.makeDryRunResult(
|
|
@@ -4740,14 +5914,18 @@ var GovernedForwarder = class {
|
|
|
4740
5914
|
wouldForward,
|
|
4741
5915
|
evidenceSatisfied,
|
|
4742
5916
|
limitsOk,
|
|
4743
|
-
budgets
|
|
5917
|
+
budgets,
|
|
5918
|
+
sessionUnresolved
|
|
4744
5919
|
);
|
|
4745
5920
|
}
|
|
4746
|
-
/**
|
|
4747
|
-
|
|
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) {
|
|
4748
5928
|
switch (keyType) {
|
|
4749
|
-
case "session":
|
|
4750
|
-
return `session:${request.sessionId ?? "unknown"}`;
|
|
4751
5929
|
case "agent":
|
|
4752
5930
|
if (!this.agentKeyWarned) {
|
|
4753
5931
|
this.agentKeyWarned = true;
|
|
@@ -4770,14 +5948,29 @@ var GovernedForwarder = class {
|
|
|
4770
5948
|
}
|
|
4771
5949
|
}
|
|
4772
5950
|
/**
|
|
4773
|
-
*
|
|
4774
|
-
*
|
|
4775
|
-
*
|
|
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).
|
|
4776
5954
|
*/
|
|
4777
|
-
|
|
4778
|
-
|
|
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
|
+
);
|
|
4779
5972
|
}
|
|
4780
|
-
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) {
|
|
4781
5974
|
if (!this.auditWriter) return;
|
|
4782
5975
|
const actuallyForwarded = forwarded && !isDryRun;
|
|
4783
5976
|
const hadForwardingError = forwardingError !== void 0;
|
|
@@ -4870,9 +6063,16 @@ var GovernedForwarder = class {
|
|
|
4870
6063
|
};
|
|
4871
6064
|
}
|
|
4872
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
|
+
}
|
|
4873
6072
|
const record = {
|
|
4874
6073
|
timestamp,
|
|
4875
|
-
session_id: request.
|
|
6074
|
+
session_id: request.session?.id ?? null,
|
|
6075
|
+
session_source: request.session?.source ?? null,
|
|
4876
6076
|
agent_id: null,
|
|
4877
6077
|
environment: this.environment ?? null,
|
|
4878
6078
|
tool_name: toolName,
|
|
@@ -4898,7 +6098,8 @@ var GovernedForwarder = class {
|
|
|
4898
6098
|
dry_run: isDryRun ?? false,
|
|
4899
6099
|
record_kind: "tool_call",
|
|
4900
6100
|
origin: "mcp",
|
|
4901
|
-
metadata: null
|
|
6101
|
+
metadata: null,
|
|
6102
|
+
protocol_version: request.protocolVersion ?? null
|
|
4902
6103
|
};
|
|
4903
6104
|
const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
|
|
4904
6105
|
if (isEnforcementDecision) {
|
|
@@ -4941,7 +6142,7 @@ var GovernedForwarder = class {
|
|
|
4941
6142
|
unsupported: true
|
|
4942
6143
|
});
|
|
4943
6144
|
}
|
|
4944
|
-
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
|
|
6145
|
+
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets, sessionUnresolved) {
|
|
4945
6146
|
const payload = {
|
|
4946
6147
|
dry_run: true,
|
|
4947
6148
|
would_forward: wouldForward,
|
|
@@ -4949,13 +6150,19 @@ var GovernedForwarder = class {
|
|
|
4949
6150
|
matched_rule: decision.matchedRule?.name ?? null,
|
|
4950
6151
|
evidence_satisfied: evidenceSatisfied,
|
|
4951
6152
|
limits_ok: limitsOk,
|
|
4952
|
-
...budgets ? { budgets } : {}
|
|
6153
|
+
...budgets ? { budgets } : {},
|
|
6154
|
+
...sessionUnresolved ? { session_unresolved: true } : {}
|
|
4953
6155
|
};
|
|
4954
6156
|
const body = {
|
|
4955
6157
|
jsonrpc: "2.0",
|
|
4956
6158
|
id: request.id ?? null,
|
|
4957
6159
|
result: {
|
|
4958
|
-
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" } : {}
|
|
4959
6166
|
}
|
|
4960
6167
|
};
|
|
4961
6168
|
const response = {
|
|
@@ -4978,15 +6185,10 @@ var GovernedForwarder = class {
|
|
|
4978
6185
|
}
|
|
4979
6186
|
makeSessionRequiredBlockResult(request, decision) {
|
|
4980
6187
|
const feedback = buildPolicyDeniedFeedback(decision);
|
|
4981
|
-
return makeErrorResult(
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
{
|
|
4986
|
-
...feedback,
|
|
4987
|
-
retry_allowed: true
|
|
4988
|
-
}
|
|
4989
|
-
);
|
|
6188
|
+
return makeErrorResult(request, POLICY_DENIED, decision.reason, {
|
|
6189
|
+
...feedback,
|
|
6190
|
+
retry_allowed: true
|
|
6191
|
+
});
|
|
4990
6192
|
}
|
|
4991
6193
|
makeClientDisconnectedBlockResult(request, decision) {
|
|
4992
6194
|
const feedback = buildClientDisconnectedFeedback(decision);
|
|
@@ -5319,6 +6521,127 @@ var RateLimiter = class {
|
|
|
5319
6521
|
}
|
|
5320
6522
|
};
|
|
5321
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
|
+
|
|
5322
6645
|
// src/audit/store.ts
|
|
5323
6646
|
import Database from "better-sqlite3";
|
|
5324
6647
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
@@ -5393,6 +6716,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5393
6716
|
id TEXT PRIMARY KEY,
|
|
5394
6717
|
timestamp TEXT NOT NULL,
|
|
5395
6718
|
session_id TEXT,
|
|
6719
|
+
session_source TEXT,
|
|
5396
6720
|
agent_id TEXT,
|
|
5397
6721
|
environment TEXT,
|
|
5398
6722
|
tool_name TEXT NOT NULL,
|
|
@@ -5416,6 +6740,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5416
6740
|
record_kind TEXT NOT NULL DEFAULT 'tool_call',
|
|
5417
6741
|
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
5418
6742
|
metadata TEXT,
|
|
6743
|
+
protocol_version TEXT,
|
|
5419
6744
|
created_at TEXT NOT NULL
|
|
5420
6745
|
);
|
|
5421
6746
|
`;
|
|
@@ -5431,19 +6756,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
|
5431
6756
|
`;
|
|
5432
6757
|
var INSERT_SQL = `
|
|
5433
6758
|
INSERT INTO audit_records (
|
|
5434
|
-
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,
|
|
5435
6760
|
policy_decision, block_reason, matched_rule, matched_rule_index, evidence_chain, approval_status,
|
|
5436
6761
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
5437
6762
|
upstream_http_status,
|
|
5438
6763
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
5439
|
-
flagged_destructive, dry_run, record_kind, origin, metadata, created_at
|
|
6764
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
|
|
5440
6765
|
) VALUES (
|
|
5441
|
-
@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,
|
|
5442
6767
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
5443
6768
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
5444
6769
|
@upstream_http_status,
|
|
5445
6770
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
5446
|
-
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
|
|
6771
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
|
|
5447
6772
|
)
|
|
5448
6773
|
`;
|
|
5449
6774
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -5456,13 +6781,20 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
5456
6781
|
"upstream_http_status",
|
|
5457
6782
|
"record_kind",
|
|
5458
6783
|
"origin",
|
|
5459
|
-
"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"
|
|
5460
6791
|
];
|
|
5461
6792
|
function deserializeRow(row) {
|
|
5462
6793
|
return {
|
|
5463
6794
|
id: row.id,
|
|
5464
6795
|
timestamp: row.timestamp,
|
|
5465
6796
|
session_id: row.session_id,
|
|
6797
|
+
session_source: row.session_source,
|
|
5466
6798
|
agent_id: row.agent_id,
|
|
5467
6799
|
environment: row.environment,
|
|
5468
6800
|
tool_name: row.tool_name,
|
|
@@ -5486,6 +6818,7 @@ function deserializeRow(row) {
|
|
|
5486
6818
|
record_kind: row.record_kind,
|
|
5487
6819
|
origin: row.origin,
|
|
5488
6820
|
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
6821
|
+
protocol_version: row.protocol_version,
|
|
5489
6822
|
created_at: row.created_at
|
|
5490
6823
|
};
|
|
5491
6824
|
}
|
|
@@ -5669,6 +7002,7 @@ var AuditStore = class {
|
|
|
5669
7002
|
id: resolvedId,
|
|
5670
7003
|
timestamp: record.timestamp,
|
|
5671
7004
|
session_id: record.session_id,
|
|
7005
|
+
session_source: record.session_source,
|
|
5672
7006
|
agent_id: record.agent_id,
|
|
5673
7007
|
environment: record.environment,
|
|
5674
7008
|
tool_name: record.tool_name,
|
|
@@ -5692,6 +7026,7 @@ var AuditStore = class {
|
|
|
5692
7026
|
record_kind: record.record_kind,
|
|
5693
7027
|
origin: record.origin,
|
|
5694
7028
|
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
7029
|
+
protocol_version: record.protocol_version,
|
|
5695
7030
|
created_at: now
|
|
5696
7031
|
});
|
|
5697
7032
|
return resolvedId;
|
|
@@ -5960,6 +7295,48 @@ var AuditWriter = class {
|
|
|
5960
7295
|
}
|
|
5961
7296
|
};
|
|
5962
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
|
+
|
|
5963
7340
|
// src/evidence/store.ts
|
|
5964
7341
|
var EvidenceStore = class _EvidenceStore {
|
|
5965
7342
|
static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
|
|
@@ -6442,15 +7819,18 @@ function asStatus(status) {
|
|
|
6442
7819
|
|
|
6443
7820
|
// src/evidence/api.ts
|
|
6444
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
|
+
});
|
|
6445
7825
|
var postEvidenceBody = z5.object({
|
|
6446
|
-
session_id:
|
|
7826
|
+
session_id: sessionIdSchema,
|
|
6447
7827
|
tool_name: z5.string().min(1),
|
|
6448
7828
|
evidence_key: z5.string().min(1),
|
|
6449
7829
|
evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
6450
7830
|
ttl_seconds: z5.number().int().positive().optional()
|
|
6451
7831
|
});
|
|
6452
7832
|
var postContextBody = z5.object({
|
|
6453
|
-
session_id:
|
|
7833
|
+
session_id: sessionIdSchema,
|
|
6454
7834
|
key: z5.string().min(1),
|
|
6455
7835
|
value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
|
|
6456
7836
|
});
|
|
@@ -6580,6 +7960,7 @@ var SWEEP_INTERVAL_MS2 = 3e4;
|
|
|
6580
7960
|
var GovernanceService = class {
|
|
6581
7961
|
policy;
|
|
6582
7962
|
environment;
|
|
7963
|
+
session;
|
|
6583
7964
|
evidenceStore;
|
|
6584
7965
|
approvalRouter;
|
|
6585
7966
|
rateLimiter;
|
|
@@ -6613,6 +7994,7 @@ var GovernanceService = class {
|
|
|
6613
7994
|
constructor(options) {
|
|
6614
7995
|
this.policy = options.policy;
|
|
6615
7996
|
this.environment = options.environment;
|
|
7997
|
+
this.session = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
6616
7998
|
this.evidenceStore = options.evidenceStore;
|
|
6617
7999
|
this.approvalRouter = options.approvalRouter;
|
|
6618
8000
|
this.rateLimiter = options.rateLimiter;
|
|
@@ -6647,6 +8029,7 @@ var GovernanceService = class {
|
|
|
6647
8029
|
if (reserved) {
|
|
6648
8030
|
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
6649
8031
|
}
|
|
8032
|
+
const sessionId = isWellFormedSessionId(req.session_id) ? req.session_id : null;
|
|
6650
8033
|
const inputBytes = byteLength(req.arguments ?? {});
|
|
6651
8034
|
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
6652
8035
|
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
@@ -6654,7 +8037,7 @@ var GovernanceService = class {
|
|
|
6654
8037
|
const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
|
|
6655
8038
|
tool: req.tool.name,
|
|
6656
8039
|
agent_id: req.agent_id,
|
|
6657
|
-
session_id:
|
|
8040
|
+
session_id: sessionId,
|
|
6658
8041
|
origin: req.origin
|
|
6659
8042
|
});
|
|
6660
8043
|
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
@@ -6676,7 +8059,8 @@ var GovernanceService = class {
|
|
|
6676
8059
|
const pipeline = decide({
|
|
6677
8060
|
toolName,
|
|
6678
8061
|
toolArguments: req.arguments,
|
|
6679
|
-
sessionId:
|
|
8062
|
+
sessionId: sessionId ?? void 0,
|
|
8063
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
6680
8064
|
policy: this.policy,
|
|
6681
8065
|
environment: this.environment,
|
|
6682
8066
|
evidenceStore: this.evidenceStore,
|
|
@@ -6693,6 +8077,8 @@ var GovernanceService = class {
|
|
|
6693
8077
|
const plans = [];
|
|
6694
8078
|
let limitsBlock;
|
|
6695
8079
|
let ruleLimitOk = true;
|
|
8080
|
+
let sessionUnresolvedDeny = false;
|
|
8081
|
+
let dryRunSessionUnresolved = false;
|
|
6696
8082
|
const reservedThisCall = [];
|
|
6697
8083
|
const reserve = (key) => {
|
|
6698
8084
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -6707,12 +8093,14 @@ var GovernanceService = class {
|
|
|
6707
8093
|
if (pipeline.isDryRun) {
|
|
6708
8094
|
wire = "dry_run";
|
|
6709
8095
|
if (decision.action === "rate_limit") {
|
|
6710
|
-
const planned = this.planRate(decision, toolName,
|
|
8096
|
+
const planned = this.planRate(decision, toolName, sessionId, senderId);
|
|
6711
8097
|
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
8098
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
6712
8099
|
ruleLimitOk = planned?.allowed ?? true;
|
|
6713
8100
|
} else if (decision.action === "spend_limit") {
|
|
6714
|
-
const planned = this.planSpend(decision, toolName,
|
|
8101
|
+
const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
|
|
6715
8102
|
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
8103
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
6716
8104
|
ruleLimitOk = planned?.allowed ?? true;
|
|
6717
8105
|
}
|
|
6718
8106
|
} else if (decision.action === "deny") {
|
|
@@ -6720,21 +8108,31 @@ var GovernanceService = class {
|
|
|
6720
8108
|
} else if (decision.action === "require_approval") {
|
|
6721
8109
|
wire = "require_approval";
|
|
6722
8110
|
} else if (decision.action === "rate_limit") {
|
|
6723
|
-
const planned = this.planRate(decision, toolName,
|
|
6724
|
-
if (planned?.
|
|
6725
|
-
|
|
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";
|
|
6726
8122
|
}
|
|
6727
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
6728
|
-
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
6729
|
-
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
6730
8123
|
} else if (decision.action === "spend_limit") {
|
|
6731
|
-
const planned = this.planSpend(decision, toolName,
|
|
6732
|
-
if (planned?.
|
|
6733
|
-
|
|
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";
|
|
6734
8135
|
}
|
|
6735
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
6736
|
-
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
6737
|
-
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
6738
8136
|
} else {
|
|
6739
8137
|
wire = "allow";
|
|
6740
8138
|
}
|
|
@@ -6746,14 +8144,27 @@ var GovernanceService = class {
|
|
|
6746
8144
|
let budgetTicketTimeoutMs;
|
|
6747
8145
|
let budgetTriggeredApproval = false;
|
|
6748
8146
|
if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
|
|
8147
|
+
const budgetSessionGate = gateSession(sessionId, this.session.onUnresolved);
|
|
6749
8148
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
6750
8149
|
toolName,
|
|
6751
8150
|
toolArguments: req.arguments,
|
|
6752
|
-
sessionId:
|
|
8151
|
+
sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
|
|
6753
8152
|
senderId
|
|
6754
8153
|
});
|
|
6755
|
-
|
|
6756
|
-
|
|
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: [] };
|
|
6757
8168
|
budgetsBlock = [
|
|
6758
8169
|
...peek.entries.map((entry2) => budgetWireBlock(entry2)),
|
|
6759
8170
|
...failures.map((failure) => budgetFailureBlock(failure))
|
|
@@ -6775,19 +8186,16 @@ var GovernanceService = class {
|
|
|
6775
8186
|
} else {
|
|
6776
8187
|
if (breaches.length > 0) budgetDryRunOk = false;
|
|
6777
8188
|
if (wire !== "dry_run") {
|
|
6778
|
-
|
|
6779
|
-
|
|
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)) {
|
|
6780
8195
|
releaseReservations();
|
|
6781
8196
|
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
6782
8197
|
}
|
|
6783
|
-
plans.push(
|
|
6784
|
-
kind: "budget",
|
|
6785
|
-
budget: charge.budget,
|
|
6786
|
-
bucketKey: charge.bucketKey,
|
|
6787
|
-
amount: charge.amount,
|
|
6788
|
-
generation: charge.generation,
|
|
6789
|
-
breached: peek.entries[index]?.allowed === false
|
|
6790
|
-
});
|
|
8198
|
+
plans.push(plan);
|
|
6791
8199
|
}
|
|
6792
8200
|
if (breaches.length > 0) {
|
|
6793
8201
|
budgetBreachEntries = breaches;
|
|
@@ -6836,12 +8244,21 @@ var GovernanceService = class {
|
|
|
6836
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."
|
|
6837
8245
|
};
|
|
6838
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
|
+
}
|
|
6839
8255
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
6840
8256
|
if (wire === "dry_run") {
|
|
6841
8257
|
responseBody["dry_run"] = {
|
|
6842
8258
|
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
6843
8259
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
6844
|
-
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
8260
|
+
limits_ok: ruleLimitOk && budgetDryRunOk,
|
|
8261
|
+
...dryRunSessionUnresolved ? { session_unresolved: true } : {}
|
|
6845
8262
|
};
|
|
6846
8263
|
}
|
|
6847
8264
|
if (pipeline.driftEvent) {
|
|
@@ -6852,7 +8269,7 @@ var GovernanceService = class {
|
|
|
6852
8269
|
timestampIso,
|
|
6853
8270
|
origin: req.origin,
|
|
6854
8271
|
agentId: req.agent_id,
|
|
6855
|
-
sessionId
|
|
8272
|
+
sessionId,
|
|
6856
8273
|
toolName,
|
|
6857
8274
|
toolInput: req.arguments ?? {},
|
|
6858
8275
|
metadata: req.metadata,
|
|
@@ -6867,7 +8284,9 @@ var GovernanceService = class {
|
|
|
6867
8284
|
// limitsBlock is also the response's `limits`, and the audit writer
|
|
6868
8285
|
// buffers records by reference until flush — a direct embedder
|
|
6869
8286
|
// editing the returned body must not be able to rewrite evidence.
|
|
6870
|
-
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
8287
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0,
|
|
8288
|
+
sessionUnresolved: sessionUnresolvedDeny,
|
|
8289
|
+
sessionChain: pipeline.sessionBlocked
|
|
6871
8290
|
});
|
|
6872
8291
|
this.tombstones.set(evaluationId, {
|
|
6873
8292
|
auditRecordId: auditId,
|
|
@@ -6900,7 +8319,7 @@ var GovernanceService = class {
|
|
|
6900
8319
|
// rewrite it (same guard as the pending entry's evidence below).
|
|
6901
8320
|
tool_input: structuredClone(req.arguments ?? {}),
|
|
6902
8321
|
matched_rule: decision.matchedRule,
|
|
6903
|
-
session_id:
|
|
8322
|
+
session_id: sessionId,
|
|
6904
8323
|
origin: req.origin,
|
|
6905
8324
|
timeout_ms: timeoutMs,
|
|
6906
8325
|
breached_budgets: budgetBreachContexts
|
|
@@ -6918,7 +8337,7 @@ var GovernanceService = class {
|
|
|
6918
8337
|
evaluationId,
|
|
6919
8338
|
origin: req.origin,
|
|
6920
8339
|
agentId: req.agent_id,
|
|
6921
|
-
sessionId
|
|
8340
|
+
sessionId,
|
|
6922
8341
|
toolName,
|
|
6923
8342
|
// Cloned: direct embedders share these references and could otherwise
|
|
6924
8343
|
// mutate the audit evidence (and desync the byte accounting) after
|
|
@@ -7137,7 +8556,9 @@ var GovernanceService = class {
|
|
|
7137
8556
|
timestampIso: new Date(this.now()).toISOString(),
|
|
7138
8557
|
origin: req.origin,
|
|
7139
8558
|
agentId: req.agent_id,
|
|
7140
|
-
|
|
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,
|
|
7141
8562
|
toolName,
|
|
7142
8563
|
toolInput: { ...req.package },
|
|
7143
8564
|
metadata: req.metadata,
|
|
@@ -7454,7 +8875,18 @@ var GovernanceService = class {
|
|
|
7454
8875
|
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
7455
8876
|
return { allowed: true };
|
|
7456
8877
|
}
|
|
7457
|
-
|
|
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
|
+
}
|
|
7458
8890
|
const peek = this.rateLimiter.peek({
|
|
7459
8891
|
key,
|
|
7460
8892
|
maxCalls: limits.maxCalls,
|
|
@@ -7474,10 +8906,19 @@ var GovernanceService = class {
|
|
|
7474
8906
|
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
7475
8907
|
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
7476
8908
|
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
7477
|
-
|
|
7478
|
-
|
|
7479
|
-
|
|
7480
|
-
|
|
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);
|
|
7481
8922
|
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
7482
8923
|
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
7483
8924
|
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
@@ -7509,18 +8950,15 @@ var GovernanceService = class {
|
|
|
7509
8950
|
/** Commit every plan of one call at /audit time; returns the chain blocks. */
|
|
7510
8951
|
commitPlans(entry, actualAmount, auditId, approvalStatus) {
|
|
7511
8952
|
let chain;
|
|
7512
|
-
const budgetPlans = entry.plans.filter(
|
|
8953
|
+
const budgetPlans = entry.plans.filter(
|
|
8954
|
+
(plan) => plan.kind === "budget"
|
|
8955
|
+
);
|
|
7513
8956
|
if (budgetPlans.length > 0 && this.budgetEngine) {
|
|
7514
8957
|
const kinds = new Map(
|
|
7515
8958
|
budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
|
|
7516
8959
|
);
|
|
7517
8960
|
const snapshots = this.budgetEngine.recordAll(
|
|
7518
|
-
budgetPlans
|
|
7519
|
-
budget: plan.budget,
|
|
7520
|
-
bucketKey: plan.bucketKey,
|
|
7521
|
-
amount: actualAmount ?? plan.amount,
|
|
7522
|
-
generation: plan.generation
|
|
7523
|
-
})),
|
|
8961
|
+
remintDeferredCharges(budgetPlans, actualAmount),
|
|
7524
8962
|
{
|
|
7525
8963
|
kind: "spend",
|
|
7526
8964
|
...kinds.size > 0 ? { kinds } : {},
|
|
@@ -7591,6 +9029,12 @@ var GovernanceService = class {
|
|
|
7591
9029
|
if (!this.auditWriter) return id;
|
|
7592
9030
|
const blockReason = deriveBlockReason(args);
|
|
7593
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
|
+
}
|
|
7594
9038
|
if (args.sidebandUnreported) {
|
|
7595
9039
|
evidenceChain = {
|
|
7596
9040
|
...evidenceChain ?? {},
|
|
@@ -7606,6 +9050,9 @@ var GovernanceService = class {
|
|
|
7606
9050
|
const record = {
|
|
7607
9051
|
timestamp: args.timestampIso,
|
|
7608
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,
|
|
7609
9056
|
agent_id: args.agentId,
|
|
7610
9057
|
environment: this.environment ?? null,
|
|
7611
9058
|
tool_name: args.toolName,
|
|
@@ -7628,7 +9075,9 @@ var GovernanceService = class {
|
|
|
7628
9075
|
dry_run: args.dryRun,
|
|
7629
9076
|
record_kind: args.recordKind,
|
|
7630
9077
|
origin: args.origin,
|
|
7631
|
-
metadata: args.metadata
|
|
9078
|
+
metadata: args.metadata,
|
|
9079
|
+
// The sideband has no MCP wire, so no protocol claim exists.
|
|
9080
|
+
protocol_version: null
|
|
7632
9081
|
};
|
|
7633
9082
|
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
7634
9083
|
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
@@ -7647,6 +9096,7 @@ function deriveBlockReason(args) {
|
|
|
7647
9096
|
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
7648
9097
|
if (args.dryRun) return null;
|
|
7649
9098
|
if (args.budgetBreachBlocked) return "budget_exceeded";
|
|
9099
|
+
if (args.sessionUnresolved) return "session_unresolved";
|
|
7650
9100
|
if (args.approvalStatus === "denied") return "approval_denied";
|
|
7651
9101
|
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
7652
9102
|
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
@@ -7685,10 +9135,8 @@ function policyCanRequireApproval(policy) {
|
|
|
7685
9135
|
}
|
|
7686
9136
|
return policy.rules.some((rule) => rule.action === "require_approval");
|
|
7687
9137
|
}
|
|
7688
|
-
function buildLimitKey(keyType, toolName,
|
|
9138
|
+
function buildLimitKey(keyType, toolName, senderId) {
|
|
7689
9139
|
switch (keyType) {
|
|
7690
|
-
case "session":
|
|
7691
|
-
return `session:${sessionId ?? "unknown"}`;
|
|
7692
9140
|
case "sender_id":
|
|
7693
9141
|
return `sender:${senderId ?? "unknown"}`;
|
|
7694
9142
|
case "agent":
|
|
@@ -8898,7 +10346,11 @@ var BudgetEngine = class {
|
|
|
8898
10346
|
}
|
|
8899
10347
|
return { charges, failures };
|
|
8900
10348
|
}
|
|
8901
|
-
/**
|
|
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
|
+
*/
|
|
8902
10354
|
peekAll(charges) {
|
|
8903
10355
|
const entries = charges.map((charge) => this.snapshot(charge));
|
|
8904
10356
|
return { allowed: entries.every((entry) => entry.allowed), entries };
|
|
@@ -9668,7 +11120,11 @@ var CSV_HEADERS = [
|
|
|
9668
11120
|
"matched_rule_index",
|
|
9669
11121
|
"record_kind",
|
|
9670
11122
|
"origin",
|
|
9671
|
-
"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"
|
|
9672
11128
|
];
|
|
9673
11129
|
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
9674
11130
|
function csvEscape(value) {
|
|
@@ -10545,6 +12001,13 @@ upstream:
|
|
|
10545
12001
|
|
|
10546
12002
|
# environment: production
|
|
10547
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
|
+
|
|
10548
12011
|
# policies:
|
|
10549
12012
|
# default: allow
|
|
10550
12013
|
# dry_run: false
|
|
@@ -10604,90 +12067,6 @@ function printConfigErrorDetails(error, prefix = "") {
|
|
|
10604
12067
|
console.error(`${prefix} ${detail.path}: ${detail.message}`);
|
|
10605
12068
|
}
|
|
10606
12069
|
}
|
|
10607
|
-
var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
|
|
10608
|
-
var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
|
|
10609
|
-
var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
|
|
10610
|
-
var ANNOTATION_PRIME_RETRY_JITTER_MS = 250;
|
|
10611
|
-
function computePrimeRetryDelayMs(attempt) {
|
|
10612
|
-
const exponent = Math.max(0, attempt - 1);
|
|
10613
|
-
const baseDelay = Math.min(
|
|
10614
|
-
ANNOTATION_PRIME_RETRY_MAX_MS,
|
|
10615
|
-
ANNOTATION_PRIME_RETRY_BASE_MS * 2 ** exponent
|
|
10616
|
-
);
|
|
10617
|
-
const jitter = Math.floor(Math.random() * ANNOTATION_PRIME_RETRY_JITTER_MS);
|
|
10618
|
-
return Math.min(ANNOTATION_PRIME_RETRY_MAX_MS, baseDelay + jitter);
|
|
10619
|
-
}
|
|
10620
|
-
async function startAnnotationPrimeLoop(governedForwarder) {
|
|
10621
|
-
let stopped = false;
|
|
10622
|
-
let primed = false;
|
|
10623
|
-
let retryAttempt = 0;
|
|
10624
|
-
let retryTimer;
|
|
10625
|
-
const clearRetryTimer = () => {
|
|
10626
|
-
if (!retryTimer) return;
|
|
10627
|
-
clearTimeout(retryTimer);
|
|
10628
|
-
retryTimer = void 0;
|
|
10629
|
-
};
|
|
10630
|
-
const stop = () => {
|
|
10631
|
-
stopped = true;
|
|
10632
|
-
clearRetryTimer();
|
|
10633
|
-
};
|
|
10634
|
-
const scheduleRetry = () => {
|
|
10635
|
-
if (stopped || primed || retryTimer) return;
|
|
10636
|
-
retryAttempt += 1;
|
|
10637
|
-
const delayMs = computePrimeRetryDelayMs(retryAttempt);
|
|
10638
|
-
console.error(
|
|
10639
|
-
`[helio] Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
|
|
10640
|
-
);
|
|
10641
|
-
retryTimer = setTimeout(() => {
|
|
10642
|
-
retryTimer = void 0;
|
|
10643
|
-
void runPrimeAttempt("retry");
|
|
10644
|
-
}, delayMs);
|
|
10645
|
-
retryTimer.unref();
|
|
10646
|
-
};
|
|
10647
|
-
const handlePrimeResult = (phase, result) => {
|
|
10648
|
-
if (stopped || primed) return;
|
|
10649
|
-
if (result.success) {
|
|
10650
|
-
primed = true;
|
|
10651
|
-
clearRetryTimer();
|
|
10652
|
-
const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
|
|
10653
|
-
console.error(
|
|
10654
|
-
`${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)`
|
|
10655
|
-
);
|
|
10656
|
-
return;
|
|
10657
|
-
}
|
|
10658
|
-
const reason = result.reason ?? "unknown reason";
|
|
10659
|
-
if (phase === "initial") {
|
|
10660
|
-
console.error(
|
|
10661
|
-
`[helio] Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
|
|
10662
|
-
);
|
|
10663
|
-
} else {
|
|
10664
|
-
console.error(
|
|
10665
|
-
`[helio] Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
|
|
10666
|
-
);
|
|
10667
|
-
}
|
|
10668
|
-
scheduleRetry();
|
|
10669
|
-
};
|
|
10670
|
-
const runPrimeAttempt = async (phase) => {
|
|
10671
|
-
const result = await governedForwarder.primeAnnotationCache();
|
|
10672
|
-
handlePrimeResult(phase, result);
|
|
10673
|
-
};
|
|
10674
|
-
const initialAttempt = runPrimeAttempt("initial");
|
|
10675
|
-
const initialOutcome = await Promise.race([
|
|
10676
|
-
initialAttempt.then(() => "completed"),
|
|
10677
|
-
new Promise((resolve2) => {
|
|
10678
|
-
setTimeout(() => {
|
|
10679
|
-
resolve2("timeout");
|
|
10680
|
-
}, ANNOTATION_PRIME_INITIAL_WAIT_MS).unref();
|
|
10681
|
-
})
|
|
10682
|
-
]);
|
|
10683
|
-
if (initialOutcome === "timeout") {
|
|
10684
|
-
console.error(
|
|
10685
|
-
`[helio] Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
|
|
10686
|
-
);
|
|
10687
|
-
scheduleRetry();
|
|
10688
|
-
}
|
|
10689
|
-
return { stop };
|
|
10690
|
-
}
|
|
10691
12070
|
async function startCommand(configPath, options) {
|
|
10692
12071
|
let config;
|
|
10693
12072
|
try {
|
|
@@ -10735,6 +12114,8 @@ async function startCommand(configPath, options) {
|
|
|
10735
12114
|
block_reason: record.block_reason,
|
|
10736
12115
|
approval_status: record.approval_status,
|
|
10737
12116
|
session_id: record.session_id,
|
|
12117
|
+
session_source: record.session_source,
|
|
12118
|
+
protocol_version: record.protocol_version,
|
|
10738
12119
|
agent_id: record.agent_id,
|
|
10739
12120
|
environment: record.environment,
|
|
10740
12121
|
timestamp: record.timestamp,
|
|
@@ -10819,6 +12200,7 @@ async function startCommand(configPath, options) {
|
|
|
10819
12200
|
}
|
|
10820
12201
|
});
|
|
10821
12202
|
budgetEngine.hydrate();
|
|
12203
|
+
const session = compileSessionIdentity(config.session);
|
|
10822
12204
|
const governedForwarder = new GovernedForwarder(forwarder, policy, {
|
|
10823
12205
|
environment: config.environment,
|
|
10824
12206
|
auditWriter,
|
|
@@ -10826,13 +12208,17 @@ async function startCommand(configPath, options) {
|
|
|
10826
12208
|
approvalRouter,
|
|
10827
12209
|
rateLimiter,
|
|
10828
12210
|
spendLimiter,
|
|
10829
|
-
budgetEngine
|
|
12211
|
+
budgetEngine,
|
|
12212
|
+
session
|
|
10830
12213
|
});
|
|
10831
|
-
const annotationPrime = await startAnnotationPrimeLoop(governedForwarder);
|
|
12214
|
+
const annotationPrime = await startAnnotationPrimeLoop(governedForwarder, policy.toolRevalidation);
|
|
10832
12215
|
const hasSlackChannels = [...channels.values()].some((ch) => ch.type === "slack");
|
|
10833
12216
|
const slackActionApp = hasSlackChannels ? createSlackActionApp({ router: approvalRouter, channels }) : void 0;
|
|
10834
12217
|
const app = createApp(config, governedForwarder, {
|
|
10835
|
-
slackActionApp
|
|
12218
|
+
slackActionApp,
|
|
12219
|
+
onHeaderMismatch: (rejection) => {
|
|
12220
|
+
auditWriter.pushImmediate(buildHeaderMismatchAuditRecord(rejection, config.environment));
|
|
12221
|
+
}
|
|
10836
12222
|
});
|
|
10837
12223
|
const handle = startServer(app, config);
|
|
10838
12224
|
let sidebandHandle;
|
|
@@ -10866,6 +12252,7 @@ async function startCommand(configPath, options) {
|
|
|
10866
12252
|
rateLimiter,
|
|
10867
12253
|
spendLimiter,
|
|
10868
12254
|
budgetEngine,
|
|
12255
|
+
session,
|
|
10869
12256
|
auditWriter,
|
|
10870
12257
|
approvalTimeoutMs: parseDuration(config.approval.timeout),
|
|
10871
12258
|
ttlMs: parseDuration(config.sdk.evaluation_ttl)
|
|
@@ -10987,6 +12374,7 @@ async function startCommand(configPath, options) {
|
|
|
10987
12374
|
}
|
|
10988
12375
|
budgetEngine.reconcile(newBudgets);
|
|
10989
12376
|
governedForwarder.updatePolicy(newPolicy);
|
|
12377
|
+
annotationPrime.reconfigure(newPolicy.toolRevalidation);
|
|
10990
12378
|
governanceService?.updatePolicy(newPolicy);
|
|
10991
12379
|
const budgetTotal = newBudgets.length;
|
|
10992
12380
|
console.error(
|