@gethelio/proxy 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,73 @@ 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
+
1045
1272
  // src/transport/content-type.ts
1046
1273
  function isJsonContentType(header) {
1047
1274
  const [essence = ""] = (header ?? "").split(";");
@@ -1065,804 +1292,1419 @@ function buildForwardHeaders(requestHeaders, allowlist) {
1065
1292
  return Object.keys(forwardHeaders).length > 0 ? forwardHeaders : void 0;
1066
1293
  }
1067
1294
 
1068
- // src/transport/response-normalizer.ts
1069
- function isObject(value) {
1070
- 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);
1071
1302
  }
1072
- function isValidJsonRpcId(value) {
1073
- 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;
1074
1352
  }
1075
- function getJsonRpcId(value) {
1076
- if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
1077
- const id = value["id"];
1078
- return isValidJsonRpcId(id) ? id : void 0;
1353
+ function isHeaderSafeMethod(method) {
1354
+ return /^[\x21-\x7E]+$/.test(method);
1079
1355
  }
1080
- function isValidJsonRpcError(value) {
1081
- if (!isObject(value)) return false;
1082
- 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;
1083
1360
  }
1084
- function isValidJsonRpcResponse(value) {
1085
- if (!isObject(value)) return false;
1086
- if (value["jsonrpc"] !== "2.0") return false;
1087
- if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
1088
- return false;
1361
+ function buildStandardRequestHeaders(method, params) {
1362
+ if (!isHeaderSafeMethod(method)) {
1363
+ return {};
1089
1364
  }
1090
- const hasResult = Object.prototype.hasOwnProperty.call(value, "result");
1091
- const hasError = Object.prototype.hasOwnProperty.call(value, "error");
1092
- if (hasResult && hasError || !hasResult && !hasError) return false;
1093
- if (hasError && !isValidJsonRpcError(value["error"])) return false;
1094
- 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;
1095
1371
  }
1096
- function makeWrappedError(requestId, message, data) {
1097
- return {
1098
- jsonrpc: "2.0",
1099
- id: requestId ?? null,
1100
- error: {
1101
- code: INTERNAL_ERROR,
1102
- message,
1103
- 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;
1104
1379
  }
1105
1380
  };
1381
+ apply(base);
1382
+ apply(forwarded);
1383
+ apply(staticHeaders);
1384
+ return out;
1106
1385
  }
1107
- function normalizeUpstreamOutcome(args) {
1108
- if (args.forwardingError) {
1109
- return {
1110
- httpStatus: 200,
1111
- wrapped: true,
1112
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1113
- failure_class: "upstream_forward_error",
1114
- failure_reason: args.forwardingError.message
1115
- })
1116
- };
1117
- }
1118
- if (!args.upstreamResponse) {
1119
- return {
1120
- httpStatus: 200,
1121
- wrapped: true,
1122
- body: makeWrappedError(args.requestId, "upstream forwarding failed", {
1123
- failure_class: "upstream_forward_error",
1124
- failure_reason: "missing upstream response"
1125
- })
1126
- };
1127
- }
1128
- const upstream = args.upstreamResponse;
1129
- const upstreamContentType = upstream.headers["content-type"] ?? null;
1130
- if (!isValidJsonRpcResponse(upstream.body)) {
1131
- return {
1132
- httpStatus: 200,
1133
- wrapped: true,
1134
- body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
1135
- failure_class: "upstream_invalid_jsonrpc",
1136
- upstream_http_status: upstream.status,
1137
- upstream_content_type: upstreamContentType,
1138
- upstream_body_type: typeof upstream.body
1139
- })
1140
- };
1141
- }
1142
- if (args.requestId !== void 0) {
1143
- const upstreamId = getJsonRpcId(upstream.body);
1144
- if (upstreamId === void 0) {
1145
- return {
1146
- httpStatus: 200,
1147
- wrapped: true,
1148
- body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
1149
- failure_class: "upstream_invalid_jsonrpc",
1150
- upstream_http_status: upstream.status,
1151
- upstream_content_type: upstreamContentType,
1152
- upstream_body_type: typeof upstream.body,
1153
- invalid_reason: "missing_response_id"
1154
- })
1155
- };
1156
- }
1157
- const expectedId = args.requestId ?? null;
1158
- if (upstreamId !== expectedId) {
1159
- return {
1160
- httpStatus: 200,
1161
- wrapped: true,
1162
- body: makeWrappedError(args.requestId, "upstream response id mismatch", {
1163
- failure_class: "upstream_id_mismatch",
1164
- expected_request_id: expectedId,
1165
- upstream_response_id: upstreamId
1166
- })
1167
- };
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;
1168
1407
  }
1408
+ current = current.cause;
1169
1409
  }
1170
- return {
1171
- httpStatus: 200,
1172
- wrapped: false,
1173
- body: upstream.body
1174
- };
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
+ );
1175
1424
  }
1176
1425
 
1177
- // src/transport/streamable-http.ts
1178
- var MCP_SESSION_HEADER = "mcp-session-id";
1179
- var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
1180
- function createStreamableHttpRoute(forwarder, options = {}) {
1181
- const app = new Hono();
1182
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1183
- app.post("/", async (c) => {
1184
- if (!isJsonContentType(c.req.header("content-type"))) {
1185
- return c.json(
1186
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1187
- 415
1188
- );
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;
1189
1446
  }
1190
- 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;
1191
1461
  try {
1192
- body = await c.req.json();
1462
+ parsed = JSON.parse(data);
1193
1463
  } catch {
1194
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
1195
- }
1196
- const parsedRequest = parseJsonRpcRequest(body);
1197
- if (!parsedRequest.success) {
1198
- return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
1464
+ return;
1199
1465
  }
1200
- const id = parsedRequest.request.id;
1201
- const method = parsedRequest.request.method;
1202
- const params = parsedRequest.request.params;
1203
- const sessionId = c.req.header(MCP_SESSION_HEADER);
1204
- const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
1205
- const mcpRequest = {
1206
- jsonrpc: "2.0",
1207
- id,
1208
- method,
1209
- params,
1210
- sessionId,
1211
- headers: forwardHeaders,
1212
- signal: c.req.raw.signal
1213
- };
1214
- if (id === void 0) {
1215
- const notificationRequest = { ...mcpRequest, signal: void 0 };
1216
- void forwarder.forward(notificationRequest).catch((err) => {
1217
- const message = err instanceof Error ? err.message : String(err);
1218
- console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
1219
- });
1220
- 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;
1221
1470
  }
1222
- let result;
1223
- try {
1224
- result = await forwarder.forward(mcpRequest);
1225
- } catch (err) {
1226
- const forwardingError = err instanceof Error ? err : new Error(String(err));
1227
- console.error("[helio] Upstream forwarding failed:", forwardingError.message);
1228
- const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
1229
- 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
+ }
1230
1484
  }
1231
- const { response } = result;
1232
- for (const [key, value] of Object.entries(response.headers)) {
1233
- if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
1234
- c.header(key, value);
1485
+ if (result.done) {
1486
+ const tail = decoder.decode();
1487
+ if (tail) {
1488
+ processChunk(tail);
1489
+ if (found) return found;
1235
1490
  }
1491
+ break;
1236
1492
  }
1237
- const normalized = normalizeUpstreamOutcome({ requestId: id, upstreamResponse: response });
1238
- return c.json(normalized.body, normalized.httpStatus);
1239
- });
1240
- return app;
1493
+ }
1494
+ throw new Error(
1495
+ `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1496
+ );
1241
1497
  }
1242
1498
 
1243
- // src/transport/sse.ts
1244
- import { randomUUID } from "crypto";
1245
- import { Hono as Hono2 } from "hono";
1246
- import { z as z3 } from "zod";
1247
- var encoder = new TextEncoder();
1248
- var STALE_THRESHOLD_MS = 9e4;
1249
- var SWEEP_INTERVAL_MS = 6e4;
1250
- var ssePostQuerySchema = z3.object({
1251
- sessionId: z3.string().min(1)
1252
- });
1253
- function sseEvent(event, data) {
1254
- return `event: ${event}
1255
- data: ${data}
1256
-
1257
- `;
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
+ };
1258
1517
  }
1259
- function createSseRoute(forwarder, options = {}) {
1260
- const sessions = /* @__PURE__ */ new Map();
1261
- const app = new Hono2();
1262
- const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
1263
- const writeSessionEvent = (sessionId, eventPayload) => {
1264
- const session = sessions.get(sessionId);
1265
- if (!session) return;
1266
- session.lastActivity = Date.now();
1267
- void session.writer.write(encoder.encode(eventPayload)).catch(() => {
1268
- sessions.delete(sessionId);
1269
- void session.writer.close().catch(() => {
1270
- });
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;
1271
1543
  });
1272
- };
1273
- const sweepInterval = setInterval(() => {
1274
- const now = Date.now();
1275
- for (const [id, session] of sessions) {
1276
- if (now - session.lastActivity > STALE_THRESHOLD_MS) {
1277
- sessions.delete(id);
1278
- void session.writer.close().catch(() => {
1279
- });
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";
1280
1578
  }
1281
1579
  }
1282
- }, SWEEP_INTERVAL_MS);
1283
- sweepInterval.unref();
1284
- app.get("/", (c) => {
1285
- const sessionId = randomUUID();
1286
- const { readable, writable } = new TransformStream();
1287
- const writer = writable.getWriter();
1288
- sessions.set(sessionId, { writer, lastActivity: Date.now() });
1289
- const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
1290
- writeSessionEvent(sessionId, endpointData);
1291
- c.req.raw.signal.addEventListener("abort", () => {
1292
- sessions.delete(sessionId);
1293
- void writer.close().catch(() => {
1294
- });
1295
- });
1296
- return new Response(readable, {
1297
- headers: {
1298
- "content-type": "text/event-stream",
1299
- "cache-control": "no-cache",
1300
- connection: "keep-alive"
1301
- }
1302
- });
1303
- });
1304
- app.post("/", async (c) => {
1305
- const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
1306
- if (!parsedQuery.success) {
1307
- return c.json(
1308
- makeJsonRpcError(null, INVALID_REQUEST, "missing sessionId query parameter"),
1309
- 400
1310
- );
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";
1311
1587
  }
1312
- const sessionId = parsedQuery.data.sessionId;
1313
- const session = sessions.get(sessionId);
1314
- if (!session) {
1315
- 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;
1316
1643
  }
1317
- if (!isJsonContentType(c.req.header("content-type"))) {
1318
- return c.json(
1319
- makeJsonRpcError(null, INVALID_REQUEST, "Content-Type must be application/json"),
1320
- 415
1321
- );
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`);
1322
1672
  }
