@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/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 (reserved.has(name.toLowerCase())) {
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
- }).strict();
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: id ?? null,
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/transport/response-normalizer.ts
1250
- function isObject(value) {
1251
- return value !== null && typeof value === "object";
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
- function isValidJsonRpcId(value) {
1254
- return value === null || typeof value === "string" || typeof value === "number";
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 getJsonRpcId(value) {
1257
- if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
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 isValidJsonRpcError(value) {
1262
- if (!isObject(value)) return false;
1263
- return typeof value["code"] === "number" && typeof value["message"] === "string";
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 isValidJsonRpcResponse(value) {
1266
- if (!isObject(value)) return false;
1267
- if (value["jsonrpc"] !== "2.0") return false;
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 hasResult = Object.prototype.hasOwnProperty.call(value, "result");
1272
- const hasError = Object.prototype.hasOwnProperty.call(value, "error");
1273
- if (hasResult && hasError || !hasResult && !hasError) return false;
1274
- if (hasError && !isValidJsonRpcError(value["error"])) return false;
1275
- return true;
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
- function makeWrappedError(requestId, message, data) {
1278
- return {
1279
- jsonrpc: "2.0",
1280
- id: requestId ?? null,
1281
- error: {
1282
- code: INTERNAL_ERROR,
1283
- message,
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
- function normalizeUpstreamOutcome(args) {
1289
- if (args.forwardingError) {
1290
- return {
1291
- httpStatus: 200,
1292
- wrapped: true,
1293
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1294
- failure_class: "upstream_forward_error",
1295
- failure_reason: args.forwardingError.message
1296
- })
1297
- };
1298
- }
1299
- if (!args.upstreamResponse) {
1300
- return {
1301
- httpStatus: 200,
1302
- wrapped: true,
1303
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1304
- failure_class: "upstream_forward_error",
1305
- failure_reason: "missing upstream response"
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
- httpStatus: 200,
1353
- wrapped: false,
1354
- body: upstream.body
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/transport/streamable-http.ts
1359
- var MCP_SESSION_HEADER = "mcp-session-id";
1360
- var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
1361
- function createStreamableHttpRoute(forwarder, options = {}) {
1362
- const app = new Hono();
1363
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1364
- app.post("/", async (c) => {
1365
- if (!isJsonContentType(c.req.header("content-type"))) {
1366
- return c.json(
1367
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1368
- 415
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
- let body;
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
- body = await c.req.json();
1646
+ parsed = JSON.parse(data);
1374
1647
  } catch {
1375
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
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
- const id = parsedRequest.request.id;
1382
- const method = parsedRequest.request.method;
1383
- const params = parsedRequest.request.params;
1384
- const sessionId = c.req.header(MCP_SESSION_HEADER);
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
- let result;
1404
- try {
1405
- result = await forwarder.forward(mcpRequest);
1406
- } catch (err) {
1407
- const forwardingError = err instanceof Error ? err : new Error(String(err));
1408
- console.error("[helio] Upstream forwarding failed:", forwardingError.message);
1409
- const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
1410
- return c.json(normalized2.body, normalized2.httpStatus);
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
- const { response } = result;
1413
- for (const [key, value] of Object.entries(response.headers)) {
1414
- if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
1415
- c.header(key, value);
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
- const normalized = normalizeUpstreamOutcome({ requestId: id, upstreamResponse: response });
1419
- return c.json(normalized.body, normalized.httpStatus);
1420
- });
1421
- return app;
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/transport/sse.ts
1425
- import { randomUUID } from "crypto";
1426
- import { Hono as Hono2 } from "hono";
1427
- import { z as z3 } from "zod";
1428
- var encoder = new TextEncoder();
1429
- var STALE_THRESHOLD_MS = 9e4;
1430
- var SWEEP_INTERVAL_MS = 6e4;
1431
- var ssePostQuerySchema = z3.object({
1432
- sessionId: z3.string().min(1)
1433
- });
1434
- function sseEvent(event, data) {
1435
- return `event: ${event}
1436
- data: ${data}
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
- function createSseRoute(forwarder, options = {}) {
1441
- const sessions = /* @__PURE__ */ new Map();
1442
- const app = new Hono2();
1443
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1444
- const writeSessionEvent = (sessionId, eventPayload) => {
1445
- const session = sessions.get(sessionId);
1446
- if (!session) return;
1447
- session.lastActivity = Date.now();
1448
- void session.writer.write(encoder.encode(eventPayload)).catch(() => {
1449
- sessions.delete(sessionId);
1450
- void session.writer.close().catch(() => {
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
- const sweepInterval = setInterval(() => {
1455
- const now = Date.now();
1456
- for (const [id, session] of sessions) {
1457
- if (now - session.lastActivity > STALE_THRESHOLD_MS) {
1458
- sessions.delete(id);
1459
- void session.writer.close().catch(() => {
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
- }, SWEEP_INTERVAL_MS);
1464
- sweepInterval.unref();
1465
- app.get("/", (c) => {
1466
- const sessionId = randomUUID();
1467
- const { readable, writable } = new TransformStream();
1468
- const writer = writable.getWriter();
1469
- sessions.set(sessionId, { writer, lastActivity: Date.now() });
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
- const sessionId = parsedQuery.data.sessionId;
1494
- const session = sessions.get(sessionId);
1495
- if (!session) {
1496
- return c.json(makeJsonRpcError(null, INVALID_REQUEST, "unknown session"), 404);
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
- if (!isJsonContentType(c.req.header("content-type"))) {
1499
- return c.json(
1500
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1501
- 415
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
- let body;
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
- body = await c.req.json();
1507
- } catch {
1508
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
1509
- }
1510
- const parsedRequest = parseJsonRpcRequest(body);
1511
- if (!parsedRequest.success) {
1512
- return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
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
- const id = parsedRequest.request.id;
1515
- const method = parsedRequest.request.method;
1516
- const params = parsedRequest.request.params;
1517
- const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
1518
- const mcpRequest = {
1519
- jsonrpc: "2.0",
1520
- id,
1521
- method,
1522
- params,
1523
- sessionId,
1524
- headers: forwardHeaders,
1525
- signal: c.req.raw.signal
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
- if (id === void 0) {
1528
- const notificationRequest = { ...mcpRequest, signal: void 0 };
1529
- void forwarder.forward(notificationRequest).catch((err) => {
1530
- const message = err instanceof Error ? err.message : String(err);
1531
- console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
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
- return c.body(null, 202);
1940
+ } catch (error) {
1941
+ throw this.describeFetchFailure(error, "server/discover probe");
1534
1942
  }
1535
- let result;
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
- result = await forwarder.forward(mcpRequest);
1538
- } catch (err) {
1539
- const forwardingError = err instanceof Error ? err : new Error(String(err));
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
- const normalized = normalizeUpstreamOutcome({
1547
- requestId: id,
1548
- upstreamResponse: result.response
1549
- });
1550
- const messageEvent = sseEvent("message", JSON.stringify(normalized.body));
1551
- writeSessionEvent(sessionId, messageEvent);
1552
- return c.body(null, 202);
1553
- });
1554
- return app;
1555
- }
1556
-
1557
- // src/server.ts
1558
- var FORCE_CONNECTION_CLOSE_GRACE_MS = 1500;
1559
- function normalizeError(error) {
1560
- if (error instanceof Error) return error;
1561
- return new Error(String(error));
1562
- }
1563
- function createServerHandle(server) {
1564
- const sockets = /* @__PURE__ */ new Set();
1565
- const nodeServer = server;
1566
- nodeServer.on("connection", (socket) => {
1567
- sockets.add(socket);
1568
- socket.on("close", () => {
1569
- sockets.delete(socket);
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
- const forceCloseConnections = () => {
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
- nodeServer.closeIdleConnections?.();
2072
+ parsed = JSON.parse(raw);
1575
2073
  } catch {
2074
+ throw new Error(`upstream ${step} returned non-JSON body`);
1576
2075
  }
1577
- if (nodeServer.closeAllConnections) {
1578
- try {
1579
- nodeServer.closeAllConnections();
1580
- } catch {
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
- for (const socket of sockets) {
1585
- socket.destroy();
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
- return {
1589
- server,
1590
- close: () => new Promise((resolve2, reject) => {
1591
- let settled = false;
1592
- let forceTimer;
1593
- const settle = (err) => {
1594
- if (settled) return;
1595
- settled = true;
1596
- if (forceTimer) {
1597
- clearTimeout(forceTimer);
1598
- forceTimer = void 0;
1599
- }
1600
- if (err) {
1601
- reject(err);
1602
- return;
1603
- }
1604
- resolve2();
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
- nodeServer.close((err) => {
1608
- if (err) {
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
- nodeServer.closeIdleConnections?.();
2148
+ chunk = await readSseChunkWithTimeout(reader, remainingMs);
1620
2149
  } catch {
2150
+ await reader.cancel().catch(() => void 0);
2151
+ return { outcome: "timed-out" };
1621
2152
  }
1622
- forceTimer = setTimeout(() => {
1623
- forceCloseConnections();
1624
- }, FORCE_CONNECTION_CLOSE_GRACE_MS);
1625
- forceTimer.unref();
1626
- })
1627
- };
1628
- }
1629
- function createApp(config, forwarder, options) {
1630
- const app = new Hono3();
1631
- const forwardHeadersAllowlist = config.upstream.forward_headers;
1632
- app.get("/healthz", (c) => c.json({ status: "ok" }));
1633
- app.route("/mcp", createStreamableHttpRoute(forwarder, { forwardHeadersAllowlist }));
1634
- app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist }));
1635
- if (options?.slackActionApp) {
1636
- app.route("/slack/actions", options.slackActionApp);
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 startServer(app, config) {
1641
- const server = serve({
1642
- fetch: app.fetch,
1643
- port: config.listen.port,
1644
- hostname: config.listen.host
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 startSidebandServer(app, port, host = "127.0.0.1") {
1649
- const server = serve({
1650
- fetch: app.fetch,
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
- // src/upstream/response.ts
1658
- async function parseUpstreamResponse(res) {
1659
- const headers = {};
1660
- res.headers.forEach((value, key) => {
1661
- headers[key] = value;
1662
- });
1663
- const contentType = res.headers.get("content-type") ?? "";
1664
- let body;
1665
- if (contentType.includes("application/json")) {
1666
- const text = await res.text();
1667
- try {
1668
- body = JSON.parse(text);
1669
- } catch {
1670
- body = text;
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
- return { status: res.status, headers, body };
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
- // src/upstream/sse-parse.ts
1679
- function parseSseChunk(chunk, state, onEvent) {
1680
- let { event, data, remainder } = state;
1681
- const text = remainder + chunk;
1682
- const lines = text.split("\n");
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
- return { event, data, remainder };
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
- async function readSseJsonRpcResponse(res, requestId) {
1703
- if (!res.body) {
1704
- throw new Error("upstream SSE response had no body");
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 reader = res.body.getReader();
1707
- const decoder = new TextDecoder();
1708
- let state = { event: "", data: "", remainder: "" };
1709
- let found;
1710
- const onEvent = (event, data) => {
1711
- if (event && event !== "message") return;
1712
- let parsed;
1713
- try {
1714
- parsed = JSON.parse(data);
1715
- } catch {
1716
- return;
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
- if (parsed === null || typeof parsed !== "object") return;
1719
- const id = parsed["id"];
1720
- if (id === requestId) {
1721
- found = parsed;
2304
+ });
2305
+ if (headerMethod === void 0) {
2306
+ if (requiresPresence) {
2307
+ return reject(`missing mcp-method header (expected ${displayCap(method)})`);
1722
2308
  }
1723
- };
1724
- const processChunk = (chunk) => {
1725
- state = parseSseChunk(chunk, state, onEvent);
1726
- };
1727
- for (; ; ) {
1728
- const result = await reader.read();
1729
- if (result.value !== void 0) {
1730
- const chunk = result.value;
1731
- processChunk(decoder.decode(chunk, { stream: true }));
1732
- if (found) {
1733
- await reader.cancel().catch(() => void 0);
1734
- return found;
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
- if (result.done) {
1738
- const tail = decoder.decode();
1739
- if (tail) {
1740
- processChunk(tail);
1741
- if (found) return found;
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
- break;
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
- throw new Error(
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/upstream/merge-headers.ts
1752
- function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1753
- const out = {};
1754
- const apply = (headers) => {
1755
- for (const [name, value] of Object.entries(headers)) {
1756
- out[name.toLowerCase()] = value;
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
- apply(base);
1760
- apply(forwarded);
1761
- apply(staticHeaders);
1762
- return out;
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/upstream/connection-error.ts
1766
- var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1767
- var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
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 describeUnreachableUpstream(error, url) {
1791
- const code = extractErrorCode(error);
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
- // src/upstream/upstream-session-manager.ts
1805
- var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
1806
- var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
1807
- var UpstreamSessionManager = class {
1808
- url;
1809
- staticHeaders;
1810
- requestTimeoutMs;
1811
- internal;
1812
- inflight;
1813
- constructor(options) {
1814
- this.url = options.url;
1815
- this.staticHeaders = options.staticHeaders;
1816
- this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
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
- /** Convert a fetch failure into an actionable error for the given step. */
1837
- describeFetchFailure(error, step) {
1838
- if (error instanceof Error && error.name === "TimeoutError") {
1839
- return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
1840
- }
1841
- return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
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
- async initialize() {
1844
- const headers = mergeUpstreamHeaders(
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
- "content-type": "application/json",
1847
- accept: "application/json, text/event-stream"
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 initBody = {
1853
- jsonrpc: "2.0",
1854
- id: 0,
1855
- method: "initialize",
1856
- params: {
1857
- protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
1858
- capabilities: {},
1859
- clientInfo: { name: "helio-proxy", version: "0" }
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
- let res;
1863
- try {
1864
- res = await fetch(this.url, {
1865
- method: "POST",
1866
- headers,
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
- } catch (error) {
1871
- throw this.describeFetchFailure(error, "initialize");
2570
+ return c.body(null, 202);
1872
2571
  }
1873
- if (!res.ok) {
1874
- throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
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 sessionId = res.headers.get("mcp-session-id") ?? void 0;
1877
- const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
1878
- res,
1879
- initBody.id,
1880
- "initialize"
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 negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
1887
- const notifyHeaders = { ...headers };
1888
- if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
1889
- notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1890
- const notifyRes = await fetch(this.url, {
1891
- method: "POST",
1892
- headers: notifyHeaders,
1893
- body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1894
- signal: AbortSignal.timeout(this.requestTimeoutMs)
1895
- }).catch((error) => {
1896
- throw this.describeFetchFailure(error, "notifications/initialized");
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
- if (!notifyRes.ok) {
1899
- throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
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
- const notifyError = await this.readOptionalJsonRpcError(notifyRes);
1902
- if (notifyError) {
1903
- throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
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
- return { sessionId, protocolVersion: negotiatedProtocolVersion };
1906
- }
1907
- async readRequiredJsonRpcEnvelope(res, requestId, step) {
1908
- const contentType = res.headers.get("content-type") ?? "";
1909
- if (contentType.includes("text/event-stream")) {
1910
- const payload = await readSseJsonRpcResponse(res, requestId);
1911
- return payload;
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 raw = await res.text();
1914
- if (!raw.trim()) {
1915
- throw new Error(`upstream ${step} returned an empty body`);
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
- let parsed;
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
- parsed = JSON.parse(raw);
2696
+ body = await c.req.json();
1920
2697
  } catch {
1921
- throw new Error(`upstream ${step} returned non-JSON body`);
2698
+ return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
1922
2699
  }
1923
- if (typeof parsed !== "object" || parsed === null) {
1924
- throw new Error(`upstream ${step} returned non-object JSON`);
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
- return parsed;
1927
- }
1928
- async readOptionalJsonRpcError(res) {
1929
- const contentType = res.headers.get("content-type") ?? "";
1930
- if (contentType.includes("text/event-stream")) {
1931
- if (!res.body) return void 0;
1932
- let errorMessage;
1933
- const reader = res.body.getReader();
1934
- const decoder = new TextDecoder();
1935
- let state = { event: "", data: "", remainder: "" };
1936
- let scannedBytes = 0;
1937
- const deadline = Date.now() + this.requestTimeoutMs;
1938
- const onEvent = (event, data) => {
1939
- if (errorMessage) return;
1940
- if (event && event !== "message") return;
1941
- let parsed2;
1942
- try {
1943
- parsed2 = JSON.parse(data);
1944
- } catch {
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
- if (typeof parsed2 !== "object" || parsed2 === null) return;
1948
- errorMessage = extractJsonRpcErrorMessage(parsed2);
2806
+ resolve2();
1949
2807
  };
1950
- for (; ; ) {
1951
- const remainingMs = deadline - Date.now();
1952
- if (remainingMs <= 0) {
1953
- await reader.cancel().catch(() => void 0);
1954
- throw new Error(
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
- return errorMessage;
1988
- }
2814
+ settle();
2815
+ });
2816
+ } catch (error) {
2817
+ settle(normalizeError(error));
2818
+ return;
1989
2819
  }
1990
- }
1991
- const raw = await res.text();
1992
- if (!raw.trim()) return void 0;
1993
- let parsed;
1994
- try {
1995
- parsed = JSON.parse(raw);
1996
- } catch {
1997
- return void 0;
1998
- }
1999
- if (typeof parsed !== "object" || parsed === null) return void 0;
2000
- return extractJsonRpcErrorMessage(parsed);
2001
- }
2002
- };
2003
- async function readSseChunkWithTimeout(reader, timeoutMs) {
2004
- let timeoutHandle;
2005
- try {
2006
- const result = await Promise.race([
2007
- reader.read(),
2008
- new Promise((_, reject) => {
2009
- timeoutHandle = setTimeout(() => {
2010
- reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
2011
- }, timeoutMs);
2012
- })
2013
- ]);
2014
- if (!isSseReadChunk(result)) {
2015
- throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
2016
- }
2017
- return result;
2018
- } finally {
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 isSseReadChunk(value) {
2023
- if (typeof value !== "object" || value === null) return false;
2024
- const candidate = value;
2025
- if (typeof candidate.done !== "boolean") return false;
2026
- if (candidate.value === void 0) return true;
2027
- return candidate.value instanceof Uint8Array;
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 extractJsonRpcErrorMessage(payload) {
2030
- const error = payload["error"];
2031
- if (typeof error === "string") return error;
2032
- if (typeof error !== "object" || error === null) return void 0;
2033
- const message = error["message"];
2034
- if (typeof message === "string" && message.trim()) return message;
2035
- return "unknown JSON-RPC error";
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
- function extractNegotiatedProtocolVersion(payload) {
2038
- const result = payload["result"];
2039
- if (typeof result !== "object" || result === null) {
2040
- return HELIO_MCP_PROTOCOL_VERSION;
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
- const protocolVersion = result["protocolVersion"];
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
- if (request.method === "initialize") {
2073
- return this.send(
2074
- request,
2075
- request.sessionId,
2076
- /* protocolVersion */
2077
- void 0
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.sessionId,
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.sessionId,
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
- async send(request, sessionId, protocolVersion, internalManaged = false) {
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 (protocolVersion && headers["mcp-protocol-version"] === void 0) {
2122
- headers["mcp-protocol-version"] = protocolVersion;
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 (request.params !== void 0) body["params"] = request.params;
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
- if (request.sessionId) {
2327
- headers["mcp-session-id"] = request.sessionId;
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, sessionId, policy, environment, evidenceStore } = input;
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: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
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.sessionId);
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, sessionId) {
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, sessionId, "tool_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: [] }, sessionId, "tool_drift_reverted");
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, sessionId, decision) {
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: sessionId ?? null,
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.sessionId,
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
- if (forwarded && !isDryRun && this.evidenceStore && request.sessionId && toolName) {
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(request.sessionId, toolName, succeeded);
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.sessionId ?? null,
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: request.sessionId ?? null,
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 peek = charges.length > 0 ? engine.peekAll(charges) : { allowed: true, entries: [] };
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.sessionId ?? null,
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.sessionId ?? null
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
- const key = this.buildLimitKey(limits.key, toolName, request);
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
- const key = this.buildSpendLimitKey(maxSpend.key, toolName, request, decision.matchedRule.index);
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 key = this.buildLimitKey(decision.matchedRule.limits.key, toolName, request);
4664
- const peekResult = this.rateLimiter.peek({
4665
- key,
4666
- maxCalls: decision.matchedRule.limits.maxCalls,
4667
- windowMs: decision.matchedRule.limits.windowMs
4668
- });
4669
- wouldForward = peekResult.allowed;
4670
- limitsOk = peekResult.allowed;
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 key = this.buildSpendLimitKey(
4691
- maxSpend.key,
4692
- toolName,
4693
- request,
4694
- decision.matchedRule.index
4695
- );
4696
- const peekResult = this.spendLimiter.peek({
4697
- key,
4698
- amount: rawAmount,
4699
- limit: maxSpend.limit,
4700
- windowMs: maxSpend.windowMs
4701
- });
4702
- wouldForward = peekResult.allowed;
4703
- limitsOk = peekResult.allowed;
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: request.sessionId ?? null,
5880
+ sessionId: sessionGate.ok ? sessionGate.session : null,
4715
5881
  senderId: null
4716
5882
  });
4717
5883
  if (failures.length > 0 || charges.length > 0) {
4718
- const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
4719
- const ok = failures.length === 0 && peek.allowed;
4720
- wouldForward &&= ok;
4721
- limitsOk &&= ok;
4722
- budgets = [
4723
- ...peek.entries.map((entry) => budgetChainBlock(entry)),
4724
- ...failures.map((failure) => ({
4725
- name: failure.budget.name,
4726
- bucket_key: failure.bucketKey,
4727
- allowed: false,
4728
- reason: failure.reason,
4729
- spent: failure.spent,
4730
- limit: failure.budget.limit,
4731
- remaining: failure.remaining,
4732
- currency: failure.budget.currency
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
- /** Construct a limit bucket key based on the configured key type. */
4747
- buildLimitKey(keyType, toolName, request) {
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
- * Construct a spend bucket key via the shared {@link spendBucketKey}
4774
- * composer see its doc for why spend buckets are rule-discriminated.
4775
- * Rate buckets keep the undiscriminated keys.
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
- buildSpendLimitKey(keyType, toolName, request, ruleIndex) {
4778
- return spendBucketKey(this.buildLimitKey(keyType, toolName, request), ruleIndex);
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.sessionId ?? null,
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
- request,
4983
- POLICY_DENIED,
4984
- "Mcp-Session-Id is required for evidence/dependency-gated policy rules",
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: z5.string().min(1),
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: z5.string().min(1),
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: req.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: req.session_id ?? void 0,
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, req.session_id, senderId);
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, req.session_id, req.arguments, senderId);
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, req.session_id, senderId);
6724
- if (planned?.plan && !reserve(planned.plan.key)) {
6725
- return { status: 503, body: { error: "limit_capacity_exhausted" } };
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, req.session_id, req.arguments, senderId);
6732
- if (planned?.plan && !reserve(planned.plan.key)) {
6733
- return { status: 503, body: { error: "limit_capacity_exhausted" } };
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: req.session_id,
8151
+ sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
6753
8152
  senderId
6754
8153
  });
6755
- if (charges.length > 0 || failures.length > 0) {
6756
- const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
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
- for (const [index, charge] of charges.entries()) {
6779
- if (!reserve(charge.bucketKey)) {
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: req.session_id,
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: req.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: req.session_id,
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
- sessionId: req.session_id,
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
- const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
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
- const key = spendBucketKey(
7478
- buildLimitKey(maxSpend.key, toolName, sessionId, senderId),
7479
- decision.matchedRule.index
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((plan) => plan.kind === "budget");
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.map((plan) => ({
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, sessionId, senderId) {
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
- /** Check every charge without mutating. All-or-nothing: one deny flips `allowed`. */
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(