@gethelio/proxy 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,79 @@ function parseJsonRpcRequest(body) {
1223
1386
  };
1224
1387
  }
1225
1388
 
1389
+ // src/mcp/session-resolver.ts
1390
+ var MAX_SESSION_ID_LENGTH = 256;
1391
+ var CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo";
1392
+ function compileSessionIdentity(config) {
1393
+ const strategySummary = config.identity.map((entry) => entry.source === "header" ? `header "${entry.name}"` : entry.source).join(", ");
1394
+ return {
1395
+ sources: config.identity,
1396
+ onUnresolved: config.on_unresolved,
1397
+ strategySummary
1398
+ };
1399
+ }
1400
+ var DEFAULT_SESSION_IDENTITY = compileSessionIdentity({
1401
+ identity: [{ source: "header", name: "x-helio-session-id" }, { source: "legacy_header" }],
1402
+ on_unresolved: "deny"
1403
+ });
1404
+ function paramsMeta(params) {
1405
+ if (params === null || typeof params !== "object" || Array.isArray(params)) return void 0;
1406
+ return params["_meta"];
1407
+ }
1408
+ var malformedValueWarned = false;
1409
+ function sanitizeCandidate(value, origin) {
1410
+ if (value === void 0) return void 0;
1411
+ if (value.trim() === "" || value.length > MAX_SESSION_ID_LENGTH) {
1412
+ if (!malformedValueWarned) {
1413
+ malformedValueWarned = true;
1414
+ console.error(
1415
+ `[helio] Warning: session identity value from ${origin} was skipped (empty or longer than ${String(MAX_SESSION_ID_LENGTH)} chars); continuing down the identity chain. Further skips will not be logged.`
1416
+ );
1417
+ }
1418
+ return void 0;
1419
+ }
1420
+ return value;
1421
+ }
1422
+ function clientInfoId(meta) {
1423
+ if (meta === null || typeof meta !== "object") return void 0;
1424
+ const clientInfo = meta[CLIENT_INFO_META_KEY];
1425
+ if (clientInfo === null || typeof clientInfo !== "object") return void 0;
1426
+ const { name, version } = clientInfo;
1427
+ if (typeof name !== "string" || name.trim() === "") return void 0;
1428
+ return `clientinfo:${name}@${typeof version === "string" ? version : "unknown"}`;
1429
+ }
1430
+ function resolveSession(input, identity) {
1431
+ for (const strategy of identity.sources) {
1432
+ switch (strategy.source) {
1433
+ case "header": {
1434
+ const value = sanitizeCandidate(input.headers[strategy.name], `header "${strategy.name}"`);
1435
+ if (value !== void 0) return { id: value, source: "header" };
1436
+ break;
1437
+ }
1438
+ case "meta": {
1439
+ const id = sanitizeCandidate(clientInfoId(input.meta), "meta clientInfo");
1440
+ if (id !== void 0) return { id, source: "meta" };
1441
+ break;
1442
+ }
1443
+ case "legacy_header": {
1444
+ const value = sanitizeCandidate(input.transportSessionId, "legacy_header");
1445
+ if (value !== void 0) return { id: value, source: "legacy_header" };
1446
+ break;
1447
+ }
1448
+ }
1449
+ }
1450
+ if (input.transportMintedId !== void 0) {
1451
+ return { id: input.transportMintedId, source: "transport" };
1452
+ }
1453
+ return void 0;
1454
+ }
1455
+
1456
+ // src/transport/content-type.ts
1457
+ function isJsonContentType(header) {
1458
+ const [essence = ""] = (header ?? "").split(";");
1459
+ return essence.trim().toLowerCase() === "application/json";
1460
+ }
1461
+
1226
1462
  // src/transport/forward-headers.ts
1227
1463
  function buildForwardHeaders(requestHeaders, allowlist) {
1228
1464
  const forwardHeaders = {};
@@ -1240,806 +1476,1419 @@ function buildForwardHeaders(requestHeaders, allowlist) {
1240
1476
  return Object.keys(forwardHeaders).length > 0 ? forwardHeaders : void 0;
1241
1477
  }
1242
1478
 
1243
- // src/transport/response-normalizer.ts
1244
- function isObject(value) {
1245
- 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);
1246
1486
  }
1247
- function isValidJsonRpcId(value) {
1248
- 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;
1249
1536
  }
1250
- function getJsonRpcId(value) {
1251
- if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
1252
- const id = value["id"];
1253
- return isValidJsonRpcId(id) ? id : void 0;
1537
+ function isHeaderSafeMethod(method) {
1538
+ return /^[\x21-\x7E]+$/.test(method);
1254
1539
  }
1255
- function isValidJsonRpcError(value) {
1256
- if (!isObject(value)) return false;
1257
- 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;
1258
1544
  }
1259
- function isValidJsonRpcResponse(value) {
1260
- if (!isObject(value)) return false;
1261
- if (value["jsonrpc"] !== "2.0") return false;
1262
- if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
1263
- return false;
1545
+ function buildStandardRequestHeaders(method, params) {
1546
+ if (!isHeaderSafeMethod(method)) {
1547
+ return {};
1264
1548
  }
1265
- const hasResult = Object.prototype.hasOwnProperty.call(value, "result");
1266
- const hasError = Object.prototype.hasOwnProperty.call(value, "error");
1267
- if (hasResult && hasError || !hasResult && !hasError) return false;
1268
- if (hasError && !isValidJsonRpcError(value["error"])) return false;
1269
- 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;
1270
1555
  }
1271
- function makeWrappedError(requestId, message, data) {
1272
- return {
1273
- jsonrpc: "2.0",
1274
- id: requestId ?? null,
1275
- error: {
1276
- code: INTERNAL_ERROR,
1277
- message,
1278
- data
1556
+
1557
+ // src/upstream/merge-headers.ts
1558
+ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1559
+ const out = {};
1560
+ const apply = (headers) => {
1561
+ for (const [name, value] of Object.entries(headers)) {
1562
+ out[name.toLowerCase()] = value;
1279
1563
  }
1280
1564
  };
1565
+ apply(base);
1566
+ apply(forwarded);
1567
+ apply(staticHeaders);
1568
+ return out;
1281
1569
  }
1282
- function normalizeUpstreamOutcome(args) {
1283
- if (args.forwardingError) {
1284
- return {
1285
- httpStatus: 200,
1286
- wrapped: true,
1287
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1288
- failure_class: "upstream_forward_error",
1289
- failure_reason: args.forwardingError.message
1290
- })
1291
- };
1292
- }
1293
- if (!args.upstreamResponse) {
1294
- return {
1295
- httpStatus: 200,
1296
- wrapped: true,
1297
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1298
- failure_class: "upstream_forward_error",
1299
- failure_reason: "missing upstream response"
1300
- })
1301
- };
1302
- }
1303
- const upstream = args.upstreamResponse;
1304
- const upstreamContentType = upstream.headers["content-type"] ?? null;
1305
- if (!isValidJsonRpcResponse(upstream.body)) {
1306
- return {
1307
- httpStatus: 200,
1308
- wrapped: true,
1309
- body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
1310
- failure_class: "upstream_invalid_jsonrpc",
1311
- upstream_http_status: upstream.status,
1312
- upstream_content_type: upstreamContentType,
1313
- upstream_body_type: typeof upstream.body
1314
- })
1315
- };
1316
- }
1317
- if (args.requestId !== void 0) {
1318
- const upstreamId = getJsonRpcId(upstream.body);
1319
- if (upstreamId === void 0) {
1320
- return {
1321
- httpStatus: 200,
1322
- wrapped: true,
1323
- body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
1324
- failure_class: "upstream_invalid_jsonrpc",
1325
- upstream_http_status: upstream.status,
1326
- upstream_content_type: upstreamContentType,
1327
- upstream_body_type: typeof upstream.body,
1328
- invalid_reason: "missing_response_id"
1329
- })
1330
- };
1331
- }
1332
- const expectedId = args.requestId ?? null;
1333
- if (upstreamId !== expectedId) {
1334
- return {
1335
- httpStatus: 200,
1336
- wrapped: true,
1337
- body: makeWrappedError(args.requestId, "upstream response id mismatch", {
1338
- failure_class: "upstream_id_mismatch",
1339
- expected_request_id: expectedId,
1340
- upstream_response_id: upstreamId
1341
- })
1342
- };
1570
+
1571
+ // src/upstream/connection-error.ts
1572
+ var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1573
+ var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
1574
+ "ECONNREFUSED",
1575
+ "ENOTFOUND",
1576
+ "EAI_AGAIN",
1577
+ "ECONNRESET",
1578
+ "EHOSTUNREACH",
1579
+ "ENETUNREACH",
1580
+ "ETIMEDOUT",
1581
+ "EPIPE",
1582
+ "UND_ERR_CONNECT_TIMEOUT",
1583
+ "UND_ERR_SOCKET"
1584
+ ]);
1585
+ function extractErrorCode(error) {
1586
+ let current = error;
1587
+ for (let depth = 0; depth < 5 && current != null; depth += 1) {
1588
+ if (typeof current === "object" && "code" in current) {
1589
+ const code = current.code;
1590
+ if (typeof code === "string") return code;
1343
1591
  }
1592
+ current = current.cause;
1344
1593
  }
1345
- return {
1346
- httpStatus: 200,
1347
- wrapped: false,
1348
- body: upstream.body
1349
- };
1594
+ return void 0;
1595
+ }
1596
+ function describeUnreachableUpstream(error, url) {
1597
+ const code = extractErrorCode(error);
1598
+ const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
1599
+ if (code !== void 0) {
1600
+ if (!UNREACHABLE_CODES.has(code)) return null;
1601
+ } else if (!isGenericFetchFailure) {
1602
+ return null;
1603
+ }
1604
+ const codeSuffix = code ? ` (${code})` : "";
1605
+ return new Error(
1606
+ `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1607
+ );
1350
1608
  }
1351
1609
 
1352
- // src/transport/streamable-http.ts
1353
- var MCP_SESSION_HEADER = "mcp-session-id";
1354
- var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
1355
- function createStreamableHttpRoute(forwarder, options = {}) {
1356
- const app = new Hono();
1357
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1358
- app.post("/", async (c) => {
1359
- const contentType = c.req.header("content-type") ?? "";
1360
- if (!contentType.includes("application/json")) {
1361
- return c.json(
1362
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1363
- 415
1364
- );
1610
+ // src/upstream/sse-parse.ts
1611
+ function parseSseChunk(chunk, state, onEvent) {
1612
+ let { event, data, remainder } = state;
1613
+ const text = remainder + chunk;
1614
+ const lines = text.split("\n");
1615
+ remainder = lines.pop() ?? "";
1616
+ for (const rawLine of lines) {
1617
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1618
+ if (line === "") {
1619
+ if (event || data) {
1620
+ onEvent(event, data);
1621
+ event = "";
1622
+ data = "";
1623
+ }
1624
+ } else if (line.startsWith("event:")) {
1625
+ const value = line.slice(6).replace(/^ /, "");
1626
+ event = value;
1627
+ } else if (line.startsWith("data:")) {
1628
+ const value = line.slice(5).replace(/^ /, "");
1629
+ data = data ? data + "\n" + value : value;
1365
1630
  }
1366
- 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;
1367
1645
  try {
1368
- body = await c.req.json();
1646
+ parsed = JSON.parse(data);
1369
1647
  } catch {
1370
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
1371
- }
1372
- const parsedRequest = parseJsonRpcRequest(body);
1373
- if (!parsedRequest.success) {
1374
- return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
1648
+ return;
1375
1649
  }
1376
- const id = parsedRequest.request.id;
1377
- const method = parsedRequest.request.method;
1378
- const params = parsedRequest.request.params;
1379
- const sessionId = c.req.header(MCP_SESSION_HEADER);
1380
- const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
1381
- const mcpRequest = {
1382
- jsonrpc: "2.0",
1383
- id,
1384
- method,
1385
- params,
1386
- sessionId,
1387
- headers: forwardHeaders,
1388
- signal: c.req.raw.signal
1389
- };
1390
- if (id === void 0) {
1391
- const notificationRequest = { ...mcpRequest, signal: void 0 };
1392
- void forwarder.forward(notificationRequest).catch((err) => {
1393
- const message = err instanceof Error ? err.message : String(err);
1394
- console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
1395
- });
1396
- return c.body(null, 202);
1650
+ if (parsed === null || typeof parsed !== "object") return;
1651
+ const id = parsed["id"];
1652
+ if (id === requestId) {
1653
+ found = parsed;
1397
1654
  }
1398
- let result;
1399
- try {
1400
- result = await forwarder.forward(mcpRequest);
1401
- } catch (err) {
1402
- const forwardingError = err instanceof Error ? err : new Error(String(err));
1403
- console.error("[helio] Upstream forwarding failed:", forwardingError.message);
1404
- const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
1405
- 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
+ }
1406
1668
  }
1407
- const { response } = result;
1408
- for (const [key, value] of Object.entries(response.headers)) {
1409
- if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
1410
- c.header(key, value);
1669
+ if (result.done) {
1670
+ const tail = decoder.decode();
1671
+ if (tail) {
1672
+ processChunk(tail);
1673
+ if (found) return found;
1411
1674
  }
1675
+ break;
1412
1676
  }
1413
- const normalized = normalizeUpstreamOutcome({ requestId: id, upstreamResponse: response });
1414
- return c.json(normalized.body, normalized.httpStatus);
1415
- });
1416
- return app;
1677
+ }
1678
+ throw new Error(
1679
+ `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1680
+ );
1417
1681
  }
1418
1682
 