1323
- 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) {
1324
1690
  try {
1325
- body = await c.req.json();
1326
- } catch {
1327
- return c.json(makeJsonRpcError(null, PARSE_ERROR, "invalid JSON"), 400);
1328
- }
1329
- const parsedRequest = parseJsonRpcRequest(body);
1330
- if (!parsedRequest.success) {
1331
- 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;
1332
1702
  }
1333
- const id = parsedRequest.request.id;
1334
- const method = parsedRequest.request.method;
1335
- const params = parsedRequest.request.params;
1336
- const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
1337
- const mcpRequest = {
1338
- jsonrpc: "2.0",
1339
- id,
1340
- method,
1341
- params,
1342
- sessionId,
1343
- headers: forwardHeaders,
1344
- 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"
1345
1718
  };
1346
- if (id === void 0) {
1347
- const notificationRequest = { ...mcpRequest, signal: void 0 };
1348
- void forwarder.forward(notificationRequest).catch((err) => {
1349
- const message = err instanceof Error ? err.message : String(err);
1350
- 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)
1351
1755
  });
1352
- return c.body(null, 202);
1756
+ } catch (error) {
1757
+ throw this.describeFetchFailure(error, "server/discover probe");
1353
1758
  }
1354
- 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;
1355
1802
  try {
1356
- result = await forwarder.forward(mcpRequest);
1357
- } catch (err) {
1358
- const forwardingError = err instanceof Error ? err : new Error(String(err));
1359
- console.error("[helio] Upstream forwarding failed:", forwardingError.message);
1360
- const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
1361
- const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
1362
- writeSessionEvent(sessionId, errorEvent);
1363
- return c.body(null, 202);
1803
+ parsed = JSON.parse(raw);
1804
+ } catch {
1805
+ return { kind: "unparseable" };
1364
1806
  }
1365
- const normalized = normalizeUpstreamOutcome({
1366
- requestId: id,
1367
- upstreamResponse: result.response
1368
- });
1369
- const messageEvent = sseEvent("message", JSON.stringify(normalized.body));
1370
- writeSessionEvent(sessionId, messageEvent);
1371
- return c.body(null, 202);
1372
- });
1373
- return app;
1374
- }
1375
-
1376
- // src/server.ts
1377
- var FORCE_CONNECTION_CLOSE_GRACE_MS = 1500;
1378
- function normalizeError(error) {
1379
- if (error instanceof Error) return error;
1380
- return new Error(String(error));
1381
- }
1382
- function createServerHandle(server) {
1383
- const sockets = /* @__PURE__ */ new Set();
1384
- const nodeServer = server;
1385
- nodeServer.on("connection", (socket) => {
1386
- sockets.add(socket);
1387
- socket.on("close", () => {
1388
- 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");
1389
1866
  });
1390
- });
1391
- 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;
1392
1887
  try {
1393
- nodeServer.closeIdleConnections?.();
1888
+ parsed = JSON.parse(raw);
1394
1889
  } catch {
1890
+ throw new Error(`upstream ${step} returned non-JSON body`);
1395
1891
  }
1396
- if (nodeServer.closeAllConnections) {
1397
- try {
1398
- nodeServer.closeAllConnections();
1399
- } 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
+ );
1400
1918
  }
1401
- return;
1402
1919
  }
1403
- for (const socket of sockets) {
1404
- 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;
1405
1927
  }
1406
- };
1407
- return {
1408
- server,
1409
- close: () => new Promise((resolve, reject) => {
1410
- let settled = false;
1411
- let forceTimer;
1412
- const settle = (err) => {
1413
- if (settled) return;
1414
- settled = true;
1415
- if (forceTimer) {
1416
- clearTimeout(forceTimer);
1417
- forceTimer = void 0;
1418
- }
1419
- if (err) {
1420
- reject(err);
1421
- return;
1422
- }
1423
- resolve();
1424
- };
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;
1425
1948
  try {
1426
- nodeServer.close((err) => {
1427
- if (err) {
1428
- settle(err);
1429
- return;
1430
- }
1431
- settle();
1432
- });
1433
- } catch (error) {
1434
- settle(normalizeError(error));
1949
+ parsed = JSON.parse(data);
1950
+ } catch {
1435
1951
  return;
1436
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;
1437
1963
  try {
1438
- nodeServer.closeIdleConnections?.();
1964
+ chunk = await readSseChunkWithTimeout(reader, remainingMs);
1439
1965
  } catch {
1966
+ await reader.cancel().catch(() => void 0);
1967
+ return { outcome: "timed-out" };
1440
1968
  }
1441
- forceTimer = setTimeout(() => {
1442
- forceCloseConnections();
1443
- }, FORCE_CONNECTION_CLOSE_GRACE_MS);
1444
- forceTimer.unref();
1445
- })
1446
- };
1447
- }
1448
- function createApp(config, forwarder, options) {
1449
- const app = new Hono3();
1450
- const forwardHeadersAllowlist = config.upstream.forward_headers;
1451
- app.get("/healthz", (c) => c.json({ status: "ok" }));
1452
- app.route("/mcp", createStreamableHttpRoute(forwarder, { forwardHeadersAllowlist }));
1453
- app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist }));
1454
- if (options?.slackActionApp) {
1455
- 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);
1456
2009
  }
1457
- return app;
1458
- }
1459
- function startServer(app, config) {
1460
- const server = serve({
1461
- fetch: app.fetch,
1462
- port: config.listen.port,
1463
- hostname: config.listen.host
1464
- });
1465
- return createServerHandle(server);
1466
2010
  }
1467
- function startSidebandServer(app, port, host = "127.0.0.1") {
1468
- const server = serve({
1469
- fetch: app.fetch,
1470
- port,
1471
- hostname: host
1472
- });
1473
- 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;
1474
2017
  }
1475
-
1476
- // src/upstream/response.ts
1477
- async function parseUpstreamResponse(res) {
1478
- const headers = {};
1479
- res.headers.forEach((value, key) => {
1480
- headers[key] = value;
1481
- });
1482
- const contentType = res.headers.get("content-type") ?? "";
1483
- let body;
1484
- if (contentType.includes("application/json")) {
1485
- const text = await res.text();
1486
- try {
1487
- body = JSON.parse(text);
1488
- } catch {
1489
- body = text;
1490
- }
1491
- } else {
1492
- 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
+ };
1493
2038
  }
1494
- 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" };
1495
2045
  }
1496
-
1497
- // src/upstream/sse-parse.ts
1498
- function parseSseChunk(chunk, state, onEvent) {
1499
- let { event, data, remainder } = state;
1500
- const text = remainder + chunk;
1501
- const lines = text.split("\n");
1502
- remainder = lines.pop() ?? "";
1503
- for (const rawLine of lines) {
1504
- const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1505
- if (line === "") {
1506
- if (event || data) {
1507
- onEvent(event, data);
1508
- event = "";
1509
- data = "";
1510
- }
1511
- } else if (line.startsWith("event:")) {
1512
- const value = line.slice(6).replace(/^ /, "");
1513
- event = value;
1514
- } else if (line.startsWith("data:")) {
1515
- const value = line.slice(5).replace(/^ /, "");
1516
- data = data ? data + "\n" + value : value;
1517
- }
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" };
1518
2052
  }
1519
- 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 };
1520
2065
  }
1521
- async function readSseJsonRpcResponse(res, requestId) {
1522
- if (!res.body) {
1523
- 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;
1524
2082
  }
1525
- const reader = res.body.getReader();
1526
- const decoder = new TextDecoder();
1527
- let state = { event: "", data: "", remainder: "" };
1528
- let found;
1529
- const onEvent = (event, data) => {
1530
- if (event && event !== "message") return;
1531
- let parsed;
1532
- try {
1533
- parsed = JSON.parse(data);
1534
- } catch {
1535
- 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 }
1536
2119
  }
1537
- if (parsed === null || typeof parsed !== "object") return;
1538
- const id = parsed["id"];
1539
- if (id === requestId) {
1540
- found = parsed;
2120
+ });
2121
+ if (headerMethod === void 0) {
2122
+ if (requiresPresence) {
2123
+ return reject(`missing mcp-method header (expected ${displayCap(method)})`);
1541
2124
  }
1542
- };
1543
- const processChunk = (chunk) => {
1544
- state = parseSseChunk(chunk, state, onEvent);
1545
- };
1546
- for (; ; ) {
1547
- const result = await reader.read();
1548
- if (result.value !== void 0) {
1549
- const chunk = result.value;
1550
- processChunk(decoder.decode(chunk, { stream: true }));
1551
- if (found) {
1552
- await reader.cancel().catch(() => void 0);
1553
- 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
+ );
1554
2141
  }
1555
2142
  }
