@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/index.js CHANGED
@@ -29,10 +29,25 @@ function parseDuration(duration) {
29
29
  }
30
30
  return value * multiplier;
31
31
  }
32
+ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
33
+ "mcp-session-id",
34
+ "mcp-protocol-version",
35
+ "content-type",
36
+ "content-length",
37
+ "host",
38
+ // Modern (2026-07-28) transport headers Helio owns on the wire for every
39
+ // Streamable HTTP POST it sends upstream — relayed client traffic and
40
+ // proxy-initiated requests (era probe, revalidation) alike — see
41
+ // upstream-session-manager.ts and streamable-http-forwarder.ts.
42
+ "mcp-method",
43
+ "mcp-name"
44
+ ]);
32
45
  var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
46
+ var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
33
47
  var upstreamSchema = z.object({
34
48
  url: z.string(),
35
49
  transport: transportSchema.default("streamable-http"),
50
+ protocol_version: protocolVersionSchema.default("auto"),
36
51
  command: z.string().optional(),
37
52
  args: z.array(z.string()).optional(),
38
53
  connect_timeout: durationSchema.default("10s"),
@@ -43,6 +58,13 @@ var upstreamSchema = z.object({
43
58
  message: '"command" is required when transport is "stdio"',
44
59
  path: ["command"]
45
60
  }).superRefine((data, ctx) => {
61
+ if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
62
+ ctx.addIssue({
63
+ code: "custom",
64
+ path: ["protocol_version"],
65
+ 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.'
66
+ });
67
+ }
46
68
  for (const [index, header] of data.forward_headers.entries()) {
47
69
  if (!header.toLowerCase().startsWith("x-")) {
48
70
  ctx.addIssue({
@@ -52,15 +74,8 @@ var upstreamSchema = z.object({
52
74
  });
53
75
  }
54
76
  }
55
- const reserved = /* @__PURE__ */ new Set([
56
- "mcp-session-id",
57
- "mcp-protocol-version",
58
- "content-type",
59
- "content-length",
60
- "host"
61
- ]);
62
77
  for (const name of Object.keys(data.headers)) {
63
- if (reserved.has(name.toLowerCase())) {
78
+ if (RESERVED_TRANSPORT_HEADERS.has(name.toLowerCase())) {
64
79
  ctx.addIssue({
65
80
  code: "custom",
66
81
  path: ["headers", name],
@@ -71,8 +86,63 @@ var upstreamSchema = z.object({
71
86
  });
72
87
  var listenSchema = z.object({
73
88
  port: z.number().int().min(1).max(65535).default(3e3),
74
- host: z.string().default("127.0.0.1")
75
- }).strict();
89
+ host: z.string().default("127.0.0.1"),
90
+ /**
91
+ * Origin allowlist for the MCP transports (issue #213). Requests to /mcp
92
+ * or /sse carrying an Origin header not in this list are refused with 403.
93
+ * Empty (the default) means every Origin is refused — MCP clients are
94
+ * non-browser processes and never send one. This is NOT CORS support: the
95
+ * proxy emits no CORS response headers, so a browser still cannot read
96
+ * responses. The list exists for deployments where a fronting proxy or
97
+ * embedding host injects an Origin the operator needs to name.
98
+ */
99
+ allowed_origins: z.array(z.string().min(1)).default([])
100
+ }).strict().superRefine((data, ctx) => {
101
+ for (const [index, entry] of data.allowed_origins.entries()) {
102
+ if (entry === "*") {
103
+ ctx.addIssue({
104
+ code: "custom",
105
+ path: ["allowed_origins", index],
106
+ message: "listen.allowed_origins does not support wildcards \u2014 list each origin exactly."
107
+ });
108
+ continue;
109
+ }
110
+ if (entry === "null") {
111
+ ctx.addIssue({
112
+ code: "custom",
113
+ path: ["allowed_origins", index],
114
+ 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.'
115
+ });
116
+ continue;
117
+ }
118
+ let parsed;
119
+ try {
120
+ parsed = new URL(entry);
121
+ } catch {
122
+ ctx.addIssue({
123
+ code: "custom",
124
+ path: ["allowed_origins", index],
125
+ message: `"${entry}" is not a serialized origin. Use scheme://host[:port], e.g. "http://localhost:5173".`
126
+ });
127
+ continue;
128
+ }
129
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
130
+ ctx.addIssue({
131
+ code: "custom",
132
+ path: ["allowed_origins", index],
133
+ message: `"${entry}" is not an http(s) origin. Allowlist entries must be serialized http(s) origins, e.g. "http://localhost:5173".`
134
+ });
135
+ continue;
136
+ }
137
+ if (parsed.origin !== entry) {
138
+ ctx.addIssue({
139
+ code: "custom",
140
+ path: ["allowed_origins", index],
141
+ message: `"${entry}" is not in serialized origin form and would never match a browser-sent Origin \u2014 did you mean "${parsed.origin}"?`
142
+ });
143
+ }
144
+ }
145
+ });
76
146
  function isLoopbackHost(host) {
77
147
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
78
148
  }
@@ -87,6 +157,56 @@ var dashboardSchema = z.object({
87
157
  allow_open_mode: z.boolean().default(false),
88
158
  sse_heartbeat_interval: durationSchema.default("30s")
89
159
  }).strict();
160
+ var sessionHeaderSourceSchema = z.object({
161
+ source: z.literal("header"),
162
+ /** Lowercased on parse — HTTP header names are case-insensitive. */
163
+ name: z.string().min(1).default("x-helio-session-id").transform((name) => name.toLowerCase())
164
+ }).strict();
165
+ var sessionMetaSourceSchema = z.object({
166
+ source: z.literal("meta")
167
+ }).strict();
168
+ var sessionLegacyHeaderSourceSchema = z.object({
169
+ source: z.literal("legacy_header")
170
+ }).strict();
171
+ var sessionIdentitySourceSchema = z.discriminatedUnion("source", [
172
+ sessionHeaderSourceSchema,
173
+ sessionMetaSourceSchema,
174
+ sessionLegacyHeaderSourceSchema
175
+ ]);
176
+ var sessionSchema = z.object({
177
+ /** Ordered identity sources; the first source that yields a value wins. */
178
+ identity: z.array(sessionIdentitySourceSchema).min(1, {
179
+ 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."
180
+ }).default([{ source: "header", name: "x-helio-session-id" }, { source: "legacy_header" }]),
181
+ on_unresolved: z.enum(["deny", "anonymous"]).default("deny")
182
+ }).strict().superRefine((session, ctx) => {
183
+ const seen = /* @__PURE__ */ new Set();
184
+ for (const [index, entry] of session.identity.entries()) {
185
+ if (entry.source !== "header") continue;
186
+ if (!entry.name.startsWith("x-")) {
187
+ ctx.addIssue({
188
+ code: "custom",
189
+ path: ["identity", index, "name"],
190
+ message: 'Session identity header names must start with "x-" (for example "x-helio-session-id")'
191
+ });
192
+ }
193
+ if (RESERVED_TRANSPORT_HEADERS.has(entry.name)) {
194
+ ctx.addIssue({
195
+ code: "custom",
196
+ path: ["identity", index, "name"],
197
+ 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.`
198
+ });
199
+ }
200
+ if (seen.has(entry.name)) {
201
+ ctx.addIssue({
202
+ code: "custom",
203
+ path: ["identity", index, "name"],
204
+ message: `Duplicate session identity header "${entry.name}" \u2014 the first entry always wins, so the duplicate is dead config. Remove it.`
205
+ });
206
+ }
207
+ seen.add(entry.name);
208
+ }
209
+ });
90
210
  var inputConditionSchema = z.object({
91
211
  eq: z.unknown().optional(),
92
212
  neq: z.unknown().optional(),
@@ -185,6 +305,21 @@ var installSchema = z.object({
185
305
  default: z.enum(["allow", "deny"]).default("allow"),
186
306
  rules: z.array(installRuleSchema).default([])
187
307
  }).strict();
308
+ var toolRevalidationSchema = z.object({
309
+ enabled: z.boolean().default(true),
310
+ interval: durationSchema.default("5m"),
311
+ // Default: `interval`, applied at compile time (undefined here means
312
+ // "same as interval" — see PoliciesConfig compilation).
313
+ max_advertised_ttl: durationSchema.optional()
314
+ }).strict().superRefine((data, ctx) => {
315
+ if (parseDuration(data.interval) < 1e4) {
316
+ ctx.addIssue({
317
+ code: "custom",
318
+ path: ["interval"],
319
+ message: "tool_revalidation.interval must be at least 10s"
320
+ });
321
+ }
322
+ });
188
323
  var policiesSchema = z.object({
189
324
  default: z.enum(["allow", "deny"]).default("allow"),
190
325
  flag_destructive: z.enum(["log", "require_approval"]).optional(),
@@ -205,6 +340,13 @@ var policiesSchema = z.object({
205
340
  * don't need the field; undefined is treated as "block".
206
341
  */
207
342
  on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
343
+ /**
344
+ * Proxy-scheduled `tools/list` revalidation and `ttlMs` clamping (issue
345
+ * #221). Optional; absent ⇒ compiled defaults (enabled: true, interval:
346
+ * "5m") in `CompiledPolicy`, except in literal `CompiledPolicy` fixtures,
347
+ * which treat an absent field as disabled — see `compilePolicies`.
348
+ */
349
+ tool_revalidation: toolRevalidationSchema.optional(),
208
350
  /**
209
351
  * Whether `helio start` should watch the config file for changes and
210
352
  * reconcile policy state on every save. Defaults to `true` when omitted.
@@ -339,6 +481,10 @@ var helioConfigBaseSchema = z.object({
339
481
  upstream: upstreamSchema,
340
482
  listen: listenSchema.prefault({}),
341
483
  environment: z.string().optional(),
484
+ // Session precedes policies deliberately: upstream/listen/environment say
485
+ // where and as-what Helio runs, session says who is calling, and
486
+ // policies/budgets then govern those calls (issue #218).
487
+ session: sessionSchema.prefault({}),
342
488
  policies: policiesSchema.prefault({}),
343
489
  // Budgets sit beside policies deliberately: they are the second half of the
344
490
  // governance declaration (policy decision → budget gate), not plumbing.
@@ -681,11 +827,18 @@ var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
681
827
  function compilePolicies(config) {
682
828
  const warnings = [];
683
829
  const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
830
+ const rv = config.tool_revalidation;
831
+ const toolRevalidation = {
832
+ enabled: rv?.enabled ?? true,
833
+ intervalMs: parseDuration(rv?.interval ?? "5m"),
834
+ maxAdvertisedTtlMs: parseDuration(rv?.max_advertised_ttl ?? rv?.interval ?? "5m")
835
+ };
684
836
  const policy = {
685
837
  defaultAction: config.default,
686
838
  flagDestructive: config.flag_destructive,
687
839
  ...config.dry_run && { dryRun: true },
688
840
  ...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
841
+ toolRevalidation,
689
842
  rules,
690
843
  ...config.install && { install: compileInstallPolicy(config.install) }
691
844
  };
@@ -970,10 +1123,17 @@ var PARSE_ERROR = -32700;
970
1123
  var INVALID_REQUEST = -32600;
971
1124
  var INVALID_PARAMS = -32602;
972
1125
  var INTERNAL_ERROR = -32603;
1126
+ var HEADER_MISMATCH = -32020;
973
1127
  function makeJsonRpcError(id, code, message) {
974
1128
  return {
975
1129
  jsonrpc: "2.0",
976
- id: id ?? null,
1130
+ id,
1131
+ error: { code, message }
1132
+ };
1133
+ }
1134
+ function makeJsonRpcErrorWithoutId(code, message) {
1135
+ return {
1136
+ jsonrpc: "2.0",
977
1137
  error: { code, message }
978
1138
  };
979
1139
  }
@@ -1042,6 +1202,79 @@ function parseJsonRpcRequest(body) {
1042
1202
  };
1043
1203
  }
1044
1204
 
1205
+ // src/mcp/session-resolver.ts
1206
+ var MAX_SESSION_ID_LENGTH = 256;
1207
+ var CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo";
1208
+ function compileSessionIdentity(config) {
1209
+ const strategySummary = config.identity.map((entry) => entry.source === "header" ? `header "${entry.name}"` : entry.source).join(", ");
1210
+ return {
1211
+ sources: config.identity,
1212
+ onUnresolved: config.on_unresolved,
1213
+ strategySummary
1214
+ };
1215
+ }
1216
+ var DEFAULT_SESSION_IDENTITY = compileSessionIdentity({
1217
+ identity: [{ source: "header", name: "x-helio-session-id" }, { source: "legacy_header" }],
1218
+ on_unresolved: "deny"
1219
+ });
1220
+ function paramsMeta(params) {
1221
+ if (params === null || typeof params !== "object" || Array.isArray(params)) return void 0;
1222
+ return params["_meta"];
1223
+ }
1224
+ var malformedValueWarned = false;
1225
+ function sanitizeCandidate(value, origin) {
1226
+ if (value === void 0) return void 0;
1227
+ if (value.trim() === "" || value.length > MAX_SESSION_ID_LENGTH) {
1228
+ if (!malformedValueWarned) {
1229
+ malformedValueWarned = true;
1230
+ console.error(
1231
+ `[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.`
1232
+ );
1233
+ }
1234
+ return void 0;
1235
+ }
1236
+ return value;
1237
+ }
1238
+ function clientInfoId(meta) {
1239
+ if (meta === null || typeof meta !== "object") return void 0;
1240
+ const clientInfo = meta[CLIENT_INFO_META_KEY];
1241
+ if (clientInfo === null || typeof clientInfo !== "object") return void 0;
1242
+ const { name, version } = clientInfo;
1243
+ if (typeof name !== "string" || name.trim() === "") return void 0;
1244
+ return `clientinfo:${name}@${typeof version === "string" ? version : "unknown"}`;
1245
+ }
1246
+ function resolveSession(input, identity) {
1247
+ for (const strategy of identity.sources) {
1248
+ switch (strategy.source) {
1249
+ case "header": {
1250
+ const value = sanitizeCandidate(input.headers[strategy.name], `header "${strategy.name}"`);
1251
+ if (value !== void 0) return { id: value, source: "header" };
1252
+ break;
1253
+ }
1254
+ case "meta": {
1255
+ const id = sanitizeCandidate(clientInfoId(input.meta), "meta clientInfo");
1256
+ if (id !== void 0) return { id, source: "meta" };
1257
+ break;
1258
+ }
1259
+ case "legacy_header": {
1260
+ const value = sanitizeCandidate(input.transportSessionId, "legacy_header");
1261
+ if (value !== void 0) return { id: value, source: "legacy_header" };
1262
+ break;
1263
+ }
1264
+ }
1265
+ }
1266
+ if (input.transportMintedId !== void 0) {
1267
+ return { id: input.transportMintedId, source: "transport" };
1268
+ }
1269
+ return void 0;
1270
+ }
1271
+
1272
+ // src/transport/content-type.ts
1273
+ function isJsonContentType(header) {
1274
+ const [essence = ""] = (header ?? "").split(";");
1275
+ return essence.trim().toLowerCase() === "application/json";
1276
+ }
1277
+
1045
1278
  // src/transport/forward-headers.ts
1046
1279
  function buildForwardHeaders(requestHeaders, allowlist) {
1047
1280
  const forwardHeaders = {};
@@ -1059,806 +1292,1419 @@ function buildForwardHeaders(requestHeaders, allowlist) {
1059
1292
  return Object.keys(forwardHeaders).length > 0 ? forwardHeaders : void 0;
1060
1293
  }
1061
1294
 
1062
- // src/transport/response-normalizer.ts
1063
- function isObject(value) {
1064
- return value !== null && typeof value === "object";
1295
+ // src/mcp/protocol-version.ts
1296
+ var HELIO_MCP_LEGACY_PROTOCOL_VERSION = "2025-06-18";
1297
+ var HELIO_MCP_MODERN_PROTOCOL_VERSION = "2026-07-28";
1298
+ function isModernProtocolClaim(rawValue) {
1299
+ if (rawValue === void 0) return false;
1300
+ const tokens = rawValue.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
1301
+ return tokens.length > 0 && tokens.every((token) => token === HELIO_MCP_MODERN_PROTOCOL_VERSION);
1065
1302
  }
1066
- function isValidJsonRpcId(value) {
1067
- return value === null || typeof value === "string" || typeof value === "number";
1303
+
1304
+ // src/upstream/standard-headers.ts
1305
+ var SENTINEL_PREFIX = "=?base64?";
1306
+ var SENTINEL_SUFFIX = "?=";
1307
+ var MCP_NAME_MAX_BYTES = 8192;
1308
+ var NAME_SOURCE_FIELD = /* @__PURE__ */ new Map([
1309
+ ["tools/call", "name"],
1310
+ ["prompts/get", "name"],
1311
+ ["resources/read", "uri"]
1312
+ ]);
1313
+ function needsSentinelEncoding(value) {
1314
+ const hasUnsafeChar = /[^\t\x20-\x7E]/.test(value);
1315
+ const hasEdgeWhitespace = /^[ \t]/.test(value) || /[ \t]$/.test(value);
1316
+ const looksLikeSentinel = value.startsWith(SENTINEL_PREFIX) && value.endsWith(SENTINEL_SUFFIX);
1317
+ return hasUnsafeChar || hasEdgeWhitespace || looksLikeSentinel;
1318
+ }
1319
+ function encodeSentinelValue(value) {
1320
+ return `${SENTINEL_PREFIX}${Buffer.from(value, "utf8").toString("base64")}${SENTINEL_SUFFIX}`;
1321
+ }
1322
+ var SENTINEL_DECODE_PATTERN = /^=\?base64\?([A-Za-z0-9+/]*={0,2})\?=$/;
1323
+ function decodeSentinelValue(value) {
1324
+ const match = SENTINEL_DECODE_PATTERN.exec(value);
1325
+ return match ? Buffer.from(match[1] ?? "", "base64").toString("utf8") : value;
1326
+ }
1327
+ function nameBearingField(method) {
1328
+ return NAME_SOURCE_FIELD.get(method);
1329
+ }
1330
+ function extractName(method, params) {
1331
+ const field = NAME_SOURCE_FIELD.get(method);
1332
+ if (!field || typeof params !== "object" || params === null || Array.isArray(params)) {
1333
+ return void 0;
1334
+ }
1335
+ let source = params;
1336
+ const maybeToJSON = params.toJSON;
1337
+ if (typeof maybeToJSON === "function") {
1338
+ try {
1339
+ source = maybeToJSON.call(params, "params");
1340
+ } catch {
1341
+ return void 0;
1342
+ }
1343
+ }
1344
+ if (typeof source !== "object" || source === null || Array.isArray(source)) {
1345
+ return void 0;
1346
+ }
1347
+ if (!Object.prototype.propertyIsEnumerable.call(source, field)) {
1348
+ return void 0;
1349
+ }
1350
+ const raw = source[field];
1351
+ return typeof raw === "string" ? raw : void 0;
1068
1352
  }
1069
- function getJsonRpcId(value) {
1070
- if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
1071
- const id = value["id"];
1072
- return isValidJsonRpcId(id) ? id : void 0;
1353
+ function isHeaderSafeMethod(method) {
1354
+ return /^[\x21-\x7E]+$/.test(method);
1073
1355
  }
1074
- function isValidJsonRpcError(value) {
1075
- if (!isObject(value)) return false;
1076
- return typeof value["code"] === "number" && typeof value["message"] === "string";
1356
+ function encodedNameValue(method, params) {
1357
+ const name = extractName(method, params);
1358
+ if (name === void 0) return void 0;
1359
+ return needsSentinelEncoding(name) ? encodeSentinelValue(name) : name;
1077
1360
  }
1078
- function isValidJsonRpcResponse(value) {
1079
- if (!isObject(value)) return false;
1080
- if (value["jsonrpc"] !== "2.0") return false;
1081
- if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
1082
- return false;
1361
+ function buildStandardRequestHeaders(method, params) {
1362
+ if (!isHeaderSafeMethod(method)) {
1363
+ return {};
1083
1364
  }
1084
- const hasResult = Object.prototype.hasOwnProperty.call(value, "result");
1085
- const hasError = Object.prototype.hasOwnProperty.call(value, "error");
1086
- if (hasResult && hasError || !hasResult && !hasError) return false;
1087
- if (hasError && !isValidJsonRpcError(value["error"])) return false;
1088
- return true;
1365
+ const headers = { "mcp-method": method };
1366
+ const value = encodedNameValue(method, params);
1367
+ if (value !== void 0 && Buffer.byteLength(value) <= MCP_NAME_MAX_BYTES) {
1368
+ headers["mcp-name"] = value;
1369
+ }
1370
+ return headers;
1089
1371
  }
1090
- function makeWrappedError(requestId, message, data) {
1091
- return {
1092
- jsonrpc: "2.0",
1093
- id: requestId ?? null,
1094
- error: {
1095
- code: INTERNAL_ERROR,
1096
- message,
1097
- data
1372
+
1373
+ // src/upstream/merge-headers.ts
1374
+ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1375
+ const out = {};
1376
+ const apply = (headers) => {
1377
+ for (const [name, value] of Object.entries(headers)) {
1378
+ out[name.toLowerCase()] = value;
1098
1379
  }
1099
1380
  };
1381
+ apply(base);
1382
+ apply(forwarded);
1383
+ apply(staticHeaders);
1384
+ return out;
1100
1385
  }
1101
- function normalizeUpstreamOutcome(args) {
1102
- if (args.forwardingError) {
1103
- return {
1104
- httpStatus: 200,
1105
- wrapped: true,
1106
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1107
- failure_class: "upstream_forward_error",
1108
- failure_reason: args.forwardingError.message
1109
- })
1110
- };
1111
- }
1112
- if (!args.upstreamResponse) {
1113
- return {
1114
- httpStatus: 200,
1115
- wrapped: true,
1116
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1117
- failure_class: "upstream_forward_error",
1118
- failure_reason: "missing upstream response"
1119
- })
1120
- };
1121
- }
1122
- const upstream = args.upstreamResponse;
1123
- const upstreamContentType = upstream.headers["content-type"] ?? null;
1124
- if (!isValidJsonRpcResponse(upstream.body)) {
1125
- return {
1126
- httpStatus: 200,
1127
- wrapped: true,
1128
- body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
1129
- failure_class: "upstream_invalid_jsonrpc",
1130
- upstream_http_status: upstream.status,
1131
- upstream_content_type: upstreamContentType,
1132
- upstream_body_type: typeof upstream.body
1133
- })
1134
- };
1135
- }
1136
- if (args.requestId !== void 0) {
1137
- const upstreamId = getJsonRpcId(upstream.body);
1138
- if (upstreamId === void 0) {
1139
- return {
1140
- httpStatus: 200,
1141
- wrapped: true,
1142
- body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
1143
- failure_class: "upstream_invalid_jsonrpc",
1144
- upstream_http_status: upstream.status,
1145
- upstream_content_type: upstreamContentType,
1146
- upstream_body_type: typeof upstream.body,
1147
- invalid_reason: "missing_response_id"
1148
- })
1149
- };
1150
- }
1151
- const expectedId = args.requestId ?? null;
1152
- if (upstreamId !== expectedId) {
1153
- return {
1154
- httpStatus: 200,
1155
- wrapped: true,
1156
- body: makeWrappedError(args.requestId, "upstream response id mismatch", {
1157
- failure_class: "upstream_id_mismatch",
1158
- expected_request_id: expectedId,
1159
- upstream_response_id: upstreamId
1160
- })
1161
- };
1386
+
1387
+ // src/upstream/connection-error.ts
1388
+ var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1389
+ var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
1390
+ "ECONNREFUSED",
1391
+ "ENOTFOUND",
1392
+ "EAI_AGAIN",
1393
+ "ECONNRESET",
1394
+ "EHOSTUNREACH",
1395
+ "ENETUNREACH",
1396
+ "ETIMEDOUT",
1397
+ "EPIPE",
1398
+ "UND_ERR_CONNECT_TIMEOUT",
1399
+ "UND_ERR_SOCKET"
1400
+ ]);
1401
+ function extractErrorCode(error) {
1402
+ let current = error;
1403
+ for (let depth = 0; depth < 5 && current != null; depth += 1) {
1404
+ if (typeof current === "object" && "code" in current) {
1405
+ const code = current.code;
1406
+ if (typeof code === "string") return code;
1162
1407
  }
1408
+ current = current.cause;
1163
1409
  }
1164
- return {
1165
- httpStatus: 200,
1166
- wrapped: false,
1167
- body: upstream.body
1168
- };
1410
+ return void 0;
1411
+ }
1412
+ function describeUnreachableUpstream(error, url) {
1413
+ const code = extractErrorCode(error);
1414
+ const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
1415
+ if (code !== void 0) {
1416
+ if (!UNREACHABLE_CODES.has(code)) return null;
1417
+ } else if (!isGenericFetchFailure) {
1418
+ return null;
1419
+ }
1420
+ const codeSuffix = code ? ` (${code})` : "";
1421
+ return new Error(
1422
+ `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}`
1423
+ );
1169
1424
  }
1170
1425
 
1171
- // src/transport/streamable-http.ts
1172
- var MCP_SESSION_HEADER = "mcp-session-id";
1173
- var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
1174
- function createStreamableHttpRoute(forwarder, options = {}) {
1175
- const app = new Hono();
1176
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1177
- app.post("/", async (c) => {
1178
- const contentType = c.req.header("content-type") ?? "";
1179
- if (!contentType.includes("application/json")) {
1180
- return c.json(
1181
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1182
- 415
1183
- );
1426
+ // src/upstream/sse-parse.ts
1427
+ function parseSseChunk(chunk, state, onEvent) {
1428
+ let { event, data, remainder } = state;
1429
+ const text = remainder + chunk;
1430
+ const lines = text.split("\n");
1431
+ remainder = lines.pop() ?? "";
1432
+ for (const rawLine of lines) {
1433
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1434
+ if (line === "") {
1435
+ if (event || data) {
1436
+ onEvent(event, data);
1437
+ event = "";
1438
+ data = "";
1439
+ }
1440
+ } else if (line.startsWith("event:")) {
1441
+ const value = line.slice(6).replace(/^ /, "");
1442
+ event = value;
1443
+ } else if (line.startsWith("data:")) {
1444
+ const value = line.slice(5).replace(/^ /, "");
1445
+ data = data ? data + "\n" + value : value;
1184
1446
  }
1185
- let body;
1447
+ }
1448
+ return { event, data, remainder };
1449
+ }
1450
+ async function readSseJsonRpcResponse(res, requestId) {
1451
+ if (!res.body) {
1452
+ throw new Error("upstream SSE response had no body");
1453
+ }
1454
+ const reader = res.body.getReader();
1455
+ const decoder = new TextDecoder();
1456
+ let state = { event: "", data: "", remainder: "" };
1457
+ let found;
1458
+ const onEvent = (event, data) => {
1459
+ if (event && event !== "message") return;
1460
+ let parsed;
1186
1461
  try {
1187
- body = await c.req.json();
1462
+ parsed = JSON.parse(data);
1188
1463
  } catch {
1189
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
1190
- }
1191
- const parsedRequest = parseJsonRpcRequest(body);
1192
- if (!parsedRequest.success) {
1193
- return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
1464
+ return;
1194
1465
  }
1195
- const id = parsedRequest.request.id;
1196
- const method = parsedRequest.request.method;
1197
- const params = parsedRequest.request.params;
1198
- const sessionId = c.req.header(MCP_SESSION_HEADER);
1199
- const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
1200
- const mcpRequest = {
1201
- jsonrpc: "2.0",
1202
- id,
1203
- method,
1204
- params,
1205
- sessionId,
1206
- headers: forwardHeaders,
1207
- signal: c.req.raw.signal
1208
- };
1209
- if (id === void 0) {
1210
- const notificationRequest = { ...mcpRequest, signal: void 0 };
1211
- void forwarder.forward(notificationRequest).catch((err) => {
1212
- const message = err instanceof Error ? err.message : String(err);
1213
- console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
1214
- });
1215
- return c.body(null, 202);
1466
+ if (parsed === null || typeof parsed !== "object") return;
1467
+ const id = parsed["id"];
1468
+ if (id === requestId) {
1469
+ found = parsed;
1216
1470
  }
1217
- let result;
1218
- try {
1219
- result = await forwarder.forward(mcpRequest);
1220
- } catch (err) {
1221
- const forwardingError = err instanceof Error ? err : new Error(String(err));
1222
- console.error("[helio] Upstream forwarding failed:", forwardingError.message);
1223
- const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
1224
- return c.json(normalized2.body, normalized2.httpStatus);
1471
+ };
1472
+ const processChunk = (chunk) => {
1473
+ state = parseSseChunk(chunk, state, onEvent);
1474
+ };
1475
+ for (; ; ) {
1476
+ const result = await reader.read();
1477
+ if (result.value !== void 0) {
1478
+ const chunk = result.value;
1479
+ processChunk(decoder.decode(chunk, { stream: true }));
1480
+ if (found) {
1481
+ await reader.cancel().catch(() => void 0);
1482
+ return found;
1483
+ }
1225
1484
  }
1226
- const { response } = result;
1227
- for (const [key, value] of Object.entries(response.headers)) {
1228
- if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
1229
- c.header(key, value);
1485
+ if (result.done) {
1486
+ const tail = decoder.decode();
1487
+ if (tail) {
1488
+ processChunk(tail);
1489
+ if (found) return found;
1230
1490
  }
1491
+ break;
1231
1492
  }
1232
- const normalized = normalizeUpstreamOutcome({ requestId: id, upstreamResponse: response });
1233
- return c.json(normalized.body, normalized.httpStatus);
1234
- });
1235
- return app;
1493
+ }
1494
+ throw new Error(
1495
+ `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1496
+ );
1236
1497
  }
1237
1498
 
1238
- // src/transport/sse.ts
1239
- import { randomUUID } from "crypto";
1240
- import { Hono as Hono2 } from "hono";
1241
- import { z as z3 } from "zod";
1242
- var encoder = new TextEncoder();
1243
- var STALE_THRESHOLD_MS = 9e4;
1244
- var SWEEP_INTERVAL_MS = 6e4;
1245
- var ssePostQuerySchema = z3.object({
1246
- sessionId: z3.string().min(1)
1247
- });
1248
- function sseEvent(event, data) {
1249
- return `event: ${event}
1250
- data: ${data}
1251
-
1252
- `;
1499
+ // src/upstream/upstream-session-manager.ts
1500
+ var ERA_PROBE_BACKOFF_MS = 3e4;
1501
+ var MAX_SSE_SCAN_BYTES = 256 * 1024;
1502
+ var ERA_PROBE_REQUEST_ID = "helio-era-probe";
1503
+ var MCP_MISSING_CLIENT_CAPABILITY_CODE = -32021;
1504
+ var MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022;
1505
+ var MCP_MODERN_ONLY_ERROR_CODES = /* @__PURE__ */ new Set([
1506
+ HEADER_MISMATCH,
1507
+ MCP_MISSING_CLIENT_CAPABILITY_CODE,
1508
+ MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE
1509
+ ]);
1510
+ var MCP_META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
1511
+ function buildInternalMeta() {
1512
+ return {
1513
+ [MCP_META_PROTOCOL_VERSION_KEY]: HELIO_MCP_MODERN_PROTOCOL_VERSION,
1514
+ "io.modelcontextprotocol/clientCapabilities": {},
1515
+ "io.modelcontextprotocol/clientInfo": { name: "helio-proxy", version: "0" }
1516
+ };
1253
1517
  }
1254
- function createSseRoute(forwarder, options = {}) {
1255
- const sessions = /* @__PURE__ */ new Map();
1256
- const app = new Hono2();
1257
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1258
- const writeSessionEvent = (sessionId, eventPayload) => {
1259
- const session = sessions.get(sessionId);
1260
- if (!session) return;
1261
- session.lastActivity = Date.now();
1262
- void session.writer.write(encoder.encode(eventPayload)).catch(() => {
1263
- sessions.delete(sessionId);
1264
- void session.writer.close().catch(() => {
1265
- });
1518
+ var UpstreamSessionManager = class {
1519
+ url;
1520
+ staticHeaders;
1521
+ requestTimeoutMs;
1522
+ pin;
1523
+ internal;
1524
+ era;
1525
+ capture;
1526
+ inflight;
1527
+ inflightProbe;
1528
+ probeBackoffUntil = 0;
1529
+ constructor(options) {
1530
+ this.url = options.url;
1531
+ this.staticHeaders = options.staticHeaders;
1532
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1533
+ this.pin = options.protocolVersion ?? "auto";
1534
+ }
1535
+ /** Return the internal session, establishing it once if needed. */
1536
+ ensureInternalSession() {
1537
+ if (this.internal) return Promise.resolve(this.internal);
1538
+ this.inflight ??= this.establish().then((session) => {
1539
+ this.internal = session;
1540
+ return session;
1541
+ }).finally(() => {
1542
+ this.inflight = void 0;
1266
1543
  });
1267
- };
1268
- const sweepInterval = setInterval(() => {
1269
- const now = Date.now();
1270
- for (const [id, session] of sessions) {
1271
- if (now - session.lastActivity > STALE_THRESHOLD_MS) {
1272
- sessions.delete(id);
1273
- void session.writer.close().catch(() => {
1274
- });
1544
+ return this.inflight;
1545
+ }
1546
+ /**
1547
+ * Drop the cached internal session and era so the next call re-probes.
1548
+ * Does not cancel any in-flight establishment. Every era wipe drops the
1549
+ * probe-time DiscoverResult capture with it; invalidation is session
1550
+ * lifecycle, not falsification, so it must NOT arm the probe backoff.
1551
+ */
1552
+ invalidateInternalSession() {
1553
+ this.internal = void 0;
1554
+ this.era = void 0;
1555
+ this.capture = void 0;
1556
+ }
1557
+ /**
1558
+ * Resolve the era a relayed request should be sent under. Evaluated in a
1559
+ * strict total order: pin, cached era, join an in-flight probe, backoff
1560
+ * presumption, start a probe. A probe failure never fails the relay — the
1561
+ * request proceeds under a per-request legacy presumption, preserving
1562
+ * today's behavior for deployments the probe cannot classify (for example
1563
+ * per-client Authorization pass-through, where the probe is refused
1564
+ * forever while relays carry the client's own credentials and succeed).
1565
+ */
1566
+ async resolveRelayEra() {
1567
+ const pinned = this.pinnedEra();
1568
+ if (pinned) return pinned;
1569
+ if (this.era) return this.era;
1570
+ const joined = this.inflightProbe;
1571
+ if (joined) {
1572
+ try {
1573
+ const outcome = await joined;
1574
+ this.cacheRelayClassification(outcome);
1575
+ return outcome.era;
1576
+ } catch {
1577
+ return "legacy";
1275
1578
  }
1276
1579
  }
1277
- }, SWEEP_INTERVAL_MS);
1278
- sweepInterval.unref();
1279
- app.get("/", (c) => {
1280
- const sessionId = randomUUID();
1281
- const { readable, writable } = new TransformStream();
1282
- const writer = writable.getWriter();
1283
- sessions.set(sessionId, { writer, lastActivity: Date.now() });
1284
- const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
1285
- writeSessionEvent(sessionId, endpointData);
1286
- c.req.raw.signal.addEventListener("abort", () => {
1287
- sessions.delete(sessionId);
1288
- void writer.close().catch(() => {
1289
- });
1290
- });
1291
- return new Response(readable, {
1292
- headers: {
1293
- "content-type": "text/event-stream",
1294
- "cache-control": "no-cache",
1295
- connection: "keep-alive"
1296
- }
1297
- });
1298
- });
1299
- app.post("/", async (c) => {
1300
- const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
1301
- if (!parsedQuery.success) {
1302
- return c.json(
1303
- makeJsonRpcError(null, INVALID_REQUEST, "missing sessionId query parameter"),
1304
- 400
1305
- );
1580
+ if (Date.now() < this.probeBackoffUntil) return "legacy";
1581
+ try {
1582
+ const outcome = await this.sharedProbe();
1583
+ this.cacheRelayClassification(outcome);
1584
+ return outcome.era;
1585
+ } catch {
1586
+ return "legacy";
1306
1587
  }
1307
- const sessionId = parsedQuery.data.sessionId;
1308
- const session = sessions.get(sessionId);
1309
- if (!session) {
1310
- return c.json(makeJsonRpcError(null, INVALID_REQUEST, "unknown session"), 404);
1588
+ }
1589
+ /**
1590
+ * The falsification door, shared by both sides: a signal just
1591
+ * contradicted a cached LEGACY era — a relayed response only a modern
1592
+ * server gives (a modern-only JSON-RPC code on any method, a 404/-32601
1593
+ * answer to a relayed initialize), or the internal initialize failing
1594
+ * against the classification that promised it would work. No-ops unless
1595
+ * the cached era is 'legacy': a cached modern era is never cleared
1596
+ * automatically — no reliable legacy-rejection signal exists, recovery
1597
+ * from an upstream downgrade is pin-or-restart, and during the
1598
+ * probe-to-initialize window a fresher relay probe may already have
1599
+ * re-classified the upstream as modern, in which case an initialize
1600
+ * failure is evidence against the STALE legacy classification, not
1601
+ * against that newer conclusion.
1602
+ */
1603
+ clearFalsifiedLegacyEra(door) {
1604
+ if (this.era !== "legacy") return;
1605
+ this.clearEraAndArmBackoff(door);
1606
+ }
1607
+ /** Probe-captured DiscoverResult fields for the relay initialize synthesis. */
1608
+ getDiscoverCapture() {
1609
+ return this.capture;
1610
+ }
1611
+ /** The era a non-auto pin dictates; undefined in auto mode. */
1612
+ pinnedEra() {
1613
+ if (this.pin === HELIO_MCP_MODERN_PROTOCOL_VERSION) return "modern";
1614
+ if (this.pin === HELIO_MCP_LEGACY_PROTOCOL_VERSION) return "legacy";
1615
+ return void 0;
1616
+ }
1617
+ /**
1618
+ * One in-flight `server/discover` per manager, shared by `establish()` and
1619
+ * `resolveRelayEra()` — whichever asks first starts it, later callers
1620
+ * join. A failure notes its time, arming the relay-path backoff, then
1621
+ * rethrows for the consumer's own handling.
1622
+ */
1623
+ sharedProbe() {
1624
+ this.inflightProbe ??= this.probeEra().catch((error) => {
1625
+ this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
1626
+ throw error;
1627
+ }).finally(() => {
1628
+ this.inflightProbe = void 0;
1629
+ });
1630
+ return this.inflightProbe;
1631
+ }
1632
+ /**
1633
+ * Relay-path caching: the probe classification alone settles the era.
1634
+ * Caching 'legacy' without a proven initialize is what makes
1635
+ * `establish()`'s legacy fast path live; the two-sided re-probe rule
1636
+ * (`clearFalsifiedLegacyEra()` and the internal initialize catch) heals a
1637
+ * wrong conclusion.
1638
+ */
1639
+ cacheRelayClassification(outcome) {
1640
+ if (outcome.era === "modern") {
1641
+ this.cacheModernClassification(outcome);
1642
+ return;
1311
1643
  }
1312
- const contentType = c.req.header("content-type") ?? "";
1313
- if (!contentType.includes("application/json")) {
1314
- return c.json(
1315
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1316
- 415
1317
- );
1644
+ this.setEra("legacy");
1645
+ }
1646
+ /** Every modern classification re-captures from its own fresh DiscoverResult. */
1647
+ cacheModernClassification(outcome) {
1648
+ this.capture = { capabilities: outcome.capabilities, instructions: outcome.instructions };
1649
+ this.setEra("modern");
1650
+ }
1651
+ /**
1652
+ * Falsified-classification clear: any cleared era means the classification
1653
+ * was just contradicted, so re-probing is throttled no matter which door
1654
+ * noticed. No-ops under a pin and on uncached eras; drops the probe-time
1655
+ * DiscoverResult capture with the era it came from; emits exactly one
1656
+ * operator line per clear.
1657
+ */
1658
+ clearEraAndArmBackoff(door) {
1659
+ if (this.pinnedEra()) return;
1660
+ if (this.era === void 0) return;
1661
+ this.era = void 0;
1662
+ this.capture = void 0;
1663
+ this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
1664
+ console.error(
1665
+ `[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1666
+ );
1667
+ }
1668
+ /** Convert a fetch failure into an actionable error for the given step. */
1669
+ describeFetchFailure(error, step) {
1670
+ if (error instanceof Error && error.name === "TimeoutError") {
1671
+ return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
1318
1672
  }
1319
- let body;
1673
+ return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
1674
+ }
1675
+ async establish() {
1676
+ const pinned = this.pinnedEra();
1677
+ if (pinned === "modern") return this.modernSession();
1678
+ if (pinned === "legacy") return this.legacyInitialize();
1679
+ if (this.era === "modern") return this.modernSession();
1680
+ if (this.era === "legacy") return this.legacyInitializeCachingEra(void 0);
1681
+ const probe = await this.sharedProbe();
1682
+ if (probe.era === "modern") {
1683
+ this.cacheModernClassification(probe);
1684
+ return this.modernSession();
1685
+ }
1686
+ return this.legacyInitializeCachingEra(probe.unsupportedModernVersions);
1687
+ }
1688
+ /** The only `initialize` call site: one handshake attempt per establishment. */
1689
+ async legacyInitializeCachingEra(unsupportedModernVersions) {
1320
1690
  try {
1321
- body = await c.req.json();
1322
- } catch {
1323
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
1324
- }
1325
- const parsedRequest = parseJsonRpcRequest(body);
1326
- if (!parsedRequest.success) {
1327
- return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
1691
+ const session = await this.legacyInitialize();
1692
+ this.setEra("legacy");
1693
+ return session;
1694
+ } catch (error) {
1695
+ this.clearFalsifiedLegacyEra("internal initialize failed against the cached legacy era");
1696
+ if (unsupportedModernVersions) {
1697
+ throw new Error(
1698
+ `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)}`
1699
+ );
1700
+ }
1701
+ throw error;
1328
1702
  }
1329
- const id = parsedRequest.request.id;
1330
- const method = parsedRequest.request.method;
1331
- const params = parsedRequest.request.params;
1332
- const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
1333
- const mcpRequest = {
1334
- jsonrpc: "2.0",
1335
- id,
1336
- method,
1337
- params,
1338
- sessionId,
1339
- headers: forwardHeaders,
1340
- signal: c.req.raw.signal
1703
+ }
1704
+ /** Single owner of era assignment and of the era-detected log line. */
1705
+ setEra(era) {
1706
+ if (this.era === era) return;
1707
+ this.era = era;
1708
+ console.error(
1709
+ era === "modern" ? `[helio] Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : "[helio] Upstream MCP era detected: legacy (initialize handshake)"
1710
+ );
1711
+ }
1712
+ /** A modern upstream neither mints nor echoes session ids — nothing to hold. */
1713
+ modernSession() {
1714
+ return {
1715
+ sessionId: void 0,
1716
+ protocolVersion: HELIO_MCP_MODERN_PROTOCOL_VERSION,
1717
+ era: "modern"
1341
1718
  };
1342
- if (id === void 0) {
1343
- const notificationRequest = { ...mcpRequest, signal: void 0 };
1344
- void forwarder.forward(notificationRequest).catch((err) => {
1345
- const message = err instanceof Error ? err.message : String(err);
1346
- console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
1719
+ }
1720
+ /**
1721
+ * Classify the upstream's era with one `server/discover` request.
1722
+ *
1723
+ * A pure classifier: it performs no handshake, so the dual-era salvage cannot
1724
+ * double-initialize. It throws when no era conclusion is possible — a
1725
+ * transport failure, a status that says nothing about the era (401/403/5xx),
1726
+ * or a known-modern server refusing Helio's own probe — leaving the era
1727
+ * uncached so the next attempt re-probes.
1728
+ */
1729
+ async probeEra() {
1730
+ const headers = mergeUpstreamHeaders(
1731
+ {
1732
+ "content-type": "application/json",
1733
+ accept: "application/json, text/event-stream",
1734
+ "mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
1735
+ "mcp-method": "server/discover"
1736
+ },
1737
+ {},
1738
+ this.staticHeaders
1739
+ );
1740
+ headers["mcp-method"] = "server/discover";
1741
+ delete headers["mcp-name"];
1742
+ const probeBody = {
1743
+ jsonrpc: "2.0",
1744
+ id: ERA_PROBE_REQUEST_ID,
1745
+ method: "server/discover",
1746
+ params: { _meta: buildInternalMeta() }
1747
+ };
1748
+ let res;
1749
+ try {
1750
+ res = await fetch(this.url, {
1751
+ method: "POST",
1752
+ headers,
1753
+ body: JSON.stringify(probeBody),
1754
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
1347
1755
  });
1348
- return c.body(null, 202);
1756
+ } catch (error) {
1757
+ throw this.describeFetchFailure(error, "server/discover probe");
1349
1758
  }
1350
- let result;
1759
+ if (!isClassifiableProbeStatus(res.status)) {
1760
+ throw new Error(`upstream server/discover probe failed: HTTP ${String(res.status)}`);
1761
+ }
1762
+ const body = await this.readProbeBody(res);
1763
+ if (body.kind === "stalled") {
1764
+ throw new Error(`upstream server/discover probe ${body.reason}`);
1765
+ }
1766
+ if (body.kind === "unparseable") {
1767
+ return { era: "legacy" };
1768
+ }
1769
+ if (body.kind === "error") {
1770
+ return classifyProbeError(body.envelope);
1771
+ }
1772
+ return classifyProbeResult(body.envelope);
1773
+ }
1774
+ async readProbeBody(res) {
1775
+ const contentType = res.headers.get("content-type") ?? "";
1776
+ if (contentType.includes("text/event-stream")) {
1777
+ if (!res.body) return { kind: "unparseable" };
1778
+ const scan = await this.scanSseEvents(
1779
+ res.body,
1780
+ (payload) => payload["id"] === ERA_PROBE_REQUEST_ID ? payload : void 0
1781
+ );
1782
+ switch (scan.outcome) {
1783
+ case "found":
1784
+ return classifyEnvelopeShape(scan.value);
1785
+ case "closed":
1786
+ return { kind: "unparseable" };
1787
+ case "timed-out":
1788
+ return {
1789
+ kind: "stalled",
1790
+ reason: `SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1791
+ };
1792
+ case "too-large":
1793
+ return {
1794
+ kind: "stalled",
1795
+ reason: `SSE response exceeded ${String(MAX_SSE_SCAN_BYTES)} bytes`
1796
+ };
1797
+ }
1798
+ }
1799
+ const raw = await res.text();
1800
+ if (!raw.trim()) return { kind: "unparseable" };
1801
+ let parsed;
1351
1802
  try {
1352
- result = await forwarder.forward(mcpRequest);
1353
- } catch (err) {
1354
- const forwardingError = err instanceof Error ? err : new Error(String(err));
1355
- console.error("[helio] Upstream forwarding failed:", forwardingError.message);
1356
- const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
1357
- const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
1358
- writeSessionEvent(sessionId, errorEvent);
1359
- return c.body(null, 202);
1803
+ parsed = JSON.parse(raw);
1804
+ } catch {
1805
+ return { kind: "unparseable" };
1360
1806
  }
1361
- const normalized = normalizeUpstreamOutcome({
1362
- requestId: id,
1363
- upstreamResponse: result.response
1364
- });
1365
- const messageEvent = sseEvent("message", JSON.stringify(normalized.body));
1366
- writeSessionEvent(sessionId, messageEvent);
1367
- return c.body(null, 202);
1368
- });
1369
- return app;
1370
- }
1371
-
1372
- // src/server.ts
1373
- var FORCE_CONNECTION_CLOSE_GRACE_MS = 1500;
1374
- function normalizeError(error) {
1375
- if (error instanceof Error) return error;
1376
- return new Error(String(error));
1377
- }
1378
- function createServerHandle(server) {
1379
- const sockets = /* @__PURE__ */ new Set();
1380
- const nodeServer = server;
1381
- nodeServer.on("connection", (socket) => {
1382
- sockets.add(socket);
1383
- socket.on("close", () => {
1384
- sockets.delete(socket);
1807
+ if (typeof parsed !== "object" || parsed === null) return { kind: "unparseable" };
1808
+ return classifyEnvelopeShape(parsed);
1809
+ }
1810
+ async legacyInitialize() {
1811
+ const headers = mergeUpstreamHeaders(
1812
+ {
1813
+ "content-type": "application/json",
1814
+ accept: "application/json, text/event-stream"
1815
+ },
1816
+ {},
1817
+ this.staticHeaders
1818
+ );
1819
+ delete headers["mcp-method"];
1820
+ delete headers["mcp-name"];
1821
+ const initBody = {
1822
+ jsonrpc: "2.0",
1823
+ id: 0,
1824
+ method: "initialize",
1825
+ params: {
1826
+ protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION,
1827
+ capabilities: {},
1828
+ clientInfo: { name: "helio-proxy", version: "0" }
1829
+ }
1830
+ };
1831
+ let res;
1832
+ try {
1833
+ res = await fetch(this.url, {
1834
+ method: "POST",
1835
+ headers,
1836
+ body: JSON.stringify(initBody),
1837
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
1838
+ });
1839
+ } catch (error) {
1840
+ throw this.describeFetchFailure(error, "initialize");
1841
+ }
1842
+ if (!res.ok) {
1843
+ throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
1844
+ }
1845
+ const sessionId = res.headers.get("mcp-session-id") ?? void 0;
1846
+ const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
1847
+ res,
1848
+ initBody.id,
1849
+ "initialize"
1850
+ );
1851
+ const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
1852
+ if (initializeError) {
1853
+ throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
1854
+ }
1855
+ const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
1856
+ const notifyHeaders = { ...headers };
1857
+ if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
1858
+ notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1859
+ const notifyRes = await fetch(this.url, {
1860
+ method: "POST",
1861
+ headers: notifyHeaders,
1862
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1863
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
1864
+ }).catch((error) => {
1865
+ throw this.describeFetchFailure(error, "notifications/initialized");
1385
1866
  });
1386
- });
1387
- const forceCloseConnections = () => {
1867
+ if (!notifyRes.ok) {
1868
+ throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
1869
+ }
1870
+ const notifyError = await this.readOptionalJsonRpcError(notifyRes);
1871
+ if (notifyError) {
1872
+ throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
1873
+ }
1874
+ return { sessionId, protocolVersion: negotiatedProtocolVersion, era: "legacy" };
1875
+ }
1876
+ async readRequiredJsonRpcEnvelope(res, requestId, step) {
1877
+ const contentType = res.headers.get("content-type") ?? "";
1878
+ if (contentType.includes("text/event-stream")) {
1879
+ const payload = await readSseJsonRpcResponse(res, requestId);
1880
+ return payload;
1881
+ }
1882
+ const raw = await res.text();
1883
+ if (!raw.trim()) {
1884
+ throw new Error(`upstream ${step} returned an empty body`);
1885
+ }
1886
+ let parsed;
1388
1887
  try {
1389
- nodeServer.closeIdleConnections?.();
1888
+ parsed = JSON.parse(raw);
1390
1889
  } catch {
1890
+ throw new Error(`upstream ${step} returned non-JSON body`);
1391
1891
  }
1392
- if (nodeServer.closeAllConnections) {
1393
- try {
1394
- nodeServer.closeAllConnections();
1395
- } catch {
1892
+ if (typeof parsed !== "object" || parsed === null) {
1893
+ throw new Error(`upstream ${step} returned non-object JSON`);
1894
+ }
1895
+ return parsed;
1896
+ }
1897
+ async readOptionalJsonRpcError(res) {
1898
+ const contentType = res.headers.get("content-type") ?? "";
1899
+ if (contentType.includes("text/event-stream")) {
1900
+ if (!res.body) return void 0;
1901
+ const scan = await this.scanSseEvents(
1902
+ res.body,
1903
+ (payload) => extractJsonRpcErrorMessage(payload)
1904
+ );
1905
+ switch (scan.outcome) {
1906
+ case "found":
1907
+ return scan.value;
1908
+ case "closed":
1909
+ return void 0;
1910
+ case "timed-out":
1911
+ throw new Error(
1912
+ `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1913
+ );
1914
+ case "too-large":
1915
+ throw new Error(
1916
+ `upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_SCAN_BYTES)} bytes`
1917
+ );
1396
1918
  }
1397
- return;
1398
1919
  }
1399
- for (const socket of sockets) {
1400
- socket.destroy();
1920
+ const raw = await res.text();
1921
+ if (!raw.trim()) return void 0;
1922
+ let parsed;
1923
+ try {
1924
+ parsed = JSON.parse(raw);
1925
+ } catch {
1926
+ return void 0;
1401
1927
  }
1402
- };
1403
- return {
1404
- server,
1405
- close: () => new Promise((resolve, reject) => {
1406
- let settled = false;
1407
- let forceTimer;
1408
- const settle = (err) => {
1409
- if (settled) return;
1410
- settled = true;
1411
- if (forceTimer) {
1412
- clearTimeout(forceTimer);
1413
- forceTimer = void 0;
1414
- }
1415
- if (err) {
1416
- reject(err);
1417
- return;
1418
- }
1419
- resolve();
1420
- };
1928
+ if (typeof parsed !== "object" || parsed === null) return void 0;
1929
+ return extractJsonRpcErrorMessage(parsed);
1930
+ }
1931
+ /**
1932
+ * Read an SSE POST response body under an explicit read deadline and byte
1933
+ * cap, returning the first `message` payload `select` accepts. The
1934
+ * fetch-level `AbortSignal.timeout` would eventually abort a stalled body
1935
+ * read; these bounds are the belt to its braces.
1936
+ */
1937
+ async scanSseEvents(body, select) {
1938
+ const reader = body.getReader();
1939
+ const decoder = new TextDecoder();
1940
+ let state = { event: "", data: "", remainder: "" };
1941
+ let found;
1942
+ let scannedBytes = 0;
1943
+ const deadline = Date.now() + this.requestTimeoutMs;
1944
+ const onEvent = (event, data) => {
1945
+ if (found !== void 0) return;
1946
+ if (event && event !== "message") return;
1947
+ let parsed;
1421
1948
  try {
1422
- nodeServer.close((err) => {
1423
- if (err) {
1424
- settle(err);
1425
- return;
1426
- }
1427
- settle();
1428
- });
1429
- } catch (error) {
1430
- settle(normalizeError(error));
1949
+ parsed = JSON.parse(data);
1950
+ } catch {
1431
1951
  return;
1432
1952
  }
1953
+ if (typeof parsed !== "object" || parsed === null) return;
1954
+ found = select(parsed);
1955
+ };
1956
+ for (; ; ) {
1957
+ const remainingMs = deadline - Date.now();
1958
+ if (remainingMs <= 0) {
1959
+ await reader.cancel().catch(() => void 0);
1960
+ return { outcome: "timed-out" };
1961
+ }
1962
+ let chunk;
1433
1963
  try {
1434
- nodeServer.closeIdleConnections?.();
1964
+ chunk = await readSseChunkWithTimeout(reader, remainingMs);
1435
1965
  } catch {
1966
+ await reader.cancel().catch(() => void 0);
1967
+ return { outcome: "timed-out" };
1436
1968
  }
1437
- forceTimer = setTimeout(() => {
1438
- forceCloseConnections();
1439
- }, FORCE_CONNECTION_CLOSE_GRACE_MS);
1440
- forceTimer.unref();
1441
- })
1442
- };
1443
- }
1444
- function createApp(config, forwarder, options) {
1445
- const app = new Hono3();
1446
- const forwardHeadersAllowlist = config.upstream.forward_headers;
1447
- app.get("/healthz", (c) => c.json({ status: "ok" }));
1448
- app.route("/mcp", createStreamableHttpRoute(forwarder, { forwardHeadersAllowlist }));
1449
- app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist }));
1450
- if (options?.slackActionApp) {
1451
- app.route("/slack/actions", options.slackActionApp);
1969
+ const { done, value } = chunk;
1970
+ if (value !== void 0) {
1971
+ scannedBytes += value.byteLength;
1972
+ if (scannedBytes > MAX_SSE_SCAN_BYTES) {
1973
+ await reader.cancel().catch(() => void 0);
1974
+ return { outcome: "too-large" };
1975
+ }
1976
+ state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
1977
+ if (found !== void 0) {
1978
+ await reader.cancel().catch(() => void 0);
1979
+ return { outcome: "found", value: found };
1980
+ }
1981
+ }
1982
+ if (done) {
1983
+ const tail = decoder.decode();
1984
+ if (tail) {
1985
+ state = parseSseChunk(tail, state, onEvent);
1986
+ }
1987
+ return found !== void 0 ? { outcome: "found", value: found } : { outcome: "closed" };
1988
+ }
1989
+ }
1990
+ }
1991
+ };
1992
+ async function readSseChunkWithTimeout(reader, timeoutMs) {
1993
+ let timeoutHandle;
1994
+ try {
1995
+ const result = await Promise.race([
1996
+ reader.read(),
1997
+ new Promise((_, reject) => {
1998
+ timeoutHandle = setTimeout(() => {
1999
+ reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
2000
+ }, timeoutMs);
2001
+ })
2002
+ ]);
2003
+ if (!isSseReadChunk(result)) {
2004
+ throw new Error("upstream SSE response returned invalid chunk");
2005
+ }
2006
+ return result;
2007
+ } finally {
2008
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1452
2009
  }
1453
- return app;
1454
- }
1455
- function startServer(app, config) {
1456
- const server = serve({
1457
- fetch: app.fetch,
1458
- port: config.listen.port,
1459
- hostname: config.listen.host
1460
- });
1461
- return createServerHandle(server);
1462
2010
  }
1463
- function startSidebandServer(app, port, host = "127.0.0.1") {
1464
- const server = serve({
1465
- fetch: app.fetch,
1466
- port,
1467
- hostname: host
1468
- });
1469
- return createServerHandle(server);
2011
+ function isSseReadChunk(value) {
2012
+ if (typeof value !== "object" || value === null) return false;
2013
+ const candidate = value;
2014
+ if (typeof candidate.done !== "boolean") return false;
2015
+ if (candidate.value === void 0) return true;
2016
+ return candidate.value instanceof Uint8Array;
1470
2017
  }
1471
-
1472
- // src/upstream/response.ts
1473
- async function parseUpstreamResponse(res) {
1474
- const headers = {};
1475
- res.headers.forEach((value, key) => {
1476
- headers[key] = value;
1477
- });
1478
- const contentType = res.headers.get("content-type") ?? "";
1479
- let body;
1480
- if (contentType.includes("application/json")) {
1481
- const text = await res.text();
1482
- try {
1483
- body = JSON.parse(text);
1484
- } catch {
1485
- body = text;
1486
- }
1487
- } else {
1488
- body = await res.text();
2018
+ function isClassifiableProbeStatus(status) {
2019
+ if (status >= 200 && status < 300) return true;
2020
+ return status === 400 || status === 404 || status === 405;
2021
+ }
2022
+ function classifyEnvelopeShape(envelope) {
2023
+ if (envelope["error"] !== void 0) return { kind: "error", envelope };
2024
+ if (envelope["result"] !== void 0) return { kind: "result", envelope };
2025
+ return { kind: "unparseable" };
2026
+ }
2027
+ function classifyProbeError(envelope) {
2028
+ const error = envelope["error"];
2029
+ const code = typeof error === "object" && error !== null ? error["code"] : void 0;
2030
+ if (code === MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE) {
2031
+ const data = error["data"];
2032
+ return {
2033
+ era: "legacy",
2034
+ unsupportedModernVersions: readStringArray(
2035
+ typeof data === "object" && data !== null ? data["supported"] : void 0
2036
+ )
2037
+ };
1489
2038
  }
1490
- return { status: res.status, headers, body };
2039
+ if (code === HEADER_MISMATCH || code === MCP_MISSING_CLIENT_CAPABILITY_CODE) {
2040
+ throw new Error(
2041
+ `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)`
2042
+ );
2043
+ }
2044
+ return { era: "legacy" };
1491
2045
  }
1492
-
1493
- // src/upstream/sse-parse.ts
1494
- function parseSseChunk(chunk, state, onEvent) {
1495
- let { event, data, remainder } = state;
1496
- const text = remainder + chunk;
1497
- const lines = text.split("\n");
1498
- remainder = lines.pop() ?? "";
1499
- for (const rawLine of lines) {
1500
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1501
- if (line === "") {
1502
- if (event || data) {
1503
- onEvent(event, data);
1504
- event = "";
1505
- data = "";
1506
- }
1507
- } else if (line.startsWith("event:")) {
1508
- const value = line.slice(6).replace(/^ /, "");
1509
- event = value;
1510
- } else if (line.startsWith("data:")) {
1511
- const value = line.slice(5).replace(/^ /, "");
1512
- data = data ? data + "\n" + value : value;
1513
- }
2046
+ function classifyProbeResult(envelope) {
2047
+ const result = envelope["result"];
2048
+ if (typeof result !== "object" || result === null) return { era: "legacy" };
2049
+ const supportedVersions = result["supportedVersions"];
2050
+ if (!Array.isArray(supportedVersions)) {
2051
+ return { era: "legacy" };
1514
2052
  }
1515
- return { event, data, remainder };
2053
+ const versions = readStringArray(supportedVersions);
2054
+ if (versions.includes(HELIO_MCP_MODERN_PROTOCOL_VERSION)) {
2055
+ const record = result;
2056
+ const capabilities = record["capabilities"];
2057
+ const instructions = record["instructions"];
2058
+ return {
2059
+ era: "modern",
2060
+ capabilities: typeof capabilities === "object" && capabilities !== null && !Array.isArray(capabilities) ? capabilities : void 0,
2061
+ instructions: typeof instructions === "string" ? instructions : void 0
2062
+ };
2063
+ }
2064
+ return { era: "legacy", unsupportedModernVersions: versions };
1516
2065
  }
1517
- async function readSseJsonRpcResponse(res, requestId) {
1518
- if (!res.body) {
1519
- throw new Error("upstream SSE response had no body");
2066
+ function readStringArray(value) {
2067
+ if (!Array.isArray(value)) return [];
2068
+ return value.filter((entry) => typeof entry === "string");
2069
+ }
2070
+ function extractJsonRpcErrorMessage(payload) {
2071
+ const error = payload["error"];
2072
+ if (typeof error === "string") return error;
2073
+ if (typeof error !== "object" || error === null) return void 0;
2074
+ const message = error["message"];
2075
+ if (typeof message === "string" && message.trim()) return message;
2076
+ return "unknown JSON-RPC error";
2077
+ }
2078
+ function extractNegotiatedProtocolVersion(payload) {
2079
+ const result = payload["result"];
2080
+ if (typeof result !== "object" || result === null) {
2081
+ return HELIO_MCP_LEGACY_PROTOCOL_VERSION;
1520
2082
  }
1521
- const reader = res.body.getReader();
1522
- const decoder = new TextDecoder();
1523
- let state = { event: "", data: "", remainder: "" };
1524
- let found;
1525
- const onEvent = (event, data) => {
1526
- if (event && event !== "message") return;
1527
- let parsed;
1528
- try {
1529
- parsed = JSON.parse(data);
1530
- } catch {
1531
- return;
2083
+ const protocolVersion = result["protocolVersion"];
2084
+ return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_LEGACY_PROTOCOL_VERSION;
2085
+ }
2086
+
2087
+ // src/transport/header-body-agreement.ts
2088
+ var DISPLAY_CAP_CHARS = 256;
2089
+ function displayCap(value) {
2090
+ if (value.length <= DISPLAY_CAP_CHARS) return value;
2091
+ return `${value.slice(0, DISPLAY_CAP_CHARS)}\u2026 (truncated)`;
2092
+ }
2093
+ function readOwnField(source, field) {
2094
+ if (typeof source !== "object" || source === null || Array.isArray(source)) return void 0;
2095
+ if (!Object.prototype.hasOwnProperty.call(source, field)) return void 0;
2096
+ return source[field];
2097
+ }
2098
+ function validateHeaderBodyAgreement(input) {
2099
+ const { method, params } = input;
2100
+ const headerMethod = input.headers["mcp-method"];
2101
+ const headerName = input.headers["mcp-name"];
2102
+ const rawVersionClaim = input.headers["mcp-protocol-version"];
2103
+ const modern = isModernProtocolClaim(rawVersionClaim);
2104
+ const isNotification = input.id === void 0;
2105
+ const requiresPresence = modern && !isNotification;
2106
+ const field = nameBearingField(method);
2107
+ const rawFieldValue = field === void 0 ? void 0 : readOwnField(params, field);
2108
+ const bodyName = typeof rawFieldValue === "string" ? rawFieldValue : void 0;
2109
+ const presentHeaders = {};
2110
+ if (headerMethod !== void 0) presentHeaders["mcp-method"] = headerMethod;
2111
+ if (headerName !== void 0) presentHeaders["mcp-name"] = headerName;
2112
+ if (rawVersionClaim !== void 0) presentHeaders["mcp-protocol-version"] = rawVersionClaim;
2113
+ const reject = (reason) => ({
2114
+ ok: false,
2115
+ reason,
2116
+ evidence: {
2117
+ headers: presentHeaders,
2118
+ ...bodyName !== void 0 && { bodyName }
1532
2119
  }
1533
- if (parsed === null || typeof parsed !== "object") return;
1534
- const id = parsed["id"];
1535
- if (id === requestId) {
1536
- found = parsed;
2120
+ });
2121
+ if (headerMethod === void 0) {
2122
+ if (requiresPresence) {
2123
+ return reject(`missing mcp-method header (expected ${displayCap(method)})`);
1537
2124
  }
1538
- };
1539
- const processChunk = (chunk) => {
1540
- state = parseSseChunk(chunk, state, onEvent);
1541
- };
1542
- for (; ; ) {
1543
- const result = await reader.read();
1544
- if (result.value !== void 0) {
1545
- const chunk = result.value;
1546
- processChunk(decoder.decode(chunk, { stream: true }));
1547
- if (found) {
1548
- await reader.cancel().catch(() => void 0);
1549
- return found;
2125
+ } else if (headerMethod !== method) {
2126
+ return reject(
2127
+ `mismatched mcp-method header (expected ${displayCap(method)}, got ${displayCap(headerMethod)})`
2128
+ );
2129
+ }
2130
+ if (bodyName !== void 0) {
2131
+ if (headerName === void 0) {
2132
+ if (requiresPresence) {
2133
+ return reject(`missing mcp-name header (expected ${displayCap(bodyName)})`);
2134
+ }
2135
+ } else {
2136
+ const decoded = decodeSentinelValue(headerName);
2137
+ if (decoded !== bodyName) {
2138
+ return reject(
2139
+ `mismatched mcp-name header (expected ${displayCap(bodyName)}, got ${displayCap(decoded)})`
2140
+ );
1550
2141
  }
1551
2142
  }
1552
- if (result.done) {
1553
- const tail = decoder.decode();
1554
- if (tail) {
1555
- processChunk(tail);
1556
- if (found) return found;
2143
+ }
2144
+ if (modern) {
2145
+ const mirror = readOwnField(readOwnField(params, "_meta"), MCP_META_PROTOCOL_VERSION_KEY);
2146
+ if (mirror === void 0) {
2147
+ if (!isNotification) {
2148
+ return reject(
2149
+ `missing params._meta["${MCP_META_PROTOCOL_VERSION_KEY}"] mirror (expected ${HELIO_MCP_MODERN_PROTOCOL_VERSION})`
2150
+ );
1557
2151
  }
1558
- break;
2152
+ } else if (mirror !== HELIO_MCP_MODERN_PROTOCOL_VERSION) {
2153
+ const display = typeof mirror === "string" ? mirror : JSON.stringify(mirror);
2154
+ return reject(
2155
+ `mismatched params._meta["${MCP_META_PROTOCOL_VERSION_KEY}"] mirror (expected ${HELIO_MCP_MODERN_PROTOCOL_VERSION}, got ${displayCap(display)})`
2156
+ );
1559
2157
  }
1560
2158
  }
1561
- throw new Error(
1562
- `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1563
- );
2159
+ return { ok: true };
1564
2160
  }
1565
2161
 
1566
- // src/upstream/merge-headers.ts
1567
- function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1568
- const out = {};
1569
- const apply = (headers) => {
1570
- for (const [name, value] of Object.entries(headers)) {
1571
- out[name.toLowerCase()] = value;
2162
+ // src/transport/origin-guard.ts
2163
+ var MAX_UNIQUE_ORIGIN_WARNINGS = 20;
2164
+ var SUPPRESSED_WARNING_SUMMARY_INTERVAL = 50;
2165
+ var LOGGED_ORIGIN_MAX_LENGTH = 256;
2166
+ function createOriginGuard(allowedOrigins) {
2167
+ const allowed = new Set(allowedOrigins);
2168
+ const warnedOrigins = /* @__PURE__ */ new Set();
2169
+ let suppressedWarningCount = 0;
2170
+ const logRejection = (origin) => {
2171
+ const displayOrigin = origin.length > LOGGED_ORIGIN_MAX_LENGTH ? `${origin.slice(0, LOGGED_ORIGIN_MAX_LENGTH)}\u2026 (truncated)` : origin;
2172
+ if (warnedOrigins.has(displayOrigin)) return;
2173
+ if (warnedOrigins.size < MAX_UNIQUE_ORIGIN_WARNINGS) {
2174
+ warnedOrigins.add(displayOrigin);
2175
+ console.error(`[helio] Rejected request with disallowed Origin: ${displayOrigin}`);
2176
+ return;
2177
+ }
2178
+ suppressedWarningCount += 1;
2179
+ if (suppressedWarningCount === 1 || suppressedWarningCount % SUPPRESSED_WARNING_SUMMARY_INTERVAL === 0) {
2180
+ console.error(
2181
+ `[helio] Origin rejection warnings: logged ${String(MAX_UNIQUE_ORIGIN_WARNINGS)} distinct origins and are suppressing the rest (${String(suppressedWarningCount)} further rejections so far).`
2182
+ );
1572
2183
  }
1573
2184
  };
1574
- apply(base);
1575
- apply(forwarded);
1576
- apply(staticHeaders);
1577
- return out;
2185
+ return async (c, next) => {
2186
+ const origin = c.req.header("origin");
2187
+ if (origin !== void 0 && !allowed.has(origin)) {
2188
+ logRejection(origin);
2189
+ return c.json(makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Origin not allowed"), 403);
2190
+ }
2191
+ await next();
2192
+ };
1578
2193
  }
1579
2194
 
1580
- // src/upstream/connection-error.ts
1581
- var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1582
- var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
1583
- "ECONNREFUSED",
1584
- "ENOTFOUND",
1585
- "EAI_AGAIN",
1586
- "ECONNRESET",
1587
- "EHOSTUNREACH",
1588
- "ENETUNREACH",
1589
- "ETIMEDOUT",
1590
- "EPIPE",
1591
- "UND_ERR_CONNECT_TIMEOUT",
1592
- "UND_ERR_SOCKET"
1593
- ]);
1594
- function extractErrorCode(error) {
1595
- let current = error;
1596
- for (let depth = 0; depth < 5 && current != null; depth += 1) {
1597
- if (typeof current === "object" && "code" in current) {
1598
- const code = current.code;
1599
- if (typeof code === "string") return code;
1600
- }
1601
- current = current.cause;
2195
+ // src/transport/response-normalizer.ts
2196
+ function isObject(value) {
2197
+ return value !== null && typeof value === "object";
2198
+ }
2199
+ function isValidJsonRpcId(value) {
2200
+ return value === null || typeof value === "string" || typeof value === "number";
2201
+ }
2202
+ function getJsonRpcId(value) {
2203
+ if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
2204
+ const id = value["id"];
2205
+ return isValidJsonRpcId(id) ? id : void 0;
2206
+ }
2207
+ function isValidJsonRpcError(value) {
2208
+ if (!isObject(value)) return false;
2209
+ return typeof value["code"] === "number" && typeof value["message"] === "string";
2210
+ }
2211
+ function isValidJsonRpcResponse(value) {
2212
+ if (!isObject(value)) return false;
2213
+ if (value["jsonrpc"] !== "2.0") return false;
2214
+ if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
2215
+ return false;
1602
2216
  }
1603
- return void 0;
2217
+ const hasResult = Object.prototype.hasOwnProperty.call(value, "result");
2218
+ const hasError = Object.prototype.hasOwnProperty.call(value, "error");
2219
+ if (hasResult && hasError || !hasResult && !hasError) return false;
2220
+ if (hasError && !isValidJsonRpcError(value["error"])) return false;
2221
+ return true;
1604
2222
  }
1605
- function describeUnreachableUpstream(error, url) {
1606
- const code = extractErrorCode(error);
1607
- const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
1608
- if (code !== void 0) {
1609
- if (!UNREACHABLE_CODES.has(code)) return null;
1610
- } else if (!isGenericFetchFailure) {
1611
- return null;
2223
+ function makeWrappedError(requestId, message, data) {
2224
+ return {
2225
+ jsonrpc: "2.0",
2226
+ id: requestId ?? null,
2227
+ error: {
2228
+ code: INTERNAL_ERROR,
2229
+ message,
2230
+ data
2231
+ }
2232
+ };
2233
+ }
2234
+ function normalizeUpstreamOutcome(args) {
2235
+ if (args.forwardingError) {
2236
+ return {
2237
+ httpStatus: 200,
2238
+ wrapped: true,
2239
+ body: makeWrappedError(args.requestId, "upstream forwarding failed", {
2240
+ failure_class: "upstream_forward_error",
2241
+ failure_reason: args.forwardingError.message
2242
+ })
2243
+ };
1612
2244
  }
1613
- const codeSuffix = code ? ` (${code})` : "";
1614
- return new Error(
1615
- `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}`
1616
- );
2245
+ if (!args.upstreamResponse) {
2246
+ return {
2247
+ httpStatus: 200,
2248
+ wrapped: true,
2249
+ body: makeWrappedError(args.requestId, "upstream forwarding failed", {
2250
+ failure_class: "upstream_forward_error",
2251
+ failure_reason: "missing upstream response"
2252
+ })
2253
+ };
2254
+ }
2255
+ const upstream = args.upstreamResponse;
2256
+ const upstreamContentType = upstream.headers["content-type"] ?? null;
2257
+ if (!isValidJsonRpcResponse(upstream.body)) {
2258
+ return {
2259
+ httpStatus: 200,
2260
+ wrapped: true,
2261
+ body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
2262
+ failure_class: "upstream_invalid_jsonrpc",
2263
+ upstream_http_status: upstream.status,
2264
+ upstream_content_type: upstreamContentType,
2265
+ upstream_body_type: typeof upstream.body
2266
+ })
2267
+ };
2268
+ }
2269
+ if (args.requestId !== void 0) {
2270
+ const upstreamId = getJsonRpcId(upstream.body);
2271
+ if (upstreamId === void 0) {
2272
+ return {
2273
+ httpStatus: 200,
2274
+ wrapped: true,
2275
+ body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
2276
+ failure_class: "upstream_invalid_jsonrpc",
2277
+ upstream_http_status: upstream.status,
2278
+ upstream_content_type: upstreamContentType,
2279
+ upstream_body_type: typeof upstream.body,
2280
+ invalid_reason: "missing_response_id"
2281
+ })
2282
+ };
2283
+ }
2284
+ const expectedId = args.requestId ?? null;
2285
+ if (upstreamId !== expectedId) {
2286
+ return {
2287
+ httpStatus: 200,
2288
+ wrapped: true,
2289
+ body: makeWrappedError(args.requestId, "upstream response id mismatch", {
2290
+ failure_class: "upstream_id_mismatch",
2291
+ expected_request_id: expectedId,
2292
+ upstream_response_id: upstreamId
2293
+ })
2294
+ };
2295
+ }
2296
+ }
2297
+ return {
2298
+ httpStatus: 200,
2299
+ wrapped: false,
2300
+ body: upstream.body
2301
+ };
1617
2302
  }
1618
2303
 
1619
- // src/upstream/upstream-session-manager.ts
1620
- var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
1621
- var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
1622
- var UpstreamSessionManager = class {
1623
- url;
1624
- staticHeaders;
1625
- requestTimeoutMs;
1626
- internal;
1627
- inflight;
1628
- constructor(options) {
1629
- this.url = options.url;
1630
- this.staticHeaders = options.staticHeaders;
1631
- this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1632
- }
1633
- /** Return the internal session, performing the handshake once if needed. */
1634
- ensureInternalSession() {
1635
- if (this.internal) return Promise.resolve(this.internal);
1636
- this.inflight ??= this.initialize().then((session) => {
1637
- this.internal = session;
1638
- return session;
1639
- }).finally(() => {
1640
- this.inflight = void 0;
2304
+ // src/transport/streamable-http.ts
2305
+ var MCP_SESSION_HEADER = "mcp-session-id";
2306
+ var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
2307
+ function createStreamableHttpRoute(forwarder, options = {}) {
2308
+ const app = new Hono();
2309
+ const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
2310
+ const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
2311
+ app.use("*", createOriginGuard(options.allowedOrigins ?? []));
2312
+ app.post("/", async (c) => {
2313
+ const handlerStart = performance.now();
2314
+ if (!isJsonContentType(c.req.header("content-type"))) {
2315
+ return c.json(
2316
+ makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Content-Type must be application/json"),
2317
+ 415
2318
+ );
2319
+ }
2320
+ let body;
2321
+ try {
2322
+ body = await c.req.json();
2323
+ } catch {
2324
+ return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
2325
+ }
2326
+ const parsedRequest = parseJsonRpcRequest(body);
2327
+ if (!parsedRequest.success) {
2328
+ const errorBody = parsedRequest.id === null ? makeJsonRpcErrorWithoutId(INVALID_REQUEST, parsedRequest.message) : makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message);
2329
+ return c.json(errorBody, 400);
2330
+ }
2331
+ const id = parsedRequest.request.id;
2332
+ const method = parsedRequest.request.method;
2333
+ const params = parsedRequest.request.params;
2334
+ const transportSessionId = c.req.header(MCP_SESSION_HEADER);
2335
+ const session = resolveSession(
2336
+ {
2337
+ headers: Object.fromEntries(c.req.raw.headers),
2338
+ meta: paramsMeta(params),
2339
+ transportSessionId
2340
+ },
2341
+ sessionIdentity
2342
+ );
2343
+ const protocolVersion = c.req.header("mcp-protocol-version");
2344
+ const agreement = validateHeaderBodyAgreement({
2345
+ method,
2346
+ id,
2347
+ params,
2348
+ headers: {
2349
+ "mcp-method": c.req.header("mcp-method"),
2350
+ "mcp-name": c.req.header("mcp-name"),
2351
+ "mcp-protocol-version": protocolVersion
2352
+ }
2353
+ });
2354
+ if (!agreement.ok) {
2355
+ options.onHeaderMismatch?.({
2356
+ reason: agreement.reason,
2357
+ method,
2358
+ params,
2359
+ ...agreement.evidence.bodyName !== void 0 && { bodyName: agreement.evidence.bodyName },
2360
+ ...protocolVersion !== void 0 && { protocolVersion },
2361
+ headers: agreement.evidence.headers,
2362
+ ...session !== void 0 && { session },
2363
+ durationMs: performance.now() - handlerStart
2364
+ });
2365
+ const errorBody = id === void 0 || id === null ? makeJsonRpcErrorWithoutId(HEADER_MISMATCH, agreement.reason) : makeJsonRpcError(id, HEADER_MISMATCH, agreement.reason);
2366
+ return c.json(errorBody, 400);
2367
+ }
2368
+ const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
2369
+ const mcpRequest = {
2370
+ jsonrpc: "2.0",
2371
+ id,
2372
+ method,
2373
+ params,
2374
+ session,
2375
+ transportSessionId,
2376
+ protocolVersion,
2377
+ headers: forwardHeaders,
2378
+ signal: c.req.raw.signal
2379
+ };
2380
+ if (id === void 0) {
2381
+ const notificationRequest = { ...mcpRequest, signal: void 0 };
2382
+ void forwarder.forward(notificationRequest).catch((err) => {
2383
+ const message = err instanceof Error ? err.message : String(err);
2384
+ console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
2385
+ });
2386
+ return c.body(null, 202);
2387
+ }
2388
+ let result;
2389
+ try {
2390
+ result = await forwarder.forward(mcpRequest);
2391
+ } catch (err) {
2392
+ const forwardingError = err instanceof Error ? err : new Error(String(err));
2393
+ console.error("[helio] Upstream forwarding failed:", forwardingError.message);
2394
+ const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
2395
+ return c.json(normalized2.body, normalized2.httpStatus);
2396
+ }
2397
+ const { response } = result;
2398
+ for (const [key, value] of Object.entries(response.headers)) {
2399
+ if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
2400
+ c.header(key, value);
2401
+ }
2402
+ }
2403
+ const normalized = normalizeUpstreamOutcome({ requestId: id, upstreamResponse: response });
2404
+ return c.json(normalized.body, normalized.httpStatus);
2405
+ });
2406
+ return app;
2407
+ }
2408
+
2409
+ // src/transport/sse.ts
2410
+ import { randomUUID } from "crypto";
2411
+ import { Hono as Hono2 } from "hono";
2412
+ import { z as z3 } from "zod";
2413
+ var encoder = new TextEncoder();
2414
+ var MCP_SESSION_HEADER2 = "mcp-session-id";
2415
+ var STALE_THRESHOLD_MS = 9e4;
2416
+ var SWEEP_INTERVAL_MS = 6e4;
2417
+ var MAX_CONCURRENT_SESSIONS = 1024;
2418
+ var REFUSAL_LOG_WINDOW_MS = 1e4;
2419
+ var ssePostQuerySchema = z3.object({
2420
+ sessionId: z3.string().min(1)
2421
+ });
2422
+ function sseEvent(event, data) {
2423
+ return `event: ${event}
2424
+ data: ${data}
2425
+
2426
+ `;
2427
+ }
2428
+ function createSseRoute(forwarder, options = {}) {
2429
+ const sessions = /* @__PURE__ */ new Map();
2430
+ const app = new Hono2();
2431
+ const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
2432
+ const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
2433
+ const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
2434
+ let refusalCount = 0;
2435
+ let lastRefusalLogAt = null;
2436
+ const logRefusal = () => {
2437
+ refusalCount += 1;
2438
+ const now = Date.now();
2439
+ if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
2440
+ lastRefusalLogAt = now;
2441
+ console.error(
2442
+ `[helio] /sse at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2443
+ );
2444
+ };
2445
+ app.use("*", createOriginGuard(options.allowedOrigins ?? []));
2446
+ const writeSessionEvent = (sessionId, eventPayload) => {
2447
+ const session = sessions.get(sessionId);
2448
+ if (!session) return;
2449
+ session.lastActivity = Date.now();
2450
+ void session.writer.write(encoder.encode(eventPayload)).catch(() => {
2451
+ sessions.delete(sessionId);
2452
+ void session.writer.close().catch(() => {
2453
+ });
2454
+ });
2455
+ };
2456
+ const sweepInterval = setInterval(() => {
2457
+ const now = Date.now();
2458
+ for (const [id, session] of sessions) {
2459
+ if (now - session.lastActivity > STALE_THRESHOLD_MS) {
2460
+ sessions.delete(id);
2461
+ void session.writer.close().catch(() => {
2462
+ });
2463
+ }
2464
+ }
2465
+ }, SWEEP_INTERVAL_MS);
2466
+ sweepInterval.unref();
2467
+ app.get("/", (c) => {
2468
+ if (sessions.size >= maxConcurrentSessions) {
2469
+ logRefusal();
2470
+ return c.json({ error: "session capacity reached" }, 503);
2471
+ }
2472
+ const sessionId = randomUUID();
2473
+ const { readable, writable } = new TransformStream();
2474
+ const writer = writable.getWriter();
2475
+ sessions.set(sessionId, { writer, lastActivity: Date.now() });
2476
+ const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
2477
+ writeSessionEvent(sessionId, endpointData);
2478
+ c.req.raw.signal.addEventListener("abort", () => {
2479
+ sessions.delete(sessionId);
2480
+ void writer.close().catch(() => {
2481
+ });
1641
2482
  });
1642
- return this.inflight;
1643
- }
1644
- /**
1645
- * Drop the cached internal session so the next call re-initializes.
1646
- * Does not cancel any in-flight initialize.
1647
- */
1648
- invalidateInternalSession() {
1649
- this.internal = void 0;
1650
- }
1651
- /** Convert a fetch failure into an actionable error for the given step. */
1652
- describeFetchFailure(error, step) {
1653
- if (error instanceof Error && error.name === "TimeoutError") {
1654
- return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
2483
+ return new Response(readable, {
2484
+ headers: {
2485
+ "content-type": "text/event-stream",
2486
+ "cache-control": "no-cache",
2487
+ connection: "keep-alive"
2488
+ }
2489
+ });
2490
+ });
2491
+ app.post("/", async (c) => {
2492
+ const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
2493
+ if (!parsedQuery.success) {
2494
+ return c.json(
2495
+ makeJsonRpcErrorWithoutId(INVALID_REQUEST, "missing sessionId query parameter"),
2496
+ 400
2497
+ );
1655
2498
  }
1656
- return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
1657
- }
1658
- async initialize() {
1659
- const headers = mergeUpstreamHeaders(
2499
+ const sessionId = parsedQuery.data.sessionId;
2500
+ const session = sessions.get(sessionId);
2501
+ if (!session) {
2502
+ return c.json(makeJsonRpcErrorWithoutId(INVALID_REQUEST, "unknown session"), 404);
2503
+ }
2504
+ if (!isJsonContentType(c.req.header("content-type"))) {
2505
+ return c.json(
2506
+ makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Content-Type must be application/json"),
2507
+ 415
2508
+ );
2509
+ }
2510
+ let body;
2511
+ try {
2512
+ body = await c.req.json();
2513
+ } catch {
2514
+ return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
2515
+ }
2516
+ const parsedRequest = parseJsonRpcRequest(body);
2517
+ if (!parsedRequest.success) {
2518
+ const errorBody = parsedRequest.id === null ? makeJsonRpcErrorWithoutId(INVALID_REQUEST, parsedRequest.message) : makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message);
2519
+ return c.json(errorBody, 400);
2520
+ }
2521
+ const id = parsedRequest.request.id;
2522
+ const method = parsedRequest.request.method;
2523
+ const params = parsedRequest.request.params;
2524
+ const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
2525
+ const transportSessionId = c.req.header(MCP_SESSION_HEADER2);
2526
+ const resolvedSession = resolveSession(
1660
2527
  {
1661
- "content-type": "application/json",
1662
- accept: "application/json, text/event-stream"
2528
+ headers: Object.fromEntries(c.req.raw.headers),
2529
+ meta: paramsMeta(params),
2530
+ transportSessionId,
2531
+ transportMintedId: sessionId
1663
2532
  },
1664
- {},
1665
- this.staticHeaders
2533
+ sessionIdentity
1666
2534
  );
1667
- const initBody = {
2535
+ const mcpRequest = {
1668
2536
  jsonrpc: "2.0",
1669
- id: 0,
1670
- method: "initialize",
1671
- params: {
1672
- protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
1673
- capabilities: {},
1674
- clientInfo: { name: "helio-proxy", version: "0" }
1675
- }
2537
+ id,
2538
+ method,
2539
+ params,
2540
+ session: resolvedSession,
2541
+ transportSessionId,
2542
+ headers: forwardHeaders,
2543
+ signal: c.req.raw.signal
1676
2544
  };
1677
- let res;
1678
- try {
1679
- res = await fetch(this.url, {
1680
- method: "POST",
1681
- headers,
1682
- body: JSON.stringify(initBody),
1683
- signal: AbortSignal.timeout(this.requestTimeoutMs)
2545
+ if (id === void 0) {
2546
+ const notificationRequest = { ...mcpRequest, signal: void 0 };
2547
+ void forwarder.forward(notificationRequest).catch((err) => {
2548
+ const message = err instanceof Error ? err.message : String(err);
2549
+ console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
1684
2550
  });
1685
- } catch (error) {
1686
- throw this.describeFetchFailure(error, "initialize");
1687
- }
1688
- if (!res.ok) {
1689
- throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
2551
+ return c.body(null, 202);
1690
2552
  }
1691
- const sessionId = res.headers.get("mcp-session-id") ?? void 0;
1692
- const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
1693
- res,
1694
- initBody.id,
1695
- "initialize"
1696
- );
1697
- const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
1698
- if (initializeError) {
1699
- throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
2553
+ let result;
2554
+ try {
2555
+ result = await forwarder.forward(mcpRequest);
2556
+ } catch (err) {
2557
+ const forwardingError = err instanceof Error ? err : new Error(String(err));
2558
+ console.error("[helio] Upstream forwarding failed:", forwardingError.message);
2559
+ const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
2560
+ const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
2561
+ writeSessionEvent(sessionId, errorEvent);
2562
+ return c.body(null, 202);
1700
2563
  }
1701
- const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
1702
- const notifyHeaders = { ...headers };
1703
- if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
1704
- notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1705
- const notifyRes = await fetch(this.url, {
1706
- method: "POST",
1707
- headers: notifyHeaders,
1708
- body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1709
- signal: AbortSignal.timeout(this.requestTimeoutMs)
1710
- }).catch((error) => {
1711
- throw this.describeFetchFailure(error, "notifications/initialized");
2564
+ const normalized = normalizeUpstreamOutcome({
2565
+ requestId: id,
2566
+ upstreamResponse: result.response
1712
2567
  });
1713
- if (!notifyRes.ok) {
1714
- throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
1715
- }
1716
- const notifyError = await this.readOptionalJsonRpcError(notifyRes);
1717
- if (notifyError) {
1718
- throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
1719
- }
1720
- return { sessionId, protocolVersion: negotiatedProtocolVersion };
1721
- }
1722
- async readRequiredJsonRpcEnvelope(res, requestId, step) {
1723
- const contentType = res.headers.get("content-type") ?? "";
1724
- if (contentType.includes("text/event-stream")) {
1725
- const payload = await readSseJsonRpcResponse(res, requestId);
1726
- return payload;
1727
- }
1728
- const raw = await res.text();
1729
- if (!raw.trim()) {
1730
- throw new Error(`upstream ${step} returned an empty body`);
1731
- }
1732
- let parsed;
2568
+ const messageEvent = sseEvent("message", JSON.stringify(normalized.body));
2569
+ writeSessionEvent(sessionId, messageEvent);
2570
+ return c.body(null, 202);
2571
+ });
2572
+ return app;
2573
+ }
2574
+
2575
+ // src/server.ts
2576
+ var FORCE_CONNECTION_CLOSE_GRACE_MS = 1500;
2577
+ function normalizeError(error) {
2578
+ if (error instanceof Error) return error;
2579
+ return new Error(String(error));
2580
+ }
2581
+ function createServerHandle(server) {
2582
+ const sockets = /* @__PURE__ */ new Set();
2583
+ const nodeServer = server;
2584
+ nodeServer.on("connection", (socket) => {
2585
+ sockets.add(socket);
2586
+ socket.on("close", () => {
2587
+ sockets.delete(socket);
2588
+ });
2589
+ });
2590
+ const forceCloseConnections = () => {
1733
2591
  try {
1734
- parsed = JSON.parse(raw);
2592
+ nodeServer.closeIdleConnections?.();
1735
2593
  } catch {
1736
- throw new Error(`upstream ${step} returned non-JSON body`);
1737
2594
  }
1738
- if (typeof parsed !== "object" || parsed === null) {
1739
- throw new Error(`upstream ${step} returned non-object JSON`);
2595
+ if (nodeServer.closeAllConnections) {
2596
+ try {
2597
+ nodeServer.closeAllConnections();
2598
+ } catch {
2599
+ }
2600
+ return;
1740
2601
  }
1741
- return parsed;
1742
- }
1743
- async readOptionalJsonRpcError(res) {
1744
- const contentType = res.headers.get("content-type") ?? "";
1745
- if (contentType.includes("text/event-stream")) {
1746
- if (!res.body) return void 0;
1747
- let errorMessage;
1748
- const reader = res.body.getReader();
1749
- const decoder = new TextDecoder();
1750
- let state = { event: "", data: "", remainder: "" };
1751
- let scannedBytes = 0;
1752
- const deadline = Date.now() + this.requestTimeoutMs;
1753
- const onEvent = (event, data) => {
1754
- if (errorMessage) return;
1755
- if (event && event !== "message") return;
1756
- let parsed2;
1757
- try {
1758
- parsed2 = JSON.parse(data);
1759
- } catch {
2602
+ for (const socket of sockets) {
2603
+ socket.destroy();
2604
+ }
2605
+ };
2606
+ return {
2607
+ server,
2608
+ close: () => new Promise((resolve, reject) => {
2609
+ let settled = false;
2610
+ let forceTimer;
2611
+ const settle = (err) => {
2612
+ if (settled) return;
2613
+ settled = true;
2614
+ if (forceTimer) {
2615
+ clearTimeout(forceTimer);
2616
+ forceTimer = void 0;
2617
+ }
2618
+ if (err) {
2619
+ reject(err);
1760
2620
  return;
1761
2621
  }
1762
- if (typeof parsed2 !== "object" || parsed2 === null) return;
1763
- errorMessage = extractJsonRpcErrorMessage(parsed2);
2622
+ resolve();
1764
2623
  };
1765
- for (; ; ) {
1766
- const remainingMs = deadline - Date.now();
1767
- if (remainingMs <= 0) {
1768
- await reader.cancel().catch(() => void 0);
1769
- throw new Error(
1770
- `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1771
- );
1772
- }
1773
- let chunk;
1774
- try {
1775
- chunk = await readSseChunkWithTimeout(reader, remainingMs);
1776
- } catch {
1777
- await reader.cancel().catch(() => void 0);
1778
- throw new Error(
1779
- `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1780
- );
1781
- }
1782
- const { done, value } = chunk;
1783
- if (value !== void 0) {
1784
- scannedBytes += value.byteLength;
1785
- if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
1786
- await reader.cancel().catch(() => void 0);
1787
- throw new Error(
1788
- `upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
1789
- );
1790
- }
1791
- state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
1792
- if (errorMessage) {
1793
- await reader.cancel().catch(() => void 0);
1794
- return errorMessage;
1795
- }
1796
- }
1797
- if (done) {
1798
- const tail = decoder.decode();
1799
- if (tail) {
1800
- state = parseSseChunk(tail, state, onEvent);
2624
+ try {
2625
+ nodeServer.close((err) => {
2626
+ if (err) {
2627
+ settle(err);
2628
+ return;
1801
2629
  }
1802
- return errorMessage;
1803
- }
2630
+ settle();
2631
+ });
2632
+ } catch (error) {
2633
+ settle(normalizeError(error));
2634
+ return;
1804
2635
  }
1805
- }
1806
- const raw = await res.text();
1807
- if (!raw.trim()) return void 0;
1808
- let parsed;
1809
- try {
1810
- parsed = JSON.parse(raw);
1811
- } catch {
1812
- return void 0;
1813
- }
1814
- if (typeof parsed !== "object" || parsed === null) return void 0;
1815
- return extractJsonRpcErrorMessage(parsed);
1816
- }
1817
- };
1818
- async function readSseChunkWithTimeout(reader, timeoutMs) {
1819
- let timeoutHandle;
1820
- try {
1821
- const result = await Promise.race([
1822
- reader.read(),
1823
- new Promise((_, reject) => {
1824
- timeoutHandle = setTimeout(() => {
1825
- reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
1826
- }, timeoutMs);
1827
- })
1828
- ]);
1829
- if (!isSseReadChunk(result)) {
1830
- throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
1831
- }
1832
- return result;
1833
- } finally {
1834
- if (timeoutHandle) clearTimeout(timeoutHandle);
2636
+ try {
2637
+ nodeServer.closeIdleConnections?.();
2638
+ } catch {
2639
+ }
2640
+ forceTimer = setTimeout(() => {
2641
+ forceCloseConnections();
2642
+ }, FORCE_CONNECTION_CLOSE_GRACE_MS);
2643
+ forceTimer.unref();
2644
+ })
2645
+ };
2646
+ }
2647
+ function createApp(config, forwarder, options) {
2648
+ const app = new Hono3();
2649
+ const forwardHeadersAllowlist = config.upstream.forward_headers;
2650
+ const allowedOrigins = config.listen.allowed_origins;
2651
+ const session = compileSessionIdentity(config.session);
2652
+ app.get("/healthz", (c) => c.json({ status: "ok" }));
2653
+ app.route(
2654
+ "/mcp",
2655
+ createStreamableHttpRoute(forwarder, {
2656
+ forwardHeadersAllowlist,
2657
+ allowedOrigins,
2658
+ session,
2659
+ onHeaderMismatch: options?.onHeaderMismatch
2660
+ })
2661
+ );
2662
+ app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist, allowedOrigins, session }));
2663
+ if (options?.slackActionApp) {
2664
+ app.route("/slack/actions", options.slackActionApp);
1835
2665
  }
2666
+ return app;
1836
2667
  }
1837
- function isSseReadChunk(value) {
1838
- if (typeof value !== "object" || value === null) return false;
1839
- const candidate = value;
1840
- if (typeof candidate.done !== "boolean") return false;
1841
- if (candidate.value === void 0) return true;
1842
- return candidate.value instanceof Uint8Array;
2668
+ function startServer(app, config) {
2669
+ const server = serve({
2670
+ fetch: app.fetch,
2671
+ port: config.listen.port,
2672
+ hostname: config.listen.host
2673
+ });
2674
+ return createServerHandle(server);
1843
2675
  }
1844
- function extractJsonRpcErrorMessage(payload) {
1845
- const error = payload["error"];
1846
- if (typeof error === "string") return error;
1847
- if (typeof error !== "object" || error === null) return void 0;
1848
- const message = error["message"];
1849
- if (typeof message === "string" && message.trim()) return message;
1850
- return "unknown JSON-RPC error";
2676
+ function startSidebandServer(app, port, host = "127.0.0.1") {
2677
+ const server = serve({
2678
+ fetch: app.fetch,
2679
+ port,
2680
+ hostname: host
2681
+ });
2682
+ return createServerHandle(server);
1851
2683
  }
1852
- function extractNegotiatedProtocolVersion(payload) {
1853
- const result = payload["result"];
1854
- if (typeof result !== "object" || result === null) {
1855
- return HELIO_MCP_PROTOCOL_VERSION;
2684
+
2685
+ // src/upstream/response.ts
2686
+ async function parseUpstreamResponse(res) {
2687
+ const headers = {};
2688
+ res.headers.forEach((value, key) => {
2689
+ headers[key] = value;
2690
+ });
2691
+ const contentType = res.headers.get("content-type") ?? "";
2692
+ let body;
2693
+ if (contentType.includes("application/json")) {
2694
+ const text = await res.text();
2695
+ try {
2696
+ body = JSON.parse(text);
2697
+ } catch {
2698
+ body = text;
2699
+ }
2700
+ } else {
2701
+ body = await res.text();
1856
2702
  }
1857
- const protocolVersion = result["protocolVersion"];
1858
- return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
2703
+ return { status: res.status, headers, body };
1859
2704
  }
1860
2705
 
1861
2706
  // src/upstream/streamable-http-forwarder.ts
2707
+ var JSON_RPC_METHOD_NOT_FOUND = -32601;
1862
2708
  var StreamableHttpForwarder = class {
1863
2709
  url;
1864
2710
  staticHeaders;
@@ -1871,7 +2717,8 @@ var StreamableHttpForwarder = class {
1871
2717
  this.sessions = new UpstreamSessionManager({
1872
2718
  url: this.url,
1873
2719
  staticHeaders: this.staticHeaders,
1874
- requestTimeoutMs: this.requestTimeoutMs
2720
+ requestTimeoutMs: this.requestTimeoutMs,
2721
+ protocolVersion: options.protocolVersion
1875
2722
  });
1876
2723
  }
1877
2724
  /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
@@ -1884,15 +2731,91 @@ var StreamableHttpForwarder = class {
1884
2731
  return Promise.resolve();
1885
2732
  }
1886
2733
  async forward(request) {
1887
- if (request.method === "initialize") {
1888
- return this.send(
1889
- request,
1890
- request.sessionId,
1891
- /* protocolVersion */
1892
- void 0
2734
+ const era = await this.sessions.resolveRelayEra();
2735
+ if (era === "modern") {
2736
+ if (request.method === "initialize") {
2737
+ return this.synthesizeInitializeResult(request);
2738
+ }
2739
+ if (request.method === "notifications/initialized") {
2740
+ return this.swallowInitializedNotification();
2741
+ }
2742
+ return this.send(request, {
2743
+ sessionId: void 0,
2744
+ protocolVersion: void 0,
2745
+ era: "modern"
2746
+ });
2747
+ }
2748
+ const result = request.method === "initialize" ? await this.send(request, {
2749
+ sessionId: request.transportSessionId,
2750
+ protocolVersion: void 0
2751
+ }) : (
2752
+ // Downstream-driven and external sessionless callers alike are
2753
+ // transparent passthrough: forward whatever session the caller did
2754
+ // (or did not) supply.
2755
+ await this.send(request, {
2756
+ sessionId: request.transportSessionId,
2757
+ protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION
2758
+ })
2759
+ );
2760
+ this.inspectLegacyRelayOutcome(request, result.response);
2761
+ return result;
2762
+ }
2763
+ /**
2764
+ * The dual-era bridge (relay leg, modern era): a modern-only server
2765
+ * answers the retired `initialize` handshake with 404/-32601, so Helio
2766
+ * synthesizes the legacy InitializeResult locally from the upstream's own
2767
+ * probe-time DiscoverResult. No `mcp-session-id` response header — the
2768
+ * legacy spec permits sessionless servers, and the downstream stays
2769
+ * sessionless. The synthesized protocolVersion is always the current
2770
+ * legacy revision, even for a client that offered an older one.
2771
+ */
2772
+ synthesizeInitializeResult(request) {
2773
+ const capture = this.sessions.getDiscoverCapture();
2774
+ const result = {
2775
+ protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION,
2776
+ capabilities: capture?.capabilities ?? { tools: {} },
2777
+ // NOT copied from upstream: the 2026-07-28 DiscoverResult has no
2778
+ // serverInfo field at all, and the bridge is Helio's own construct —
2779
+ // matching buildInternalMeta()'s identity.
2780
+ serverInfo: { name: "helio-proxy", version: "0" }
2781
+ };
2782
+ if (capture?.instructions !== void 0) {
2783
+ result["instructions"] = capture.instructions;
2784
+ }
2785
+ const response = {
2786
+ status: 200,
2787
+ headers: { "content-type": "application/json" },
2788
+ body: { jsonrpc: "2.0", id: request.id ?? null, result }
2789
+ };
2790
+ return { response, durationMs: 0 };
2791
+ }
2792
+ /**
2793
+ * The modern upstream removed `notifications/initialized`; answer the
2794
+ * same minimal success envelope the SSE-notification path returns.
2795
+ */
2796
+ swallowInitializedNotification() {
2797
+ const response = { status: 200, headers: {}, body: { jsonrpc: "2.0" } };
2798
+ return { response, durationMs: 0 };
2799
+ }
2800
+ /**
2801
+ * The relay-side era falsification door (issue #219): a legacy-leg relay whose answer only a
2802
+ * modern server gives clears the cached legacy era (the manager no-ops on
2803
+ * pins, uncached eras, and cached modern). The response still flows to the
2804
+ * client unchanged — no in-place retry.
2805
+ */
2806
+ inspectLegacyRelayOutcome(request, response) {
2807
+ const errorCode = readJsonRpcErrorCode(response.body);
2808
+ if (errorCode !== void 0 && MCP_MODERN_ONLY_ERROR_CODES.has(errorCode)) {
2809
+ this.sessions.clearFalsifiedLegacyEra(
2810
+ `a relayed response carried the modern-only JSON-RPC error ${String(errorCode)}`
2811
+ );
2812
+ return;
2813
+ }
2814
+ if (request.method === "initialize" && (response.status === 404 || errorCode === JSON_RPC_METHOD_NOT_FOUND)) {
2815
+ this.sessions.clearFalsifiedLegacyEra(
2816
+ response.status === 404 ? "a relayed initialize was answered with HTTP 404" : "a relayed initialize was answered with JSON-RPC -32601"
1893
2817
  );
1894
2818
  }
1895
- return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
1896
2819
  }
1897
2820
  /**
1898
2821
  * Helio-internal execution path (startup prime / internal maintenance) that
@@ -1903,8 +2826,7 @@ var StreamableHttpForwarder = class {
1903
2826
  try {
1904
2827
  return await this.send(
1905
2828
  request,
1906
- session.sessionId,
1907
- session.protocolVersion,
2829
+ session,
1908
2830
  /* internalManaged */
1909
2831
  true
1910
2832
  );
@@ -1914,8 +2836,7 @@ var StreamableHttpForwarder = class {
1914
2836
  const fresh = await this.sessions.ensureInternalSession();
1915
2837
  return this.send(
1916
2838
  request,
1917
- fresh.sessionId,
1918
- fresh.protocolVersion,
2839
+ fresh,
1919
2840
  /* internalManaged */
1920
2841
  true
1921
2842
  );
@@ -1923,7 +2844,50 @@ var StreamableHttpForwarder = class {
1923
2844
  throw error;
1924
2845
  }
1925
2846
  }
1926
- async send(request, sessionId, protocolVersion, internalManaged = false) {
2847
+ /** Drop the managed internal session AND the cached era; next internal call re-probes. */
2848
+ resetInternalSession() {
2849
+ this.sessions.invalidateInternalSession();
2850
+ }
2851
+ async send(request, session, internalManaged = false) {
2852
+ const modern = session.era === "modern";
2853
+ let outboundParams;
2854
+ if (modern) {
2855
+ if (request.params !== void 0 && !isPlainObject(request.params)) {
2856
+ throw new Error(
2857
+ "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"
2858
+ );
2859
+ }
2860
+ const params = request.params ?? {};
2861
+ const rawMeta = params["_meta"];
2862
+ const existingMeta = isPlainObject(rawMeta) ? rawMeta : {};
2863
+ const internalMeta = buildInternalMeta();
2864
+ const clientCapabilities = existingMeta["io.modelcontextprotocol/clientCapabilities"];
2865
+ const clientInfo = existingMeta["io.modelcontextprotocol/clientInfo"];
2866
+ outboundParams = {
2867
+ ...params,
2868
+ _meta: {
2869
+ ...existingMeta,
2870
+ [MCP_META_PROTOCOL_VERSION_KEY]: HELIO_MCP_MODERN_PROTOCOL_VERSION,
2871
+ "io.modelcontextprotocol/clientCapabilities": clientCapabilities !== void 0 ? clientCapabilities : internalMeta["io.modelcontextprotocol/clientCapabilities"],
2872
+ "io.modelcontextprotocol/clientInfo": clientInfo !== void 0 ? clientInfo : internalMeta["io.modelcontextprotocol/clientInfo"]
2873
+ }
2874
+ };
2875
+ } else {
2876
+ outboundParams = request.params;
2877
+ }
2878
+ if (modern) {
2879
+ if (!isHeaderSafeMethod(request.method)) {
2880
+ throw new Error(
2881
+ "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"
2882
+ );
2883
+ }
2884
+ const nameValue = encodedNameValue(request.method, outboundParams);
2885
+ if (nameValue !== void 0 && Buffer.byteLength(nameValue) > MCP_NAME_MAX_BYTES) {
2886
+ throw new Error(
2887
+ `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`
2888
+ );
2889
+ }
2890
+ }
1927
2891
  const headers = mergeUpstreamHeaders(
1928
2892
  {
1929
2893
  "content-type": "application/json",
@@ -1932,16 +2896,26 @@ var StreamableHttpForwarder = class {
1932
2896
  request.headers ?? {},
1933
2897
  this.staticHeaders
1934
2898
  );
1935
- if (sessionId) headers["mcp-session-id"] = sessionId;
1936
- if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
1937
- headers["mcp-protocol-version"] = protocolVersion;
1938
- }
2899
+ if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
2900
+ if (modern) {
2901
+ delete headers["mcp-session-id"];
2902
+ headers["mcp-protocol-version"] = HELIO_MCP_MODERN_PROTOCOL_VERSION;
2903
+ } else if (session.protocolVersion && headers["mcp-protocol-version"] === void 0) {
2904
+ headers["mcp-protocol-version"] = session.protocolVersion;
2905
+ }
2906
+ delete headers["mcp-method"];
2907
+ delete headers["mcp-name"];
2908
+ Object.assign(headers, buildStandardRequestHeaders(request.method, outboundParams));
1939
2909
  const body = {
1940
2910
  jsonrpc: request.jsonrpc,
1941
2911
  method: request.method
1942
2912
  };
1943
2913
  if (request.id !== void 0) body["id"] = request.id;
1944
- if (request.params !== void 0) body["params"] = request.params;
2914
+ if (modern) {
2915
+ body["params"] = outboundParams;
2916
+ } else if (request.params !== void 0) {
2917
+ body["params"] = outboundParams;
2918
+ }
1945
2919
  const start = performance.now();
1946
2920
  const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
1947
2921
  const requestSignal = request.signal;
@@ -1957,7 +2931,7 @@ var StreamableHttpForwarder = class {
1957
2931
  }
1958
2932
  throw describeUnreachableUpstream(error, this.url) ?? error;
1959
2933
  }
1960
- if (internalManaged && res.status === 404 && sessionId) {
2934
+ if (internalManaged && res.status === 404 && session.sessionId) {
1961
2935
  await res.text().catch(() => void 0);
1962
2936
  throw new UpstreamSessionExpiredError();
1963
2937
  }
@@ -1967,6 +2941,7 @@ var StreamableHttpForwarder = class {
1967
2941
  res.headers.forEach((value, key) => {
1968
2942
  responseHeaders[key] = value;
1969
2943
  });
2944
+ if (modern) delete responseHeaders["mcp-session-id"];
1970
2945
  if (request.id === void 0) {
1971
2946
  await res.body?.cancel().catch(() => void 0);
1972
2947
  const response3 = {
@@ -1981,6 +2956,7 @@ var StreamableHttpForwarder = class {
1981
2956
  return { response: response2, durationMs: performance.now() - start };
1982
2957
  }
1983
2958
  const response = await parseUpstreamResponse(res);
2959
+ if (modern) delete response.headers["mcp-session-id"];
1984
2960
  return { response, durationMs: performance.now() - start };
1985
2961
  }
1986
2962
  };
@@ -1990,6 +2966,16 @@ var UpstreamSessionExpiredError = class extends Error {
1990
2966
  this.name = "UpstreamSessionExpiredError";
1991
2967
  }
1992
2968
  };
2969
+ function isPlainObject(value) {
2970
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2971
+ }
2972
+ function readJsonRpcErrorCode(body) {
2973
+ if (typeof body !== "object" || body === null) return void 0;
2974
+ const error = body["error"];
2975
+ if (typeof error !== "object" || error === null) return void 0;
2976
+ const code = error["code"];
2977
+ return typeof code === "number" ? code : void 0;
2978
+ }
1993
2979
 
1994
2980
  // src/upstream/forwarder.ts
1995
2981
  var UpstreamForwarder = class extends StreamableHttpForwarder {
@@ -2142,8 +3128,11 @@ var SseUpstreamForwarder = class {
2142
3128
  request.headers ?? {},
2143
3129
  this.staticHeaders
2144
3130
  );
2145
- if (request.sessionId) {
2146
- headers["mcp-session-id"] = request.sessionId;
3131
+ delete headers["mcp-method"];
3132
+ delete headers["mcp-name"];
3133
+ delete headers["mcp-session-id"];
3134
+ if (request.transportSessionId) {
3135
+ headers["mcp-session-id"] = request.transportSessionId;
2147
3136
  }
2148
3137
  const start = performance.now();
2149
3138
  const signal = buildRequestSignal(request, this.requestTimeoutMs);
@@ -2572,6 +3561,79 @@ function evaluatePolicy(policy, ctx) {
2572
3561
  // src/policy/governed-forwarder.ts
2573
3562
  import { randomUUID as randomUUID2 } from "crypto";
2574
3563
 
3564
+ // src/policy/session-gate.ts
3565
+ function isWellFormedSessionId(id) {
3566
+ return id != null && id.trim() !== "";
3567
+ }
3568
+ function gateSession(sessionId, onUnresolved) {
3569
+ if (isWellFormedSessionId(sessionId)) {
3570
+ return { ok: true, session: sessionId, anonymous: false };
3571
+ }
3572
+ if (onUnresolved === "anonymous") {
3573
+ return { ok: true, session: "unknown", anonymous: true };
3574
+ }
3575
+ return { ok: false };
3576
+ }
3577
+ function sessionLimitKey(session) {
3578
+ return `session:${session}`;
3579
+ }
3580
+ function gateBudgetCharges(resolved, gate) {
3581
+ const sessionEngaged = resolved.charges.some((charge) => charge.budget.key === "session") || resolved.failures.some((failure) => failure.budget.key === "session");
3582
+ if (!gate.ok) {
3583
+ if (sessionEngaged) return { ok: false, unresolvedEngaged: true };
3584
+ return { ok: true, charges: resolved.charges };
3585
+ }
3586
+ if (gate.anonymous && sessionEngaged) warnAnonymousPoolingOnce();
3587
+ return { ok: true, charges: resolved.charges };
3588
+ }
3589
+ function freezeGatedPlans(charges, breached) {
3590
+ if (breached.length !== charges.length) {
3591
+ throw new Error(
3592
+ `freezeGatedPlans: breach markers must pair positionally with charges (${String(breached.length)} markers for ${String(charges.length)} charges)`
3593
+ );
3594
+ }
3595
+ return charges.map(
3596
+ (charge, index) => ({
3597
+ kind: "budget",
3598
+ budget: charge.budget,
3599
+ bucketKey: charge.bucketKey,
3600
+ amount: charge.amount,
3601
+ generation: charge.generation,
3602
+ breached: breached[index] === true
3603
+ })
3604
+ );
3605
+ }
3606
+ function remintDeferredCharges(frozen, actualAmount) {
3607
+ return frozen.map((plan) => ({
3608
+ budget: plan.budget,
3609
+ bucketKey: plan.bucketKey,
3610
+ amount: actualAmount ?? plan.amount,
3611
+ generation: plan.generation
3612
+ }));
3613
+ }
3614
+ function sessionUnresolvedControlMessage(tried) {
3615
+ return `No session identity resolved (tried: ${tried}) \u2014 a session-keyed limit or budget requires one. See session.identity in helio.yaml.`;
3616
+ }
3617
+ function sessionRequiredForGroundingMessage(tried) {
3618
+ return `No session identity resolved (tried: ${tried}) \u2014 rules using evidence.requires or requires need one. See session.identity in helio.yaml.`;
3619
+ }
3620
+ var unresolvedEngagementWarned = false;
3621
+ var anonymousPoolingWarned = false;
3622
+ function warnSessionUnresolvedEngagementOnce(tried) {
3623
+ if (unresolvedEngagementWarned) return;
3624
+ unresolvedEngagementWarned = true;
3625
+ console.error(
3626
+ `[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.`
3627
+ );
3628
+ }
3629
+ function warnAnonymousPoolingOnce() {
3630
+ if (anonymousPoolingWarned) return;
3631
+ anonymousPoolingWarned = true;
3632
+ console.error(
3633
+ '[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.'
3634
+ );
3635
+ }
3636
+
2575
3637
  // src/evidence/grounding.ts
2576
3638
  function checkEvidence(store, sessionId, requirements) {
2577
3639
  if (requirements.length === 0) {
@@ -2617,7 +3679,8 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
2617
3679
 
2618
3680
  // src/policy/decision-pipeline.ts
2619
3681
  function decide(input) {
2620
- const { toolName, toolArguments, sessionId, policy, environment, evidenceStore } = input;
3682
+ const { toolName, toolArguments, policy, environment, evidenceStore } = input;
3683
+ const sessionId = isWellFormedSessionId(input.sessionId) ? input.sessionId : void 0;
2621
3684
  const annotations = input.baselineAnnotations;
2622
3685
  const driftEvent = input.driftEvent;
2623
3686
  const driftMode = policy.onToolDrift ?? "block";
@@ -2676,7 +3739,9 @@ function decide(input) {
2676
3739
  decision = {
2677
3740
  action: "deny",
2678
3741
  matchedRule: decision.matchedRule,
2679
- reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
3742
+ reason: sessionRequiredForGroundingMessage(
3743
+ input.sessionStrategySummary ?? "the configured session.identity chain"
3744
+ )
2680
3745
  };
2681
3746
  }
2682
3747
  if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
@@ -3127,6 +4192,17 @@ function buildToolDriftFeedback(drift, action) {
3127
4192
  retry_allowed: false
3128
4193
  };
3129
4194
  }
4195
+ function buildSessionUnresolvedFeedback(decision, control, tried) {
4196
+ return {
4197
+ blocked: true,
4198
+ reason: "session_unresolved",
4199
+ ...ruleInfo(decision.matchedRule),
4200
+ control,
4201
+ tried,
4202
+ 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.`,
4203
+ retry_allowed: true
4204
+ };
4205
+ }
3130
4206
  function buildSpendLimitedFeedback(decision, result, currency) {
3131
4207
  const info = ruleInfo(decision.matchedRule);
3132
4208
  const windowSeconds = Math.round(result.windowMs / 1e3);
@@ -3584,6 +4660,7 @@ var GovernedForwarder = class {
3584
4660
  inner;
3585
4661
  policy;
3586
4662
  environment;
4663
+ session;
3587
4664
  auditWriter;
3588
4665
  evidenceStore;
3589
4666
  approvalRouter;
@@ -3603,6 +4680,7 @@ var GovernedForwarder = class {
3603
4680
  this.rateLimiter = options?.rateLimiter;
3604
4681
  this.spendLimiter = options?.spendLimiter;
3605
4682
  this.budgetEngine = options?.budgetEngine;
4683
+ this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
3606
4684
  if (this.evidenceStore) {
3607
4685
  this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
3608
4686
  }
@@ -3676,6 +4754,7 @@ var GovernedForwarder = class {
3676
4754
  try {
3677
4755
  const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
3678
4756
  if (result.response.status >= 400) {
4757
+ internal.resetInternalSession?.();
3679
4758
  return {
3680
4759
  success: false,
3681
4760
  toolsCached: this.annotationCache.size,
@@ -3684,6 +4763,7 @@ var GovernedForwarder = class {
3684
4763
  }
3685
4764
  const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
3686
4765
  if (!update.updated) {
4766
+ internal.resetInternalSession?.();
3687
4767
  return {
3688
4768
  success: false,
3689
4769
  toolsCached: this.annotationCache.size,
@@ -3692,6 +4772,7 @@ var GovernedForwarder = class {
3692
4772
  }
3693
4773
  return { success: true, toolsCached: this.annotationCache.size };
3694
4774
  } catch (error) {
4775
+ internal.resetInternalSession?.();
3695
4776
  return {
3696
4777
  success: false,
3697
4778
  toolsCached: this.annotationCache.size,
@@ -3705,17 +4786,42 @@ var GovernedForwarder = class {
3705
4786
  }
3706
4787
  const result = await this.inner.forward(request);
3707
4788
  if (request.method === "tools/list") {
3708
- this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
4789
+ this.applyToolDefinitionUpdate(result.response.body, request.session);
4790
+ this.clampCacheHints(result.response.body);
3709
4791
  }
3710
4792
  return result;
3711
4793
  }
4794
+ /**
4795
+ * Clamp an over-long `result.ttlMs` on a `tools/list` response to
4796
+ * `policies.tool_revalidation.max_advertised_ttl` (issue #221, D7).
4797
+ *
4798
+ * Downward-only: a `ttlMs` at or below the cap is left untouched, and a
4799
+ * response with no `ttlMs` never gains one — Helio does not manufacture a
4800
+ * cache hint the upstream never advertised. Non-numeric values are left
4801
+ * alone rather than coerced. `cacheScope` passes through untouched: Helio
4802
+ * baselines and vouches for tool *definitions* only, and its own
4803
+ * `tools/list` view is not caller-varying, so it has no basis to alter a
4804
+ * scope hint the upstream set. No-op when tool revalidation is disabled
4805
+ * (including hand-built `CompiledPolicy` fixtures that omit the field).
4806
+ */
4807
+ clampCacheHints(responseBody) {
4808
+ const rv = this.policy.toolRevalidation;
4809
+ if (!rv?.enabled) return;
4810
+ if (typeof responseBody !== "object" || responseBody === null) return;
4811
+ const result = responseBody["result"];
4812
+ if (typeof result !== "object" || result === null) return;
4813
+ const r = result;
4814
+ if (typeof r["ttlMs"] === "number" && r["ttlMs"] > rv.maxAdvertisedTtlMs) {
4815
+ r["ttlMs"] = rv.maxAdvertisedTtlMs;
4816
+ }
4817
+ }
3712
4818
  /**
3713
4819
  * Apply a tools/list response to the definition cache and surface any
3714
4820
  * drift: console warning + immediate audit record per event. Single entry
3715
4821
  * point for both runtime tools/list responses and startup priming, so the
3716
4822
  * cache is updated exactly once per response.
3717
4823
  */
3718
- applyToolDefinitionUpdate(responseBody, sessionId) {
4824
+ applyToolDefinitionUpdate(responseBody, session) {
3719
4825
  const update = this.annotationCache.update(responseBody);
3720
4826
  if (!update.updated) return update;
3721
4827
  for (const drift of update.drifted) {
@@ -3723,22 +4829,23 @@ var GovernedForwarder = class {
3723
4829
  console.error(
3724
4830
  `[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
3725
4831
  );
3726
- this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
4832
+ this.writeDriftAuditRecord(drift, session, "tool_drift");
3727
4833
  }
3728
4834
  for (const toolName of update.reverted) {
3729
4835
  console.error(
3730
4836
  `[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
3731
4837
  );
3732
- this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
4838
+ this.writeDriftAuditRecord({ toolName, changes: [] }, session, "tool_drift_reverted");
3733
4839
  }
3734
4840
  return update;
3735
4841
  }
3736
4842
  /** Write an immediate audit record for a drift event (not a tool call). */
3737
- writeDriftAuditRecord(drift, sessionId, decision) {
4843
+ writeDriftAuditRecord(drift, session, decision) {
3738
4844
  if (!this.auditWriter) return;
3739
4845
  this.auditWriter.pushImmediate({
3740
4846
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3741
- session_id: sessionId ?? null,
4847
+ session_id: session?.id ?? null,
4848
+ session_source: session?.source ?? null,
3742
4849
  agent_id: null,
3743
4850
  environment: this.environment ?? null,
3744
4851
  tool_name: drift.toolName,
@@ -3761,7 +4868,9 @@ var GovernedForwarder = class {
3761
4868
  dry_run: false,
3762
4869
  record_kind: "drift_event",
3763
4870
  origin: "mcp",
3764
- metadata: null
4871
+ metadata: null,
4872
+ // Drift is a cache event, not a request: no protocol claim exists.
4873
+ protocol_version: null
3765
4874
  });
3766
4875
  }
3767
4876
  async handleToolsCall(original) {
@@ -3798,7 +4907,8 @@ var GovernedForwarder = class {
3798
4907
  } = decide({
3799
4908
  toolName,
3800
4909
  toolArguments,
3801
- sessionId: request.sessionId,
4910
+ sessionId: request.session?.id,
4911
+ sessionStrategySummary: this.session.strategySummary,
3802
4912
  policy: this.policy,
3803
4913
  environment: this.environment,
3804
4914
  evidenceStore: this.evidenceStore,
@@ -3891,9 +5001,10 @@ var GovernedForwarder = class {
3891
5001
  });
3892
5002
  }
3893
5003
  try {
3894
- if (forwarded && !isDryRun && this.evidenceStore && request.sessionId && toolName) {
5004
+ const dependencySessionId = request.session?.id;
5005
+ if (forwarded && !isDryRun && this.evidenceStore && isWellFormedSessionId(dependencySessionId) && toolName) {
3895
5006
  const succeeded = !hasJsonRpcError(result);
3896
- this.evidenceStore.recordToolCall(request.sessionId, toolName, succeeded);
5007
+ this.evidenceStore.recordToolCall(dependencySessionId, toolName, succeeded);
3897
5008
  }
3898
5009
  } catch (err) {
3899
5010
  console.error("[helio] dependency tracking failed after forward:", err);
@@ -3914,6 +5025,7 @@ var GovernedForwarder = class {
3914
5025
  evidenceResult,
3915
5026
  dependencyResult,
3916
5027
  evidenceBlocked,
5028
+ sessionBlocked,
3917
5029
  approvalOutcome,
3918
5030
  approvalContext,
3919
5031
  rateLimitResult,
@@ -3944,7 +5056,7 @@ var GovernedForwarder = class {
3944
5056
  tool_name: toolName,
3945
5057
  tool_input: toolArguments ?? {},
3946
5058
  matched_rule: decision.matchedRule,
3947
- session_id: request.sessionId ?? null,
5059
+ session_id: request.session?.id ?? null,
3948
5060
  breached_budgets: gate.breachContexts,
3949
5061
  approval: gate.approval
3950
5062
  },
@@ -4087,15 +5199,25 @@ var GovernedForwarder = class {
4087
5199
  gateBudgets(request, decision, toolName, toolArguments) {
4088
5200
  const engine = this.budgetEngine;
4089
5201
  if (!engine) return { kind: "proceed" };
5202
+ const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
4090
5203
  const { charges, failures } = engine.resolveCharges({
4091
5204
  toolName,
4092
5205
  toolArguments,
4093
- sessionId: request.sessionId ?? null,
5206
+ sessionId: sessionGate.ok ? sessionGate.session : null,
4094
5207
  senderId: null
4095
5208
  // adapter context; absent on the MCP path
4096
5209
  });
4097
5210
  if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
4098
- const peek = charges.length > 0 ? engine.peekAll(charges) : { allowed: true, entries: [] };
5211
+ const gated = gateBudgetCharges({ charges, failures }, sessionGate);
5212
+ if (!gated.ok) {
5213
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
5214
+ return {
5215
+ kind: "blocked",
5216
+ result: this.makeSessionUnresolvedResult(request, decision, "budget"),
5217
+ chain: []
5218
+ };
5219
+ }
5220
+ const peek = charges.length > 0 ? engine.peekAll(gated.charges) : { allowed: true, entries: [] };
4099
5221
  const breaches = peek.entries.filter((entry) => !entry.allowed);
4100
5222
  const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
4101
5223
  if (breaches.length > 0) engine.reportBreaches(breaches);
@@ -4128,7 +5250,7 @@ var GovernedForwarder = class {
4128
5250
  const peekBlockByName = new Map(
4129
5251
  peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
4130
5252
  );
4131
- const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
5253
+ const commit = (auditRecordId, kinds) => engine.recordAll(gated.charges, {
4132
5254
  kind: "spend",
4133
5255
  ...kinds ? { kinds } : {},
4134
5256
  auditRecordId,
@@ -4204,7 +5326,8 @@ var GovernedForwarder = class {
4204
5326
  const toolInput = { raw_params: params ?? null };
4205
5327
  this.auditWriter.pushImmediate({
4206
5328
  timestamp,
4207
- session_id: request.sessionId ?? null,
5329
+ session_id: request.session?.id ?? null,
5330
+ session_source: request.session?.source ?? null,
4208
5331
  agent_id: null,
4209
5332
  environment: this.environment ?? null,
4210
5333
  tool_name: "<nameless>",
@@ -4227,7 +5350,8 @@ var GovernedForwarder = class {
4227
5350
  dry_run: false,
4228
5351
  record_kind: "tool_call",
4229
5352
  origin: "mcp",
4230
- metadata: null
5353
+ metadata: null,
5354
+ protocol_version: request.protocolVersion ?? null
4231
5355
  });
4232
5356
  }
4233
5357
  return result;
@@ -4240,7 +5364,7 @@ var GovernedForwarder = class {
4240
5364
  tool_name: toolName,
4241
5365
  tool_input: toolArguments ?? {},
4242
5366
  matched_rule: decision.matchedRule,
4243
- session_id: request.sessionId ?? null
5367
+ session_id: request.session?.id ?? null
4244
5368
  },
4245
5369
  request.signal
4246
5370
  );
@@ -4336,7 +5460,20 @@ var GovernedForwarder = class {
4336
5460
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
4337
5461
  };
4338
5462
  }
4339
- const key = this.buildLimitKey(limits.key, toolName, request);
5463
+ let key;
5464
+ if (limits.key === "session") {
5465
+ const sessionKey = this.gateSessionLimitKey(request);
5466
+ if (sessionKey === null) {
5467
+ return {
5468
+ proceed: false,
5469
+ result: this.makeSessionUnresolvedResult(request, decision, "rate_limit"),
5470
+ approvalWaitMs: 0
5471
+ };
5472
+ }
5473
+ key = sessionKey;
5474
+ } else {
5475
+ key = this.buildLimitKey(limits.key, toolName);
5476
+ }
4340
5477
  const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
4341
5478
  const rateLimitResult = limiter.peek(params);
4342
5479
  if (!rateLimitResult.allowed) {
@@ -4374,7 +5511,21 @@ var GovernedForwarder = class {
4374
5511
  spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
4375
5512
  };
4376
5513
  }
4377
- const key = this.buildSpendLimitKey(maxSpend.key, toolName, request, decision.matchedRule.index);
5514
+ let baseKey;
5515
+ if (maxSpend.key === "session") {
5516
+ const sessionKey = this.gateSessionLimitKey(request);
5517
+ if (sessionKey === null) {
5518
+ return {
5519
+ proceed: false,
5520
+ result: this.makeSessionUnresolvedResult(request, decision, "spend_limit"),
5521
+ approvalWaitMs: 0
5522
+ };
5523
+ }
5524
+ baseKey = sessionKey;
5525
+ } else {
5526
+ baseKey = this.buildLimitKey(maxSpend.key, toolName);
5527
+ }
5528
+ const key = spendBucketKey(baseKey, decision.matchedRule.index);
4378
5529
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
4379
5530
  if (typeof rawAmount !== "number") {
4380
5531
  console.error(
@@ -4438,6 +5589,7 @@ var GovernedForwarder = class {
4438
5589
  const evidenceSatisfied = !evidenceBlocked;
4439
5590
  let wouldForward = false;
4440
5591
  let limitsOk = true;
5592
+ let sessionUnresolved = false;
4441
5593
  if (!evidenceBlocked) {
4442
5594
  switch (decision.action) {
4443
5595
  case "allow":
@@ -4445,14 +5597,21 @@ var GovernedForwarder = class {
4445
5597
  break;
4446
5598
  case "rate_limit":
4447
5599
  if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
4448
- const key = this.buildLimitKey(decision.matchedRule.limits.key, toolName, request);
4449
- const peekResult = this.rateLimiter.peek({
4450
- key,
4451
- maxCalls: decision.matchedRule.limits.maxCalls,
4452
- windowMs: decision.matchedRule.limits.windowMs
4453
- });
4454
- wouldForward = peekResult.allowed;
4455
- limitsOk = peekResult.allowed;
5600
+ const limits = decision.matchedRule.limits;
5601
+ const key = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5602
+ if (key === null) {
5603
+ wouldForward = false;
5604
+ limitsOk = false;
5605
+ sessionUnresolved = true;
5606
+ } else {
5607
+ const peekResult = this.rateLimiter.peek({
5608
+ key,
5609
+ maxCalls: decision.matchedRule.limits.maxCalls,
5610
+ windowMs: decision.matchedRule.limits.windowMs
5611
+ });
5612
+ wouldForward = peekResult.allowed;
5613
+ limitsOk = peekResult.allowed;
5614
+ }
4456
5615
  }
4457
5616
  break;
4458
5617
  case "spend_limit":
@@ -4472,20 +5631,21 @@ var GovernedForwarder = class {
4472
5631
  wouldForward = false;
4473
5632
  limitsOk = false;
4474
5633
  } else {
4475
- const key = this.buildSpendLimitKey(
4476
- maxSpend.key,
4477
- toolName,
4478
- request,
4479
- decision.matchedRule.index
4480
- );
4481
- const peekResult = this.spendLimiter.peek({
4482
- key,
4483
- amount: rawAmount,
4484
- limit: maxSpend.limit,
4485
- windowMs: maxSpend.windowMs
4486
- });
4487
- wouldForward = peekResult.allowed;
4488
- limitsOk = peekResult.allowed;
5634
+ const baseKey = maxSpend.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(maxSpend.key, toolName);
5635
+ if (baseKey === null) {
5636
+ wouldForward = false;
5637
+ limitsOk = false;
5638
+ sessionUnresolved = true;
5639
+ } else {
5640
+ const peekResult = this.spendLimiter.peek({
5641
+ key: spendBucketKey(baseKey, decision.matchedRule.index),
5642
+ amount: rawAmount,
5643
+ limit: maxSpend.limit,
5644
+ windowMs: maxSpend.windowMs
5645
+ });
5646
+ wouldForward = peekResult.allowed;
5647
+ limitsOk = peekResult.allowed;
5648
+ }
4489
5649
  }
4490
5650
  }
4491
5651
  break;
@@ -4493,30 +5653,39 @@ var GovernedForwarder = class {
4493
5653
  }
4494
5654
  let budgets;
4495
5655
  if (wouldForward && this.budgetEngine) {
5656
+ const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
4496
5657
  const { charges, failures } = this.budgetEngine.resolveCharges({
4497
5658
  toolName,
4498
5659
  toolArguments,
4499
- sessionId: request.sessionId ?? null,
5660
+ sessionId: sessionGate.ok ? sessionGate.session : null,
4500
5661
  senderId: null
4501
5662
  });
4502
5663
  if (failures.length > 0 || charges.length > 0) {
4503
- const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
4504
- const ok = failures.length === 0 && peek.allowed;
4505
- wouldForward &&= ok;
4506
- limitsOk &&= ok;
4507
- budgets = [
4508
- ...peek.entries.map((entry) => budgetChainBlock(entry)),
4509
- ...failures.map((failure) => ({
4510
- name: failure.budget.name,
4511
- bucket_key: failure.bucketKey,
4512
- allowed: false,
4513
- reason: failure.reason,
4514
- spent: failure.spent,
4515
- limit: failure.budget.limit,
4516
- remaining: failure.remaining,
4517
- currency: failure.budget.currency
4518
- }))
4519
- ];
5664
+ const gated = gateBudgetCharges({ charges, failures }, sessionGate);
5665
+ if (!gated.ok) {
5666
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
5667
+ wouldForward = false;
5668
+ limitsOk = false;
5669
+ sessionUnresolved = true;
5670
+ } else {
5671
+ const peek = charges.length > 0 ? this.budgetEngine.peekAll(gated.charges) : { allowed: true, entries: [] };
5672
+ const ok = failures.length === 0 && peek.allowed;
5673
+ wouldForward &&= ok;
5674
+ limitsOk &&= ok;
5675
+ budgets = [
5676
+ ...peek.entries.map((entry) => budgetChainBlock(entry)),
5677
+ ...failures.map((failure) => ({
5678
+ name: failure.budget.name,
5679
+ bucket_key: failure.bucketKey,
5680
+ allowed: false,
5681
+ reason: failure.reason,
5682
+ spent: failure.spent,
5683
+ limit: failure.budget.limit,
5684
+ remaining: failure.remaining,
5685
+ currency: failure.budget.currency
5686
+ }))
5687
+ ];
5688
+ }
4520
5689
  }
4521
5690
  }
4522
5691
  return this.makeDryRunResult(
@@ -4525,14 +5694,18 @@ var GovernedForwarder = class {
4525
5694
  wouldForward,
4526
5695
  evidenceSatisfied,
4527
5696
  limitsOk,
4528
- budgets
5697
+ budgets,
5698
+ sessionUnresolved
4529
5699
  );
4530
5700
  }
4531
- /** Construct a limit bucket key based on the configured key type. */
4532
- buildLimitKey(keyType, toolName, request) {
5701
+ /**
5702
+ * Construct a non-session limit bucket key. Session keys are deliberately
5703
+ * NOT built here: they come only from the gate module's `sessionLimitKey`,
5704
+ * whose `GatedSession` parameter makes skipping the identity gate a
5705
+ * compile error (issue #218) — call sites branch on `key === 'session'`.
5706
+ */
5707
+ buildLimitKey(keyType, toolName) {
4533
5708
  switch (keyType) {
4534
- case "session":
4535
- return `session:${request.sessionId ?? "unknown"}`;
4536
5709
  case "agent":
4537
5710
  if (!this.agentKeyWarned) {
4538
5711
  this.agentKeyWarned = true;
@@ -4555,14 +5728,29 @@ var GovernedForwarder = class {
4555
5728
  }
4556
5729
  }
4557
5730
  /**
4558
- * Construct a spend bucket key via the shared {@link spendBucketKey}
4559
- * composer see its doc for why spend buckets are rule-discriminated.
4560
- * Rate buckets keep the undiscriminated keys.
5731
+ * Gate a session-keyed limit at its key-build site (issue #218). Returns
5732
+ * the bucket key, or null when identity is unresolved under deny mode —
5733
+ * the caller denies (enforce) or reports the marker (dry-run).
4561
5734
  */
4562
- buildSpendLimitKey(keyType, toolName, request, ruleIndex) {
4563
- return spendBucketKey(this.buildLimitKey(keyType, toolName, request), ruleIndex);
5735
+ gateSessionLimitKey(request) {
5736
+ const gate = gateSession(request.session?.id, this.session.onUnresolved);
5737
+ if (!gate.ok) {
5738
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
5739
+ return null;
5740
+ }
5741
+ if (gate.anonymous) warnAnonymousPoolingOnce();
5742
+ return sessionLimitKey(gate.session);
5743
+ }
5744
+ makeSessionUnresolvedResult(request, decision, control) {
5745
+ const feedback = buildSessionUnresolvedFeedback(decision, control, this.session.strategySummary);
5746
+ return makeErrorResult(
5747
+ request,
5748
+ POLICY_DENIED,
5749
+ sessionUnresolvedControlMessage(this.session.strategySummary),
5750
+ { ...feedback }
5751
+ );
4564
5752
  }
4565
- writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
5753
+ 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) {
4566
5754
  if (!this.auditWriter) return;
4567
5755
  const actuallyForwarded = forwarded && !isDryRun;
4568
5756
  const hadForwardingError = forwardingError !== void 0;
@@ -4655,9 +5843,16 @@ var GovernedForwarder = class {
4655
5843
  };
4656
5844
  }
4657
5845
  const blockReason = extractBlockReason(result);
5846
+ if (sessionBlocked || blockReason === "session_unresolved") {
5847
+ evidenceChain = {
5848
+ ...evidenceChain ?? {},
5849
+ session: { unresolved: true, tried: this.session.strategySummary }
5850
+ };
5851
+ }
4658
5852
  const record = {
4659
5853
  timestamp,
4660
- session_id: request.sessionId ?? null,
5854
+ session_id: request.session?.id ?? null,
5855
+ session_source: request.session?.source ?? null,
4661
5856
  agent_id: null,
4662
5857
  environment: this.environment ?? null,
4663
5858
  tool_name: toolName,
@@ -4683,7 +5878,8 @@ var GovernedForwarder = class {
4683
5878
  dry_run: isDryRun ?? false,
4684
5879
  record_kind: "tool_call",
4685
5880
  origin: "mcp",
4686
- metadata: null
5881
+ metadata: null,
5882
+ protocol_version: request.protocolVersion ?? null
4687
5883
  };
4688
5884
  const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
4689
5885
  if (isEnforcementDecision) {
@@ -4726,7 +5922,7 @@ var GovernedForwarder = class {
4726
5922
  unsupported: true
4727
5923
  });
4728
5924
  }
4729
- makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
5925
+ makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets, sessionUnresolved) {
4730
5926
  const payload = {
4731
5927
  dry_run: true,
4732
5928
  would_forward: wouldForward,
@@ -4734,13 +5930,19 @@ var GovernedForwarder = class {
4734
5930
  matched_rule: decision.matchedRule?.name ?? null,
4735
5931
  evidence_satisfied: evidenceSatisfied,
4736
5932
  limits_ok: limitsOk,
4737
- ...budgets ? { budgets } : {}
5933
+ ...budgets ? { budgets } : {},
5934
+ ...sessionUnresolved ? { session_unresolved: true } : {}
4738
5935
  };
4739
5936
  const body = {
4740
5937
  jsonrpc: "2.0",
4741
5938
  id: request.id ?? null,
4742
5939
  result: {
4743
- content: [{ type: "text", text: JSON.stringify(payload) }]
5940
+ content: [{ type: "text", text: JSON.stringify(payload) }],
5941
+ // `resultType` is REQUIRED on every 2026-07-28 result; earlier
5942
+ // revisions never defined it, so the field rides on the client's
5943
+ // validated wire claim — the same tokenizer the #226 door uses for
5944
+ // its tier decision, keeping the two verdicts in agreement.
5945
+ ...isModernProtocolClaim(request.protocolVersion) ? { resultType: "complete" } : {}
4744
5946
  }
4745
5947
  };
4746
5948
  const response = {
@@ -4763,15 +5965,10 @@ var GovernedForwarder = class {
4763
5965
  }
4764
5966
  makeSessionRequiredBlockResult(request, decision) {
4765
5967
  const feedback = buildPolicyDeniedFeedback(decision);
4766
- return makeErrorResult(
4767
- request,
4768
- POLICY_DENIED,
4769
- "Mcp-Session-Id is required for evidence/dependency-gated policy rules",
4770
- {
4771
- ...feedback,
4772
- retry_allowed: true
4773
- }
4774
- );
5968
+ return makeErrorResult(request, POLICY_DENIED, decision.reason, {
5969
+ ...feedback,
5970
+ retry_allowed: true
5971
+ });
4775
5972
  }
4776
5973
  makeClientDisconnectedBlockResult(request, decision) {
4777
5974
  const feedback = buildClientDisconnectedFeedback(decision);
@@ -5201,7 +6398,11 @@ var BudgetEngine = class {
5201
6398
  }
5202
6399
  return { charges, failures };
5203
6400
  }
5204
- /** Check every charge without mutating. All-or-nothing: one deny flips `allowed`. */
6401
+ /**
6402
+ * Check every charge without mutating. All-or-nothing: one deny flips
6403
+ * `allowed`. Accepts only gate-branded charges (issue #218) — a caller
6404
+ * cannot peek budget state without having run the session engagement check.
6405
+ */
5205
6406
  peekAll(charges) {
5206
6407
  const entries = charges.map((charge) => this.snapshot(charge));
5207
6408
  return { allowed: entries.every((entry) => entry.allowed), entries };
@@ -5707,6 +6908,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
5707
6908
  id TEXT PRIMARY KEY,
5708
6909
  timestamp TEXT NOT NULL,
5709
6910
  session_id TEXT,
6911
+ session_source TEXT,
5710
6912
  agent_id TEXT,
5711
6913
  environment TEXT,
5712
6914
  tool_name TEXT NOT NULL,
@@ -5730,6 +6932,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
5730
6932
  record_kind TEXT NOT NULL DEFAULT 'tool_call',
5731
6933
  origin TEXT NOT NULL DEFAULT 'mcp',
5732
6934
  metadata TEXT,
6935
+ protocol_version TEXT,
5733
6936
  created_at TEXT NOT NULL
5734
6937
  );
5735
6938
  `;
@@ -5745,19 +6948,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
5745
6948
  `;
5746
6949
  var INSERT_SQL = `
5747
6950
  INSERT INTO audit_records (
5748
- id, timestamp, session_id, agent_id, environment, tool_name, tool_input,
6951
+ id, timestamp, session_id, session_source, agent_id, environment, tool_name, tool_input,
5749
6952
  policy_decision, block_reason, matched_rule, matched_rule_index, evidence_chain, approval_status,
5750
6953
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
5751
6954
  upstream_http_status,
5752
6955
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
5753
- flagged_destructive, dry_run, record_kind, origin, metadata, created_at
6956
+ flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
5754
6957
  ) VALUES (
5755
- @id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
6958
+ @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
5756
6959
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
5757
6960
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
5758
6961
  @upstream_http_status,
5759
6962
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
5760
- @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
6963
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
5761
6964
  )
5762
6965
  `;
5763
6966
  var REQUIRED_AUDIT_COLUMNS = [
@@ -5770,13 +6973,20 @@ var REQUIRED_AUDIT_COLUMNS = [
5770
6973
  "upstream_http_status",
5771
6974
  "record_kind",
5772
6975
  "origin",
5773
- "metadata"
6976
+ "metadata",
6977
+ // Deliberately listed (issue #218): pre-0.12 local DBs fail fast with the
6978
+ // documented delete-these-files message — the pre-1.0 clean-break policy.
6979
+ "session_source",
6980
+ // Same clean break, same unreleased cycle (issue #219): released users see
6981
+ // ONE break, at v0.12.0.
6982
+ "protocol_version"
5774
6983
  ];
5775
6984
  function deserializeRow(row) {
5776
6985
  return {
5777
6986
  id: row.id,
5778
6987
  timestamp: row.timestamp,
5779
6988
  session_id: row.session_id,
6989
+ session_source: row.session_source,
5780
6990
  agent_id: row.agent_id,
5781
6991
  environment: row.environment,
5782
6992
  tool_name: row.tool_name,
@@ -5800,6 +7010,7 @@ function deserializeRow(row) {
5800
7010
  record_kind: row.record_kind,
5801
7011
  origin: row.origin,
5802
7012
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
7013
+ protocol_version: row.protocol_version,
5803
7014
  created_at: row.created_at
5804
7015
  };
5805
7016
  }
@@ -5983,6 +7194,7 @@ var AuditStore = class {
5983
7194
  id: resolvedId,
5984
7195
  timestamp: record.timestamp,
5985
7196
  session_id: record.session_id,
7197
+ session_source: record.session_source,
5986
7198
  agent_id: record.agent_id,
5987
7199
  environment: record.environment,
5988
7200
  tool_name: record.tool_name,
@@ -6006,6 +7218,7 @@ var AuditStore = class {
6006
7218
  record_kind: record.record_kind,
6007
7219
  origin: record.origin,
6008
7220
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7221
+ protocol_version: record.protocol_version,
6009
7222
  created_at: now
6010
7223
  });
6011
7224
  return resolvedId;
@@ -6194,7 +7407,11 @@ var CSV_HEADERS = [
6194
7407
  "matched_rule_index",
6195
7408
  "record_kind",
6196
7409
  "origin",
6197
- "metadata"
7410
+ "metadata",
7411
+ // Appended LAST (issues #218, #219): positional consumers of the existing
7412
+ // columns keep working — new columns always go at the end.
7413
+ "session_source",
7414
+ "protocol_version"
6198
7415
  ];
6199
7416
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
6200
7417
  function csvEscape(value) {
@@ -6738,15 +7955,18 @@ function asStatus(status) {
6738
7955
 
6739
7956
  // src/evidence/api.ts
6740
7957
  var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
7958
+ var sessionIdSchema = z5.string().min(1).refine((value) => value.trim() !== "", {
7959
+ message: "session_id must not be whitespace-only"
7960
+ });
6741
7961
  var postEvidenceBody = z5.object({
6742
- session_id: z5.string().min(1),
7962
+ session_id: sessionIdSchema,
6743
7963
  tool_name: z5.string().min(1),
6744
7964
  evidence_key: z5.string().min(1),
6745
7965
  evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
6746
7966
  ttl_seconds: z5.number().int().positive().optional()
6747
7967
  });
6748
7968
  var postContextBody = z5.object({
6749
- session_id: z5.string().min(1),
7969
+ session_id: sessionIdSchema,
6750
7970
  key: z5.string().min(1),
6751
7971
  value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
6752
7972
  });
@@ -6876,6 +8096,7 @@ var SWEEP_INTERVAL_MS2 = 3e4;
6876
8096
  var GovernanceService = class {
6877
8097
  policy;
6878
8098
  environment;
8099
+ session;
6879
8100
  evidenceStore;
6880
8101
  approvalRouter;
6881
8102
  rateLimiter;
@@ -6909,6 +8130,7 @@ var GovernanceService = class {
6909
8130
  constructor(options) {
6910
8131
  this.policy = options.policy;
6911
8132
  this.environment = options.environment;
8133
+ this.session = options.session ?? DEFAULT_SESSION_IDENTITY;
6912
8134
  this.evidenceStore = options.evidenceStore;
6913
8135
  this.approvalRouter = options.approvalRouter;
6914
8136
  this.rateLimiter = options.rateLimiter;
@@ -6943,6 +8165,7 @@ var GovernanceService = class {
6943
8165
  if (reserved) {
6944
8166
  return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
6945
8167
  }
8168
+ const sessionId = isWellFormedSessionId(req.session_id) ? req.session_id : null;
6946
8169
  const inputBytes = byteLength(req.arguments ?? {});
6947
8170
  if (inputBytes > MAX_TOOL_INPUT_BYTES) {
6948
8171
  return { status: 413, body: { error: "tool_input_too_large" } };
@@ -6950,7 +8173,7 @@ var GovernanceService = class {
6950
8173
  const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
6951
8174
  tool: req.tool.name,
6952
8175
  agent_id: req.agent_id,
6953
- session_id: req.session_id,
8176
+ session_id: sessionId,
6954
8177
  origin: req.origin
6955
8178
  });
6956
8179
  if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
@@ -6972,7 +8195,8 @@ var GovernanceService = class {
6972
8195
  const pipeline = decide({
6973
8196
  toolName,
6974
8197
  toolArguments: req.arguments,
6975
- sessionId: req.session_id ?? void 0,
8198
+ sessionId: sessionId ?? void 0,
8199
+ sessionStrategySummary: this.session.strategySummary,
6976
8200
  policy: this.policy,
6977
8201
  environment: this.environment,
6978
8202
  evidenceStore: this.evidenceStore,
@@ -6989,6 +8213,8 @@ var GovernanceService = class {
6989
8213
  const plans = [];
6990
8214
  let limitsBlock;
6991
8215
  let ruleLimitOk = true;
8216
+ let sessionUnresolvedDeny = false;
8217
+ let dryRunSessionUnresolved = false;
6992
8218
  const reservedThisCall = [];
6993
8219
  const reserve = (key) => {
6994
8220
  const preexisting = this.senderKeys.has(key);
@@ -7003,12 +8229,14 @@ var GovernanceService = class {
7003
8229
  if (pipeline.isDryRun) {
7004
8230
  wire = "dry_run";
7005
8231
  if (decision.action === "rate_limit") {
7006
- const planned = this.planRate(decision, toolName, req.session_id, senderId);
8232
+ const planned = this.planRate(decision, toolName, sessionId, senderId);
7007
8233
  if (planned?.block) limitsBlock = { rate: planned.block };
8234
+ if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
7008
8235
  ruleLimitOk = planned?.allowed ?? true;
7009
8236
  } else if (decision.action === "spend_limit") {
7010
- const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
8237
+ const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
7011
8238
  if (planned?.block) limitsBlock = { spend: planned.block };
8239
+ if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
7012
8240
  ruleLimitOk = planned?.allowed ?? true;
7013
8241
  }
7014
8242
  } else if (decision.action === "deny") {
@@ -7016,21 +8244,31 @@ var GovernanceService = class {
7016
8244
  } else if (decision.action === "require_approval") {
7017
8245
  wire = "require_approval";
7018
8246
  } else if (decision.action === "rate_limit") {
7019
- const planned = this.planRate(decision, toolName, req.session_id, senderId);
7020
- if (planned?.plan && !reserve(planned.plan.key)) {
7021
- return { status: 503, body: { error: "limit_capacity_exhausted" } };
8247
+ const planned = this.planRate(decision, toolName, sessionId, senderId);
8248
+ if (planned?.sessionUnresolved) {
8249
+ wire = "deny";
8250
+ sessionUnresolvedDeny = true;
8251
+ } else {
8252
+ if (planned?.plan && !reserve(planned.plan.key)) {
8253
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
8254
+ }
8255
+ if (planned?.plan) plans.push(planned.plan);
8256
+ limitsBlock = planned?.block ? { rate: planned.block } : void 0;
8257
+ wire = planned?.allowed ? "allow" : "rate_limited";
7022
8258
  }
7023
- if (planned?.plan) plans.push(planned.plan);
7024
- limitsBlock = planned?.block ? { rate: planned.block } : void 0;
7025
- wire = planned?.allowed ? "allow" : "rate_limited";
7026
8259
  } else if (decision.action === "spend_limit") {
7027
- const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
7028
- if (planned?.plan && !reserve(planned.plan.key)) {
7029
- return { status: 503, body: { error: "limit_capacity_exhausted" } };
8260
+ const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
8261
+ if (planned?.sessionUnresolved) {
8262
+ wire = "deny";
8263
+ sessionUnresolvedDeny = true;
8264
+ } else {
8265
+ if (planned?.plan && !reserve(planned.plan.key)) {
8266
+ return { status: 503, body: { error: "limit_capacity_exhausted" } };
8267
+ }
8268
+ if (planned?.plan) plans.push(planned.plan);
8269
+ limitsBlock = planned?.block ? { spend: planned.block } : void 0;
8270
+ wire = planned?.allowed ? "allow" : "spend_limited";
7030
8271
  }
7031
- if (planned?.plan) plans.push(planned.plan);
7032
- limitsBlock = planned?.block ? { spend: planned.block } : void 0;
7033
- wire = planned?.allowed ? "allow" : "spend_limited";
7034
8272
  } else {
7035
8273
  wire = "allow";
7036
8274
  }
@@ -7042,14 +8280,27 @@ var GovernanceService = class {
7042
8280
  let budgetTicketTimeoutMs;
7043
8281
  let budgetTriggeredApproval = false;
7044
8282
  if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
8283
+ const budgetSessionGate = gateSession(sessionId, this.session.onUnresolved);
7045
8284
  const { charges, failures } = this.budgetEngine.resolveCharges({
7046
8285
  toolName,
7047
8286
  toolArguments: req.arguments,
7048
- sessionId: req.session_id,
8287
+ sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
7049
8288
  senderId
7050
8289
  });
7051
- if (charges.length > 0 || failures.length > 0) {
7052
- const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
8290
+ const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
8291
+ if (gatedCharges && !gatedCharges.ok) {
8292
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
8293
+ if (wire === "dry_run") {
8294
+ budgetDryRunOk = false;
8295
+ dryRunSessionUnresolved = true;
8296
+ } else {
8297
+ releaseReservations();
8298
+ plans.length = 0;
8299
+ wire = "deny";
8300
+ sessionUnresolvedDeny = true;
8301
+ }
8302
+ } else if (gatedCharges) {
8303
+ const peek = charges.length > 0 ? this.budgetEngine.peekAll(gatedCharges.charges) : { allowed: true, entries: [] };
7053
8304
  budgetsBlock = [
7054
8305
  ...peek.entries.map((entry2) => budgetWireBlock(entry2)),
7055
8306
  ...failures.map((failure) => budgetFailureBlock(failure))
@@ -7071,19 +8322,16 @@ var GovernanceService = class {
7071
8322
  } else {
7072
8323
  if (breaches.length > 0) budgetDryRunOk = false;
7073
8324
  if (wire !== "dry_run") {
7074
- for (const [index, charge] of charges.entries()) {
7075
- if (!reserve(charge.bucketKey)) {
8325
+ const frozen = freezeGatedPlans(
8326
+ gatedCharges.charges,
8327
+ charges.map((_, index) => peek.entries[index]?.allowed === false)
8328
+ );
8329
+ for (const plan of frozen) {
8330
+ if (!reserve(plan.bucketKey)) {
7076
8331
  releaseReservations();
7077
8332
  return { status: 503, body: { error: "limit_capacity_exhausted" } };
7078
8333
  }
7079
- plans.push({
7080
- kind: "budget",
7081
- budget: charge.budget,
7082
- bucketKey: charge.bucketKey,
7083
- amount: charge.amount,
7084
- generation: charge.generation,
7085
- breached: peek.entries[index]?.allowed === false
7086
- });
8334
+ plans.push(plan);
7087
8335
  }
7088
8336
  if (breaches.length > 0) {
7089
8337
  budgetBreachEntries = breaches;
@@ -7132,12 +8380,21 @@ var GovernanceService = class {
7132
8380
  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."
7133
8381
  };
7134
8382
  }
8383
+ if (sessionUnresolvedDeny) {
8384
+ const message = sessionUnresolvedControlMessage(this.session.strategySummary);
8385
+ responseBody["reason"] = message;
8386
+ responseBody["feedback"] = {
8387
+ message,
8388
+ suggestion: "Send a session_id the identity policy accepts, or set session.on_unresolved: anonymous to restore shared pooling."
8389
+ };
8390
+ }
7135
8391
  if (limitsBlock) responseBody["limits"] = limitsBlock;
7136
8392
  if (wire === "dry_run") {
7137
8393
  responseBody["dry_run"] = {
7138
8394
  would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
7139
8395
  evidence_satisfied: !pipeline.evidenceBlocked,
7140
- limits_ok: ruleLimitOk && budgetDryRunOk
8396
+ limits_ok: ruleLimitOk && budgetDryRunOk,
8397
+ ...dryRunSessionUnresolved ? { session_unresolved: true } : {}
7141
8398
  };
7142
8399
  }
7143
8400
  if (pipeline.driftEvent) {
@@ -7148,7 +8405,7 @@ var GovernanceService = class {
7148
8405
  timestampIso,
7149
8406
  origin: req.origin,
7150
8407
  agentId: req.agent_id,
7151
- sessionId: req.session_id,
8408
+ sessionId,
7152
8409
  toolName,
7153
8410
  toolInput: req.arguments ?? {},
7154
8411
  metadata: req.metadata,
@@ -7163,7 +8420,9 @@ var GovernanceService = class {
7163
8420
  // limitsBlock is also the response's `limits`, and the audit writer
7164
8421
  // buffers records by reference until flush — a direct embedder
7165
8422
  // editing the returned body must not be able to rewrite evidence.
7166
- limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
8423
+ limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0,
8424
+ sessionUnresolved: sessionUnresolvedDeny,
8425
+ sessionChain: pipeline.sessionBlocked
7167
8426
  });
7168
8427
  this.tombstones.set(evaluationId, {
7169
8428
  auditRecordId: auditId,
@@ -7196,7 +8455,7 @@ var GovernanceService = class {
7196
8455
  // rewrite it (same guard as the pending entry's evidence below).
7197
8456
  tool_input: structuredClone(req.arguments ?? {}),
7198
8457
  matched_rule: decision.matchedRule,
7199
- session_id: req.session_id,
8458
+ session_id: sessionId,
7200
8459
  origin: req.origin,
7201
8460
  timeout_ms: timeoutMs,
7202
8461
  breached_budgets: budgetBreachContexts
@@ -7214,7 +8473,7 @@ var GovernanceService = class {
7214
8473
  evaluationId,
7215
8474
  origin: req.origin,
7216
8475
  agentId: req.agent_id,
7217
- sessionId: req.session_id,
8476
+ sessionId,
7218
8477
  toolName,
7219
8478
  // Cloned: direct embedders share these references and could otherwise
7220
8479
  // mutate the audit evidence (and desync the byte accounting) after
@@ -7433,7 +8692,9 @@ var GovernanceService = class {
7433
8692
  timestampIso: new Date(this.now()).toISOString(),
7434
8693
  origin: req.origin,
7435
8694
  agentId: req.agent_id,
7436
- sessionId: req.session_id,
8695
+ // Same trim-empty normalization as /evaluate: a whitespace-only id
8696
+ // must not land in the audit row as attributed sideband identity.
8697
+ sessionId: isWellFormedSessionId(req.session_id) ? req.session_id : null,
7437
8698
  toolName,
7438
8699
  toolInput: { ...req.package },
7439
8700
  metadata: req.metadata,
@@ -7750,7 +9011,18 @@ var GovernanceService = class {
7750
9011
  if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
7751
9012
  return { allowed: true };
7752
9013
  }
7753
- const key = buildLimitKey(limits.key, toolName, sessionId, senderId);
9014
+ let key;
9015
+ if (limits.key === "session") {
9016
+ const gate = gateSession(sessionId, this.session.onUnresolved);
9017
+ if (!gate.ok) {
9018
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
9019
+ return { allowed: false, sessionUnresolved: true };
9020
+ }
9021
+ if (gate.anonymous) warnAnonymousPoolingOnce();
9022
+ key = sessionLimitKey(gate.session);
9023
+ } else {
9024
+ key = buildLimitKey(limits.key, toolName, senderId);
9025
+ }
7754
9026
  const peek = this.rateLimiter.peek({
7755
9027
  key,
7756
9028
  maxCalls: limits.maxCalls,
@@ -7770,10 +9042,19 @@ var GovernanceService = class {
7770
9042
  planSpend(decision, toolName, sessionId, args, senderId) {
7771
9043
  const maxSpend = decision.matchedRule?.limits?.maxSpend;
7772
9044
  if (!this.spendLimiter || !maxSpend) return { allowed: true };
7773
- const key = spendBucketKey(
7774
- buildLimitKey(maxSpend.key, toolName, sessionId, senderId),
7775
- decision.matchedRule.index
7776
- );
9045
+ let baseKey;
9046
+ if (maxSpend.key === "session") {
9047
+ const gate = gateSession(sessionId, this.session.onUnresolved);
9048
+ if (!gate.ok) {
9049
+ warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
9050
+ return { allowed: false, sessionUnresolved: true };
9051
+ }
9052
+ if (gate.anonymous) warnAnonymousPoolingOnce();
9053
+ baseKey = sessionLimitKey(gate.session);
9054
+ } else {
9055
+ baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
9056
+ }
9057
+ const key = spendBucketKey(baseKey, decision.matchedRule.index);
7777
9058
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
7778
9059
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
7779
9060
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -7805,18 +9086,15 @@ var GovernanceService = class {
7805
9086
  /** Commit every plan of one call at /audit time; returns the chain blocks. */
7806
9087
  commitPlans(entry, actualAmount, auditId, approvalStatus) {
7807
9088
  let chain;
7808
- const budgetPlans = entry.plans.filter((plan) => plan.kind === "budget");
9089
+ const budgetPlans = entry.plans.filter(
9090
+ (plan) => plan.kind === "budget"
9091
+ );
7809
9092
  if (budgetPlans.length > 0 && this.budgetEngine) {
7810
9093
  const kinds = new Map(
7811
9094
  budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
7812
9095
  );
7813
9096
  const snapshots = this.budgetEngine.recordAll(
7814
- budgetPlans.map((plan) => ({
7815
- budget: plan.budget,
7816
- bucketKey: plan.bucketKey,
7817
- amount: actualAmount ?? plan.amount,
7818
- generation: plan.generation
7819
- })),
9097
+ remintDeferredCharges(budgetPlans, actualAmount),
7820
9098
  {
7821
9099
  kind: "spend",
7822
9100
  ...kinds.size > 0 ? { kinds } : {},
@@ -7887,6 +9165,12 @@ var GovernanceService = class {
7887
9165
  if (!this.auditWriter) return id;
7888
9166
  const blockReason = deriveBlockReason(args);
7889
9167
  let evidenceChain = args.limitsChain ?? null;
9168
+ if (args.sessionUnresolved || args.sessionChain) {
9169
+ evidenceChain = {
9170
+ ...evidenceChain ?? {},
9171
+ session: { unresolved: true, tried: this.session.strategySummary }
9172
+ };
9173
+ }
7890
9174
  if (args.sidebandUnreported) {
7891
9175
  evidenceChain = {
7892
9176
  ...evidenceChain ?? {},
@@ -7902,6 +9186,9 @@ var GovernanceService = class {
7902
9186
  const record = {
7903
9187
  timestamp: args.timestampIso,
7904
9188
  session_id: args.sessionId,
9189
+ // Adapter-supplied ids are attributed to the sideband door itself —
9190
+ // the MCP resolver's source vocabulary does not apply here.
9191
+ session_source: args.sessionId != null ? "sideband" : null,
7905
9192
  agent_id: args.agentId,
7906
9193
  environment: this.environment ?? null,
7907
9194
  tool_name: args.toolName,
@@ -7924,7 +9211,9 @@ var GovernanceService = class {
7924
9211
  dry_run: args.dryRun,
7925
9212
  record_kind: args.recordKind,
7926
9213
  origin: args.origin,
7927
- metadata: args.metadata
9214
+ metadata: args.metadata,
9215
+ // The sideband has no MCP wire, so no protocol claim exists.
9216
+ protocol_version: null
7928
9217
  };
7929
9218
  const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
7930
9219
  if (isEnforcement) this.auditWriter.pushImmediate(record, id);
@@ -7943,6 +9232,7 @@ function deriveBlockReason(args) {
7943
9232
  if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
7944
9233
  if (args.dryRun) return null;
7945
9234
  if (args.budgetBreachBlocked) return "budget_exceeded";
9235
+ if (args.sessionUnresolved) return "session_unresolved";
7946
9236
  if (args.approvalStatus === "denied") return "approval_denied";
7947
9237
  if (args.approvalStatus === "timeout") return "approval_timeout";
7948
9238
  if (args.approvalStatus === "cancelled") return "cancelled";
@@ -7981,10 +9271,8 @@ function policyCanRequireApproval(policy) {
7981
9271
  }
7982
9272
  return policy.rules.some((rule) => rule.action === "require_approval");
7983
9273
  }
7984
- function buildLimitKey(keyType, toolName, sessionId, senderId) {
9274
+ function buildLimitKey(keyType, toolName, senderId) {
7985
9275
  switch (keyType) {
7986
- case "session":
7987
- return `session:${sessionId ?? "unknown"}`;
7988
9276
  case "sender_id":
7989
9277
  return `sender:${senderId ?? "unknown"}`;
7990
9278
  case "agent":
@@ -8188,6 +9476,48 @@ var AuditWriter = class {
8188
9476
  }
8189
9477
  };
8190
9478
 
9479
+ // src/audit/header-mismatch.ts
9480
+ function buildHeaderMismatchAuditRecord(rejection, environment) {
9481
+ return {
9482
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9483
+ session_id: rejection.session?.id ?? null,
9484
+ session_source: rejection.session?.source ?? null,
9485
+ agent_id: null,
9486
+ environment: environment ?? null,
9487
+ tool_name: rejection.bodyName ?? "<header_mismatch>",
9488
+ // Wrap parity with the nameless precedent: the wire params always nest
9489
+ // under `raw_params`, so a wrapped scalar can never be confused with an
9490
+ // object that happens to contain a `raw_params` key. Headers are the
9491
+ // present markers only, verbatim as received.
9492
+ tool_input: {
9493
+ raw_params: rejection.params ?? null,
9494
+ body_method: rejection.method,
9495
+ mismatch_reason: rejection.reason,
9496
+ headers: { ...rejection.headers }
9497
+ },
9498
+ policy_decision: "rejected",
9499
+ block_reason: "header_mismatch",
9500
+ matched_rule: null,
9501
+ matched_rule_index: null,
9502
+ evidence_chain: null,
9503
+ approval_status: null,
9504
+ approved_by: null,
9505
+ upstream_response: null,
9506
+ upstream_error: null,
9507
+ upstream_http_status: null,
9508
+ upstream_latency_ms: null,
9509
+ total_duration_ms: rejection.durationMs,
9510
+ approval_wait_ms: 0,
9511
+ proxy_compute_ms: rejection.durationMs,
9512
+ flagged_destructive: false,
9513
+ dry_run: false,
9514
+ record_kind: "tool_call",
9515
+ origin: "mcp",
9516
+ metadata: null,
9517
+ protocol_version: rejection.protocolVersion ?? null
9518
+ };
9519
+ }
9520
+
8191
9521
  // src/approval/queue.ts
8192
9522
  import { randomUUID as randomUUID7 } from "crypto";
8193
9523
  var ApprovalQueue = class {
@@ -9900,6 +11230,7 @@ export {
9900
11230
  UpstreamForwarder,
9901
11231
  VERSION,
9902
11232
  WebhookChannel,
11233
+ buildHeaderMismatchAuditRecord,
9903
11234
  compileBudgets,
9904
11235
  compilePolicies,
9905
11236
  createApp,