1419
- // src/transport/sse.ts
1420
- import { randomUUID } from "crypto";
1421
- import { Hono as Hono2 } from "hono";
1422
- import { z as z3 } from "zod";
1423
- var encoder = new TextEncoder();
1424
- var STALE_THRESHOLD_MS = 9e4;
1425
- var SWEEP_INTERVAL_MS = 6e4;
1426
- var ssePostQuerySchema = z3.object({
1427
- sessionId: z3.string().min(1)
1428
- });
1429
- function sseEvent(event, data) {
1430
- return `event: ${event}
1431
- data: ${data}
1432
-
1433
- `;
1683
+ // src/upstream/upstream-session-manager.ts
1684
+ var ERA_PROBE_BACKOFF_MS = 3e4;
1685
+ var MAX_SSE_SCAN_BYTES = 256 * 1024;
1686
+ var ERA_PROBE_REQUEST_ID = "helio-era-probe";
1687
+ var MCP_MISSING_CLIENT_CAPABILITY_CODE = -32021;
1688
+ var MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022;
1689
+ var MCP_MODERN_ONLY_ERROR_CODES = /* @__PURE__ */ new Set([
1690
+ HEADER_MISMATCH,
1691
+ MCP_MISSING_CLIENT_CAPABILITY_CODE,
1692
+ MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE
1693
+ ]);
1694
+ var MCP_META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
1695
+ function buildInternalMeta() {
1696
+ return {
1697
+ [MCP_META_PROTOCOL_VERSION_KEY]: HELIO_MCP_MODERN_PROTOCOL_VERSION,
1698
+ "io.modelcontextprotocol/clientCapabilities": {},
1699
+ "io.modelcontextprotocol/clientInfo": { name: "helio-proxy", version: "0" }
1700
+ };
1434
1701
  }
1435
- function createSseRoute(forwarder, options = {}) {
1436
- const sessions = /* @__PURE__ */ new Map();
1437
- const app = new Hono2();
1438
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1439
- const writeSessionEvent = (sessionId, eventPayload) => {
1440
- const session = sessions.get(sessionId);
1441
- if (!session) return;
1442
- session.lastActivity = Date.now();
1443
- void session.writer.write(encoder.encode(eventPayload)).catch(() => {
1444
- sessions.delete(sessionId);
1445
- void session.writer.close().catch(() => {
1446
- });
1702
+ var UpstreamSessionManager = class {
1703
+ url;
1704
+ staticHeaders;
1705
+ requestTimeoutMs;
1706
+ pin;
1707
+ internal;
1708
+ era;
1709
+ capture;
1710
+ inflight;
1711
+ inflightProbe;
1712
+ probeBackoffUntil = 0;
1713
+ constructor(options) {
1714
+ this.url = options.url;
1715
+ this.staticHeaders = options.staticHeaders;
1716
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1717
+ this.pin = options.protocolVersion ?? "auto";
1718
+ }
1719
+ /** Return the internal session, establishing it once if needed. */
1720
+ ensureInternalSession() {
1721
+ if (this.internal) return Promise.resolve(this.internal);
1722
+ this.inflight ??= this.establish().then((session) => {
1723
+ this.internal = session;
1724
+ return session;
1725
+ }).finally(() => {
1726
+ this.inflight = void 0;
1447
1727
  });
1448
- };
1449
- const sweepInterval = setInterval(() => {
1450
- const now = Date.now();
1451
- for (const [id, session] of sessions) {
1452
- if (now - session.lastActivity > STALE_THRESHOLD_MS) {
1453
- sessions.delete(id);
1454
- void session.writer.close().catch(() => {
1455
- });
1728
+ return this.inflight;
1729
+ }
1730
+ /**
1731
+ * Drop the cached internal session and era so the next call re-probes.
1732
+ * Does not cancel any in-flight establishment. Every era wipe drops the
1733
+ * probe-time DiscoverResult capture with it; invalidation is session
1734
+ * lifecycle, not falsification, so it must NOT arm the probe backoff.
1735
+ */
1736
+ invalidateInternalSession() {
1737
+ this.internal = void 0;
1738
+ this.era = void 0;
1739
+ this.capture = void 0;
1740
+ }
1741
+ /**
1742
+ * Resolve the era a relayed request should be sent under. Evaluated in a
1743
+ * strict total order: pin, cached era, join an in-flight probe, backoff
1744
+ * presumption, start a probe. A probe failure never fails the relay — the
1745
+ * request proceeds under a per-request legacy presumption, preserving
1746
+ * today's behavior for deployments the probe cannot classify (for example
1747
+ * per-client Authorization pass-through, where the probe is refused
1748
+ * forever while relays carry the client's own credentials and succeed).
1749
+ */
1750
+ async resolveRelayEra() {
1751
+ const pinned = this.pinnedEra();
1752
+ if (pinned) return pinned;
1753
+ if (this.era) return this.era;
1754
+ const joined = this.inflightProbe;
1755
+ if (joined) {
1756
+ try {
1757
+ const outcome = await joined;
1758
+ this.cacheRelayClassification(outcome);
1759
+ return outcome.era;
1760
+ } catch {
1761
+ return "legacy";
1456
1762
  }
1457
1763
  }
1458
- }, SWEEP_INTERVAL_MS);
1459
- sweepInterval.unref();
1460
- app.get("/", (c) => {
1461
- const sessionId = randomUUID();
1462
- const { readable, writable } = new TransformStream();
1463
- const writer = writable.getWriter();
1464
- sessions.set(sessionId, { writer, lastActivity: Date.now() });
1465
- const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
1466
- writeSessionEvent(sessionId, endpointData);
1467
- c.req.raw.signal.addEventListener("abort", () => {
1468
- sessions.delete(sessionId);
1469
- void writer.close().catch(() => {
1470
- });
1471
- });
1472
- return new Response(readable, {
1473
- headers: {
1474
- "content-type": "text/event-stream",
1475
- "cache-control": "no-cache",
1476
- connection: "keep-alive"
1477
- }
1478
- });
1479
- });
1480
- app.post("/", async (c) => {
1481
- const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
1482
- if (!parsedQuery.success) {
1483
- return c.json(
1484
- makeJsonRpcError(null, INVALID_REQUEST, "missing sessionId query parameter"),
1485
- 400
1486
- );
1764
+ if (Date.now() < this.probeBackoffUntil) return "legacy";
1765
+ try {
1766
+ const outcome = await this.sharedProbe();
1767
+ this.cacheRelayClassification(outcome);
1768
+ return outcome.era;
1769
+ } catch {
1770
+ return "legacy";
1487
1771
  }
1488
- const sessionId = parsedQuery.data.sessionId;
1489
- const session = sessions.get(sessionId);
1490
- if (!session) {
1491
- 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;
1492
1827
  }
1493
- const contentType = c.req.header("content-type") ?? "";
1494
- if (!contentType.includes("application/json")) {
1495
- return c.json(
1496
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1497
- 415
1498
- );
1828
+ this.setEra("legacy");
1829
+ }
1830
+ /** Every modern classification re-captures from its own fresh DiscoverResult. */
1831
+ cacheModernClassification(outcome) {
1832
+ this.capture = { capabilities: outcome.capabilities, instructions: outcome.instructions };
1833
+ this.setEra("modern");
1834
+ }
1835
+ /**
1836
+ * Falsified-classification clear: any cleared era means the classification
1837
+ * was just contradicted, so re-probing is throttled no matter which door
1838
+ * noticed. No-ops under a pin and on uncached eras; drops the probe-time
1839
+ * DiscoverResult capture with the era it came from; emits exactly one
1840
+ * operator line per clear.
1841
+ */
1842
+ clearEraAndArmBackoff(door) {
1843
+ if (this.pinnedEra()) return;
1844
+ if (this.era === void 0) return;
1845
+ this.era = void 0;
1846
+ this.capture = void 0;
1847
+ this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
1848
+ console.error(
1849
+ `[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1850
+ );
1851
+ }
1852
+ /** Convert a fetch failure into an actionable error for the given step. */
1853
+ describeFetchFailure(error, step) {
1854
+ if (error instanceof Error && error.name === "TimeoutError") {
1855
+ return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
1499
1856
  }
1500
- 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) {
1501
1874
  try {
1502
- body = await c.req.json();
1503
- } catch {
1504
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
1505
- }
1506
- const parsedRequest = parseJsonRpcRequest(body);
1507
- if (!parsedRequest.success) {
1508
- 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;
1509
1886
  }
1510
- const id = parsedRequest.request.id;
1511
- const method = parsedRequest.request.method;
1512
- const params = parsedRequest.request.params;
1513
- const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
1514
- const mcpRequest = {
1515
- jsonrpc: "2.0",
1516
- id,
1517
- method,
1518
- params,
1519
- sessionId,
1520
- headers: forwardHeaders,
1521
- 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"
1522
1902
  };
1523
- if (id === void 0) {
1524
- const notificationRequest = { ...mcpRequest, signal: void 0 };
1525
- void forwarder.forward(notificationRequest).catch((err) => {
1526
- const message = err instanceof Error ? err.message : String(err);
1527
- 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)
1528
1939
  });
1529
- return c.body(null, 202);
1940
+ } catch (error) {
1941
+ throw this.describeFetchFailure(error, "server/discover probe");
1530
1942
  }
1531
- 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;
1532
1986
  try {
1533
- result = await forwarder.forward(mcpRequest);
1534
- } catch (err) {
1535
- const forwardingError = err instanceof Error ? err : new Error(String(err));
1536
- console.error("[helio] Upstream forwarding failed:", forwardingError.message);
1537
- const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
1538
- const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
1539
- writeSessionEvent(sessionId, errorEvent);
1540
- return c.body(null, 202);
1987
+ parsed = JSON.parse(raw);
1988
+ } catch {
1989
+ return { kind: "unparseable" };
1541
1990
  }
1542
- const normalized = normalizeUpstreamOutcome({
1543
- requestId: id,
1544
- upstreamResponse: result.response
1545
- });
1546
- const messageEvent = sseEvent("message", JSON.stringify(normalized.body));
1547
- writeSessionEvent(sessionId, messageEvent);
1548
- return c.body(null, 202);
1549
- });
1550
- return app;
1551
- }
1552
-
1553
- // src/server.ts
1554
- var FORCE_CONNECTION_CLOSE_GRACE_MS = 1500;
1555
- function normalizeError(error) {
1556
- if (error instanceof Error) return error;
1557
- return new Error(String(error));
1558
- }
1559
- function createServerHandle(server) {
1560
- const sockets = /* @__PURE__ */ new Set();
1561
- const nodeServer = server;
1562
- nodeServer.on("connection", (socket) => {
1563
- sockets.add(socket);
1564
- socket.on("close", () => {
1565
- 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");
1566
2050
  });
1567
- });
1568
- 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;
1569
2071
  try {
1570
- nodeServer.closeIdleConnections?.();
2072
+ parsed = JSON.parse(raw);
1571
2073
  } catch {
2074
+ throw new Error(`upstream ${step} returned non-JSON body`);
1572
2075
  }
1573
- if (nodeServer.closeAllConnections) {
1574
- try {
1575
- nodeServer.closeAllConnections();
1576
- } 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
+ );
1577
2102
  }
1578
- return;
1579
2103
  }
1580
- for (const socket of sockets) {
1581
- 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;
1582
2111
  }
1583
- };
1584
- return {
1585
- server,
1586
- close: () => new Promise((resolve2, reject) => {
1587
- let settled = false;
1588
- let forceTimer;
1589
- const settle = (err) => {
1590
- if (settled) return;
1591
- settled = true;
1592
- if (forceTimer) {
1593
- clearTimeout(forceTimer);
1594
- forceTimer = void 0;
1595
- }
1596
- if (err) {
1597
- reject(err);
1598
- return;
1599
- }
1600
- resolve2();
1601
- };
2112
+ if (typeof parsed !== "object" || parsed === null) return void 0;
2113
+ return extractJsonRpcErrorMessage(parsed);
2114
+ }
2115
+ /**
2116
+ * Read an SSE POST response body under an explicit read deadline and byte
2117
+ * cap, returning the first `message` payload `select` accepts. The
2118
+ * fetch-level `AbortSignal.timeout` would eventually abort a stalled body
2119
+ * read; these bounds are the belt to its braces.
2120
+ */
2121
+ async scanSseEvents(body, select) {
2122
+ const reader = body.getReader();
2123
+ const decoder = new TextDecoder();
2124
+ let state = { event: "", data: "", remainder: "" };
2125
+ let found;
2126
+ let scannedBytes = 0;
2127
+ const deadline = Date.now() + this.requestTimeoutMs;
2128
+ const onEvent = (event, data) => {
2129
+ if (found !== void 0) return;
2130
+ if (event && event !== "message") return;
2131
+ let parsed;
1602
2132
  try {
1603
- nodeServer.close((err) => {
1604
- if (err) {
1605
- settle(err);
1606
- return;
1607
- }
1608
- settle();
1609
- });
1610
- } catch (error) {
1611
- settle(normalizeError(error));
2133
+ parsed = JSON.parse(data);
2134
+ } catch {
1612
2135
  return;
1613
2136
  }
2137
+ if (typeof parsed !== "object" || parsed === null) return;
2138
+ found = select(parsed);
2139
+ };
2140
+ for (; ; ) {
2141
+ const remainingMs = deadline - Date.now();
2142
+ if (remainingMs <= 0) {
2143
+ await reader.cancel().catch(() => void 0);
2144
+ return { outcome: "timed-out" };
2145
+ }
2146
+ let chunk;
1614
2147
  try {
1615
- nodeServer.closeIdleConnections?.();
2148
+ chunk = await readSseChunkWithTimeout(reader, remainingMs);
1616
2149
  } catch {
2150
+ await reader.cancel().catch(() => void 0);
2151
+ return { outcome: "timed-out" };
1617
2152
  }
1618
- forceTimer = setTimeout(() => {
1619
- forceCloseConnections();
1620
- }, FORCE_CONNECTION_CLOSE_GRACE_MS);
1621
- forceTimer.unref();
1622
- })
1623
- };
1624
- }
1625
- function createApp(config, forwarder, options) {
1626
- const app = new Hono3();
1627
- const forwardHeadersAllowlist = config.upstream.forward_headers;
1628
- app.get("/healthz", (c) => c.json({ status: "ok" }));
1629
- app.route("/mcp", createStreamableHttpRoute(forwarder, { forwardHeadersAllowlist }));
1630
- app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist }));
1631
- if (options?.slackActionApp) {
1632
- 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);
1633
2193
  }
1634
- return app;
1635
2194
  }
1636
- function startServer(app, config) {
1637
- const server = serve({
1638
- fetch: app.fetch,
1639
- port: config.listen.port,
1640
- hostname: config.listen.host
1641
- });
1642
- return createServerHandle(server);
2195
+ function isSseReadChunk(value) {
2196
+ if (typeof value !== "object" || value === null) return false;
2197
+ const candidate = value;
2198
+ if (typeof candidate.done !== "boolean") return false;
2199
+ if (candidate.value === void 0) return true;
2200
+ return candidate.value instanceof Uint8Array;
1643
2201
  }
1644
- function startSidebandServer(app, port, host = "127.0.0.1") {
1645
- const server = serve({
1646
- fetch: app.fetch,
1647
- port,
1648
- hostname: host
1649
- });
1650
- return createServerHandle(server);
2202
+ function isClassifiableProbeStatus(status) {
2203
+ if (status >= 200 && status < 300) return true;
2204
+ return status === 400 || status === 404 || status === 405;
1651
2205
  }
1652
-
1653
- // src/upstream/response.ts
1654
- async function parseUpstreamResponse(res) {
1655
- const headers = {};
1656
- res.headers.forEach((value, key) => {
1657
- headers[key] = value;
1658
- });
1659
- const contentType = res.headers.get("content-type") ?? "";
1660
- let body;
1661
- if (contentType.includes("application/json")) {
1662
- const text = await res.text();
1663
- try {
1664
- body = JSON.parse(text);
1665
- } catch {
1666
- body = text;
1667
- }
1668
- } else {
1669
- body = await res.text();
2206
+ function classifyEnvelopeShape(envelope) {
2207
+ if (envelope["error"] !== void 0) return { kind: "error", envelope };
2208
+ if (envelope["result"] !== void 0) return { kind: "result", envelope };
2209
+ return { kind: "unparseable" };
2210
+ }
2211
+ function classifyProbeError(envelope) {
2212
+ const error = envelope["error"];
2213
+ const code = typeof error === "object" && error !== null ? error["code"] : void 0;
2214
+ if (code === MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE) {
2215
+ const data = error["data"];
2216
+ return {
2217
+ era: "legacy",
2218
+ unsupportedModernVersions: readStringArray(
2219
+ typeof data === "object" && data !== null ? data["supported"] : void 0
2220
+ )
2221
+ };
1670
2222
  }
1671
- 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" };
1672
2229
  }
1673
-
1674
- // src/upstream/sse-parse.ts
1675
- function parseSseChunk(chunk, state, onEvent) {
1676
- let { event, data, remainder } = state;
1677
- const text = remainder + chunk;
1678
- const lines = text.split("\n");
1679
- remainder = lines.pop() ?? "";
1680
- for (const rawLine of lines) {
1681
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1682
- if (line === "") {
1683
- if (event || data) {
1684
- onEvent(event, data);
1685
- event = "";
1686
- data = "";
1687
- }
1688
- } else if (line.startsWith("event:")) {
1689
- const value = line.slice(6).replace(/^ /, "");
1690
- event = value;
1691
- } else if (line.startsWith("data:")) {
1692
- const value = line.slice(5).replace(/^ /, "");
1693
- data = data ? data + "\n" + value : value;
1694
- }
2230
+ function classifyProbeResult(envelope) {
2231
+ const result = envelope["result"];
2232
+ if (typeof result !== "object" || result === null) return { era: "legacy" };
2233
+ const supportedVersions = result["supportedVersions"];
2234
+ if (!Array.isArray(supportedVersions)) {
2235
+ return { era: "legacy" };
1695
2236
  }
1696
- 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 };
1697
2249
  }
1698
- async function readSseJsonRpcResponse(res, requestId) {
1699
- if (!res.body) {
1700
- 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;
1701
2266
  }
1702
- const reader = res.body.getReader();
1703
- const decoder = new TextDecoder();
1704
- let state = { event: "", data: "", remainder: "" };
1705
- let found;
1706
- const onEvent = (event, data) => {
1707
- if (event && event !== "message") return;
1708
- let parsed;
1709
- try {
1710
- parsed = JSON.parse(data);
1711
- } catch {
1712
- 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 }
1713
2303
  }
1714
- if (parsed === null || typeof parsed !== "object") return;
1715
- const id = parsed["id"];
1716
- if (id === requestId) {
1717
- found = parsed;
2304
+ });
2305
+ if (headerMethod === void 0) {
2306
+ if (requiresPresence) {
2307
+ return reject(`missing mcp-method header (expected ${displayCap(method)})`);
1718
2308
  }
1719
- };
1720
- const processChunk = (chunk) => {
1721
- state = parseSseChunk(chunk, state, onEvent);
1722
- };
1723
- for (; ; ) {
1724
- const result = await reader.read();
1725
- if (result.value !== void 0) {
1726
- const chunk = result.value;
1727
- processChunk(decoder.decode(chunk, { stream: true }));
1728
- if (found) {
1729
- await reader.cancel().catch(() => void 0);
1730
- 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
+ );
1731
2325
  }
1732
2326
  }
1733
- if (result.done) {
1734
- const tail = decoder.decode();
1735
- if (tail) {
1736
- processChunk(tail);
1737
- 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
+ );
1738
2335
  }
1739
- 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
+ );
1740
2341
  }
1741
2342
  }
1742
- throw new Error(
1743
- `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1744
- );
2343
+ return { ok: true };
1745
2344
  }
1746
2345
 
1747
- // src/upstream/merge-headers.ts
1748
- function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1749
- const out = {};
1750
- const apply = (headers) => {
1751
- for (const [name, value] of Object.entries(headers)) {
1752
- 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
+ );
1753
2367
  }
1754
2368
  };
1755
- apply(base);
1756
- apply(forwarded);
1757
- apply(staticHeaders);
1758
- 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
+ };
1759
2377
  }
1760
2378
 
1761
- // src/upstream/connection-error.ts
1762
- var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1763
- var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
1764
- "ECONNREFUSED",
1765
- "ENOTFOUND",
1766
- "EAI_AGAIN",
1767
- "ECONNRESET",
1768
- "EHOSTUNREACH",
1769
- "ENETUNREACH",
1770
- "ETIMEDOUT",
1771
- "EPIPE",
1772
- "UND_ERR_CONNECT_TIMEOUT",
1773
- "UND_ERR_SOCKET"
1774
- ]);
1775
- function extractErrorCode(error) {
1776
- let current = error;
1777
- for (let depth = 0; depth < 5 && current != null; depth += 1) {
1778
- if (typeof current === "object" && "code" in current) {
1779
- const code = current.code;
1780
- if (typeof code === "string") return code;
1781
- }
1782
- current = current.cause;
1783
- }
1784
- return void 0;
2379
+ // src/transport/response-normalizer.ts
2380
+ function isObject(value) {
2381
+ return value !== null && typeof value === "object";
1785
2382
  }
1786
- function describeUnreachableUpstream(error, url) {
1787
- const code = extractErrorCode(error);
1788
- const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
1789
- if (code !== void 0) {
1790
- if (!UNREACHABLE_CODES.has(code)) return null;
1791
- } else if (!isGenericFetchFailure) {
1792
- return null;
1793
- }
1794
- const codeSuffix = code ? ` (${code})` : "";
1795
- return new Error(
1796
- `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1797
- );
2383
+ function isValidJsonRpcId(value) {
2384
+ return value === null || typeof value === "string" || typeof value === "number";
1798
2385
  }
1799
-
1800
- // src/upstream/upstream-session-manager.ts
1801
- var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
1802
- var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
1803
- var UpstreamSessionManager = class {
1804
- url;
1805
- staticHeaders;
1806
- requestTimeoutMs;
1807
- internal;
1808
- inflight;
1809
- constructor(options) {
1810
- this.url = options.url;
1811
- this.staticHeaders = options.staticHeaders;
1812
- this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1813
- }
1814
- /** Return the internal session, performing the handshake once if needed. */
1815
- ensureInternalSession() {
1816
- if (this.internal) return Promise.resolve(this.internal);
1817
- this.inflight ??= this.initialize().then((session) => {
1818
- this.internal = session;
1819
- return session;
1820
- }).finally(() => {
1821
- this.inflight = void 0;
1822
- });
1823
- return this.inflight;
1824
- }
1825
- /**
1826
- * Drop the cached internal session so the next call re-initializes.
1827
- * Does not cancel any in-flight initialize.
1828
- */
1829
- invalidateInternalSession() {
1830
- this.internal = void 0;
2386
+ function getJsonRpcId(value) {
2387
+ if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
2388
+ const id = value["id"];
2389
+ return isValidJsonRpcId(id) ? id : void 0;
2390
+ }
2391
+ function isValidJsonRpcError(value) {
2392
+ if (!isObject(value)) return false;
2393
+ return typeof value["code"] === "number" && typeof value["message"] === "string";
2394
+ }
2395
+ function isValidJsonRpcResponse(value) {
2396
+ if (!isObject(value)) return false;
2397
+ if (value["jsonrpc"] !== "2.0") return false;
2398
+ if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
2399
+ return false;
1831
2400
  }
1832
- /** Convert a fetch failure into an actionable error for the given step. */
1833
- describeFetchFailure(error, step) {
1834
- if (error instanceof Error && error.name === "TimeoutError") {
1835
- return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
1836
- }
1837
- 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
+ };
1838
2428
  }
1839
- async initialize() {
1840
- 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(
1841
2520
  {
1842
- "content-type": "application/json",
1843
- accept: "application/json, text/event-stream"
2521
+ headers: Object.fromEntries(c.req.raw.headers),
2522
+ meta: paramsMeta(params),
2523
+ transportSessionId
1844
2524
  },
1845
- {},
1846
- this.staticHeaders
2525
+ sessionIdentity
1847
2526
  );
1848
- const initBody = {
1849
- jsonrpc: "2.0",
1850
- id: 0,
1851
- method: "initialize",
1852
- params: {
1853
- protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
1854
- capabilities: {},
1855
- 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
1856
2536
  }
2537
+ });
2538
+ if (!agreement.ok) {
2539
+ options.onHeaderMismatch?.({
2540
+ reason: agreement.reason,
2541
+ method,
2542
+ params,
2543
+ ...agreement.evidence.bodyName !== void 0 && { bodyName: agreement.evidence.bodyName },
2544
+ ...protocolVersion !== void 0 && { protocolVersion },
2545
+ headers: agreement.evidence.headers,
2546
+ ...session !== void 0 && { session },
2547
+ durationMs: performance.now() - handlerStart
2548
+ });
2549
+ const errorBody = id === void 0 || id === null ? makeJsonRpcErrorWithoutId(HEADER_MISMATCH, agreement.reason) : makeJsonRpcError(id, HEADER_MISMATCH, agreement.reason);
2550
+ return c.json(errorBody, 400);
2551
+ }
2552
+ const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
2553
+ const mcpRequest = {
2554
+ jsonrpc: "2.0",
2555
+ id,
2556
+ method,
2557
+ params,
2558
+ session,
2559
+ transportSessionId,
2560
+ protocolVersion,
2561
+ headers: forwardHeaders,
2562
+ signal: c.req.raw.signal
1857
2563
  };
1858
- let res;
1859
- try {
1860
- res = await fetch(this.url, {
1861
- method: "POST",
1862
- headers,
1863
- body: JSON.stringify(initBody),
1864
- signal: AbortSignal.timeout(this.requestTimeoutMs)
2564
+ if (id === void 0) {
2565
+ const notificationRequest = { ...mcpRequest, signal: void 0 };
2566
+ void forwarder.forward(notificationRequest).catch((err) => {
2567
+ const message = err instanceof Error ? err.message : String(err);
2568
+ console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
1865
2569
  });
1866
- } catch (error) {
1867
- throw this.describeFetchFailure(error, "initialize");
2570
+ return c.body(null, 202);
1868
2571
  }
1869
- if (!res.ok) {
1870
- 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);
1871
2580
  }
1872
- const sessionId = res.headers.get("mcp-session-id") ?? void 0;
1873
- const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
1874
- res,
1875
- initBody.id,
1876
- "initialize"
1877
- );
1878
- const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
1879
- if (initializeError) {
1880
- throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
2581
+ const { response } = result;
2582
+ for (const [key, value] of Object.entries(response.headers)) {
2583
+ if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
2584
+ c.header(key, value);
2585
+ }
1881
2586
  }
1882
- const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
1883
- const notifyHeaders = { ...headers };
1884
- if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
1885
- notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1886
- const notifyRes = await fetch(this.url, {
1887
- method: "POST",
1888
- headers: notifyHeaders,
1889
- body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1890
- signal: AbortSignal.timeout(this.requestTimeoutMs)
1891
- }).catch((error) => {
1892
- 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
+ });
1893
2638
  });
1894
- if (!notifyRes.ok) {
1895
- 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
+ }
1896
2648
  }
1897
- const notifyError = await this.readOptionalJsonRpcError(notifyRes);
1898
- if (notifyError) {
1899
- 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);
1900
2655
  }
1901
- return { sessionId, protocolVersion: negotiatedProtocolVersion };
1902
- }
1903
- async readRequiredJsonRpcEnvelope(res, requestId, step) {
1904
- const contentType = res.headers.get("content-type") ?? "";
1905
- if (contentType.includes("text/event-stream")) {
1906
- const payload = await readSseJsonRpcResponse(res, requestId);
1907
- 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
+ );
1908
2682
  }
1909
- const raw = await res.text();
1910
- if (!raw.trim()) {
1911
- 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);
1912
2687
  }
1913
- 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;
1914
2695
  try {
1915
- parsed = JSON.parse(raw);
2696
+ body = await c.req.json();
1916
2697
  } catch {
1917
- throw new Error(`upstream ${step} returned non-JSON body`);
2698
+ return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
1918
2699
  }
1919
- if (typeof parsed !== "object" || parsed === null) {
1920
- 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);
1921
2704
  }
1922
- return parsed;
1923
- }
1924
- async readOptionalJsonRpcError(res) {
1925
- const contentType = res.headers.get("content-type") ?? "";
1926
- if (contentType.includes("text/event-stream")) {
1927
- if (!res.body) return void 0;
1928
- let errorMessage;
1929
- const reader = res.body.getReader();
1930
- const decoder = new TextDecoder();
1931
- let state = { event: "", data: "", remainder: "" };
1932
- let scannedBytes = 0;
1933
- const deadline = Date.now() + this.requestTimeoutMs;
1934
- const onEvent = (event, data) => {
1935
- if (errorMessage) return;
1936
- if (event && event !== "message") return;
1937
- let parsed2;
1938
- try {
1939
- parsed2 = JSON.parse(data);
1940
- } 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);
1941
2804
  return;
1942
2805
  }
1943
- if (typeof parsed2 !== "object" || parsed2 === null) return;
1944
- errorMessage = extractJsonRpcErrorMessage(parsed2);
2806
+ resolve2();
1945
2807
  };
1946
- for (; ; ) {
1947
- const remainingMs = deadline - Date.now();
1948
- if (remainingMs <= 0) {
1949
- await reader.cancel().catch(() => void 0);
1950
- throw new Error(
1951
- `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1952
- );
1953
- }
1954
- let chunk;
1955
- try {
1956
- chunk = await readSseChunkWithTimeout(reader, remainingMs);
1957
- } catch {
1958
- await reader.cancel().catch(() => void 0);
1959
- throw new Error(
1960
- `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1961
- );
1962
- }
1963
- const { done, value } = chunk;
1964
- if (value !== void 0) {
1965
- scannedBytes += value.byteLength;
1966
- if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
1967
- await reader.cancel().catch(() => void 0);
1968
- throw new Error(
1969
- `upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
1970
- );
1971
- }
1972
- state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
1973
- if (errorMessage) {
1974
- await reader.cancel().catch(() => void 0);
1975
- return errorMessage;
1976
- }
1977
- }
1978
- if (done) {
1979
- const tail = decoder.decode();
1980
- if (tail) {
1981
- state = parseSseChunk(tail, state, onEvent);
2808
+ try {
2809
+ nodeServer.close((err) => {
2810
+ if (err) {
2811
+ settle(err);
2812
+ return;
1982
2813
  }
1983
- return errorMessage;
1984
- }
2814
+ settle();
2815
+ });
2816
+ } catch (error) {
2817
+ settle(normalizeError(error));
2818
+ return;
1985
2819
  }
1986
- }
1987
- const raw = await res.text();
1988
- if (!raw.trim()) return void 0;
1989
- let parsed;
1990
- try {
1991
- parsed = JSON.parse(raw);
1992
- } catch {
1993
- return void 0;
1994
- }
1995
- if (typeof parsed !== "object" || parsed === null) return void 0;
1996
- return extractJsonRpcErrorMessage(parsed);
1997
- }
1998
- };
1999
- async function readSseChunkWithTimeout(reader, timeoutMs) {
2000
- let timeoutHandle;
2001
- try {
2002
- const result = await Promise.race([
2003
- reader.read(),
2004
- new Promise((_, reject) => {
2005
- timeoutHandle = setTimeout(() => {
2006
- reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
2007
- }, timeoutMs);
2008
- })
2009
- ]);
2010
- if (!isSseReadChunk(result)) {
2011
- throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
2012
- }
2013
- return result;
2014
- } finally {
2015
- if (timeoutHandle) clearTimeout(timeoutHandle);
2820
+ try {
2821
+ nodeServer.closeIdleConnections?.();
2822
+ } catch {
2823
+ }
2824
+ forceTimer = setTimeout(() => {
2825
+ forceCloseConnections();
2826
+ }, FORCE_CONNECTION_CLOSE_GRACE_MS);
2827
+ forceTimer.unref();
2828
+ })
2829
+ };
2830
+ }
2831
+ function createApp(config, forwarder, options) {
2832
+ const app = new Hono3();
2833
+ const forwardHeadersAllowlist = config.upstream.forward_headers;
2834
+ const allowedOrigins = config.listen.allowed_origins;
2835
+ const session = compileSessionIdentity(config.session);
2836
+ app.get("/healthz", (c) => c.json({ status: "ok" }));
2837
+ app.route(
2838
+ "/mcp",
2839
+ createStreamableHttpRoute(forwarder, {
2840
+ forwardHeadersAllowlist,
2841
+ allowedOrigins,
2842
+ session,
2843
+ onHeaderMismatch: options?.onHeaderMismatch
2844
+ })
2845
+ );
2846
+ app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist, allowedOrigins, session }));
2847
+ if (options?.slackActionApp) {
2848
+ app.route("/slack/actions", options.slackActionApp);
2016
2849
  }