1556
- if (result.done) {
1557
- const tail = decoder.decode();
1558
- if (tail) {
1559
- processChunk(tail);
1560
- 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
+ );
1561
2151
  }
1562
- 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
+ );
1563
2157
  }
1564
2158
  }
1565
- throw new Error(
1566
- `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1567
- );
2159
+ return { ok: true };
1568
2160
  }
1569
2161
 
1570
- // src/upstream/merge-headers.ts
1571
- function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1572
- const out = {};
1573
- const apply = (headers) => {
1574
- for (const [name, value] of Object.entries(headers)) {
1575
- 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
+ );
1576
2183
  }
1577
2184
  };
1578
- apply(base);
1579
- apply(forwarded);
1580
- apply(staticHeaders);
1581
- 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
+ };
1582
2193
  }
1583
2194
 
1584
- // src/upstream/connection-error.ts
1585
- var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1586
- var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
1587
- "ECONNREFUSED",
1588
- "ENOTFOUND",
1589
- "EAI_AGAIN",
1590
- "ECONNRESET",
1591
- "EHOSTUNREACH",
1592
- "ENETUNREACH",
1593
- "ETIMEDOUT",
1594
- "EPIPE",
1595
- "UND_ERR_CONNECT_TIMEOUT",
1596
- "UND_ERR_SOCKET"
1597
- ]);
1598
- function extractErrorCode(error) {
1599
- let current = error;
1600
- for (let depth = 0; depth < 5 && current != null; depth += 1) {
1601
- if (typeof current === "object" && "code" in current) {
1602
- const code = current.code;
1603
- if (typeof code === "string") return code;
1604
- }
1605
- 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;
1606
2216
  }
1607
- 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;
1608
2222
  }
1609
- function describeUnreachableUpstream(error, url) {
1610
- const code = extractErrorCode(error);
1611
- const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
1612
- if (code !== void 0) {
1613
- if (!UNREACHABLE_CODES.has(code)) return null;
1614
- } else if (!isGenericFetchFailure) {
1615
- 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
+ };
1616
2244
  }
1617
- const codeSuffix = code ? ` (${code})` : "";
1618
- return new Error(
1619
- `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}`
1620
- );
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
+ };
1621
2302
  }
1622
2303
 
1623
- // src/upstream/upstream-session-manager.ts
1624
- var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
1625
- var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
1626
- var UpstreamSessionManager = class {
1627
- url;
1628
- staticHeaders;
1629
- requestTimeoutMs;
1630
- internal;
1631
- inflight;
1632
- constructor(options) {
1633
- this.url = options.url;
1634
- this.staticHeaders = options.staticHeaders;
1635
- this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1636
- }
1637
- /** Return the internal session, performing the handshake once if needed. */
1638
- ensureInternalSession() {
1639
- if (this.internal) return Promise.resolve(this.internal);
1640
- this.inflight ??= this.initialize().then((session) => {
1641
- this.internal = session;
1642
- return session;
1643
- }).finally(() => {
1644
- 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
+ });
1645
2482
  });
1646
- return this.inflight;
1647
- }
1648
- /**
1649
- * Drop the cached internal session so the next call re-initializes.
1650
- * Does not cancel any in-flight initialize.
1651
- */
1652
- invalidateInternalSession() {
1653
- this.internal = void 0;
1654
- }
1655
- /** Convert a fetch failure into an actionable error for the given step. */
1656
- describeFetchFailure(error, step) {
1657
- if (error instanceof Error && error.name === "TimeoutError") {
1658
- 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
+ );
1659
2498
  }
1660
- return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
1661
- }
1662
- async initialize() {
1663
- 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(
1664
2527
  {
1665
- "content-type": "application/json",
1666
- accept: "application/json, text/event-stream"
2528
+ headers: Object.fromEntries(c.req.raw.headers),
2529
+ meta: paramsMeta(params),
2530
+ transportSessionId,
2531
+ transportMintedId: sessionId
1667
2532
  },
1668
- {},
1669
- this.staticHeaders
2533
+ sessionIdentity
1670
2534
  );
1671
- const initBody = {
2535
+ const mcpRequest = {
1672
2536
  jsonrpc: "2.0",
1673
- id: 0,
1674
- method: "initialize",
1675
- params: {
1676
- protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
1677
- capabilities: {},
1678
- clientInfo: { name: "helio-proxy", version: "0" }
1679
- }
2537
+ id,
2538
+ method,
2539
+ params,
2540
+ session: resolvedSession,
2541
+ transportSessionId,
2542
+ headers: forwardHeaders,
2543
+ signal: c.req.raw.signal
1680
2544
  };
1681
- let res;
1682
- try {
1683
- res = await fetch(this.url, {
1684
- method: "POST",
1685
- headers,
1686
- body: JSON.stringify(initBody),
1687
- 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}`);
1688
2550
  });
1689
- } catch (error) {
1690
- throw this.describeFetchFailure(error, "initialize");
1691
- }
1692
- if (!res.ok) {
1693
- throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
2551
+ return c.body(null, 202);
1694
2552
  }
1695
- const sessionId = res.headers.get("mcp-session-id") ?? void 0;
1696
- const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
1697
- res,
1698
- initBody.id,
1699
- "initialize"
1700
- );
1701
- const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
1702
- if (initializeError) {
1703
- 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);
1704
2563
  }
