@gethelio/proxy 0.1.1 → 0.3.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
@@ -37,7 +37,8 @@ var upstreamSchema = z.object({
37
37
  args: z.array(z.string()).optional(),
38
38
  connect_timeout: durationSchema.default("10s"),
39
39
  request_timeout: durationSchema.default("30s"),
40
- forward_headers: z.array(z.string().min(1)).default([])
40
+ forward_headers: z.array(z.string().min(1)).default([]),
41
+ headers: z.record(z.string(), z.string()).default({})
41
42
  }).refine((data) => data.transport !== "stdio" || data.command !== void 0, {
42
43
  message: '"command" is required when transport is "stdio"',
43
44
  path: ["command"]
@@ -51,6 +52,22 @@ var upstreamSchema = z.object({
51
52
  });
52
53
  }
53
54
  }
55
+ const reserved = /* @__PURE__ */ new Set([
56
+ "mcp-session-id",
57
+ "mcp-protocol-version",
58
+ "content-type",
59
+ "content-length",
60
+ "host"
61
+ ]);
62
+ for (const name of Object.keys(data.headers)) {
63
+ if (reserved.has(name.toLowerCase())) {
64
+ ctx.addIssue({
65
+ code: "custom",
66
+ path: ["headers", name],
67
+ message: `upstream.headers must not set reserved header "${name}"`
68
+ });
69
+ }
70
+ }
54
71
  });