2850
+ return app;
2017
2851
  }
2018
- function isSseReadChunk(value) {
2019
- if (typeof value !== "object" || value === null) return false;
2020
- const candidate = value;
2021
- if (typeof candidate.done !== "boolean") return false;
2022
- if (candidate.value === void 0) return true;
2023
- 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);
2024
2859
  }
2025
- function extractJsonRpcErrorMessage(payload) {
2026
- const error = payload["error"];
2027
- if (typeof error === "string") return error;
2028
- if (typeof error !== "object" || error === null) return void 0;
2029
- const message = error["message"];
2030
- if (typeof message === "string" && message.trim()) return message;
2031
- 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);
2032
2867
  }
2033
- function extractNegotiatedProtocolVersion(payload) {
2034
- const result = payload["result"];
2035
- if (typeof result !== "object" || result === null) {
2036
- 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();
2037
2886
  }
2038
- const protocolVersion = result["protocolVersion"];
2039
- return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
2887
+ return { status: res.status, headers, body };
2040
2888
  }
2041
2889
 
2042
2890
  // src/upstream/streamable-http-forwarder.ts
2891
+ var JSON_RPC_METHOD_NOT_FOUND = -32601;
2043
2892
  var StreamableHttpForwarder = class {
2044
2893
  url;
2045
2894
  staticHeaders;
@@ -2052,7 +2901,8 @@ var StreamableHttpForwarder = class {
2052
2901
  this.sessions = new UpstreamSessionManager({
2053
2902
  url: this.url,
2054
2903
  staticHeaders: this.staticHeaders,
2055
- requestTimeoutMs: this.requestTimeoutMs
2904
+ requestTimeoutMs: this.requestTimeoutMs,
2905
+ protocolVersion: options.protocolVersion
2056
2906
  });
2057
2907
  }
2058
2908
  /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
@@ -2065,15 +2915,91 @@ var StreamableHttpForwarder = class {
2065
2915
  return Promise.resolve();
2066
2916
  }
2067
2917
  async forward(request) {
2068
- if (request.method === "initialize") {
2069
- return this.send(
2070
- request,
2071
- request.sessionId,
2072
- /* protocolVersion */
2073
- 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"
2074
3001
  );
2075
3002
  }
2076
- return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
2077
3003
  }
2078
3004
  /**
2079
3005
  * Helio-internal execution path (startup prime / internal maintenance) that
@@ -2084,8 +3010,7 @@ var StreamableHttpForwarder = class {
2084
3010
  try {
2085
3011
  return await this.send(
2086
3012
  request,
2087
- session.sessionId,
2088
- session.protocolVersion,
3013
+ session,
2089
3014
  /* internalManaged */
2090
3015
  true
2091
3016
  );