1705
- const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
1706
- const notifyHeaders = { ...headers };
1707
- if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
1708
- notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1709
- const notifyRes = await fetch(this.url, {
1710
- method: "POST",
1711
- headers: notifyHeaders,
1712
- body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1713
- signal: AbortSignal.timeout(this.requestTimeoutMs)
1714
- }).catch((error) => {
1715
- throw this.describeFetchFailure(error, "notifications/initialized");
2564
+ const normalized = normalizeUpstreamOutcome({
2565
+ requestId: id,
2566
+ upstreamResponse: result.response
1716
2567
  });
1717
- if (!notifyRes.ok) {
1718
- throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
1719
- }
1720
- const notifyError = await this.readOptionalJsonRpcError(notifyRes);
1721
- if (notifyError) {
1722
- throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
1723
- }
1724
- return { sessionId, protocolVersion: negotiatedProtocolVersion };
1725
- }
1726
- async readRequiredJsonRpcEnvelope(res, requestId, step) {
1727
- const contentType = res.headers.get("content-type") ?? "";
1728
- if (contentType.includes("text/event-stream")) {
1729
- const payload = await readSseJsonRpcResponse(res, requestId);
1730
- return payload;
1731
- }
1732
- const raw = await res.text();
1733
- if (!raw.trim()) {
1734
- throw new Error(`upstream ${step} returned an empty body`);
1735
- }
1736
- 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 = () => {
1737
2591
  try {
1738
- parsed = JSON.parse(raw);
2592
+ nodeServer.closeIdleConnections?.();
1739
2593
  } catch {
1740
- throw new Error(`upstream ${step} returned non-JSON body`);
1741
2594
  }
1742
- if (typeof parsed !== "object" || parsed === null) {
1743
- throw new Error(`upstream ${step} returned non-object JSON`);
2595
+ if (nodeServer.closeAllConnections) {
2596
+ try {
2597
+ nodeServer.closeAllConnections();
2598
+ } catch {
2599
+ }
2600
+ return;
1744
2601
  }
1745
- return parsed;
1746
- }
1747
- async readOptionalJsonRpcError(res) {
1748
- const contentType = res.headers.get("content-type") ?? "";
1749
- if (contentType.includes("text/event-stream")) {
1750
- if (!res.body) return void 0;
1751
- let errorMessage;
1752
- const reader = res.body.getReader();
1753
- const decoder = new TextDecoder();
1754
- let state = { event: "", data: "", remainder: "" };
1755
- let scannedBytes = 0;
1756
- const deadline = Date.now() + this.requestTimeoutMs;
1757
- const onEvent = (event, data) => {
1758
- if (errorMessage) return;
1759
- if (event && event !== "message") return;
1760
- let parsed2;
1761
- try {
1762
- parsed2 = JSON.parse(data);
1763
- } 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);
1764
2620
  return;
1765
2621
  }
1766
- if (typeof parsed2 !== "object" || parsed2 === null) return;
1767
- errorMessage = extractJsonRpcErrorMessage(parsed2);
2622
+ resolve();
1768
2623
  };
1769
- for (; ; ) {
1770
- const remainingMs = deadline - Date.now();
1771
- if (remainingMs <= 0) {
1772
- await reader.cancel().catch(() => void 0);
1773
- throw new Error(
1774
- `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1775
- );
1776
- }
1777
- let chunk;
1778
- try {
1779
- chunk = await readSseChunkWithTimeout(reader, remainingMs);
1780
- } catch {
1781
- await reader.cancel().catch(() => void 0);
1782
- throw new Error(
1783
- `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1784
- );
1785
- }
1786
- const { done, value } = chunk;
1787
- if (value !== void 0) {
1788
- scannedBytes += value.byteLength;
1789
- if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
1790
- await reader.cancel().catch(() => void 0);
1791
- throw new Error(
1792
- `upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
1793
- );
1794
- }
1795
- state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
1796
- if (errorMessage) {
1797
- await reader.cancel().catch(() => void 0);
1798
- return errorMessage;
1799
- }
1800
- }
1801
- if (done) {
1802
- const tail = decoder.decode();
1803
- if (tail) {
1804
- state = parseSseChunk(tail, state, onEvent);
2624
+ try {
2625
+ nodeServer.close((err) => {
2626
+ if (err) {
2627
+ settle(err);
2628
+ return;
1805
2629
  }
1806
- return errorMessage;
1807
- }
2630
+ settle();
2631
+ });
2632
+ } catch (error) {
2633
+ settle(normalizeError(error));
2634
+ return;
1808
2635
  }
1809
- }
1810
- const raw = await res.text();
1811
- if (!raw.trim()) return void 0;
1812
- let parsed;
1813
- try {
1814
- parsed = JSON.parse(raw);
1815
- } catch {
1816
- return void 0;
1817
- }
1818
- if (typeof parsed !== "object" || parsed === null) return void 0;
1819
- return extractJsonRpcErrorMessage(parsed);
1820
- }
1821
- };
1822
- async function readSseChunkWithTimeout(reader, timeoutMs) {
1823
- let timeoutHandle;
1824
- try {
1825
- const result = await Promise.race([
1826
- reader.read(),
1827
- new Promise((_, reject) => {
1828
- timeoutHandle = setTimeout(() => {
1829
- reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
1830
- }, timeoutMs);
1831
- })
1832
- ]);
1833
- if (!isSseReadChunk(result)) {
1834
- throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
1835
- }
1836
- return result;
1837
- } finally {
1838
- 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);
1839
2665
  }
2666
+ return app;
1840
2667
  }
1841
- function isSseReadChunk(value) {
1842
- if (typeof value !== "object" || value === null) return false;
1843
- const candidate = value;
1844
- if (typeof candidate.done !== "boolean") return false;
1845
- if (candidate.value === void 0) return true;
1846
- 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);
1847
2675
  }
1848
- function extractJsonRpcErrorMessage(payload) {
1849
- const error = payload["error"];
1850
- if (typeof error === "string") return error;
1851
- if (typeof error !== "object" || error === null) return void 0;
1852
- const message = error["message"];
1853
- if (typeof message === "string" && message.trim()) return message;
1854
- 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);
1855
2683
  }
1856
- function extractNegotiatedProtocolVersion(payload) {
1857
- const result = payload["result"];
1858
- if (typeof result !== "object" || result === null) {
1859
- 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();
1860
2702
  }
1861
- const protocolVersion = result["protocolVersion"];
1862
- return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
2703
+ return { status: res.status, headers, body };
1863
2704
  }
1864
2705
 
1865
2706
  // src/upstream/streamable-http-forwarder.ts
2707
+ var JSON_RPC_METHOD_NOT_FOUND = -32601;
1866
2708
  var StreamableHttpForwarder = class {
1867
2709
  url;
1868
2710
  staticHeaders;
@@ -1875,7 +2717,8 @@ var StreamableHttpForwarder = class {
1875
2717
  this.sessions = new UpstreamSessionManager({
1876
2718
  url: this.url,
1877
2719
  staticHeaders: this.staticHeaders,
1878
- requestTimeoutMs: this.requestTimeoutMs
2720
+ requestTimeoutMs: this.requestTimeoutMs,
2721
+ protocolVersion: options.protocolVersion
1879
2722
  });
1880
2723
  }
1881
2724
  /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
@@ -1888,15 +2731,91 @@ var StreamableHttpForwarder = class {
1888
2731
  return Promise.resolve();
1889
2732
  }
1890
2733
  async forward(request) {
1891
- if (request.method === "initialize") {
1892
- return this.send(
1893
- request,
1894
- request.sessionId,
1895
- /* protocolVersion */
1896
- 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"
1897
2817
  );
1898
2818
  }
1899
- return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
1900
2819
  }
1901
2820
  /**
1902
2821
  * Helio-internal execution path (startup prime / internal maintenance) that
@@ -1907,8 +2826,7 @@ var StreamableHttpForwarder = class {
1907
2826
  try {
1908
2827
  return await this.send(
1909
2828
  request,
1910
- session.sessionId,
1911
- session.protocolVersion,
2829
+ session,
1912
2830
  /* internalManaged */
1913
2831
  true
1914
2832
  );
@@ -1918,8 +2836,7 @@ var StreamableHttpForwarder = class {
1918
2836
  const fresh = await this.sessions.ensureInternalSession();
1919
2837
  return this.send(
1920
2838
  request,
1921
- fresh.sessionId,
1922
- fresh.protocolVersion,
2839
+ fresh,
1923
2840
  /* internalManaged */
1924
2841
  true
1925
2842
  );
@@ -1927,7 +2844,50 @@ var StreamableHttpForwarder = class {
1927
2844
  throw error;
1928
2845
  }
1929
2846
  }
1930
- 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
+ }
1931
2891
  const headers = mergeUpstreamHeaders(
1932
2892
  {
1933
2893
  "content-type": "application/json",
@@ -1936,16 +2896,26 @@ var StreamableHttpForwarder = class {
1936
2896
  request.headers ?? {},
1937
2897
  this.staticHeaders
1938
2898
  );
1939
- if (sessionId) headers["mcp-session-id"] = sessionId;
1940
- if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
1941
- headers["mcp-protocol-version"] = protocolVersion;
1942
- }
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));
1943
2909
  const body = {
1944
2910
  jsonrpc: request.jsonrpc,
1945
2911
  method: request.method
1946
2912
  };
1947
2913
  if (request.id !== void 0) body["id"] = request.id;
1948
- 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
+ }
1949
2919
  const start = performance.now();
1950
2920
  const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
1951
2921
  const requestSignal = request.signal;
@@ -1961,7 +2931,7 @@ var StreamableHttpForwarder = class {
1961
2931
  }
1962
2932
  throw describeUnreachableUpstream(error, this.url) ?? error;
1963
2933
  }
1964
- if (internalManaged && res.status === 404 && sessionId) {
2934
+ if (internalManaged && res.status === 404 && session.sessionId) {
1965
2935
  await res.text().catch(() => void 0);
1966
2936
  throw new UpstreamSessionExpiredError();
1967
2937
  }
@@ -1971,6 +2941,7 @@ var StreamableHttpForwarder = class {
1971
2941
  res.headers.forEach((value, key) => {
1972
2942
  responseHeaders[key] = value;
1973
2943
  });
2944
+ if (modern) delete responseHeaders["mcp-session-id"];
1974
2945
  if (request.id === void 0) {
1975
2946
  await res.body?.cancel().catch(() => void 0);
1976
2947
  const response3 = {
@@ -1985,6 +2956,7 @@ var StreamableHttpForwarder = class {
1985
2956
  return { response: response2, durationMs: performance.now() - start };
1986
2957
  }
1987
2958
  const response = await parseUpstreamResponse(res);
2959
+ if (modern) delete response.headers["mcp-session-id"];
1988
2960
  return { response, durationMs: performance.now() - start };
1989
2961
  }
1990
2962
  };
@@ -1994,6 +2966,16 @@ var UpstreamSessionExpiredError = class extends Error {
1994
2966
  this.name = "UpstreamSessionExpiredError";
1995
2967
  }
1996
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
+ }
1997
2979
 
1998
2980
  // src/upstream/forwarder.ts
1999
2981
  var UpstreamForwarder = class extends StreamableHttpForwarder {
@@ -2146,8 +3128,11 @@ var SseUpstreamForwarder = class {
2146
3128
  request.headers ?? {},
2147
3129
  this.staticHeaders
2148
3130
  );
2149
- if (request.sessionId) {
2150
- 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;
2151
3136
  }
2152
3137
  const start = performance.now();
2153
3138
  const signal = buildRequestSignal(request, this.requestTimeoutMs);
@@ -2576,6 +3561,79 @@ function evaluatePolicy(policy, ctx) {
2576
3561
  // src/policy/governed-forwarder.ts
2577
3562
  import { randomUUID as randomUUID2 } from "crypto";
2578
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
+
2579
3637
  // src/evidence/grounding.ts
2580
3638
  function checkEvidence(store, sessionId, requirements) {
2581
3639
  if (requirements.length === 0) {
@@ -2621,7 +3679,8 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
2621
3679
 
2622
3680
  // src/policy/decision-pipeline.ts
2623
3681
  function decide(input) {
2624
- 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;
2625
3684
  const annotations = input.baselineAnnotations;
2626
3685
  const driftEvent = input.driftEvent;
2627
3686
  const driftMode = policy.onToolDrift ?? "block";
@@ -2680,7 +3739,9 @@ function decide(input) {
2680
3739
  decision = {
2681
3740
  action: "deny",
2682
3741
  matchedRule: decision.matchedRule,
2683
- reason: "Mcp-Session-Id is required for evidence/dependency-gated policy rules"
3742
+ reason: sessionRequiredForGroundingMessage(
3743
+ input.sessionStrategySummary ?? "the configured session.identity chain"
3744
+ )
2684
3745
  };
2685
3746
  }
2686
3747
  if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
@@ -3131,6 +4192,17 @@ function buildToolDriftFeedback(drift, action) {
3131
4192
  retry_allowed: false
3132
4193
  };
3133
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
+ }
3134
4206
  function buildSpendLimitedFeedback(decision, result, currency) {
3135
4207
  const info = ruleInfo(decision.matchedRule);
3136
4208
  const windowSeconds = Math.round(result.windowMs / 1e3);
@@ -3588,6 +4660,7 @@ var GovernedForwarder = class {
3588
4660
  inner;
3589
4661
  policy;
3590
4662
  environment;
4663
+ session;
3591
4664
  auditWriter;
3592
4665
  evidenceStore;
3593
4666
  approvalRouter;
@@ -3607,6 +4680,7 @@ var GovernedForwarder = class {
3607
4680
  this.rateLimiter = options?.rateLimiter;
3608
4681
  this.spendLimiter = options?.spendLimiter;
3609
4682
  this.budgetEngine = options?.budgetEngine;
4683
+ this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
3610
4684
  if (this.evidenceStore) {
3611
4685
  this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
3612
4686
  }
@@ -3680,6 +4754,7 @@ var GovernedForwarder = class {
3680
4754
  try {
3681
4755
  const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
3682
4756
  if (result.response.status >= 400) {
4757
+ internal.resetInternalSession?.();
3683
4758
  return {
3684
4759
  success: false,
3685
4760
  toolsCached: this.annotationCache.size,
@@ -3688,6 +4763,7 @@ var GovernedForwarder = class {
3688
4763
  }
3689
4764
  const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
3690
4765
  if (!update.updated) {
4766
+ internal.resetInternalSession?.();
3691
4767
  return {
3692
4768
  success: false,
3693
4769
  toolsCached: this.annotationCache.size,
@@ -3696,6 +4772,7 @@ var GovernedForwarder = class {
3696
4772
  }
3697
4773
  return { success: true, toolsCached: this.annotationCache.size };
3698
4774
  } catch (error) {
4775
+ internal.resetInternalSession?.();
3699
4776
  return {
3700
4777
  success: false,
3701
4778
  toolsCached: this.annotationCache.size,
@@ -3709,17 +4786,42 @@ var GovernedForwarder = class {
3709
4786
  }
3710
4787
  const result = await this.inner.forward(request);
3711
4788
  if (request.method === "tools/list") {
3712
- this.applyToolDefinitionUpdate(result.response.body, request.sessionId);
4789
+ this.applyToolDefinitionUpdate(result.response.body, request.session);
4790
+ this.clampCacheHints(result.response.body);
3713
4791
  }
3714
4792
  return result;
3715
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
+ }
3716
4818
  /**
3717
4819
  * Apply a tools/list response to the definition cache and surface any
3718
4820
  * drift: console warning + immediate audit record per event. Single entry
3719
4821
  * point for both runtime tools/list responses and startup priming, so the
3720
4822
  * cache is updated exactly once per response.
3721
4823
  */
3722
- applyToolDefinitionUpdate(responseBody, sessionId) {
4824
+ applyToolDefinitionUpdate(responseBody, session) {
3723
4825
  const update = this.annotationCache.update(responseBody);
3724
4826
  if (!update.updated) return update;
3725
4827
  for (const drift of update.drifted) {
@@ -3727,22 +4829,23 @@ var GovernedForwarder = class {
3727
4829
  console.error(
3728
4830
  `[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
3729
4831
  );
3730
- this.writeDriftAuditRecord(drift, sessionId, "tool_drift");
4832
+ this.writeDriftAuditRecord(drift, session, "tool_drift");
3731
4833
  }
3732
4834
  for (const toolName of update.reverted) {
3733
4835
  console.error(
3734
4836
  `[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
3735
4837
  );
3736
- this.writeDriftAuditRecord({ toolName, changes: [] }, sessionId, "tool_drift_reverted");
4838
+ this.writeDriftAuditRecord({ toolName, changes: [] }, session, "tool_drift_reverted");
3737
4839
  }
3738
4840
  return update;
3739
4841
  }
3740
4842
  /** Write an immediate audit record for a drift event (not a tool call). */
3741
- writeDriftAuditRecord(drift, sessionId, decision) {
4843
+ writeDriftAuditRecord(drift, session, decision) {
3742
4844
  if (!this.auditWriter) return;
3743
4845
  this.auditWriter.pushImmediate({
3744
4846
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3745
- session_id: sessionId ?? null,
4847
+ session_id: session?.id ?? null,
4848
+ session_source: session?.source ?? null,
3746
4849
  agent_id: null,
3747
4850
  environment: this.environment ?? null,
3748
4851
  tool_name: drift.toolName,
@@ -3765,7 +4868,9 @@ var GovernedForwarder = class {
3765
4868
  dry_run: false,
3766
4869
  record_kind: "drift_event",
3767
4870
  origin: "mcp",
3768
- metadata: null
4871
+ metadata: null,
4872
+ // Drift is a cache event, not a request: no protocol claim exists.
4873
+ protocol_version: null
3769
4874
  });
3770
4875
  }
3771
4876
  async handleToolsCall(original) {
@@ -3802,7 +4907,8 @@ var GovernedForwarder = class {
3802
4907
  } = decide({
3803
4908
  toolName,
3804
4909
  toolArguments,
3805
- sessionId: request.sessionId,
4910
+ sessionId: request.session?.id,
4911
+ sessionStrategySummary: this.session.strategySummary,
3806
4912
  policy: this.policy,
3807
4913
  environment: this.environment,
3808
4914
  evidenceStore: this.evidenceStore,
@@ -3895,9 +5001,10 @@ var GovernedForwarder = class {
3895
5001
  });
3896
5002
  }
3897
5003
  try {
3898
- if (forwarded && !isDryRun && this.evidenceStore && request.sessionId && toolName) {
5004
+ const dependencySessionId = request.session?.id;
5005
+ if (forwarded && !isDryRun && this.evidenceStore && isWellFormedSessionId(dependencySessionId) && toolName) {
3899
5006
  const succeeded = !hasJsonRpcError(result);
3900
- this.evidenceStore.recordToolCall(request.sessionId, toolName, succeeded);
5007
+ this.evidenceStore.recordToolCall(dependencySessionId, toolName, succeeded);
3901
5008
  }
3902
5009
  } catch (err) {
3903
5010
  console.error("[helio] dependency tracking failed after forward:", err);
@@ -3918,6 +5025,7 @@ var GovernedForwarder = class {
3918
5025
  evidenceResult,
3919
5026
  dependencyResult,
3920
5027
  evidenceBlocked,
5028
+ sessionBlocked,
3921
5029
  approvalOutcome,
3922
5030
  approvalContext,
3923
5031
  rateLimitResult,
@@ -3948,7 +5056,7 @@ var GovernedForwarder = class {
3948
5056
  tool_name: toolName,
3949
5057
  tool_input: toolArguments ?? {},
3950
5058
  matched_rule: decision.matchedRule,
3951
- session_id: request.sessionId ?? null,
5059
+ session_id: request.session?.id ?? null,
3952
5060
  breached_budgets: gate.breachContexts,
3953
5061
  approval: gate.approval
3954
5062
  },
@@ -4091,15 +5199,25 @@ var GovernedForwarder = class {
4091
5199
  gateBudgets(request, decision, toolName, toolArguments) {
4092
5200
  const engine = this.budgetEngine;
4093
5201
  if (!engine) return { kind: "proceed" };
5202
+ const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
4094
5203
  const { charges, failures } = engine.resolveCharges({
4095
5204
  toolName,
4096
5205
  toolArguments,
4097
- sessionId: request.sessionId ?? null,
5206
+ sessionId: sessionGate.ok ? sessionGate.session : null,
4098
5207
  senderId: null
4099
5208
  // adapter context; absent on the MCP path
4100
5209
  });
4101
5210
  if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
4102
- 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: [] };
4103
5221
  const breaches = peek.entries.filter((entry) => !entry.allowed);
4104
5222
  const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
4105
5223
  if (breaches.length > 0) engine.reportBreaches(breaches);
@@ -4132,7 +5250,7 @@ var GovernedForwarder = class {
4132
5250
  const peekBlockByName = new Map(
4133
5251
  peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
4134
5252
  );
4135
- const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
5253
+ const commit = (auditRecordId, kinds) => engine.recordAll(gated.charges, {
4136
5254
  kind: "spend",
4137
5255
  ...kinds ? { kinds } : {},
4138
5256
  auditRecordId,
@@ -4208,7 +5326,8 @@ var GovernedForwarder = class {
4208
5326
  const toolInput = { raw_params: params ?? null };
4209
5327
  this.auditWriter.pushImmediate({
4210
5328
  timestamp,
4211
- session_id: request.sessionId ?? null,
5329
+ session_id: request.session?.id ?? null,
5330
+ session_source: request.session?.source ?? null,
4212
5331
  agent_id: null,
4213
5332
  environment: this.environment ?? null,
4214
5333
  tool_name: "<nameless>",
@@ -4231,7 +5350,8 @@ var GovernedForwarder = class {
4231
5350
  dry_run: false,
4232
5351
  record_kind: "tool_call",
4233
5352
  origin: "mcp",
4234
- metadata: null
5353
+ metadata: null,
5354
+ protocol_version: request.protocolVersion ?? null
4235
5355
  });
4236
5356
  }
4237
5357
  return result;
@@ -4244,7 +5364,7 @@ var GovernedForwarder = class {
4244
5364
  tool_name: toolName,
4245
5365
  tool_input: toolArguments ?? {},
4246
5366
  matched_rule: decision.matchedRule,
4247
- session_id: request.sessionId ?? null
5367
+ session_id: request.session?.id ?? null
4248
5368
  },
4249
5369
  request.signal
4250
5370
  );
@@ -4340,7 +5460,20 @@ var GovernedForwarder = class {
4340
5460
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
4341
5461
  };
4342
5462
  }
4343
- 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
+ }
4344
5477
  const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
4345
5478
  const rateLimitResult = limiter.peek(params);
4346
5479
  if (!rateLimitResult.allowed) {
@@ -4378,7 +5511,21 @@ var GovernedForwarder = class {
4378
5511
  spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
4379
5512
  };
4380
5513
  }
4381
- 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);
4382
5529
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
4383
5530
  if (typeof rawAmount !== "number") {
4384
5531
  console.error(
@@ -4442,6 +5589,7 @@ var GovernedForwarder = class {
4442
5589
  const evidenceSatisfied = !evidenceBlocked;
4443
5590
  let wouldForward = false;
4444
5591
  let limitsOk = true;
5592
+ let sessionUnresolved = false;
4445
5593
  if (!evidenceBlocked) {
4446
5594
  switch (decision.action) {
4447
5595
  case "allow":
@@ -4449,14 +5597,21 @@ var GovernedForwarder = class {
4449
5597
  break;
4450
5598
  case "rate_limit":
4451
5599
  if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
4452
- const key = this.buildLimitKey(decision.matchedRule.limits.key, toolName, request);
4453
- const peekResult = this.rateLimiter.peek({
4454
- key,
4455
- maxCalls: decision.matchedRule.limits.maxCalls,
4456
- windowMs: decision.matchedRule.limits.windowMs
4457
- });
4458
- wouldForward = peekResult.allowed;
4459
- 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
+ }
4460
5615
  }
4461
5616
  break;
4462
5617
  case "spend_limit":
@@ -4476,20 +5631,21 @@ var GovernedForwarder = class {
4476
5631
  wouldForward = false;
4477
5632
  limitsOk = false;
4478
5633
  } else {
4479
- const key = this.buildSpendLimitKey(
4480
- maxSpend.key,
4481
- toolName,
4482
- request,
4483
- decision.matchedRule.index
4484
- );
4485
- const peekResult = this.spendLimiter.peek({
4486
- key,
4487
- amount: rawAmount,
4488
- limit: maxSpend.limit,
4489
- windowMs: maxSpend.windowMs
4490
- });
4491
- wouldForward = peekResult.allowed;
4492
- 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
+ }
4493
5649
  }
4494
5650
  }
4495
5651
  break;
@@ -4497,30 +5653,39 @@ var GovernedForwarder = class {
4497
5653
  }
4498
5654
  let budgets;
4499
5655
  if (wouldForward && this.budgetEngine) {
5656
+ const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
4500
5657
  const { charges, failures } = this.budgetEngine.resolveCharges({
4501
5658
  toolName,
4502
5659
  toolArguments,
4503
- sessionId: request.sessionId ?? null,
5660
+ sessionId: sessionGate.ok ? sessionGate.session : null,
4504
5661
  senderId: null
4505
5662
  });
4506
5663
  if (failures.length > 0 || charges.length > 0) {
4507
- const peek = charges.length > 0 ? this.budgetEngine.peekAll(charges) : { allowed: true, entries: [] };
4508
- const ok = failures.length === 0 && peek.allowed;
4509
- wouldForward &&= ok;
4510
- limitsOk &&= ok;
4511
- budgets = [
4512
- ...peek.entries.map((entry) => budgetChainBlock(entry)),
4513
- ...failures.map((failure) => ({
4514
- name: failure.budget.name,
4515
- bucket_key: failure.bucketKey,
4516
- allowed: false,
4517
- reason: failure.reason,
4518
- spent: failure.spent,
4519
- limit: failure.budget.limit,
4520
- remaining: failure.remaining,
4521
- currency: failure.budget.currency
4522
- }))
4523
- ];
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
+ }
4524
5689
  }
4525
5690
  }
4526
5691
  return this.makeDryRunResult(
@@ -4529,14 +5694,18 @@ var GovernedForwarder = class {
4529
5694
  wouldForward,
4530
5695
  evidenceSatisfied,
4531
5696
  limitsOk,
4532
- budgets
5697
+ budgets,
5698
+ sessionUnresolved
4533
5699
  );
4534
5700
  }
4535
- /** Construct a limit bucket key based on the configured key type. */
4536
- 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) {
4537
5708
  switch (keyType) {
4538
- case "session":
4539
- return `session:${request.sessionId ?? "unknown"}`;
4540
5709
  case "agent":
4541
5710
  if (!this.agentKeyWarned) {
4542
5711
  this.agentKeyWarned = true;
@@ -4559,14 +5728,29 @@ var GovernedForwarder = class {
4559
5728
  }
4560
5729
  }
4561
5730
  /**
4562
- * Construct a spend bucket key via the shared {@link spendBucketKey}
4563
- * composer see its doc for why spend buckets are rule-discriminated.
4564
- * 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).
4565
5734
  */
4566
- buildSpendLimitKey(keyType, toolName, request, ruleIndex) {
4567
- 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
+ );
4568
5752
  }
4569
- 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) {
4570
5754
  if (!this.auditWriter) return;
4571
5755
  const actuallyForwarded = forwarded && !isDryRun;
4572
5756
  const hadForwardingError = forwardingError !== void 0;
@@ -4659,9 +5843,16 @@ var GovernedForwarder = class {
4659
5843
  };
4660
5844
  }
4661
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
+ }
4662
5852
  const record = {
4663
5853
  timestamp,
4664
- session_id: request.sessionId ?? null,
5854
+ session_id: request.session?.id ?? null,
5855
+ session_source: request.session?.source ?? null,
4665
5856
  agent_id: null,
4666
5857
  environment: this.environment ?? null,
4667
5858
  tool_name: toolName,
@@ -4687,7 +5878,8 @@ var GovernedForwarder = class {
4687
5878
  dry_run: isDryRun ?? false,
4688
5879
  record_kind: "tool_call",
4689
5880
  origin: "mcp",
4690
- metadata: null
5881
+ metadata: null,
5882
+ protocol_version: request.protocolVersion ?? null
4691
5883
  };
4692
5884
  const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
4693
5885
  if (isEnforcementDecision) {
@@ -4730,7 +5922,7 @@ var GovernedForwarder = class {
4730
5922
  unsupported: true
4731
5923
  });
4732
5924
  }
4733
- makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
5925
+ makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets, sessionUnresolved) {
4734
5926
  const payload = {
4735
5927
  dry_run: true,
4736
5928
  would_forward: wouldForward,
@@ -4738,13 +5930,19 @@ var GovernedForwarder = class {
4738
5930
  matched_rule: decision.matchedRule?.name ?? null,
4739
5931
  evidence_satisfied: evidenceSatisfied,
4740
5932
  limits_ok: limitsOk,
4741
- ...budgets ? { budgets } : {}
5933
+ ...budgets ? { budgets } : {},
5934
+ ...sessionUnresolved ? { session_unresolved: true } : {}
4742
5935
  };
4743
5936
  const body = {
4744
5937
  jsonrpc: "2.0",
4745
5938
  id: request.id ?? null,
4746
5939
  result: {
4747
- 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" } : {}
4748
5946
  }
4749
5947
  };
4750
5948
  const response = {
@@ -4767,15 +5965,10 @@ var GovernedForwarder = class {
4767
5965
  }
4768
5966
  makeSessionRequiredBlockResult(request, decision) {
4769
5967
  const feedback = buildPolicyDeniedFeedback(decision);
4770
- return makeErrorResult(
4771
- request,
4772
- POLICY_DENIED,
4773
- "Mcp-Session-Id is required for evidence/dependency-gated policy rules",
4774
- {
4775
- ...feedback,
4776
- retry_allowed: true
4777
- }
4778
- );
5968
+ return makeErrorResult(request, POLICY_DENIED, decision.reason, {
5969
+ ...feedback,
5970
+ retry_allowed: true
5971
+ });
4779
5972
  }
4780
5973
  makeClientDisconnectedBlockResult(request, decision) {
4781
5974
  const feedback = buildClientDisconnectedFeedback(decision);
@@ -5205,7 +6398,11 @@ var BudgetEngine = class {
5205
6398
  }
5206
6399
  return { charges, failures };
5207
6400
  }
5208
- /** 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
+ */
5209
6406
  peekAll(charges) {
5210
6407
  const entries = charges.map((charge) => this.snapshot(charge));
5211
6408
  return { allowed: entries.every((entry) => entry.allowed), entries };
@@ -5711,6 +6908,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
5711
6908
  id TEXT PRIMARY KEY,
5712
6909
  timestamp TEXT NOT NULL,
5713
6910
  session_id TEXT,
6911
+ session_source TEXT,
5714
6912
  agent_id TEXT,
5715
6913
  environment TEXT,
5716
6914
  tool_name TEXT NOT NULL,
@@ -5734,6 +6932,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
5734
6932
  record_kind TEXT NOT NULL DEFAULT 'tool_call',
5735
6933
  origin TEXT NOT NULL DEFAULT 'mcp',
5736
6934
  metadata TEXT,
6935
+ protocol_version TEXT,
5737
6936
  created_at TEXT NOT NULL
5738
6937
  );
5739
6938
  `;
@@ -5749,19 +6948,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
5749
6948
  `;
5750
6949
  var INSERT_SQL = `
5751
6950
  INSERT INTO audit_records (
5752
- 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,
5753
6952
  policy_decision, block_reason, matched_rule, matched_rule_index, evidence_chain, approval_status,
5754
6953
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
5755
6954
  upstream_http_status,
5756
6955
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
5757
- flagged_destructive, dry_run, record_kind, origin, metadata, created_at
6956
+ flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
5758
6957
  ) VALUES (
5759
- @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,
5760
6959
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
5761
6960
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
5762
6961
  @upstream_http_status,
5763
6962
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
5764
- @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
6963
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
5765
6964
  )
5766
6965
  `;
5767
6966
  var REQUIRED_AUDIT_COLUMNS = [
@@ -5774,13 +6973,20 @@ var REQUIRED_AUDIT_COLUMNS = [
5774
6973
  "upstream_http_status",
5775
6974
  "record_kind",
5776
6975
  "origin",
5777
- "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"
5778
6983
  ];
5779
6984
  function deserializeRow(row) {
5780
6985
  return {
5781
6986
  id: row.id,
5782
6987
  timestamp: row.timestamp,
5783
6988
  session_id: row.session_id,
6989
+ session_source: row.session_source,
5784
6990
  agent_id: row.agent_id,
5785
6991
  environment: row.environment,
5786
6992
  tool_name: row.tool_name,
@@ -5804,6 +7010,7 @@ function deserializeRow(row) {
5804
7010
  record_kind: row.record_kind,
5805
7011
  origin: row.origin,
5806
7012
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
7013
+ protocol_version: row.protocol_version,
5807
7014
  created_at: row.created_at
5808
7015
  };
5809
7016
  }
@@ -5987,6 +7194,7 @@ var AuditStore = class {
5987
7194
  id: resolvedId,
5988
7195
  timestamp: record.timestamp,
5989
7196
  session_id: record.session_id,
7197
+ session_source: record.session_source,
5990
7198
  agent_id: record.agent_id,
5991
7199
  environment: record.environment,
5992
7200
  tool_name: record.tool_name,
@@ -6010,6 +7218,7 @@ var AuditStore = class {
6010
7218
  record_kind: record.record_kind,
6011
7219
  origin: record.origin,
6012
7220
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7221
+ protocol_version: record.protocol_version,
6013
7222
  created_at: now
6014
7223
  });
6015
7224
  return resolvedId;
@@ -6198,7 +7407,11 @@ var CSV_HEADERS = [
6198
7407
  "matched_rule_index",
6199
7408
  "record_kind",
6200
7409
  "origin",
6201
- "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"
6202
7415
  ];
6203
7416
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
6204
7417
  function csvEscape(value) {
@@ -6742,15 +7955,18 @@ function asStatus(status) {
6742
7955
 
6743
7956
  // src/evidence/api.ts
6744
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
+ });
6745
7961
  var postEvidenceBody = z5.object({
6746
- session_id: z5.string().min(1),
7962
+ session_id: sessionIdSchema,
6747
7963
  tool_name: z5.string().min(1),
6748
7964
  evidence_key: z5.string().min(1),
6749
7965
  evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
6750
7966
  ttl_seconds: z5.number().int().positive().optional()
6751
7967
  });
6752
7968
  var postContextBody = z5.object({
6753
- session_id: z5.string().min(1),
7969
+ session_id: sessionIdSchema,
6754
7970
  key: z5.string().min(1),
6755
7971
  value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
6756
7972
  });
@@ -6880,6 +8096,7 @@ var SWEEP_INTERVAL_MS2 = 3e4;
6880
8096
  var GovernanceService = class {
6881
8097
  policy;
6882
8098
  environment;
8099
+ session;
6883
8100
  evidenceStore;
6884
8101
  approvalRouter;
6885
8102
  rateLimiter;
@@ -6913,6 +8130,7 @@ var GovernanceService = class {
6913
8130
  constructor(options) {
6914
8131
  this.policy = options.policy;
6915
8132
  this.environment = options.environment;
8133
+ this.session = options.session ?? DEFAULT_SESSION_IDENTITY;
6916
8134
  this.evidenceStore = options.evidenceStore;
6917
8135
  this.approvalRouter = options.approvalRouter;
6918
8136
  this.rateLimiter = options.rateLimiter;
@@ -6947,6 +8165,7 @@ var GovernanceService = class {
6947
8165
  if (reserved) {
6948
8166
  return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
6949
8167
  }
8168
+ const sessionId = isWellFormedSessionId(req.session_id) ? req.session_id : null;
6950
8169
  const inputBytes = byteLength(req.arguments ?? {});
6951
8170
  if (inputBytes > MAX_TOOL_INPUT_BYTES) {
6952
8171
  return { status: 413, body: { error: "tool_input_too_large" } };
@@ -6954,7 +8173,7 @@ var GovernanceService = class {
6954
8173
  const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
6955
8174
  tool: req.tool.name,
6956
8175
  agent_id: req.agent_id,
6957
- session_id: req.session_id,
8176
+ session_id: sessionId,
6958
8177
  origin: req.origin
6959
8178
  });
6960
8179
  if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
@@ -6976,7 +8195,8 @@ var GovernanceService = class {
6976
8195
  const pipeline = decide({
6977
8196
  toolName,
6978
8197
  toolArguments: req.arguments,
6979
- sessionId: req.session_id ?? void 0,
8198
+ sessionId: sessionId ?? void 0,
8199
+ sessionStrategySummary: this.session.strategySummary,
6980
8200
  policy: this.policy,
6981
8201
  environment: this.environment,
6982
8202
  evidenceStore: this.evidenceStore,
@@ -6993,6 +8213,8 @@ var GovernanceService = class {
6993
8213
  const plans = [];
6994
8214
  let limitsBlock;
6995
8215
  let ruleLimitOk = true;
8216
+ let sessionUnresolvedDeny = false;
8217
+ let dryRunSessionUnresolved = false;
6996
8218
  const reservedThisCall = [];
6997
8219
  const reserve = (key) => {
6998
8220
  const preexisting = this.senderKeys.has(key);
@@ -7007,12 +8229,14 @@ var GovernanceService = class {
7007
8229
  if (pipeline.isDryRun) {
7008
8230
  wire = "dry_run";
7009
8231
  if (decision.action === "rate_limit") {
7010
- const planned = this.planRate(decision, toolName, req.session_id, senderId);
8232
+ const planned = this.planRate(decision, toolName, sessionId, senderId);
7011
8233
  if (planned?.block) limitsBlock = { rate: planned.block };
8234
+ if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
7012
8235
  ruleLimitOk = planned?.allowed ?? true;
7013
8236
  } else if (decision.action === "spend_limit") {
7014
- const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
8237
+ const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
7015
8238
  if (planned?.block) limitsBlock = { spend: planned.block };
8239
+ if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
7016
8240
  ruleLimitOk = planned?.allowed ?? true;
7017
8241
  }
7018
8242
  } else if (decision.action === "deny") {
@@ -7020,21 +8244,31 @@ var GovernanceService = class {
7020
8244
  } else if (decision.action === "require_approval") {
7021
8245
  wire = "require_approval";
7022
8246
  } else if (decision.action === "rate_limit") {
7023
- const planned = this.planRate(decision, toolName, req.session_id, senderId);
7024
- if (planned?.plan && !reserve(planned.plan.key)) {
7025
- 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";
7026
8258
  }
7027
- if (planned?.plan) plans.push(planned.plan);
7028
- limitsBlock = planned?.block ? { rate: planned.block } : void 0;
7029
- wire = planned?.allowed ? "allow" : "rate_limited";
7030
8259
  } else if (decision.action === "spend_limit") {
7031
- const planned = this.planSpend(decision, toolName, req.session_id, req.arguments, senderId);
7032
- if (planned?.plan && !reserve(planned.plan.key)) {
7033
- 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";
7034
8271
  }
7035
- if (planned?.plan) plans.push(planned.plan);
7036
- limitsBlock = planned?.block ? { spend: planned.block } : void 0;
7037
- wire = planned?.allowed ? "allow" : "spend_limited";
7038
8272
  } else {
7039
8273
  wire = "allow";
7040
8274
  }
@@ -7046,14 +8280,27 @@ var GovernanceService = class {
7046
8280
  let budgetTicketTimeoutMs;
7047
8281
  let budgetTriggeredApproval = false;
7048
8282
  if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
8283
+ const budgetSessionGate = gateSession(sessionId, this.session.onUnresolved);
7049
8284
  const { charges, failures } = this.budgetEngine.resolveCharges({
7050
8285
  toolName,
7051
8286
  toolArguments: req.arguments,
7052
- sessionId: req.session_id,
8287
+ sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
7053
8288
  senderId
7054
8289
  });
7055
- if (charges.length > 0 || failures.length > 0) {
7056
- 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: [] };
7057
8304
  budgetsBlock = [
7058
8305
  ...peek.entries.map((entry2) => budgetWireBlock(entry2)),
7059
8306
  ...failures.map((failure) => budgetFailureBlock(failure))
@@ -7075,19 +8322,16 @@ var GovernanceService = class {
7075
8322
  } else {
7076
8323
  if (breaches.length > 0) budgetDryRunOk = false;
7077
8324
  if (wire !== "dry_run") {
7078
- for (const [index, charge] of charges.entries()) {
7079
- 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)) {
7080
8331
  releaseReservations();
7081
8332
  return { status: 503, body: { error: "limit_capacity_exhausted" } };
7082
8333
  }
7083
- plans.push({
7084
- kind: "budget",
7085
- budget: charge.budget,
7086
- bucketKey: charge.bucketKey,
7087
- amount: charge.amount,
7088
- generation: charge.generation,
7089
- breached: peek.entries[index]?.allowed === false
7090
- });
8334
+ plans.push(plan);
7091
8335
  }
7092
8336
  if (breaches.length > 0) {
7093
8337
  budgetBreachEntries = breaches;
@@ -7136,12 +8380,21 @@ var GovernanceService = class {
7136
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."
7137
8381
  };
7138
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
+ }
7139
8391
  if (limitsBlock) responseBody["limits"] = limitsBlock;
7140
8392
  if (wire === "dry_run") {
7141
8393
  responseBody["dry_run"] = {
7142
8394
  would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
7143
8395
  evidence_satisfied: !pipeline.evidenceBlocked,
7144
- limits_ok: ruleLimitOk && budgetDryRunOk
8396
+ limits_ok: ruleLimitOk && budgetDryRunOk,
8397
+ ...dryRunSessionUnresolved ? { session_unresolved: true } : {}
7145
8398
  };
7146
8399
  }
7147
8400
  if (pipeline.driftEvent) {
@@ -7152,7 +8405,7 @@ var GovernanceService = class {
7152
8405
  timestampIso,
7153
8406
  origin: req.origin,
7154
8407
  agentId: req.agent_id,
7155
- sessionId: req.session_id,
8408
+ sessionId,
7156
8409
  toolName,
7157
8410
  toolInput: req.arguments ?? {},
7158
8411
  metadata: req.metadata,
@@ -7167,7 +8420,9 @@ var GovernanceService = class {
7167
8420
  // limitsBlock is also the response's `limits`, and the audit writer
7168
8421
  // buffers records by reference until flush — a direct embedder
7169
8422
  // editing the returned body must not be able to rewrite evidence.
7170
- limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
8423
+ limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0,
8424
+ sessionUnresolved: sessionUnresolvedDeny,
8425
+ sessionChain: pipeline.sessionBlocked
7171
8426
  });
7172
8427
  this.tombstones.set(evaluationId, {
7173
8428
  auditRecordId: auditId,
@@ -7200,7 +8455,7 @@ var GovernanceService = class {
7200
8455
  // rewrite it (same guard as the pending entry's evidence below).
7201
8456
  tool_input: structuredClone(req.arguments ?? {}),
7202
8457
  matched_rule: decision.matchedRule,
7203
- session_id: req.session_id,
8458
+ session_id: sessionId,
7204
8459
  origin: req.origin,
7205
8460
  timeout_ms: timeoutMs,
7206
8461
  breached_budgets: budgetBreachContexts
@@ -7218,7 +8473,7 @@ var GovernanceService = class {
7218
8473
  evaluationId,
7219
8474
  origin: req.origin,
7220
8475
  agentId: req.agent_id,
7221
- sessionId: req.session_id,
8476
+ sessionId,
7222
8477
  toolName,
7223
8478
  // Cloned: direct embedders share these references and could otherwise
7224
8479
  // mutate the audit evidence (and desync the byte accounting) after
@@ -7437,7 +8692,9 @@ var GovernanceService = class {
7437
8692
  timestampIso: new Date(this.now()).toISOString(),
7438
8693
  origin: req.origin,
7439
8694
  agentId: req.agent_id,
7440
- 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,
7441
8698
  toolName,
7442
8699
  toolInput: { ...req.package },
7443
8700
  metadata: req.metadata,
@@ -7754,7 +9011,18 @@ var GovernanceService = class {
7754
9011
  if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
7755
9012
  return { allowed: true };
7756
9013
  }
7757
- 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
+ }
7758
9026
  const peek = this.rateLimiter.peek({
7759
9027
  key,
7760
9028
  maxCalls: limits.maxCalls,
@@ -7774,10 +9042,19 @@ var GovernanceService = class {
7774
9042
  planSpend(decision, toolName, sessionId, args, senderId) {
7775
9043
  const maxSpend = decision.matchedRule?.limits?.maxSpend;
7776
9044
  if (!this.spendLimiter || !maxSpend) return { allowed: true };
7777
- const key = spendBucketKey(
7778
- buildLimitKey(maxSpend.key, toolName, sessionId, senderId),
7779
- decision.matchedRule.index
7780
- );
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);
7781
9058
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
7782
9059
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
7783
9060
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -7809,18 +9086,15 @@ var GovernanceService = class {
7809
9086
  /** Commit every plan of one call at /audit time; returns the chain blocks. */
7810
9087
  commitPlans(entry, actualAmount, auditId, approvalStatus) {
7811
9088
  let chain;
7812
- const budgetPlans = entry.plans.filter((plan) => plan.kind === "budget");
9089
+ const budgetPlans = entry.plans.filter(
9090
+ (plan) => plan.kind === "budget"
9091
+ );
7813
9092
  if (budgetPlans.length > 0 && this.budgetEngine) {
7814
9093
  const kinds = new Map(
7815
9094
  budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
7816
9095
  );
7817
9096
  const snapshots = this.budgetEngine.recordAll(
7818
- budgetPlans.map((plan) => ({
7819
- budget: plan.budget,
7820
- bucketKey: plan.bucketKey,
7821
- amount: actualAmount ?? plan.amount,
7822
- generation: plan.generation
7823
- })),
9097
+ remintDeferredCharges(budgetPlans, actualAmount),
7824
9098
  {
7825
9099
  kind: "spend",
7826
9100
  ...kinds.size > 0 ? { kinds } : {},
@@ -7891,6 +9165,12 @@ var GovernanceService = class {
7891
9165
  if (!this.auditWriter) return id;
7892
9166
  const blockReason = deriveBlockReason(args);
7893
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
+ }
7894
9174
  if (args.sidebandUnreported) {
7895
9175
  evidenceChain = {
7896
9176
  ...evidenceChain ?? {},
@@ -7906,6 +9186,9 @@ var GovernanceService = class {
7906
9186
  const record = {
7907
9187
  timestamp: args.timestampIso,
7908
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,
7909
9192
  agent_id: args.agentId,
7910
9193
  environment: this.environment ?? null,
7911
9194
  tool_name: args.toolName,
@@ -7928,7 +9211,9 @@ var GovernanceService = class {
7928
9211
  dry_run: args.dryRun,
7929
9212
  record_kind: args.recordKind,
7930
9213
  origin: args.origin,
7931
- metadata: args.metadata
9214
+ metadata: args.metadata,
9215
+ // The sideband has no MCP wire, so no protocol claim exists.
9216
+ protocol_version: null
7932
9217
  };
7933
9218
  const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
7934
9219
  if (isEnforcement) this.auditWriter.pushImmediate(record, id);
@@ -7947,6 +9232,7 @@ function deriveBlockReason(args) {
7947
9232
  if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
7948
9233
  if (args.dryRun) return null;
7949
9234
  if (args.budgetBreachBlocked) return "budget_exceeded";
9235
+ if (args.sessionUnresolved) return "session_unresolved";
7950
9236
  if (args.approvalStatus === "denied") return "approval_denied";
7951
9237
  if (args.approvalStatus === "timeout") return "approval_timeout";
7952
9238
  if (args.approvalStatus === "cancelled") return "cancelled";
@@ -7985,10 +9271,8 @@ function policyCanRequireApproval(policy) {
7985
9271
  }
7986
9272
  return policy.rules.some((rule) => rule.action === "require_approval");
7987
9273
  }
7988
- function buildLimitKey(keyType, toolName, sessionId, senderId) {
9274
+ function buildLimitKey(keyType, toolName, senderId) {
7989
9275
  switch (keyType) {
7990
- case "session":
7991
- return `session:${sessionId ?? "unknown"}`;
7992
9276
  case "sender_id":
7993
9277
  return `sender:${senderId ?? "unknown"}`;
7994
9278
  case "agent":
@@ -8192,6 +9476,48 @@ var AuditWriter = class {
8192
9476
  }
8193
9477
  };
8194
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
+
8195
9521
  // src/approval/queue.ts
8196
9522
  import { randomUUID as randomUUID7 } from "crypto";
8197
9523
  var ApprovalQueue = class {
@@ -9904,6 +11230,7 @@ export {
9904
11230
  UpstreamForwarder,
9905
11231
  VERSION,
9906
11232
  WebhookChannel,
11233
+ buildHeaderMismatchAuditRecord,
9907
11234
  compileBudgets,
9908
11235
  compilePolicies,
9909
11236
  createApp,