55
72
  var listenSchema = z.object({
56
73
  port: z.number().int().min(1).max(65535).default(3e3),
@@ -1092,6 +1109,93 @@ async function parseUpstreamResponse(res) {
1092
1109
  return { status: res.status, headers, body };
1093
1110
  }
1094
1111
 
1112
+ // src/upstream/sse-parse.ts
1113
+ function parseSseChunk(chunk, state, onEvent) {
1114
+ let { event, data, remainder } = state;
1115
+ const text = remainder + chunk;
1116
+ const lines = text.split("\n");
1117
+ remainder = lines.pop() ?? "";
1118
+ for (const rawLine of lines) {
1119
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1120
+ if (line === "") {
1121
+ if (event || data) {
1122
+ onEvent(event, data);
1123
+ event = "";
1124
+ data = "";
1125
+ }
1126
+ } else if (line.startsWith("event:")) {
1127
+ const value = line.slice(6).replace(/^ /, "");
1128
+ event = value;
1129
+ } else if (line.startsWith("data:")) {
1130
+ const value = line.slice(5).replace(/^ /, "");
1131
+ data = data ? data + "\n" + value : value;
1132
+ }
1133
+ }
1134
+ return { event, data, remainder };
1135
+ }
1136
+ async function readSseJsonRpcResponse(res, requestId) {
1137
+ if (!res.body) {
1138
+ throw new Error("upstream SSE response had no body");
1139
+ }
1140
+ const reader = res.body.getReader();
1141
+ const decoder = new TextDecoder();
1142
+ let state = { event: "", data: "", remainder: "" };
1143
+ let found;
1144
+ const onEvent = (event, data) => {
1145
+ if (event && event !== "message") return;
1146
+ let parsed;
1147
+ try {
1148
+ parsed = JSON.parse(data);
1149
+ } catch {
1150
+ return;
1151
+ }
1152
+ if (parsed === null || typeof parsed !== "object") return;
1153
+ const id = parsed["id"];
1154
+ if (id === requestId) {
1155
+ found = parsed;
1156
+ }
1157
+ };
1158
+ const processChunk = (chunk) => {
1159
+ state = parseSseChunk(chunk, state, onEvent);
1160
+ };
1161
+ for (; ; ) {
1162
+ const result = await reader.read();
1163
+ if (result.value !== void 0) {
1164
+ const chunk = result.value;
1165
+ processChunk(decoder.decode(chunk, { stream: true }));
1166
+ if (found) {
1167
+ await reader.cancel().catch(() => void 0);
1168
+ return found;
1169
+ }
1170
+ }
1171
+ if (result.done) {
1172
+ const tail = decoder.decode();
1173
+ if (tail) {
1174
+ processChunk(tail);
1175
+ if (found) return found;
1176
+ }
1177
+ break;
1178
+ }
1179
+ }
1180
+ throw new Error(
1181
+ `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1182
+ );
1183
+ }
1184
+
1185
+ // src/upstream/merge-headers.ts
1186
+ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1187
+ const out = {};
1188
+ const apply = (headers) => {
1189
+ for (const [name, value] of Object.entries(headers)) {
1190
+ out[name.toLowerCase()] = value;
1191
+ }
1192
+ };
1193
+ apply(base);
1194
+ apply(forwarded);
1195
+ apply(staticHeaders);
1196
+ return out;
1197
+ }
1198
+
1095
1199
  // src/upstream/connection-error.ts
1096
1200
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1097
1201
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1131,26 +1235,325 @@ function describeUnreachableUpstream(error, url) {
1131
1235
  );
1132
1236
  }
1133
1237
 
1134
- // src/upstream/forwarder.ts
1135
- var UpstreamForwarder = class {
1238
+ // src/upstream/upstream-session-manager.ts
1239
+ var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
1240
+ var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
1241
+ var UpstreamSessionManager = class {
1242
+ url;
1243
+ staticHeaders;
1244
+ requestTimeoutMs;
1245
+ internal;
1246
+ inflight;
1247
+ constructor(options) {
1248
+ this.url = options.url;
1249
+ this.staticHeaders = options.staticHeaders;
1250
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1251
+ }
1252
+ /** Return the internal session, performing the handshake once if needed. */
1253
+ ensureInternalSession() {
1254
+ if (this.internal) return Promise.resolve(this.internal);
1255
+ this.inflight ??= this.initialize().then((session) => {
1256
+ this.internal = session;
1257
+ return session;
1258
+ }).finally(() => {
1259
+ this.inflight = void 0;
1260
+ });
1261
+ return this.inflight;
1262
+ }
1263
+ /**
1264
+ * Drop the cached internal session so the next call re-initializes.
1265
+ * Does not cancel any in-flight initialize.
1266
+ */
1267
+ invalidateInternalSession() {
1268
+ this.internal = void 0;
1269
+ }
1270
+ /** Convert a fetch failure into an actionable error for the given step. */
1271
+ describeFetchFailure(error, step) {
1272
+ if (error instanceof Error && error.name === "TimeoutError") {
1273
+ return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
1274
+ }
1275
+ return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
1276
+ }
1277
+ async initialize() {
1278
+ const headers = mergeUpstreamHeaders(
1279
+ {
1280
+ "content-type": "application/json",
1281
+ accept: "application/json, text/event-stream"
1282
+ },
1283
+ {},
1284
+ this.staticHeaders
1285
+ );
1286
+ const initBody = {
1287
+ jsonrpc: "2.0",
1288
+ id: 0,
1289
+ method: "initialize",
1290
+ params: {
1291
+ protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
1292
+ capabilities: {},
1293
+ clientInfo: { name: "helio-proxy", version: "0" }
1294
+ }
1295
+ };
1296
+ let res;
1297
+ try {
1298
+ res = await fetch(this.url, {
1299
+ method: "POST",
1300
+ headers,
1301
+ body: JSON.stringify(initBody),
1302
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
1303
+ });
1304
+ } catch (error) {
1305
+ throw this.describeFetchFailure(error, "initialize");
1306
+ }
1307
+ if (!res.ok) {
1308
+ throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
1309
+ }
1310
+ const sessionId = res.headers.get("mcp-session-id") ?? void 0;
1311
+ const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
1312
+ res,
1313
+ initBody.id,
1314
+ "initialize"
1315
+ );
1316
+ const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
1317
+ if (initializeError) {
1318
+ throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
1319
+ }
1320
+ const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
1321
+ const notifyHeaders = { ...headers };
1322
+ if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
1323
+ notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1324
+ const notifyRes = await fetch(this.url, {
1325
+ method: "POST",
1326
+ headers: notifyHeaders,
1327
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1328
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
1329
+ }).catch((error) => {
1330
+ throw this.describeFetchFailure(error, "notifications/initialized");
1331
+ });
1332
+ if (!notifyRes.ok) {
1333
+ throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
1334
+ }
1335
+ const notifyError = await this.readOptionalJsonRpcError(notifyRes);
1336
+ if (notifyError) {
1337
+ throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
1338
+ }
1339
+ return { sessionId, protocolVersion: negotiatedProtocolVersion };
1340
+ }
1341
+ async readRequiredJsonRpcEnvelope(res, requestId, step) {
1342
+ const contentType = res.headers.get("content-type") ?? "";
1343
+ if (contentType.includes("text/event-stream")) {
1344
+ const payload = await readSseJsonRpcResponse(res, requestId);
1345
+ return payload;
1346
+ }
1347
+ const raw = await res.text();
1348
+ if (!raw.trim()) {
1349
+ throw new Error(`upstream ${step} returned an empty body`);
1350
+ }
1351
+ let parsed;
1352
+ try {
1353
+ parsed = JSON.parse(raw);
1354
+ } catch {
1355
+ throw new Error(`upstream ${step} returned non-JSON body`);
1356
+ }
1357
+ if (typeof parsed !== "object" || parsed === null) {
1358
+ throw new Error(`upstream ${step} returned non-object JSON`);
1359
+ }
1360
+ return parsed;
1361
+ }
1362
+ async readOptionalJsonRpcError(res) {
1363
+ const contentType = res.headers.get("content-type") ?? "";
1364
+ if (contentType.includes("text/event-stream")) {
1365
+ if (!res.body) return void 0;
1366
+ let errorMessage;
1367
+ const reader = res.body.getReader();
1368
+ const decoder = new TextDecoder();
1369
+ let state = { event: "", data: "", remainder: "" };
1370
+ let scannedBytes = 0;
1371
+ const deadline = Date.now() + this.requestTimeoutMs;
1372
+ const onEvent = (event, data) => {
1373
+ if (errorMessage) return;
1374
+ if (event && event !== "message") return;
1375
+ let parsed2;
1376
+ try {
1377
+ parsed2 = JSON.parse(data);
1378
+ } catch {
1379
+ return;
1380
+ }
1381
+ if (typeof parsed2 !== "object" || parsed2 === null) return;
1382
+ errorMessage = extractJsonRpcErrorMessage(parsed2);
1383
+ };
1384
+ for (; ; ) {
1385
+ const remainingMs = deadline - Date.now();
1386
+ if (remainingMs <= 0) {
1387
+ await reader.cancel().catch(() => void 0);
1388
+ throw new Error(
1389
+ `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1390
+ );
1391
+ }
1392
+ let chunk;
1393
+ try {
1394
+ chunk = await readSseChunkWithTimeout(reader, remainingMs);
1395
+ } catch {
1396
+ await reader.cancel().catch(() => void 0);
1397
+ throw new Error(
1398
+ `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1399
+ );
1400
+ }
1401
+ const { done, value } = chunk;
1402
+ if (value !== void 0) {
1403
+ scannedBytes += value.byteLength;
1404
+ if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
1405
+ await reader.cancel().catch(() => void 0);
1406
+ throw new Error(
1407
+ `upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
1408
+ );
1409
+ }
1410
+ state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
1411
+ if (errorMessage) {
1412
+ await reader.cancel().catch(() => void 0);
1413
+ return errorMessage;
1414
+ }
1415
+ }
1416
+ if (done) {
1417
+ const tail = decoder.decode();
1418
+ if (tail) {
1419
+ state = parseSseChunk(tail, state, onEvent);
1420
+ }
1421
+ return errorMessage;
1422
+ }
1423
+ }
1424
+ }
1425
+ const raw = await res.text();
1426
+ if (!raw.trim()) return void 0;
1427
+ let parsed;
1428
+ try {
1429
+ parsed = JSON.parse(raw);
1430
+ } catch {
1431
+ return void 0;
1432
+ }
1433
+ if (typeof parsed !== "object" || parsed === null) return void 0;
1434
+ return extractJsonRpcErrorMessage(parsed);
1435
+ }
1436
+ };
1437
+ async function readSseChunkWithTimeout(reader, timeoutMs) {
1438
+ let timeoutHandle;
1439
+ try {
1440
+ const result = await Promise.race([
1441
+ reader.read(),
1442
+ new Promise((_, reject) => {
1443
+ timeoutHandle = setTimeout(() => {
1444
+ reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
1445
+ }, timeoutMs);
1446
+ })
1447
+ ]);
1448
+ if (!isSseReadChunk(result)) {
1449
+ throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
1450
+ }
1451
+ return result;
1452
+ } finally {
1453
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1454
+ }
1455
+ }
1456
+ function isSseReadChunk(value) {
1457
+ if (typeof value !== "object" || value === null) return false;
1458
+ const candidate = value;
1459
+ if (typeof candidate.done !== "boolean") return false;
1460
+ if (candidate.value === void 0) return true;
1461
+ return candidate.value instanceof Uint8Array;
1462
+ }
1463
+ function extractJsonRpcErrorMessage(payload) {
1464
+ const error = payload["error"];
1465
+ if (typeof error === "string") return error;
1466
+ if (typeof error !== "object" || error === null) return void 0;
1467
+ const message = error["message"];
1468
+ if (typeof message === "string" && message.trim()) return message;
1469
+ return "unknown JSON-RPC error";
1470
+ }
1471
+ function extractNegotiatedProtocolVersion(payload) {
1472
+ const result = payload["result"];
1473
+ if (typeof result !== "object" || result === null) {
1474
+ return HELIO_MCP_PROTOCOL_VERSION;
1475
+ }
1476
+ const protocolVersion = result["protocolVersion"];
1477
+ return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
1478
+ }
1479
+
1480
+ // src/upstream/streamable-http-forwarder.ts
1481
+ var StreamableHttpForwarder = class {
1136
1482
  url;
1137
1483
  staticHeaders;
1138
1484
  requestTimeoutMs;
1485
+ sessions;
1139
1486
  constructor(options) {
1140
1487
  this.url = options.url;
1141
1488
  this.staticHeaders = options.headers ?? {};
1142
1489
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1490
+ this.sessions = new UpstreamSessionManager({
1491
+ url: this.url,
1492
+ staticHeaders: this.staticHeaders,
1493
+ requestTimeoutMs: this.requestTimeoutMs
1494
+ });
1495
+ }
1496
+ /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
1497
+ connect() {
1498
+ return Promise.resolve();
1499
+ }
1500
+ /** Lifecycle parity with sse/stdio. */
1501
+ close() {
1502
+ this.sessions.invalidateInternalSession();
1503
+ return Promise.resolve();
1143
1504
  }
1144
1505
  async forward(request) {
1145
- const requestHeaders = request.headers ?? {};
1146
- const headers = {
1147
- "content-type": "application/json",
1148
- accept: "application/json, text/event-stream",
1149
- ...this.staticHeaders,
1150
- ...requestHeaders
1151
- };
1152
- if (request.sessionId) {
1153
- headers["mcp-session-id"] = request.sessionId;
1506
+ if (request.method === "initialize") {
1507
+ return this.send(
1508
+ request,
1509
+ request.sessionId,
1510
+ /* protocolVersion */
1511
+ void 0
1512
+ );
1513
+ }
1514
+ return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
1515
+ }
1516
+ /**
1517
+ * Helio-internal execution path (startup prime / internal maintenance) that
1518
+ * may borrow the proxy-managed internal session.
1519
+ */
1520
+ async forwardInternal(request) {
1521
+ const session = await this.sessions.ensureInternalSession();
1522
+ try {
1523
+ return await this.send(
1524
+ request,
1525
+ session.sessionId,
1526
+ session.protocolVersion,
1527
+ /* internalManaged */
1528
+ true
1529
+ );
1530
+ } catch (error) {
1531
+ if (error instanceof UpstreamSessionExpiredError) {
1532
+ this.sessions.invalidateInternalSession();
1533
+ const fresh = await this.sessions.ensureInternalSession();
1534
+ return this.send(
1535
+ request,
1536
+ fresh.sessionId,
1537
+ fresh.protocolVersion,
1538
+ /* internalManaged */
1539
+ true
1540
+ );
1541
+ }
1542
+ throw error;
1543
+ }
1544
+ }
1545
+ async send(request, sessionId, protocolVersion, internalManaged = false) {
1546
+ const headers = mergeUpstreamHeaders(
1547
+ {
1548
+ "content-type": "application/json",
1549
+ accept: "application/json, text/event-stream"
1550
+ },
1551
+ request.headers ?? {},
1552
+ this.staticHeaders
1553
+ );
1554
+ if (sessionId) headers["mcp-session-id"] = sessionId;
1555
+ if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
1556
+ headers["mcp-protocol-version"] = protocolVersion;
1154
1557
  }
1155
1558
  const body = {
1156
1559
  jsonrpc: request.jsonrpc,
@@ -1164,34 +1567,53 @@ var UpstreamForwarder = class {
1164
1567
  const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal;
1165
1568
  let res;
1166
1569
  try {
1167
- res = await fetch(this.url, {
1168
- method: "POST",
1169
- headers,
1170
- body: JSON.stringify(body),
1171
- signal
1172
- });
1570
+ res = await fetch(this.url, { method: "POST", headers, body: JSON.stringify(body), signal });
1173
1571
  } catch (error) {
1174
1572
  const isTimeout = error instanceof Error && error.name === "TimeoutError";
1175
- if (requestSignal?.aborted) {
1176
- throw new Error("request aborted by downstream client");
1177
- }
1573
+ if (requestSignal?.aborted) throw new Error("request aborted by downstream client");
1178
1574
  if (isTimeout) {
1179
1575
  throw new Error(`upstream request timed out after ${String(this.requestTimeoutMs)}ms`);
1180
1576
  }
1181
1577
  throw describeUnreachableUpstream(error, this.url) ?? error;
1182
1578
  }
1183
- const durationMs = performance.now() - start;
1579
+ if (internalManaged && res.status === 404 && sessionId) {
1580
+ await res.text().catch(() => void 0);
1581
+ throw new UpstreamSessionExpiredError();
1582
+ }
1184
1583
  const contentType = res.headers.get("content-type") ?? "";
1185
1584
  if (contentType.includes("text/event-stream")) {
1186
- throw new Error(
1187
- "upstream returned text/event-stream; streamable-http passthrough is not supported in v0.1"
1188
- );
1585
+ const responseHeaders = {};
1586
+ res.headers.forEach((value, key) => {
1587
+ responseHeaders[key] = value;
1588
+ });
1589
+ if (request.id === void 0) {
1590
+ await res.body?.cancel().catch(() => void 0);
1591
+ const response3 = {
1592
+ status: res.status,
1593
+ headers: responseHeaders,
1594
+ body: { jsonrpc: "2.0" }
1595
+ };
1596
+ return { response: response3, durationMs: performance.now() - start };
1597
+ }
1598
+ const jsonRpc = await readSseJsonRpcResponse(res, request.id);
1599
+ const response2 = { status: res.status, headers: responseHeaders, body: jsonRpc };
1600
+ return { response: response2, durationMs: performance.now() - start };
1189
1601
  }
1190
1602
  const response = await parseUpstreamResponse(res);
1191
- return { response, durationMs };
1603
+ return { response, durationMs: performance.now() - start };
1604
+ }
1605
+ };
1606
+ var UpstreamSessionExpiredError = class extends Error {
1607
+ constructor() {
1608
+ super("upstream session expired (HTTP 404) for Helio-managed internal session");
1609
+ this.name = "UpstreamSessionExpiredError";
1192
1610
  }
1193
1611
  };
1194
1612
 
1613
+ // src/upstream/forwarder.ts
1614
+ var UpstreamForwarder = class extends StreamableHttpForwarder {
1615
+ };
1616
+
1195
1617
  // src/mcp/pending-requests.ts
1196
1618
  var DEFAULT_TIMEOUT_MS = 3e4;
1197
1619
  var PendingRequests = class {
@@ -1261,26 +1683,6 @@ function buildRequestSignal(request, timeoutMs) {
1261
1683
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
1262
1684
  return request.signal ? AbortSignal.any([request.signal, timeoutSignal]) : timeoutSignal;
1263
1685
  }
1264
- function parseSseChunk(chunk, state, onEvent) {
1265
- let { event, data, remainder } = state;
1266
- const text = remainder + chunk;
1267
- const lines = text.split("\n");
1268
- remainder = lines.pop() ?? "";
1269
- for (const line of lines) {
1270
- if (line === "") {
1271
- if (event || data) {
1272
- onEvent(event, data);
1273
- event = "";
1274
- data = "";
1275
- }
1276
- } else if (line.startsWith("event: ")) {
1277
- event = line.slice(7);
1278
- } else if (line.startsWith("data: ")) {
1279
- data = data ? data + "\n" + line.slice(6) : line.slice(6);
1280
- }
1281
- }
1282
- return { event, data, remainder };
1283
- }
1284
1686
  var SseUpstreamForwarder = class {
1285
1687
  url;
1286
1688
  staticHeaders;
@@ -1354,12 +1756,11 @@ var SseUpstreamForwarder = class {
1354
1756
  };
1355
1757
  if (request.id !== void 0) body["id"] = request.id;
1356
1758
  if (request.params !== void 0) body["params"] = request.params;
1357
- const requestHeaders = request.headers ?? {};
1358
- const headers = {
1359
- "content-type": "application/json",
1360
- ...this.staticHeaders,
1361
- ...requestHeaders
1362
- };
1759
+ const headers = mergeUpstreamHeaders(
1760
+ { "content-type": "application/json" },
1761
+ request.headers ?? {},
1762
+ this.staticHeaders
1763
+ );
1363
1764
  if (request.sessionId) {
1364
1765
  headers["mcp-session-id"] = request.sessionId;
1365
1766
  }
@@ -2127,6 +2528,11 @@ var GovernedForwarder = class {
2127
2528
  *
2128
2529
  * This path is intended for startup warm-up and intentionally bypasses policy
2129
2530
  * and audit handling. Runtime tools/list requests still flow through forward().
2531
+ *
2532
+ * When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
2533
+ * request is routed through it so session-enforcing servers (e.g. Streamable
2534
+ * HTTP upstreams) receive the request on the managed internal session rather
2535
+ * than as a sessionless call that they would reject with HTTP 400.
2130
2536
  */
2131
2537
  async primeAnnotationCache() {
2132
2538
  const syntheticToolsList = {
@@ -2134,14 +2540,22 @@ var GovernedForwarder = class {
2134
2540
  id: "helio-prime-annotations",
2135
2541
  method: "tools/list"
2136
2542
  };
2543
+ const internal = this.inner;
2137
2544
  try {
2138
- const result = await this.inner.forward(syntheticToolsList);
2545
+ const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
2546
+ if (result.response.status >= 400) {
2547
+ return {
2548
+ success: false,
2549
+ toolsCached: this.annotationCache.size,
2550
+ reason: classifyPrimeFailure(result.response)
2551
+ };
2552
+ }
2139
2553
  const updated = this.annotationCache.update(result.response.body);
2140
2554
  if (!updated) {
2141
2555
  return {
2142
2556
  success: false,
2143
2557
  toolsCached: this.annotationCache.size,
2144
- reason: "upstream tools/list response had unexpected shape"
2558
+ reason: classifyPrimeFailure(result.response)
2145
2559
  };
2146
2560
  }
2147
2561
  return { success: true, toolsCached: this.annotationCache.size };
@@ -2742,6 +3156,27 @@ function hasJsonRpcError(result) {
2742
3156
  const body = result.response.body;
2743
3157
  return body?.["error"] !== void 0;
2744
3158
  }
3159
+ function classifyPrimeFailure(response) {
3160
+ if (response.status >= 400) {
3161
+ return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
3162
+ }
3163
+ const rawBody = response.body;
3164
+ if (typeof rawBody !== "object" || rawBody === null) {
3165
+ return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
3166
+ }
3167
+ const body = rawBody;
3168
+ const error = body["error"];
3169
+ if (typeof error === "string") {
3170
+ return `upstream tools/list returned a JSON-RPC error: ${error}`;
3171
+ }
3172
+ if (error !== null && typeof error === "object") {
3173
+ const message = error["message"];
3174
+ if (typeof message === "string") {
3175
+ return `upstream tools/list returned a JSON-RPC error: ${message}`;
3176
+ }
3177
+ }
3178
+ return "upstream tools/list response was missing result.tools";
3179
+ }
2745
3180
  function extractBlockReason(result) {
2746
3181
  const body = result.response.body;
2747
3182
  const error = body?.["error"];
@@ -5694,6 +6129,7 @@ export {
5694
6129
  SpendLimiter,
5695
6130
  SseUpstreamForwarder,
5696
6131
  StdioForwarder,
6132
+ StreamableHttpForwarder,
5697
6133
  UpstreamForwarder,
5698
6134
  VERSION,
5699
6135
  WebhookChannel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethelio/proxy",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Open-source MCP governance proxy for AI agents",
6
6
  "license": "Apache-2.0",