@@ -2095,8 +3020,7 @@ var StreamableHttpForwarder = class {
2095
3020
  const fresh = await this.sessions.ensureInternalSession();
2096
3021
  return this.send(
2097
3022
  request,
2098
- fresh.sessionId,
2099
- fresh.protocolVersion,
3023
+ fresh,
2100
3024
  /* internalManaged */
2101
3025
  true
2102
3026
  );
@@ -2104,7 +3028,50 @@ var StreamableHttpForwarder = class {
2104
3028
  throw error;
2105
3029
  }
2106
3030
  }
2107
- 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
+ }
2108
3075
  const headers = mergeUpstreamHeaders(
2109
3076
  {
2110
3077
  "content-type": "application/json",
@@ -2113,16 +3080,26 @@ var StreamableHttpForwarder = class {
2113
3080
  request.headers ?? {},
2114
3081
  this.staticHeaders
2115
3082
  );
2116
- if (sessionId) headers["mcp-session-id"] = sessionId;
2117
- if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
2118
- headers["mcp-protocol-version"] = protocolVersion;
2119
- }
3083
+ if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
3084
+ if (modern) {
3085
+ delete headers["mcp-session-id"];
3086
+ headers["mcp-protocol-version"] = HELIO_MCP_MODERN_PROTOCOL_VERSION;
3087
+ } else if (session.protocolVersion && headers["mcp-protocol-version"] === void 0) {
3088
+ headers["mcp-protocol-version"] = session.protocolVersion;
3089
+ }
3090
+ delete headers["mcp-method"];
3091
+ delete headers["mcp-name"];
3092
+ Object.assign(headers, buildStandardRequestHeaders(request.method, outboundParams));
2120
3093
  const body = {
2121
3094
  jsonrpc: request.jsonrpc,
2122
3095
  method: request.method
2123
3096
  };
2124
3097
  if (request.id !== void 0) body["id"] = request.id;
2125
- if (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
+ }
2126
3103
  const start = performance.now();
2127
3104
  const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
2128
3105
  const requestSignal = request.signal;
@@ -2138,7 +3115,7 @@ var StreamableHttpForwarder = class {
2138
3115
  }
2139
3116
  throw describeUnreachableUpstream(error, this.url) ?? error;
2140
3117
  }
2141
- if (internalManaged && res.status === 404 && sessionId) {
3118
+ if (internalManaged && res.status === 404 && session.sessionId) {
2142
3119
  await res.text().catch(() => void 0);
2143
3120
  throw new UpstreamSessionExpiredError();
2144
3121
  }
@@ -2148,6 +3125,7 @@ var StreamableHttpForwarder = class {
2148
3125
  res.headers.forEach((value, key) => {
2149
3126
  responseHeaders[key] = value;
2150
3127
  });
3128
+ if (modern) delete responseHeaders["mcp-session-id"];
2151
3129
  if (request.id === void 0) {
2152
3130
  await res.body?.cancel().catch(() => void 0);
2153
3131
  const response3 = {
@@ -2162,6 +3140,7 @@ var StreamableHttpForwarder = class {
2162
3140
  return { response: response2, durationMs: performance.now() - start };
2163
3141
  }
2164
3142
  const response = await parseUpstreamResponse(res);
3143
+ if (modern) delete response.headers["mcp-session-id"];
2165
3144
  return { response, durationMs: performance.now() - start };
2166
3145
  }
2167
3146
  };
@@ -2171,6 +3150,16 @@ var UpstreamSessionExpiredError = class extends Error {
2171
3150
  this.name = "UpstreamSessionExpiredError";
2172
3151
  }
2173
3152
  };
3153
+ function isPlainObject(value) {
3154
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3155
+ }
3156
+ function readJsonRpcErrorCode(body) {
3157
+ if (typeof body !== "object" || body === null) return void 0;
3158
+ const error = body["error"];
3159
+ if (typeof error !== "object" || error === null) return void 0;
3160
+ const code = error["code"];
3161
+ return typeof code === "number" ? code : void 0;
3162
+ }
2174
3163
 
2175
3164
  // src/mcp/pending-requests.ts
2176
3165
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -2319,8 +3308,11 @@ var SseUpstreamForwarder = class {
2319
3308
  request.headers ?? {},
2320
3309
  this.staticHeaders
2321
3310
  );
2322
- if (request.sessionId) {
2323
- 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;
2324
3316
  }
2325
3317
  const start = performance.now();
2326
3318
  const signal = buildRequestSignal(request, this.requestTimeoutMs);
@@ -2637,8 +3629,14 @@ async function createForwarderFromConfig(config) {
2637
3629
  const http = new StreamableHttpForwarder({
2638
3630
  url: config.upstream.url,
2639
3631
  headers: config.upstream.headers,
2640
- requestTimeoutMs: parseDuration(config.upstream.request_timeout)
3632
+ requestTimeoutMs: parseDuration(config.upstream.request_timeout),
3633
+ protocolVersion: config.upstream.protocol_version
2641
3634
  });
3635
+ if (config.upstream.protocol_version !== "auto") {
3636
+ console.error(
3637
+ `[helio] Upstream MCP protocol version pinned: ${config.upstream.protocol_version} (upstream.protocol_version)`
3638
+ );
3639
+ }
2642
3640
  await http.connect();
2643
3641
  return { forwarder: http, close: () => http.close() };
2644
3642
  }
@@ -2783,6 +3781,79 @@ function evaluatePolicy(policy, ctx) {
2783
3781
  // src/policy/governed-forwarder.ts
2784
3782
  import { randomUUID as randomUUID2 } from "crypto";
2785
3783
 
3784
+ // src/policy/session-gate.ts
3785
+ function isWellFormedSessionId(id) {
3786
+ return id != null && id.trim() !== "";
3787
+ }
3788
+ function gateSession(sessionId, onUnresolved) {
3789
+ if (isWellFormedSessionId(sessionId)) {
3790
+ return { ok: true, session: sessionId, anonymous: false };
3791
+ }
3792
+ if (onUnresolved === "anonymous") {
3793
+ return { ok: true, session: "unknown", anonymous: true };
3794
+ }
3795
+ return { ok: false };
3796
+ }
3797
+ function sessionLimitKey(session) {
3798
+ return `session:${session}`;
3799
+ }
3800
+ function gateBudgetCharges(resolved, gate) {
3801
+ const sessionEngaged = resolved.charges.some((charge) => charge.budget.key === "session") || resolved.failures.some((failure) => failure.budget.key === "session");
3802
+ if (!gate.ok) {
3803
+ if (sessionEngaged) return { ok: false, unresolvedEngaged: true };
3804
+ return { ok: true, charges: resolved.charges };
3805
+ }
3806
+ if (gate.anonymous && sessionEngaged) warnAnonymousPoolingOnce();
3807
+ return { ok: true, charges: resolved.charges };
3808
+ }
3809
+ function freezeGatedPlans(charges, breached) {
3810
+ if (breached.length !== charges.length) {
3811
+ throw new Error(
3812
+ `freezeGatedPlans: breach markers must pair positionally with charges (${String(breached.length)} markers for ${String(charges.length)} charges)`
3813
+ );
3814
+ }
3815
+ return charges.map(
3816
+ (charge, index) => ({
3817
+ kind: "budget",
3818
+ budget: charge.budget,
3819
+ bucketKey: charge.bucketKey,
3820
+ amount: charge.amount,
3821
+ generation: charge.generation,
3822
+ breached: breached[index] === true
3823
+ })
3824
+ );
3825
+ }
3826
+ function remintDeferredCharges(frozen, actualAmount) {
3827
+ return frozen.map((plan) => ({
3828
+ budget: plan.budget,
3829
+ bucketKey: plan.bucketKey,
3830
+ amount: actualAmount ?? plan.amount,
3831
+ generation: plan.generation
3832
+ }));
3833
+ }
3834
+ function sessionUnresolvedControlMessage(tried) {
3835
+ return `No session identity resolved (tried: ${tried}) \u2014 a session-keyed limit or budget requires one. See session.identity in helio.yaml.`;
3836
+ }
3837
+ function sessionRequiredForGroundingMessage(tried) {
3838
+ return `No session identity resolved (tried: ${tried}) \u2014 rules using evidence.requires or requires need one. See session.identity in helio.yaml.`;
3839
+ }
3840
+ var unresolvedEngagementWarned = false;
3841
+ var anonymousPoolingWarned = false;
3842
+ function warnSessionUnresolvedEngagementOnce(tried) {
3843
+ if (unresolvedEngagementWarned) return;
3844
+ unresolvedEngagementWarned = true;
3845
+ console.error(
3846
+ `[helio] Warning: a session-keyed control was engaged with no resolved session identity (tried: ${tried}); session.on_unresolved: deny denies such requests (dry-run reports them). Send an identity the chain can read (e.g. the x-helio-session-id header), or set session.on_unresolved: anonymous to restore pre-0.12 shared pooling.`
3847
+ );
3848
+ }
3849
+ function warnAnonymousPoolingOnce() {
3850
+ if (anonymousPoolingWarned) return;
3851
+ anonymousPoolingWarned = true;
3852
+ console.error(
3853
+ '[helio] Warning: session identity unresolved; session-keyed limits and budgets are pooling into the shared "unknown" bucket (session.on_unresolved: anonymous). Have callers send session identity to isolate them from each other.'
3854
+ );
3855
+ }
3856
+
2786
3857
  // src/evidence/grounding.ts
2787
3858
  function checkEvidence(store, sessionId, requirements) {
2788
3859
  if (requirements.length === 0) {
@@ -2828,7 +3899,8 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
2828
3899
 
2829
3900
  // src/policy/decision-pipeline.ts
2830
3901
  function decide(input) {
2831
- const { toolName, toolArguments, sessionId, policy, environment, evidenceStore } = input;
3902
+ const { toolName, toolArguments, policy, environment, evidenceStore } = input;
3903
+ const sessionId = isWellFormedSessionId(input.sessionId) ? input.sessionId : void 0;
2832
3904
  const annotations = input.baselineAnnotations;
2833
3905
  const driftEvent = input.driftEvent;
2834
3906
  const driftMode = policy.onToolDrift ?? "block";
@@ -2887,7 +3959,9 @@ function decide(input) {
2887
3959
  decision = {
2888
3960
  action: "deny",
2889
3961
  matchedRule: decision.matchedRule,
2890
- reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
3962
+ reason: sessionRequiredForGroundingMessage(
3963
+ input.sessionStrategySummary ?? "the configured session.identity chain"
3964
+ )
2891
3965
  };
2892
3966
  }
2893
3967
  if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
@@ -3338,6 +4412,17 @@ function buildToolDriftFeedback(drift, action) {
3338
4412
  retry_allowed: false
3339
4413
  };
3340
4414
  }
4415
+ function buildSessionUnresolvedFeedback(decision, control, tried) {
4416
+ return {
4417
+ blocked: true,
4418
+ reason: "session_unresolved",
4419
+ ...ruleInfo(decision.matchedRule),
4420
+ control,
4421
+ tried,
4422
+ suggestion: `No session identity resolved (tried: ${tried}). Send an identity the chain can read \u2014 for example set the x-helio-session-id header once per agent run \u2014 or set session.on_unresolved: anonymous to restore shared pooling.`,
4423
+ retry_allowed: true
4424
+ };
4425
+ }
3341
4426
  function buildSpendLimitedFeedback(decision, result, currency) {
3342
4427
  const info = ruleInfo(decision.matchedRule);
3343
4428
  const windowSeconds = Math.round(result.windowMs / 1e3);
@@ -3795,6 +4880,7 @@ var GovernedForwarder = class {
3795
4880
  inner;
3796
4881
  policy;
3797
4882
  environment;
4883
+ session;
3798
4884
  auditWriter;
3799
4885
  evidenceStore;
3800
4886
  approvalRouter;
@@ -3814,6 +4900,7 @@ var GovernedForwarder = class {
3814
4900
  this.rateLimiter = options?.rateLimiter;
3815
4901
  this.spendLimiter = options?.spendLimiter;
3816
4902
  this.budgetEngine = options?.budgetEngine;
4903
+ this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
3817
4904
  if (this.evidenceStore) {
3818
4905
  this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
3819
4906
  }
@@ -3887,6 +4974,7 @@ var GovernedForwarder = class {
3887
4974
  try {
3888
4975
  const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
3889
4976
  if (result.response.status >= 400) {
4977
+ internal.resetInternalSession?.();
3890
4978
  return {
3891
4979
  success: false,
3892
4980
  toolsCached: this.annotationCache.size,
@@ -3895,6 +4983,7 @@ var GovernedForwarder = class {
3895
4983
  }
3896
4984
  const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
3897
4985
  if (!update.updated) {
4986
+ internal.resetInternalSession?.();
3898
4987
  return {
3899
4988
  success: false,
3900
4989
  toolsCached: this.annotationCache.size,
@@ -3903,6 +4992,7 @@ var GovernedForwarder = class {
3903
4992
  }
3904
4993
  return { success: true, toolsCached: this.annotationCache.size };
3905
4994
  } catch (error) {
4995
+ internal.resetInternalSession?.();
3906
4996
  return {
3907
4997
  success: false,
3908
4998
  toolsCached: this.annotationCache.size,
@@ -3916,17 +5006,42 @@ var GovernedForwarder = class {
3916
5006
  }
3917
5007
  const result = await this.inner.forward(request);
3918
5008
  if (request.method === "tools/list") {
3919
- this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
5009
+ this.applyToolDefinitionUpdate(result.response.body, request.session);
5010
+ this.clampCacheHints(result.response.body);
3920
5011
  }
3921
5012
  return result;
3922
5013
  }
5014
+ /**
5015
+ * Clamp an over-long `result.ttlMs` on a `tools/list` response to
5016
+ * `policies.tool_revalidation.max_advertised_ttl` (issue #221, D7).
5017
+ *
5018
+ * Downward-only: a `ttlMs` at or below the cap is left untouched, and a
5019
+ * response with no `ttlMs` never gains one — Helio does not manufacture a
5020
+ * cache hint the upstream never advertised. Non-numeric values are left
5021
+ * alone rather than coerced. `cacheScope` passes through untouched: Helio
5022
+ * baselines and vouches for tool *definitions* only, and its own
5023
+ * `tools/list` view is not caller-varying, so it has no basis to alter a
5024
+ * scope hint the upstream set. No-op when tool revalidation is disabled
5025
+ * (including hand-built `CompiledPolicy` fixtures that omit the field).
5026
+ */
5027
+ clampCacheHints(responseBody) {
5028
+ const rv = this.policy.toolRevalidation;
5029
+ if (!rv?.enabled) return;
5030
+ if (typeof responseBody !== "object" || responseBody === null) return;
5031
+ const result = responseBody["result"];
5032
+ if (typeof result !== "object" || result === null) return;
5033
+ const r = result;
5034
+ if (typeof r["ttlMs"] === "number" && r["ttlMs"] > rv.maxAdvertisedTtlMs) {
5035
+ r["ttlMs"] = rv.maxAdvertisedTtlMs;
5036
+ }
5037
+ }
3923
5038
  /**
3924
5039
  * Apply a tools/list response to the definition cache and surface any
3925
5040
  * drift: console warning + immediate audit record per event. Single entry
3926
5041
  * point for both runtime tools/list responses and startup priming, so the
3927
5042
  * cache is updated exactly once per response.
3928
5043
  */
3929
- applyToolDefinitionUpdate(responseBody, sessionId) {
5044
+ applyToolDefinitionUpdate(responseBody, session) {
3930
5045
  const update = this.annotationCache.update(responseBody);
3931
5046
  if (!update.updated) return update;
3932
5047
  for (const drift of update.drifted) {
@@ -3934,22 +5049,23 @@ var GovernedForwarder = class {
3934
5049
  console.error(
3935
5050
  `[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
3936
5051
  );
3937
- this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
5052
+ this.writeDriftAuditRecord(drift, session, "tool_drift");
3938
5053
  }
3939
5054
  for (const toolName of update.reverted) {
3940
5055
  console.error(
3941
5056
  `[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
3942
5057
  );
3943
- this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
5058
+ this.writeDriftAuditRecord({ toolName, changes: [] }, session, "tool_drift_reverted");
3944
5059
  }
3945
5060
  return update;
3946
5061
  }
3947
5062
  /** Write an immediate audit record for a drift event (not a tool call). */
3948
- writeDriftAuditRecord(drift, sessionId, decision) {
5063
+ writeDriftAuditRecord(drift, session, decision) {
3949
5064
  if (!this.auditWriter) return;
3950
5065
  this.auditWriter.pushImmediate({
3951
5066
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3952
- session_id: sessionId ?? null,
5067
+ session_id: session?.id ?? null,
5068
+ session_source: session?.source ?? null,
3953
5069
  agent_id: null,
3954
5070
  environment: this.environment ?? null,
3955
5071
  tool_name: drift.toolName,
@@ -3972,7 +5088,9 @@ var GovernedForwarder = class {
3972
5088
  dry_run: false,
3973
5089
  record_kind: "drift_event",
3974
5090
  origin: "mcp",
3975
- metadata: null
5091
+ metadata: null,
5092
+ // Drift is a cache event, not a request: no protocol claim exists.
5093
+ protocol_version: null
3976
5094
  });
3977
5095
  }
3978
5096
  async handleToolsCall(original) {
@@ -4009,7 +5127,8 @@ var GovernedForwarder = class {
4009
5127
  } = decide({
4010
5128
  toolName,
4011
5129
  toolArguments,
4012
- sessionId: request.sessionId,
5130
+ sessionId: request.session?.id,
5131
+ sessionStrategySummary: this.session.strategySummary,
4013
5132
  policy: this.policy,
4014
5133
  environment: this.environment,
4015
5134
  evidenceStore: this.evidenceStore,
@@ -4102,9 +5221,10 @@ var GovernedForwarder = class {
4102
5221
  });
4103
5222
  }
4104
5223
  try {
4105
- if (forwarded && !isDryRun && this.evidenceStore && request.sessionId && toolName) {
5224
+ const dependencySessionId = request.session?.id;
5225
+ if (forwarded && !isDryRun && this.evidenceStore && isWellFormedSessionId(dependencySessionId) && toolName) {
4106
5226
  const succeeded = !hasJsonRpcError(result);
4107
- this.evidenceStore.recordToolCall(request.sessionId, toolName, succeeded);
5227
+ this.evidenceStore.recordToolCall(dependencySessionId, toolName, succeeded);
4108
5228
  }
4109
5229
  } catch (err) {
4110
5230
  console.error("[helio] dependency tracking failed after forward:", err);
@@ -4125,6 +5245,7 @@ var GovernedForwarder = class {
4125
5245
  evidenceResult,
4126
5246
  dependencyResult,
4127
5247
  evidenceBlocked,
5248
+ sessionBlocked,
4128
5249
  approvalOutcome,
4129
5250
  approvalContext,
4130
5251
  rateLimitResult,
@@ -4155,7 +5276,7 @@ var GovernedForwarder = class {
4155
5276
  tool_name: toolName,
4156
5277
  tool_input: toolArguments ?? {},
4157
5278
  matched_rule: decision.matchedRule,
4158
- session_id: request.sessionId ?? null,
5279
+ session_id: request.session?.id ?? null,
4159
5280
  breached_budgets: gate.breachContexts,
4160
5281
  approval: gate.approval
4161
5282
  },
@@ -4298,15 +5419,25 @@ var GovernedForwarder = class {
4298
5419
  gateBudgets(request, decision, toolName, toolArguments) {
4299
5420
  const engine = this.budgetEngine;
4300
5421
  if (!engine) return { kind: "proceed" };
5422
+ const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
4301
5423
  const { charges, failures } = engine.resolveCharges({
4302
5424
  toolName,
4303
5425
  toolArguments,
4304
- sessionId: request.sessionId ?? null,
5426
+ sessionId: sessionGate.ok ? sessionGate.session : null,
4305
5427
  senderId: null
4306
5428
  // adapter context; absent on the MCP path
4307
5429
  });
4308
5430
  if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
4309
- const 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: [] };
4310
5441
  const breaches = peek.entries.filter((entry) => !entry.allowed);
4311
5442
  const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
4312
5443
  if (breaches.length > 0) engine.reportBreaches(breaches);
@@ -4339,7 +5470,7 @@ var GovernedForwarder = class {
4339
5470
  const peekBlockByName = new Map(
4340
5471
  peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
4341
5472
  );
4342
- const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
5473
+ const commit = (auditRecordId, kinds) => engine.recordAll(gated.charges, {
4343
5474
  kind: "spend",
4344
5475
  ...kinds ? { kinds } : {},
4345
5476
  auditRecordId,
@@ -4415,7 +5546,8 @@ var GovernedForwarder = class {
4415
5546
  const toolInput = { raw_params: params ?? null };
4416
5547
  this.auditWriter.pushImmediate({
4417
5548
  timestamp,
4418
- session_id: request.sessionId ?? null,
5549
+ session_id: request.session?.id ?? null,
5550
+ session_source: request.session?.source ?? null,
4419
5551
  agent_id: null,
4420
5552
  environment: this.environment ?? null,
4421
5553
  tool_name: "<nameless>",
@@ -4438,7 +5570,8 @@ var GovernedForwarder = class {
4438
5570
  dry_run: false,
4439
5571
  record_kind: "tool_call",
4440
5572
  origin: "mcp",
4441
- metadata: null
5573
+ metadata: null,
5574
+ protocol_version: request.protocolVersion ?? null
4442
5575
  });
4443
5576
  }
4444
5577
  return result;
@@ -4451,7 +5584,7 @@ var GovernedForwarder = class {
4451
5584
  tool_name: toolName,
4452
5585
  tool_input: toolArguments ?? {},
4453
5586
  matched_rule: decision.matchedRule,
4454
- session_id: request.sessionId ?? null
5587
+ session_id: request.session?.id ?? null
4455
5588
  },
4456
5589
  request.signal
4457
5590
  );
@@ -4547,7 +5680,20 @@ var GovernedForwarder = class {
4547
5680
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
4548
5681
  };
4549
5682
  }
4550
- 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
+ }
4551
5697
  const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
4552
5698
  const rateLimitResult = limiter.peek(params);
4553
5699
  if (!rateLimitResult.allowed) {
@@ -4585,7 +5731,21 @@ var GovernedForwarder = class {
4585
5731
  spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
4586
5732
  };
4587
5733
  }
4588
- 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);
4589
5749
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
4590
5750
  if (typeof rawAmount !== "number") {
4591
5751
  console.error(
@@ -4649,6 +5809,7 @@ var GovernedForwarder = class {
4649
5809
  const evidenceSatisfied = !evidenceBlocked;
4650
5810
  let wouldForward = false;
4651
5811
  let limitsOk = true;
5812
+ let sessionUnresolved = false;
4652
5813
  if (!evidenceBlocked) {
4653
5814
  switch (decision.action) {
4654
5815
  case "allow":
@@ -4656,14 +5817,21 @@ var GovernedForwarder = class {
4656
5817
  break;
4657
5818
  case "rate_limit":
4658
5819
  if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
4659
- const key = this.buildLimitKey(decision.matchedRule.limits.key, toolName, request);
4660
- const peekResult = this.rateLimiter.peek({
4661
- key,
4662
- maxCalls: decision.matchedRule.limits.maxCalls,
4663
- windowMs: decision.matchedRule.limits.windowMs
4664
- });
4665
- wouldForward = peekResult.allowed;
4666
- 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
+ }
4667
5835
  }
4668
5836
  break;
4669
5837
  case "spend_limit":
@@ -4683,20 +5851,21 @@ var GovernedForwarder = class {
4683
5851
  wouldForward = false;
4684
5852
  limitsOk = false;
4685
5853
  } else {
4686
- const key = this.buildSpendLimitKey(
4687
- maxSpend.key,
4688
- toolName,
4689
- request,
4690
- decision.matchedRule.index
4691
- );
4692
- const peekResult = this.spendLimiter.peek({
4693
- key,
4694
- amount: rawAmount,
4695
- limit: maxSpend.limit,
4696
- windowMs: maxSpend.windowMs
4697
- });
4698
- wouldForward = peekResult.allowed;
4699
- 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
+ }
4700
5869
  }
4701
5870
  }
4702
5871
  break;
@@ -4704,30 +5873,39 @@ var GovernedForwarder = class {
4704
5873
  }
4705
5874
  let budgets;
4706
5875
  if (wouldForward && this.budgetEngine) {
5876
+ const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
4707
5877
  const { charges, failures } = this.budgetEngine.resolveCharges({
4708
5878
  toolName,
4709
5879
  toolArguments,
4710
- sessionId: request.sessionId ?? null,
5880
+ sessionId: sessionGate.ok ? sessionGate.session : null,
4711
5881
  senderId: null
4712
5882
  });
4713
5883
  if (failures.length > 0 || charges.length > 0) {
4714
- const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
4715
- const ok = failures.length === 0 && peek.allowed;
4716
- wouldForward &&= ok;
4717
- limitsOk &&= ok;
4718
- budgets = [
4719
- ...peek.entries.map((entry) => budgetChainBlock(entry)),
4720
- ...failures.map((failure) => ({
4721
- name: failure.budget.name,
4722
- bucket_key: failure.bucketKey,
4723
- allowed: false,
4724
- reason: failure.reason,
4725
- spent: failure.spent,
4726
- limit: failure.budget.limit,
4727
- remaining: failure.remaining,
4728
- currency: failure.budget.currency
4729
- }))
4730
- ];
5884
+ const gated = gateBudgetCharges({ charges, failures }, sessionGate);
5885
+ if (!gated.ok) {
5886
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
5887
+ wouldForward = false;
5888
+ limitsOk = false;
5889
+ sessionUnresolved = true;
5890
+ } else {
5891
+ const peek = charges.length > 0 ? this.budgetEngine.peekAll(gated.charges) : { allowed: true, entries: [] };
5892
+ const ok = failures.length === 0 && peek.allowed;
5893
+ wouldForward &&= ok;
5894
+ limitsOk &&= ok;
5895
+ budgets = [
5896
+ ...peek.entries.map((entry) => budgetChainBlock(entry)),
5897
+ ...failures.map((failure) => ({
5898
+ name: failure.budget.name,
5899
+ bucket_key: failure.bucketKey,
5900
+ allowed: false,
5901
+ reason: failure.reason,
5902
+ spent: failure.spent,
5903
+ limit: failure.budget.limit,
5904
+ remaining: failure.remaining,
5905
+ currency: failure.budget.currency
5906
+ }))
5907
+ ];
5908
+ }
4731
5909
  }
4732
5910
  }
4733
5911
  return this.makeDryRunResult(
@@ -4736,14 +5914,18 @@ var GovernedForwarder = class {
4736
5914
  wouldForward,
4737
5915
  evidenceSatisfied,
4738
5916
  limitsOk,
4739
- budgets
5917
+ budgets,
5918
+ sessionUnresolved
4740
5919
  );
4741
5920
  }
4742
- /** Construct a limit bucket key based on the configured key type. */
4743
- 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) {
4744
5928
  switch (keyType) {
4745
- case "session":
4746
- return `session:${request.sessionId ?? "unknown"}`;
4747
5929
  case "agent":
4748
5930
  if (!this.agentKeyWarned) {
4749
5931
  this.agentKeyWarned = true;
@@ -4766,14 +5948,29 @@ var GovernedForwarder = class {
4766
5948
  }
4767
5949
  }
4768
5950
  /**
4769
- * Construct a spend bucket key via the shared {@link spendBucketKey}
4770
- * composer see its doc for why spend buckets are rule-discriminated.
4771
- * 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).
4772
5954
  */
4773
- buildSpendLimitKey(keyType, toolName, request, ruleIndex) {
4774
- 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
+ );
4775
5972
  }
4776
- writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
5973
+ writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, sessionBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
4777
5974
  if (!this.auditWriter) return;
4778
5975
  const actuallyForwarded = forwarded && !isDryRun;
4779
5976
  const hadForwardingError = forwardingError !== void 0;
@@ -4866,9 +6063,16 @@ var GovernedForwarder = class {
4866
6063
  };
4867
6064
  }
4868
6065
  const blockReason = extractBlockReason(result);
6066
+ if (sessionBlocked || blockReason === "session_unresolved") {
6067
+ evidenceChain = {
6068
+ ...evidenceChain ?? {},
6069
+ session: { unresolved: true, tried: this.session.strategySummary }
6070
+ };
6071
+ }
4869
6072
  const record = {
4870
6073
  timestamp,
4871
- session_id: request.sessionId ?? null,
6074
+ session_id: request.session?.id ?? null,
6075
+ session_source: request.session?.source ?? null,
4872
6076
  agent_id: null,
4873
6077
  environment: this.environment ?? null,
4874
6078
  tool_name: toolName,
@@ -4894,7 +6098,8 @@ var GovernedForwarder = class {
4894
6098
  dry_run: isDryRun ?? false,
4895
6099
  record_kind: "tool_call",
4896
6100
  origin: "mcp",
4897
- metadata: null
6101
+ metadata: null,
6102
+ protocol_version: request.protocolVersion ?? null
4898
6103
  };
4899
6104
  const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
4900
6105
  if (isEnforcementDecision) {
@@ -4937,7 +6142,7 @@ var GovernedForwarder = class {
4937
6142
  unsupported: true
4938
6143
  });
4939
6144
  }
4940
- makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
6145
+ makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets, sessionUnresolved) {
4941
6146
  const payload = {
4942
6147
  dry_run: true,
4943
6148
  would_forward: wouldForward,
@@ -4945,13 +6150,19 @@ var GovernedForwarder = class {
4945
6150
  matched_rule: decision.matchedRule?.name ?? null,
4946
6151
  evidence_satisfied: evidenceSatisfied,
4947
6152
  limits_ok: limitsOk,
4948
- ...budgets ? { budgets } : {}
6153
+ ...budgets ? { budgets } : {},
6154
+ ...sessionUnresolved ? { session_unresolved: true } : {}
4949
6155
  };
4950
6156
  const body = {
4951
6157
  jsonrpc: "2.0",
4952
6158
  id: request.id ?? null,
4953
6159
  result: {
4954
- content: [{ type: "text", text: JSON.stringify(payload) }]
6160
+ content: [{ type: "text", text: JSON.stringify(payload) }],
6161
+ // `resultType` is REQUIRED on every 2026-07-28 result; earlier
6162
+ // revisions never defined it, so the field rides on the client's
6163
+ // validated wire claim — the same tokenizer the #226 door uses for
6164
+ // its tier decision, keeping the two verdicts in agreement.
6165
+ ...isModernProtocolClaim(request.protocolVersion) ? { resultType: "complete" } : {}
4955
6166
  }
4956
6167
  };
4957
6168
  const response = {
@@ -4974,15 +6185,10 @@ var GovernedForwarder = class {
4974
6185
  }
4975
6186
  makeSessionRequiredBlockResult(request, decision) {
4976
6187
  const feedback = buildPolicyDeniedFeedback(decision);
4977
- return makeErrorResult(
4978
- request,
4979
- POLICY_DENIED,
4980
- "Mcp-Session-Id is required for evidence/dependency-gated policy rules",
4981
- {
4982
- ...feedback,
4983
- retry_allowed: true
4984
- }
4985
- );
6188
+ return makeErrorResult(request, POLICY_DENIED, decision.reason, {
6189
+ ...feedback,
6190
+ retry_allowed: true
6191
+ });
4986
6192
  }
4987
6193
  makeClientDisconnectedBlockResult(request, decision) {
4988
6194
  const feedback = buildClientDisconnectedFeedback(decision);
@@ -5315,6 +6521,127 @@ var RateLimiter = class {
5315
6521
  }
5316
6522
  };
5317
6523
 
6524
+ // src/policy/annotation-prime-loop.ts
6525
+ var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
6526
+ var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
6527
+ var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
6528
+ var ANNOTATION_PRIME_RETRY_JITTER_MS = 250;
6529
+ function computePrimeRetryDelayMs(attempt) {
6530
+ const exponent = Math.max(0, attempt - 1);
6531
+ const baseDelay = Math.min(
6532
+ ANNOTATION_PRIME_RETRY_MAX_MS,
6533
+ ANNOTATION_PRIME_RETRY_BASE_MS * 2 ** exponent
6534
+ );
6535
+ const jitter = Math.floor(Math.random() * ANNOTATION_PRIME_RETRY_JITTER_MS);
6536
+ return Math.min(ANNOTATION_PRIME_RETRY_MAX_MS, baseDelay + jitter);
6537
+ }
6538
+ async function startAnnotationPrimeLoop(forwarder, revalidation) {
6539
+ let stopped = false;
6540
+ let primed = false;
6541
+ let retryAttempt = 0;
6542
+ let retryTimer;
6543
+ let current = revalidation;
6544
+ let revalidateTimer;
6545
+ let revalidateEpoch = 0;
6546
+ const clearRetryTimer = () => {
6547
+ if (!retryTimer) return;
6548
+ clearTimeout(retryTimer);
6549
+ retryTimer = void 0;
6550
+ };
6551
+ const clearRevalidateTimer = () => {
6552
+ if (!revalidateTimer) return;
6553
+ clearTimeout(revalidateTimer);
6554
+ revalidateTimer = void 0;
6555
+ };
6556
+ const scheduleRevalidation = () => {
6557
+ const rv = current;
6558
+ if (stopped || !primed || !rv?.enabled || revalidateTimer) return;
6559
+ const epoch = revalidateEpoch;
6560
+ revalidateTimer = setTimeout(() => {
6561
+ revalidateTimer = void 0;
6562
+ void forwarder.primeAnnotationCache().then((result) => {
6563
+ if (epoch !== revalidateEpoch) return;
6564
+ if (!result.success) {
6565
+ console.error(
6566
+ `[helio] Tool revalidation failed: ${result.reason ?? "unknown reason"} \u2014 keeping the last baselines; next attempt in ${String(rv.intervalMs)}ms`
6567
+ );
6568
+ }
6569
+ scheduleRevalidation();
6570
+ });
6571
+ }, rv.intervalMs);
6572
+ revalidateTimer.unref();
6573
+ };
6574
+ const stop = () => {
6575
+ stopped = true;
6576
+ revalidateEpoch += 1;
6577
+ clearRetryTimer();
6578
+ clearRevalidateTimer();
6579
+ };
6580
+ const reconfigure = (next) => {
6581
+ current = next;
6582
+ revalidateEpoch += 1;
6583
+ clearRevalidateTimer();
6584
+ scheduleRevalidation();
6585
+ };
6586
+ const scheduleRetry = () => {
6587
+ if (stopped || primed || retryTimer) return;
6588
+ retryAttempt += 1;
6589
+ const delayMs = computePrimeRetryDelayMs(retryAttempt);
6590
+ console.error(
6591
+ `[helio] Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
6592
+ );
6593
+ retryTimer = setTimeout(() => {
6594
+ retryTimer = void 0;
6595
+ void runPrimeAttempt("retry");
6596
+ }, delayMs);
6597
+ retryTimer.unref();
6598
+ };
6599
+ const handlePrimeResult = (phase, result) => {
6600
+ if (stopped || primed) return;
6601
+ if (result.success) {
6602
+ primed = true;
6603
+ clearRetryTimer();
6604
+ const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
6605
+ console.error(
6606
+ `${prefix}: ${String(result.toolsCached)} tool definitions baselined for drift detection (baselines are per-process; a restart re-baselines \u2014 review tool_drift audit records before restarting)`
6607
+ );
6608
+ scheduleRevalidation();
6609
+ return;
6610
+ }
6611
+ const reason = result.reason ?? "unknown reason";
6612
+ if (phase === "initial") {
6613
+ console.error(
6614
+ `[helio] Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
6615
+ );
6616
+ } else {
6617
+ console.error(
6618
+ `[helio] Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
6619
+ );
6620
+ }
6621
+ scheduleRetry();
6622
+ };
6623
+ const runPrimeAttempt = async (phase) => {
6624
+ const result = await forwarder.primeAnnotationCache();
6625
+ handlePrimeResult(phase, result);
6626
+ };
6627
+ const initialAttempt = runPrimeAttempt("initial");
6628
+ const initialOutcome = await Promise.race([
6629
+ initialAttempt.then(() => "completed"),
6630
+ new Promise((resolve2) => {
6631
+ setTimeout(() => {
6632
+ resolve2("timeout");
6633
+ }, ANNOTATION_PRIME_INITIAL_WAIT_MS).unref();
6634
+ })
6635
+ ]);
6636
+ if (initialOutcome === "timeout") {
6637
+ console.error(
6638
+ `[helio] Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
6639
+ );
6640
+ scheduleRetry();
6641
+ }
6642
+ return { stop, reconfigure };
6643
+ }
6644
+
5318
6645
  // src/audit/store.ts
5319
6646
  import Database from "better-sqlite3";
5320
6647
  import { randomUUID as randomUUID3 } from "crypto";
@@ -5389,6 +6716,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
5389
6716
  id TEXT PRIMARY KEY,
5390
6717
  timestamp TEXT NOT NULL,
5391
6718
  session_id TEXT,
6719
+ session_source TEXT,
5392
6720
  agent_id TEXT,
5393
6721
  environment TEXT,
5394
6722
  tool_name TEXT NOT NULL,
@@ -5412,6 +6740,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
5412
6740
  record_kind TEXT NOT NULL DEFAULT 'tool_call',
5413
6741
  origin TEXT NOT NULL DEFAULT 'mcp',
5414
6742
  metadata TEXT,
6743
+ protocol_version TEXT,
5415
6744
  created_at TEXT NOT NULL
5416
6745
  );
5417
6746
  `;
@@ -5427,19 +6756,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
5427
6756
  `;
5428
6757
  var INSERT_SQL = `
5429
6758
  INSERT INTO audit_records (
5430
- id, timestamp, session_id, agent_id, environment, tool_name, tool_input,
6759
+ id, timestamp, session_id, session_source, agent_id, environment, tool_name, tool_input,
5431
6760
  policy_decision, block_reason, matched_rule, matched_rule_index, evidence_chain, approval_status,
5432
6761
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
5433
6762
  upstream_http_status,
5434
6763
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
5435
- flagged_destructive, dry_run, record_kind, origin, metadata, created_at
6764
+ flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
5436
6765
  ) VALUES (
5437
- @id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
6766
+ @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
5438
6767
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
5439
6768
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
5440
6769
  @upstream_http_status,
5441
6770
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
5442
- @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
6771
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
5443
6772
  )
5444
6773
  `;
5445
6774
  var REQUIRED_AUDIT_COLUMNS = [
@@ -5452,13 +6781,20 @@ var REQUIRED_AUDIT_COLUMNS = [
5452
6781
  "upstream_http_status",
5453
6782
  "record_kind",
5454
6783
  "origin",
5455
- "metadata"
6784
+ "metadata",
6785
+ // Deliberately listed (issue #218): pre-0.12 local DBs fail fast with the
6786
+ // documented delete-these-files message — the pre-1.0 clean-break policy.
6787
+ "session_source",
6788
+ // Same clean break, same unreleased cycle (issue #219): released users see
6789
+ // ONE break, at v0.12.0.
6790
+ "protocol_version"
5456
6791
  ];
5457
6792
  function deserializeRow(row) {
5458
6793
  return {
5459
6794
  id: row.id,
5460
6795
  timestamp: row.timestamp,
5461
6796
  session_id: row.session_id,
6797
+ session_source: row.session_source,
5462
6798
  agent_id: row.agent_id,
5463
6799
  environment: row.environment,
5464
6800
  tool_name: row.tool_name,
@@ -5482,6 +6818,7 @@ function deserializeRow(row) {
5482
6818
  record_kind: row.record_kind,
5483
6819
  origin: row.origin,
5484
6820
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
6821
+ protocol_version: row.protocol_version,
5485
6822
  created_at: row.created_at
5486
6823
  };
5487
6824
  }
@@ -5665,6 +7002,7 @@ var AuditStore = class {
5665
7002
  id: resolvedId,
5666
7003
  timestamp: record.timestamp,
5667
7004
  session_id: record.session_id,
7005
+ session_source: record.session_source,
5668
7006
  agent_id: record.agent_id,
5669
7007
  environment: record.environment,
5670
7008
  tool_name: record.tool_name,
@@ -5688,6 +7026,7 @@ var AuditStore = class {
5688
7026
  record_kind: record.record_kind,
5689
7027
  origin: record.origin,
5690
7028
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7029
+ protocol_version: record.protocol_version,
5691
7030
  created_at: now
5692
7031
  });
5693
7032
  return resolvedId;
@@ -5956,6 +7295,48 @@ var AuditWriter = class {
5956
7295
  }
5957
7296
  };
5958
7297
 
7298
+ // src/audit/header-mismatch.ts
7299
+ function buildHeaderMismatchAuditRecord(rejection, environment) {
7300
+ return {
7301
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7302
+ session_id: rejection.session?.id ?? null,
7303
+ session_source: rejection.session?.source ?? null,
7304
+ agent_id: null,
7305
+ environment: environment ?? null,
7306
+ tool_name: rejection.bodyName ?? "<header_mismatch>",
7307
+ // Wrap parity with the nameless precedent: the wire params always nest
7308
+ // under `raw_params`, so a wrapped scalar can never be confused with an
7309
+ // object that happens to contain a `raw_params` key. Headers are the
7310
+ // present markers only, verbatim as received.
7311
+ tool_input: {
7312
+ raw_params: rejection.params ?? null,
7313
+ body_method: rejection.method,
7314
+ mismatch_reason: rejection.reason,
7315
+ headers: { ...rejection.headers }
7316
+ },
7317
+ policy_decision: "rejected",
7318
+ block_reason: "header_mismatch",
7319
+ matched_rule: null,
7320
+ matched_rule_index: null,
7321
+ evidence_chain: null,
7322
+ approval_status: null,
7323
+ approved_by: null,
7324
+ upstream_response: null,
7325
+ upstream_error: null,
7326
+ upstream_http_status: null,
7327
+ upstream_latency_ms: null,
7328
+ total_duration_ms: rejection.durationMs,
7329
+ approval_wait_ms: 0,
7330
+ proxy_compute_ms: rejection.durationMs,
7331
+ flagged_destructive: false,
7332
+ dry_run: false,
7333
+ record_kind: "tool_call",
7334
+ origin: "mcp",
7335
+ metadata: null,
7336
+ protocol_version: rejection.protocolVersion ?? null
7337
+ };
7338
+ }
7339
+
5959
7340
  // src/evidence/store.ts
5960
7341
  var EvidenceStore = class _EvidenceStore {
5961
7342
  static EVIDENCE_ALLOWLIST_PREVIEW_LIMIT = 20;
@@ -6438,15 +7819,18 @@ function asStatus(status) {
6438
7819
 
6439
7820
  // src/evidence/api.ts
6440
7821
  var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
7822
+ var sessionIdSchema = z5.string().min(1).refine((value) => value.trim() !== "", {
7823
+ message: "session_id must not be whitespace-only"
7824
+ });
6441
7825
  var postEvidenceBody = z5.object({
6442
- session_id: z5.string().min(1),
7826
+ session_id: sessionIdSchema,
6443
7827
  tool_name: z5.string().min(1),
6444
7828
  evidence_key: z5.string().min(1),
6445
7829
  evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
6446
7830
  ttl_seconds: z5.number().int().positive().optional()
6447
7831
  });
6448
7832
  var postContextBody = z5.object({
6449
- session_id: z5.string().min(1),
7833
+ session_id: sessionIdSchema,
6450
7834
  key: z5.string().min(1),
6451
7835
  value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
6452
7836
  });
@@ -6576,6 +7960,7 @@ var SWEEP_INTERVAL_MS2 = 3e4;
6576
7960
  var GovernanceService = class {
6577
7961
  policy;
6578
7962
  environment;
7963
+ session;
6579
7964
  evidenceStore;
6580
7965
  approvalRouter;
6581
7966
  rateLimiter;
@@ -6609,6 +7994,7 @@ var GovernanceService = class {
6609
7994
  constructor(options) {
6610
7995
  this.policy = options.policy;
6611
7996
  this.environment = options.environment;
7997
+ this.session = options.session ?? DEFAULT_SESSION_IDENTITY;
6612
7998
  this.evidenceStore = options.evidenceStore;
6613
7999
  this.approvalRouter = options.approvalRouter;
6614
8000
  this.rateLimiter = options.rateLimiter;
@@ -6643,6 +8029,7 @@ var GovernanceService = class {
6643
8029
  if (reserved) {
6644
8030
  return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
6645
8031
  }
8032
+ const sessionId = isWellFormedSessionId(req.session_id) ? req.session_id : null;
6646
8033
  const inputBytes = byteLength(req.arguments ?? {});
6647
8034
  if (inputBytes > MAX_TOOL_INPUT_BYTES) {
6648
8035
  return { status: 413, body: { error: "tool_input_too_large" } };
@@ -6650,7 +8037,7 @@ var GovernanceService = class {
6650
8037
  const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
6651
8038
  tool: req.tool.name,
6652
8039
  agent_id: req.agent_id,
6653
- session_id: req.session_id,
8040
+ session_id: sessionId,
6654
8041
  origin: req.origin
6655
8042
  });
6656
8043
  if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
@@ -6672,7 +8059,8 @@ var GovernanceService = class {
6672
8059
  const pipeline = decide({
6673
8060
  toolName,
6674
8061
  toolArguments: req.arguments,
6675
- sessionId: req.session_id ?? void 0,
8062
+ sessionId: sessionId ?? void 0,
8063
+ sessionStrategySummary: this.session.strategySummary,
6676
8064
  policy: this.policy,
6677
8065
  environment: this.environment,
6678
8066
  evidenceStore: this.evidenceStore,
@@ -6689,6 +8077,8 @@ var GovernanceService = class {
6689
8077
  const plans = [];
6690
8078
  let limitsBlock;
6691
8079
  let ruleLimitOk = true;
8080
+ let sessionUnresolvedDeny = false;
8081
+ let dryRunSessionUnresolved = false;
6692
8082
  const reservedThisCall = [];
6693
8083
  const reserve = (key) => {
6694
8084
  const preexisting = this.senderKeys.has(key);
@@ -6703,12 +8093,14 @@ var GovernanceService = class {
6703
8093
  if (pipeline.isDryRun) {
6704
8094
  wire = "dry_run";
6705
8095
  if (decision.action === "rate_limit") {
6706
- const planned = this.planRate(decision, toolName, req.session_id, senderId);
8096
+ const planned = this.planRate(decision, toolName, sessionId, senderId);
6707
8097
  if (planned?.block) limitsBlock = { rate: planned.block };
8098
+ if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
6708
8099
  ruleLimitOk = planned?.allowed ?? true;
6709
8100
  } else if (decision.action === "spend_limit") {
6710
- const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
8101
+ const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
6711
8102
  if (planned?.block) limitsBlock = { spend: planned.block };
8103
+ if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
6712
8104
  ruleLimitOk = planned?.allowed ?? true;
6713
8105
  }
6714
8106
  } else if (decision.action === "deny") {
@@ -6716,21 +8108,31 @@ var GovernanceService = class {
6716
8108
  } else if (decision.action === "require_approval") {
6717
8109
  wire = "require_approval";
6718
8110
  } else if (decision.action === "rate_limit") {
6719
- const planned = this.planRate(decision, toolName, req.session_id, senderId);
6720
- if (planned?.plan && !reserve(planned.plan.key)) {
6721
- 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";
6722
8122
  }
6723
- if (planned?.plan) plans.push(planned.plan);
6724
- limitsBlock = planned?.block ? { rate: planned.block } : void 0;
6725
- wire = planned?.allowed ? "allow" : "rate_limited";
6726
8123
  } else if (decision.action === "spend_limit") {
6727
- const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
6728
- if (planned?.plan && !reserve(planned.plan.key)) {
6729
- 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";
6730
8135
  }
6731
- if (planned?.plan) plans.push(planned.plan);
6732
- limitsBlock = planned?.block ? { spend: planned.block } : void 0;
6733
- wire = planned?.allowed ? "allow" : "spend_limited";
6734
8136
  } else {
6735
8137
  wire = "allow";
6736
8138
  }
@@ -6742,14 +8144,27 @@ var GovernanceService = class {
6742
8144
  let budgetTicketTimeoutMs;
6743
8145
  let budgetTriggeredApproval = false;
6744
8146
  if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
8147
+ const budgetSessionGate = gateSession(sessionId, this.session.onUnresolved);
6745
8148
  const { charges, failures } = this.budgetEngine.resolveCharges({
6746
8149
  toolName,
6747
8150
  toolArguments: req.arguments,
6748
- sessionId: req.session_id,
8151
+ sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
6749
8152
  senderId
6750
8153
  });
6751
- if (charges.length > 0 || failures.length > 0) {
6752
- 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: [] };
6753
8168
  budgetsBlock = [
6754
8169
  ...peek.entries.map((entry2) => budgetWireBlock(entry2)),
6755
8170
  ...failures.map((failure) => budgetFailureBlock(failure))
@@ -6771,19 +8186,16 @@ var GovernanceService = class {
6771
8186
  } else {
6772
8187
  if (breaches.length > 0) budgetDryRunOk = false;
6773
8188
  if (wire !== "dry_run") {
6774
- for (const [index, charge] of charges.entries()) {
6775
- 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)) {
6776
8195
  releaseReservations();
6777
8196
  return { status: 503, body: { error: "limit_capacity_exhausted" } };
6778
8197
  }
6779
- plans.push({
6780
- kind: "budget",
6781
- budget: charge.budget,
6782
- bucketKey: charge.bucketKey,
6783
- amount: charge.amount,
6784
- generation: charge.generation,
6785
- breached: peek.entries[index]?.allowed === false
6786
- });
8198
+ plans.push(plan);
6787
8199
  }
6788
8200
  if (breaches.length > 0) {
6789
8201
  budgetBreachEntries = breaches;
@@ -6832,12 +8244,21 @@ var GovernanceService = class {
6832
8244
  suggestion: budgetDenial.invalid.length > 0 ? "Retry with a non-negative finite amount in the expected field." : "Wait for the window to reset or reduce the amount."
6833
8245
  };
6834
8246
  }
8247
+ if (sessionUnresolvedDeny) {
8248
+ const message = sessionUnresolvedControlMessage(this.session.strategySummary);
8249
+ responseBody["reason"] = message;
8250
+ responseBody["feedback"] = {
8251
+ message,
8252
+ suggestion: "Send a session_id the identity policy accepts, or set session.on_unresolved: anonymous to restore shared pooling."
8253
+ };
8254
+ }
6835
8255
  if (limitsBlock) responseBody["limits"] = limitsBlock;
6836
8256
  if (wire === "dry_run") {
6837
8257
  responseBody["dry_run"] = {
6838
8258
  would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
6839
8259
  evidence_satisfied: !pipeline.evidenceBlocked,
6840
- limits_ok: ruleLimitOk && budgetDryRunOk
8260
+ limits_ok: ruleLimitOk && budgetDryRunOk,
8261
+ ...dryRunSessionUnresolved ? { session_unresolved: true } : {}
6841
8262
  };
6842
8263
  }
6843
8264
  if (pipeline.driftEvent) {
@@ -6848,7 +8269,7 @@ var GovernanceService = class {
6848
8269
  timestampIso,
6849
8270
  origin: req.origin,
6850
8271
  agentId: req.agent_id,
6851
- sessionId: req.session_id,
8272
+ sessionId,
6852
8273
  toolName,
6853
8274
  toolInput: req.arguments ?? {},
6854
8275
  metadata: req.metadata,
@@ -6863,7 +8284,9 @@ var GovernanceService = class {
6863
8284
  // limitsBlock is also the response's `limits`, and the audit writer
6864
8285
  // buffers records by reference until flush — a direct embedder
6865
8286
  // editing the returned body must not be able to rewrite evidence.
6866
- limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
8287
+ limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0,
8288
+ sessionUnresolved: sessionUnresolvedDeny,
8289
+ sessionChain: pipeline.sessionBlocked
6867
8290
  });
6868
8291
  this.tombstones.set(evaluationId, {
6869
8292
  auditRecordId: auditId,
@@ -6896,7 +8319,7 @@ var GovernanceService = class {
6896
8319
  // rewrite it (same guard as the pending entry's evidence below).
6897
8320
  tool_input: structuredClone(req.arguments ?? {}),
6898
8321
  matched_rule: decision.matchedRule,
6899
- session_id: req.session_id,
8322
+ session_id: sessionId,
6900
8323
  origin: req.origin,
6901
8324
  timeout_ms: timeoutMs,
6902
8325
  breached_budgets: budgetBreachContexts
@@ -6914,7 +8337,7 @@ var GovernanceService = class {
6914
8337
  evaluationId,
6915
8338
  origin: req.origin,
6916
8339
  agentId: req.agent_id,
6917
- sessionId: req.session_id,
8340
+ sessionId,
6918
8341
  toolName,
6919
8342
  // Cloned: direct embedders share these references and could otherwise
6920
8343
  // mutate the audit evidence (and desync the byte accounting) after
@@ -7133,7 +8556,9 @@ var GovernanceService = class {
7133
8556
  timestampIso: new Date(this.now()).toISOString(),
7134
8557
  origin: req.origin,
7135
8558
  agentId: req.agent_id,
7136
- 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,
7137
8562
  toolName,
7138
8563
  toolInput: { ...req.package },
7139
8564
  metadata: req.metadata,
@@ -7450,7 +8875,18 @@ var GovernanceService = class {
7450
8875
  if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
7451
8876
  return { allowed: true };
7452
8877
  }
7453
- 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
+ }
7454
8890
  const peek = this.rateLimiter.peek({
7455
8891
  key,
7456
8892
  maxCalls: limits.maxCalls,
@@ -7470,10 +8906,19 @@ var GovernanceService = class {
7470
8906
  planSpend(decision, toolName, sessionId, args, senderId) {
7471
8907
  const maxSpend = decision.matchedRule?.limits?.maxSpend;
7472
8908
  if (!this.spendLimiter || !maxSpend) return { allowed: true };
7473
- const key = spendBucketKey(
7474
- buildLimitKey(maxSpend.key, toolName, sessionId, senderId),
7475
- decision.matchedRule.index
7476
- );
8909
+ let baseKey;
8910
+ if (maxSpend.key === "session") {
8911
+ const gate = gateSession(sessionId, this.session.onUnresolved);
8912
+ if (!gate.ok) {
8913
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
8914
+ return { allowed: false, sessionUnresolved: true };
8915
+ }
8916
+ if (gate.anonymous) warnAnonymousPoolingOnce();
8917
+ baseKey = sessionLimitKey(gate.session);
8918
+ } else {
8919
+ baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
8920
+ }
8921
+ const key = spendBucketKey(baseKey, decision.matchedRule.index);
7477
8922
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
7478
8923
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
7479
8924
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -7505,18 +8950,15 @@ var GovernanceService = class {
7505
8950
  /** Commit every plan of one call at /audit time; returns the chain blocks. */
7506
8951
  commitPlans(entry, actualAmount, auditId, approvalStatus) {
7507
8952
  let chain;
7508
- const budgetPlans = entry.plans.filter((plan) => plan.kind === "budget");
8953
+ const budgetPlans = entry.plans.filter(
8954
+ (plan) => plan.kind === "budget"
8955
+ );
7509
8956
  if (budgetPlans.length > 0 && this.budgetEngine) {
7510
8957
  const kinds = new Map(
7511
8958
  budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
7512
8959
  );
7513
8960
  const snapshots = this.budgetEngine.recordAll(
7514
- budgetPlans.map((plan) => ({
7515
- budget: plan.budget,
7516
- bucketKey: plan.bucketKey,
7517
- amount: actualAmount ?? plan.amount,
7518
- generation: plan.generation
7519
- })),
8961
+ remintDeferredCharges(budgetPlans, actualAmount),
7520
8962
  {
7521
8963
  kind: "spend",
7522
8964
  ...kinds.size > 0 ? { kinds } : {},
@@ -7587,6 +9029,12 @@ var GovernanceService = class {
7587
9029
  if (!this.auditWriter) return id;
7588
9030
  const blockReason = deriveBlockReason(args);
7589
9031
  let evidenceChain = args.limitsChain ?? null;
9032
+ if (args.sessionUnresolved || args.sessionChain) {
9033
+ evidenceChain = {
9034
+ ...evidenceChain ?? {},
9035
+ session: { unresolved: true, tried: this.session.strategySummary }
9036
+ };
9037
+ }
7590
9038
  if (args.sidebandUnreported) {
7591
9039
  evidenceChain = {
7592
9040
  ...evidenceChain ?? {},
@@ -7602,6 +9050,9 @@ var GovernanceService = class {
7602
9050
  const record = {
7603
9051
  timestamp: args.timestampIso,
7604
9052
  session_id: args.sessionId,
9053
+ // Adapter-supplied ids are attributed to the sideband door itself —
9054
+ // the MCP resolver's source vocabulary does not apply here.
9055
+ session_source: args.sessionId != null ? "sideband" : null,
7605
9056
  agent_id: args.agentId,
7606
9057
  environment: this.environment ?? null,
7607
9058
  tool_name: args.toolName,
@@ -7624,7 +9075,9 @@ var GovernanceService = class {
7624
9075
  dry_run: args.dryRun,
7625
9076
  record_kind: args.recordKind,
7626
9077
  origin: args.origin,
7627
- metadata: args.metadata
9078
+ metadata: args.metadata,
9079
+ // The sideband has no MCP wire, so no protocol claim exists.
9080
+ protocol_version: null
7628
9081
  };
7629
9082
  const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
7630
9083
  if (isEnforcement) this.auditWriter.pushImmediate(record, id);
@@ -7643,6 +9096,7 @@ function deriveBlockReason(args) {
7643
9096
  if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
7644
9097
  if (args.dryRun) return null;
7645
9098
  if (args.budgetBreachBlocked) return "budget_exceeded";
9099
+ if (args.sessionUnresolved) return "session_unresolved";
7646
9100
  if (args.approvalStatus === "denied") return "approval_denied";
7647
9101
  if (args.approvalStatus === "timeout") return "approval_timeout";
7648
9102
  if (args.approvalStatus === "cancelled") return "cancelled";
@@ -7681,10 +9135,8 @@ function policyCanRequireApproval(policy) {
7681
9135
  }
7682
9136
  return policy.rules.some((rule) => rule.action === "require_approval");
7683
9137
  }
7684
- function buildLimitKey(keyType, toolName, sessionId, senderId) {
9138
+ function buildLimitKey(keyType, toolName, senderId) {
7685
9139
  switch (keyType) {
7686
- case "session":
7687
- return `session:${sessionId ?? "unknown"}`;
7688
9140
  case "sender_id":
7689
9141
  return `sender:${senderId ?? "unknown"}`;
7690
9142
  case "agent":
@@ -8894,7 +10346,11 @@ var BudgetEngine = class {
8894
10346
  }
8895
10347
  return { charges, failures };
8896
10348
  }
8897
- /** 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
+ */
8898
10354
  peekAll(charges) {
8899
10355
  const entries = charges.map((charge) => this.snapshot(charge));
8900
10356
  return { allowed: entries.every((entry) => entry.allowed), entries };
@@ -9664,7 +11120,11 @@ var CSV_HEADERS = [
9664
11120
  "matched_rule_index",
9665
11121
  "record_kind",
9666
11122
  "origin",
9667
- "metadata"
11123
+ "metadata",
11124
+ // Appended LAST (issues #218, #219): positional consumers of the existing
11125
+ // columns keep working — new columns always go at the end.
11126
+ "session_source",
11127
+ "protocol_version"
9668
11128
  ];
9669
11129
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
9670
11130
  function csvEscape(value) {
@@ -10541,6 +12001,13 @@ upstream:
10541
12001
 
10542
12002
  # environment: production
10543
12003
 
12004
+ # session:
12005
+ # identity: # ordered; first match wins
12006
+ # - source: header
12007
+ # name: x-helio-session-id
12008
+ # - source: legacy_header # verbatim Mcp-Session-Id (deprecation window)
12009
+ # on_unresolved: deny # deny | anonymous
12010
+
10544
12011
  # policies:
10545
12012
  # default: allow
10546
12013
  # dry_run: false
@@ -10600,90 +12067,6 @@ function printConfigErrorDetails(error, prefix = "") {
10600
12067
  console.error(`${prefix} ${detail.path}: ${detail.message}`);
10601
12068
  }
10602
12069
  }
10603
- var ANNOTATION_PRIME_INITIAL_WAIT_MS = 1500;
10604
- var ANNOTATION_PRIME_RETRY_BASE_MS = 1e3;
10605
- var ANNOTATION_PRIME_RETRY_MAX_MS = 3e4;
10606
- var ANNOTATION_PRIME_RETRY_JITTER_MS = 250;
10607
- function computePrimeRetryDelayMs(attempt) {
10608
- const exponent = Math.max(0, attempt - 1);
10609
- const baseDelay = Math.min(
10610
- ANNOTATION_PRIME_RETRY_MAX_MS,
10611
- ANNOTATION_PRIME_RETRY_BASE_MS * 2 ** exponent
10612
- );
10613
- const jitter = Math.floor(Math.random() * ANNOTATION_PRIME_RETRY_JITTER_MS);
10614
- return Math.min(ANNOTATION_PRIME_RETRY_MAX_MS, baseDelay + jitter);
10615
- }
10616
- async function startAnnotationPrimeLoop(governedForwarder) {
10617
- let stopped = false;
10618
- let primed = false;
10619
- let retryAttempt = 0;
10620
- let retryTimer;
10621
- const clearRetryTimer = () => {
10622
- if (!retryTimer) return;
10623
- clearTimeout(retryTimer);
10624
- retryTimer = void 0;
10625
- };
10626
- const stop = () => {
10627
- stopped = true;
10628
- clearRetryTimer();
10629
- };
10630
- const scheduleRetry = () => {
10631
- if (stopped || primed || retryTimer) return;
10632
- retryAttempt += 1;
10633
- const delayMs = computePrimeRetryDelayMs(retryAttempt);
10634
- console.error(
10635
- `[helio] Annotation cache prime retry ${String(retryAttempt)} scheduled in ${String(delayMs)}ms`
10636
- );
10637
- retryTimer = setTimeout(() => {
10638
- retryTimer = void 0;
10639
- void runPrimeAttempt("retry");
10640
- }, delayMs);
10641
- retryTimer.unref();
10642
- };
10643
- const handlePrimeResult = (phase, result) => {
10644
- if (stopped || primed) return;
10645
- if (result.success) {
10646
- primed = true;
10647
- clearRetryTimer();
10648
- const prefix = phase === "initial" ? "[helio] Annotation cache primed" : `[helio] Annotation cache primed after retry ${String(retryAttempt)}`;
10649
- console.error(
10650
- `${prefix}: ${String(result.toolsCached)} tool definitions baselined for drift detection (baselines are per-process; a restart re-baselines \u2014 review tool_drift audit records before restarting)`
10651
- );
10652
- return;
10653
- }
10654
- const reason = result.reason ?? "unknown reason";
10655
- if (phase === "initial") {
10656
- console.error(
10657
- `[helio] Annotation cache priming failed: ${reason} \u2014 undocumented tools will be denied (fail-closed) until priming succeeds`
10658
- );
10659
- } else {
10660
- console.error(
10661
- `[helio] Annotation cache prime retry ${String(retryAttempt)} failed: ${reason} \u2014 still fail-closed`
10662
- );
10663
- }
10664
- scheduleRetry();
10665
- };
10666
- const runPrimeAttempt = async (phase) => {
10667
- const result = await governedForwarder.primeAnnotationCache();
10668
- handlePrimeResult(phase, result);
10669
- };
10670
- const initialAttempt = runPrimeAttempt("initial");
10671
- const initialOutcome = await Promise.race([
10672
- initialAttempt.then(() => "completed"),
10673
- new Promise((resolve2) => {
10674
- setTimeout(() => {
10675
- resolve2("timeout");
10676
- }, ANNOTATION_PRIME_INITIAL_WAIT_MS).unref();
10677
- })
10678
- ]);
10679
- if (initialOutcome === "timeout") {
10680
- console.error(
10681
- `[helio] Annotation cache priming did not complete within ${String(ANNOTATION_PRIME_INITIAL_WAIT_MS)}ms; continuing startup fail-closed and retrying in background`
10682
- );
10683
- scheduleRetry();
10684
- }
10685
- return { stop };
10686
- }
10687
12070
  async function startCommand(configPath, options) {
10688
12071
  let config;
10689
12072
  try {
@@ -10731,6 +12114,8 @@ async function startCommand(configPath, options) {
10731
12114
  block_reason: record.block_reason,
10732
12115
  approval_status: record.approval_status,
10733
12116
  session_id: record.session_id,
12117
+ session_source: record.session_source,
12118
+ protocol_version: record.protocol_version,
10734
12119
  agent_id: record.agent_id,
10735
12120
  environment: record.environment,
10736
12121
  timestamp: record.timestamp,
@@ -10815,6 +12200,7 @@ async function startCommand(configPath, options) {
10815
12200
  }
10816
12201
  });
10817
12202
  budgetEngine.hydrate();
12203
+ const session = compileSessionIdentity(config.session);
10818
12204
  const governedForwarder = new GovernedForwarder(forwarder, policy, {
10819
12205
  environment: config.environment,
10820
12206
  auditWriter,
@@ -10822,13 +12208,17 @@ async function startCommand(configPath, options) {
10822
12208
  approvalRouter,
10823
12209
  rateLimiter,
10824
12210
  spendLimiter,
10825
- budgetEngine
12211
+ budgetEngine,
12212
+ session
10826
12213
  });
10827
- const annotationPrime = await startAnnotationPrimeLoop(governedForwarder);
12214
+ const annotationPrime = await startAnnotationPrimeLoop(governedForwarder, policy.toolRevalidation);
10828
12215
  const hasSlackChannels = [...channels.values()].some((ch) => ch.type === "slack");
10829
12216
  const slackActionApp = hasSlackChannels ? createSlackActionApp({ router: approvalRouter, channels }) : void 0;
10830
12217
  const app = createApp(config, governedForwarder, {
10831
- slackActionApp
12218
+ slackActionApp,
12219
+ onHeaderMismatch: (rejection) => {
12220
+ auditWriter.pushImmediate(buildHeaderMismatchAuditRecord(rejection, config.environment));
12221
+ }
10832
12222
  });
10833
12223
  const handle = startServer(app, config);
10834
12224
  let sidebandHandle;
@@ -10862,6 +12252,7 @@ async function startCommand(configPath, options) {
10862
12252
  rateLimiter,
10863
12253
  spendLimiter,
10864
12254
  budgetEngine,
12255
+ session,
10865
12256
  auditWriter,
10866
12257
  approvalTimeoutMs: parseDuration(config.approval.timeout),
10867
12258
  ttlMs: parseDuration(config.sdk.evaluation_ttl)
@@ -10983,6 +12374,7 @@ async function startCommand(configPath, options) {
10983
12374
  }
10984
12375
  budgetEngine.reconcile(newBudgets);
10985
12376
  governedForwarder.updatePolicy(newPolicy);
12377
+ annotationPrime.reconfigure(newPolicy.toolRevalidation);
10986
12378
  governanceService?.updatePolicy(newPolicy);
10987
12379
  const budgetTotal = newBudgets.length;
10988
12380
  console.error(