@gethelio/proxy 0.2.0 → 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/cli.js CHANGED
@@ -1215,6 +1215,93 @@ async function parseUpstreamResponse(res) {
1215
1215
  return { status: res.status, headers, body };
1216
1216
  }
1217
1217
 
1218
+ // src/upstream/sse-parse.ts
1219
+ function parseSseChunk(chunk, state, onEvent) {
1220
+ let { event, data, remainder } = state;
1221
+ const text = remainder + chunk;
1222
+ const lines = text.split("\n");
1223
+ remainder = lines.pop() ?? "";
1224
+ for (const rawLine of lines) {
1225
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
1226
+ if (line === "") {
1227
+ if (event || data) {
1228
+ onEvent(event, data);
1229
+ event = "";
1230
+ data = "";
1231
+ }
1232
+ } else if (line.startsWith("event:")) {
1233
+ const value = line.slice(6).replace(/^ /, "");
1234
+ event = value;
1235
+ } else if (line.startsWith("data:")) {
1236
+ const value = line.slice(5).replace(/^ /, "");
1237
+ data = data ? data + "\n" + value : value;
1238
+ }
1239
+ }
1240
+ return { event, data, remainder };
1241
+ }
1242
+ async function readSseJsonRpcResponse(res, requestId) {
1243
+ if (!res.body) {
1244
+ throw new Error("upstream SSE response had no body");
1245
+ }
1246
+ const reader = res.body.getReader();
1247
+ const decoder = new TextDecoder();
1248
+ let state = { event: "", data: "", remainder: "" };
1249
+ let found;
1250
+ const onEvent = (event, data) => {
1251
+ if (event && event !== "message") return;
1252
+ let parsed;
1253
+ try {
1254
+ parsed = JSON.parse(data);
1255
+ } catch {
1256
+ return;
1257
+ }
1258
+ if (parsed === null || typeof parsed !== "object") return;
1259
+ const id = parsed["id"];
1260
+ if (id === requestId) {
1261
+ found = parsed;
1262
+ }
1263
+ };
1264
+ const processChunk = (chunk) => {
1265
+ state = parseSseChunk(chunk, state, onEvent);
1266
+ };
1267
+ for (; ; ) {
1268
+ const result = await reader.read();
1269
+ if (result.value !== void 0) {
1270
+ const chunk = result.value;
1271
+ processChunk(decoder.decode(chunk, { stream: true }));
1272
+ if (found) {
1273
+ await reader.cancel().catch(() => void 0);
1274
+ return found;
1275
+ }
1276
+ }
1277
+ if (result.done) {
1278
+ const tail = decoder.decode();
1279
+ if (tail) {
1280
+ processChunk(tail);
1281
+ if (found) return found;
1282
+ }
1283
+ break;
1284
+ }
1285
+ }
1286
+ throw new Error(
1287
+ `upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
1288
+ );
1289
+ }
1290
+
1291
+ // src/upstream/merge-headers.ts
1292
+ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1293
+ const out = {};
1294
+ const apply = (headers) => {
1295
+ for (const [name, value] of Object.entries(headers)) {
1296
+ out[name.toLowerCase()] = value;
1297
+ }
1298
+ };
1299
+ apply(base);
1300
+ apply(forwarded);
1301
+ apply(staticHeaders);
1302
+ return out;
1303
+ }
1304
+
1218
1305
  // src/upstream/connection-error.ts
1219
1306
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1220
1307
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1254,31 +1341,314 @@ function describeUnreachableUpstream(error, url) {
1254
1341
  );
1255
1342
  }
1256
1343
 
1257
- // src/upstream/merge-headers.ts
1258
- function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1259
- const out = {};
1260
- const apply = (headers) => {
1261
- for (const [name, value] of Object.entries(headers)) {
1262
- out[name.toLowerCase()] = value;
1344
+ // src/upstream/upstream-session-manager.ts
1345
+ var HELIO_MCP_PROTOCOL_VERSION = "2025-06-18";
1346
+ var MAX_SSE_ERROR_SCAN_BYTES = 256 * 1024;
1347
+ var UpstreamSessionManager = class {
1348
+ url;
1349
+ staticHeaders;
1350
+ requestTimeoutMs;
1351
+ internal;
1352
+ inflight;
1353
+ constructor(options) {
1354
+ this.url = options.url;
1355
+ this.staticHeaders = options.staticHeaders;
1356
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1357
+ }
1358
+ /** Return the internal session, performing the handshake once if needed. */
1359
+ ensureInternalSession() {
1360
+ if (this.internal) return Promise.resolve(this.internal);
1361
+ this.inflight ??= this.initialize().then((session) => {
1362
+ this.internal = session;
1363
+ return session;
1364
+ }).finally(() => {
1365
+ this.inflight = void 0;
1366
+ });
1367
+ return this.inflight;
1368
+ }
1369
+ /**
1370
+ * Drop the cached internal session so the next call re-initializes.
1371
+ * Does not cancel any in-flight initialize.
1372
+ */
1373
+ invalidateInternalSession() {
1374
+ this.internal = void 0;
1375
+ }
1376
+ /** Convert a fetch failure into an actionable error for the given step. */
1377
+ describeFetchFailure(error, step) {
1378
+ if (error instanceof Error && error.name === "TimeoutError") {
1379
+ return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
1263
1380
  }
1264
- };
1265
- apply(base);
1266
- apply(forwarded);
1267
- apply(staticHeaders);
1268
- return out;
1381
+ return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
1382
+ }
1383
+ async initialize() {
1384
+ const headers = mergeUpstreamHeaders(
1385
+ {
1386
+ "content-type": "application/json",
1387
+ accept: "application/json, text/event-stream"
1388
+ },
1389
+ {},
1390
+ this.staticHeaders
1391
+ );
1392
+ const initBody = {
1393
+ jsonrpc: "2.0",
1394
+ id: 0,
1395
+ method: "initialize",
1396
+ params: {
1397
+ protocolVersion: HELIO_MCP_PROTOCOL_VERSION,
1398
+ capabilities: {},
1399
+ clientInfo: { name: "helio-proxy", version: "0" }
1400
+ }
1401
+ };
1402
+ let res;
1403
+ try {
1404
+ res = await fetch(this.url, {
1405
+ method: "POST",
1406
+ headers,
1407
+ body: JSON.stringify(initBody),
1408
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
1409
+ });
1410
+ } catch (error) {
1411
+ throw this.describeFetchFailure(error, "initialize");
1412
+ }
1413
+ if (!res.ok) {
1414
+ throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
1415
+ }
1416
+ const sessionId = res.headers.get("mcp-session-id") ?? void 0;
1417
+ const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
1418
+ res,
1419
+ initBody.id,
1420
+ "initialize"
1421
+ );
1422
+ const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
1423
+ if (initializeError) {
1424
+ throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
1425
+ }
1426
+ const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
1427
+ const notifyHeaders = { ...headers };
1428
+ if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
1429
+ notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
1430
+ const notifyRes = await fetch(this.url, {
1431
+ method: "POST",
1432
+ headers: notifyHeaders,
1433
+ body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
1434
+ signal: AbortSignal.timeout(this.requestTimeoutMs)
1435
+ }).catch((error) => {
1436
+ throw this.describeFetchFailure(error, "notifications/initialized");
1437
+ });
1438
+ if (!notifyRes.ok) {
1439
+ throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
1440
+ }
1441
+ const notifyError = await this.readOptionalJsonRpcError(notifyRes);
1442
+ if (notifyError) {
1443
+ throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
1444
+ }
1445
+ return { sessionId, protocolVersion: negotiatedProtocolVersion };
1446
+ }
1447
+ async readRequiredJsonRpcEnvelope(res, requestId, step) {
1448
+ const contentType = res.headers.get("content-type") ?? "";
1449
+ if (contentType.includes("text/event-stream")) {
1450
+ const payload = await readSseJsonRpcResponse(res, requestId);
1451
+ return payload;
1452
+ }
1453
+ const raw = await res.text();
1454
+ if (!raw.trim()) {
1455
+ throw new Error(`upstream ${step} returned an empty body`);
1456
+ }
1457
+ let parsed;
1458
+ try {
1459
+ parsed = JSON.parse(raw);
1460
+ } catch {
1461
+ throw new Error(`upstream ${step} returned non-JSON body`);
1462
+ }
1463
+ if (typeof parsed !== "object" || parsed === null) {
1464
+ throw new Error(`upstream ${step} returned non-object JSON`);
1465
+ }
1466
+ return parsed;
1467
+ }
1468
+ async readOptionalJsonRpcError(res) {
1469
+ const contentType = res.headers.get("content-type") ?? "";
1470
+ if (contentType.includes("text/event-stream")) {
1471
+ if (!res.body) return void 0;
1472
+ let errorMessage;
1473
+ const reader = res.body.getReader();
1474
+ const decoder = new TextDecoder();
1475
+ let state = { event: "", data: "", remainder: "" };
1476
+ let scannedBytes = 0;
1477
+ const deadline = Date.now() + this.requestTimeoutMs;
1478
+ const onEvent = (event, data) => {
1479
+ if (errorMessage) return;
1480
+ if (event && event !== "message") return;
1481
+ let parsed2;
1482
+ try {
1483
+ parsed2 = JSON.parse(data);
1484
+ } catch {
1485
+ return;
1486
+ }
1487
+ if (typeof parsed2 !== "object" || parsed2 === null) return;
1488
+ errorMessage = extractJsonRpcErrorMessage(parsed2);
1489
+ };
1490
+ for (; ; ) {
1491
+ const remainingMs = deadline - Date.now();
1492
+ if (remainingMs <= 0) {
1493
+ await reader.cancel().catch(() => void 0);
1494
+ throw new Error(
1495
+ `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1496
+ );
1497
+ }
1498
+ let chunk;
1499
+ try {
1500
+ chunk = await readSseChunkWithTimeout(reader, remainingMs);
1501
+ } catch {
1502
+ await reader.cancel().catch(() => void 0);
1503
+ throw new Error(
1504
+ `upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
1505
+ );
1506
+ }
1507
+ const { done, value } = chunk;
1508
+ if (value !== void 0) {
1509
+ scannedBytes += value.byteLength;
1510
+ if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
1511
+ await reader.cancel().catch(() => void 0);
1512
+ throw new Error(
1513
+ `upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
1514
+ );
1515
+ }
1516
+ state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
1517
+ if (errorMessage) {
1518
+ await reader.cancel().catch(() => void 0);
1519
+ return errorMessage;
1520
+ }
1521
+ }
1522
+ if (done) {
1523
+ const tail = decoder.decode();
1524
+ if (tail) {
1525
+ state = parseSseChunk(tail, state, onEvent);
1526
+ }
1527
+ return errorMessage;
1528
+ }
1529
+ }
1530
+ }
1531
+ const raw = await res.text();
1532
+ if (!raw.trim()) return void 0;
1533
+ let parsed;
1534
+ try {
1535
+ parsed = JSON.parse(raw);
1536
+ } catch {
1537
+ return void 0;
1538
+ }
1539
+ if (typeof parsed !== "object" || parsed === null) return void 0;
1540
+ return extractJsonRpcErrorMessage(parsed);
1541
+ }
1542
+ };
1543
+ async function readSseChunkWithTimeout(reader, timeoutMs) {
1544
+ let timeoutHandle;
1545
+ try {
1546
+ const result = await Promise.race([
1547
+ reader.read(),
1548
+ new Promise((_, reject) => {
1549
+ timeoutHandle = setTimeout(() => {
1550
+ reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
1551
+ }, timeoutMs);
1552
+ })
1553
+ ]);
1554
+ if (!isSseReadChunk(result)) {
1555
+ throw new Error("upstream notifications/initialized SSE response returned invalid chunk");
1556
+ }
1557
+ return result;
1558
+ } finally {
1559
+ if (timeoutHandle) clearTimeout(timeoutHandle);
1560
+ }
1561
+ }
1562
+ function isSseReadChunk(value) {
1563
+ if (typeof value !== "object" || value === null) return false;
1564
+ const candidate = value;
1565
+ if (typeof candidate.done !== "boolean") return false;
1566
+ if (candidate.value === void 0) return true;
1567
+ return candidate.value instanceof Uint8Array;
1568
+ }
1569
+ function extractJsonRpcErrorMessage(payload) {
1570
+ const error = payload["error"];
1571
+ if (typeof error === "string") return error;
1572
+ if (typeof error !== "object" || error === null) return void 0;
1573
+ const message = error["message"];
1574
+ if (typeof message === "string" && message.trim()) return message;
1575
+ return "unknown JSON-RPC error";
1576
+ }
1577
+ function extractNegotiatedProtocolVersion(payload) {
1578
+ const result = payload["result"];
1579
+ if (typeof result !== "object" || result === null) {
1580
+ return HELIO_MCP_PROTOCOL_VERSION;
1581
+ }
1582
+ const protocolVersion = result["protocolVersion"];
1583
+ return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
1269
1584
  }
1270
1585
 
1271
- // src/upstream/forwarder.ts
1272
- var UpstreamForwarder = class {
1586
+ // src/upstream/streamable-http-forwarder.ts
1587
+ var StreamableHttpForwarder = class {
1273
1588
  url;
1274
1589
  staticHeaders;
1275
1590
  requestTimeoutMs;
1591
+ sessions;
1276
1592
  constructor(options) {
1277
1593
  this.url = options.url;
1278
1594
  this.staticHeaders = options.headers ?? {};
1279
1595
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1596
+ this.sessions = new UpstreamSessionManager({
1597
+ url: this.url,
1598
+ staticHeaders: this.staticHeaders,
1599
+ requestTimeoutMs: this.requestTimeoutMs
1600
+ });
1601
+ }
1602
+ /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
1603
+ connect() {
1604
+ return Promise.resolve();
1605
+ }
1606
+ /** Lifecycle parity with sse/stdio. */
1607
+ close() {
1608
+ this.sessions.invalidateInternalSession();
1609
+ return Promise.resolve();
1280
1610
  }
1281
1611
  async forward(request) {
1612
+ if (request.method === "initialize") {
1613
+ return this.send(
1614
+ request,
1615
+ request.sessionId,
1616
+ /* protocolVersion */
1617
+ void 0
1618
+ );
1619
+ }
1620
+ return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
1621
+ }
1622
+ /**
1623
+ * Helio-internal execution path (startup prime / internal maintenance) that
1624
+ * may borrow the proxy-managed internal session.
1625
+ */
1626
+ async forwardInternal(request) {
1627
+ const session = await this.sessions.ensureInternalSession();
1628
+ try {
1629
+ return await this.send(
1630
+ request,
1631
+ session.sessionId,
1632
+ session.protocolVersion,
1633
+ /* internalManaged */
1634
+ true
1635
+ );
1636
+ } catch (error) {
1637
+ if (error instanceof UpstreamSessionExpiredError) {
1638
+ this.sessions.invalidateInternalSession();
1639
+ const fresh = await this.sessions.ensureInternalSession();
1640
+ return this.send(
1641
+ request,
1642
+ fresh.sessionId,
1643
+ fresh.protocolVersion,
1644
+ /* internalManaged */
1645
+ true
1646
+ );
1647
+ }
1648
+ throw error;
1649
+ }
1650
+ }
1651
+ async send(request, sessionId, protocolVersion, internalManaged = false) {
1282
1652
  const headers = mergeUpstreamHeaders(
1283
1653
  {
1284
1654
  "content-type": "application/json",
@@ -1287,8 +1657,9 @@ var UpstreamForwarder = class {
1287
1657
  request.headers ?? {},
1288
1658
  this.staticHeaders
1289
1659
  );
1290
- if (request.sessionId) {
1291
- headers["mcp-session-id"] = request.sessionId;
1660
+ if (sessionId) headers["mcp-session-id"] = sessionId;
1661
+ if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
1662
+ headers["mcp-protocol-version"] = protocolVersion;
1292
1663
  }
1293
1664
  const body = {
1294
1665
  jsonrpc: request.jsonrpc,
@@ -1302,31 +1673,46 @@ var UpstreamForwarder = class {
1302
1673
  const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal;
1303
1674
  let res;
1304
1675
  try {
1305
- res = await fetch(this.url, {
1306
- method: "POST",
1307
- headers,
1308
- body: JSON.stringify(body),
1309
- signal
1310
- });
1676
+ res = await fetch(this.url, { method: "POST", headers, body: JSON.stringify(body), signal });
1311
1677
  } catch (error) {
1312
1678
  const isTimeout = error instanceof Error && error.name === "TimeoutError";
1313
- if (requestSignal?.aborted) {
1314
- throw new Error("request aborted by downstream client");
1315
- }
1679
+ if (requestSignal?.aborted) throw new Error("request aborted by downstream client");
1316
1680
  if (isTimeout) {
1317
1681
  throw new Error(`upstream request timed out after ${String(this.requestTimeoutMs)}ms`);
1318
1682
  }
1319
1683
  throw describeUnreachableUpstream(error, this.url) ?? error;
1320
1684
  }
1321
- const durationMs = performance.now() - start;
1685
+ if (internalManaged && res.status === 404 && sessionId) {
1686
+ await res.text().catch(() => void 0);
1687
+ throw new UpstreamSessionExpiredError();
1688
+ }
1322
1689
  const contentType = res.headers.get("content-type") ?? "";
1323
1690
  if (contentType.includes("text/event-stream")) {
1324
- throw new Error(
1325
- "upstream returned text/event-stream; streamable-http passthrough is not supported in v0.1"
1326
- );
1691
+ const responseHeaders = {};
1692
+ res.headers.forEach((value, key) => {
1693
+ responseHeaders[key] = value;
1694
+ });
1695
+ if (request.id === void 0) {
1696
+ await res.body?.cancel().catch(() => void 0);
1697
+ const response3 = {
1698
+ status: res.status,
1699
+ headers: responseHeaders,
1700
+ body: { jsonrpc: "2.0" }
1701
+ };
1702
+ return { response: response3, durationMs: performance.now() - start };
1703
+ }
1704
+ const jsonRpc = await readSseJsonRpcResponse(res, request.id);
1705
+ const response2 = { status: res.status, headers: responseHeaders, body: jsonRpc };
1706
+ return { response: response2, durationMs: performance.now() - start };
1327
1707
  }
1328
1708
  const response = await parseUpstreamResponse(res);
1329
- return { response, durationMs };
1709
+ return { response, durationMs: performance.now() - start };
1710
+ }
1711
+ };
1712
+ var UpstreamSessionExpiredError = class extends Error {
1713
+ constructor() {
1714
+ super("upstream session expired (HTTP 404) for Helio-managed internal session");
1715
+ this.name = "UpstreamSessionExpiredError";
1330
1716
  }
1331
1717
  };
1332
1718
 
@@ -1399,26 +1785,6 @@ function buildRequestSignal(request, timeoutMs) {
1399
1785
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
1400
1786
  return request.signal ? AbortSignal.any([request.signal, timeoutSignal]) : timeoutSignal;
1401
1787
  }
1402
- function parseSseChunk(chunk, state, onEvent) {
1403
- let { event, data, remainder } = state;
1404
- const text = remainder + chunk;
1405
- const lines = text.split("\n");
1406
- remainder = lines.pop() ?? "";
1407
- for (const line of lines) {
1408
- if (line === "") {
1409
- if (event || data) {
1410
- onEvent(event, data);
1411
- event = "";
1412
- data = "";
1413
- }
1414
- } else if (line.startsWith("event: ")) {
1415
- event = line.slice(7);
1416
- } else if (line.startsWith("data: ")) {
1417
- data = data ? data + "\n" + line.slice(6) : line.slice(6);
1418
- }
1419
- }
1420
- return { event, data, remainder };
1421
- }
1422
1788
  var SseUpstreamForwarder = class {
1423
1789
  url;
1424
1790
  staticHeaders;
@@ -1812,13 +2178,13 @@ var StdioForwarder = class {
1812
2178
  async function createForwarderFromConfig(config) {
1813
2179
  switch (config.upstream.transport) {
1814
2180
  case "streamable-http": {
1815
- return {
1816
- forwarder: new UpstreamForwarder({
1817
- url: config.upstream.url,
1818
- headers: config.upstream.headers,
1819
- requestTimeoutMs: parseDuration(config.upstream.request_timeout)
1820
- })
1821
- };
2181
+ const http = new StreamableHttpForwarder({
2182
+ url: config.upstream.url,
2183
+ headers: config.upstream.headers,
2184
+ requestTimeoutMs: parseDuration(config.upstream.request_timeout)
2185
+ });
2186
+ await http.connect();
2187
+ return { forwarder: http, close: () => http.close() };
1822
2188
  }
1823
2189
  case "sse": {
1824
2190
  const sse = new SseUpstreamForwarder({
@@ -2298,6 +2664,11 @@ var GovernedForwarder = class {
2298
2664
  *
2299
2665
  * This path is intended for startup warm-up and intentionally bypasses policy
2300
2666
  * and audit handling. Runtime tools/list requests still flow through forward().
2667
+ *
2668
+ * When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
2669
+ * request is routed through it so session-enforcing servers (e.g. Streamable
2670
+ * HTTP upstreams) receive the request on the managed internal session rather
2671
+ * than as a sessionless call that they would reject with HTTP 400.
2301
2672
  */
2302
2673
  async primeAnnotationCache() {
2303
2674
  const syntheticToolsList = {
@@ -2305,14 +2676,22 @@ var GovernedForwarder = class {
2305
2676
  id: "helio-prime-annotations",
2306
2677
  method: "tools/list"
2307
2678
  };
2679
+ const internal = this.inner;
2308
2680
  try {
2309
- const result = await this.inner.forward(syntheticToolsList);
2681
+ const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
2682
+ if (result.response.status >= 400) {
2683
+ return {
2684
+ success: false,
2685
+ toolsCached: this.annotationCache.size,
2686
+ reason: classifyPrimeFailure(result.response)
2687
+ };
2688
+ }
2310
2689
  const updated = this.annotationCache.update(result.response.body);
2311
2690
  if (!updated) {
2312
2691
  return {
2313
2692
  success: false,
2314
2693
  toolsCached: this.annotationCache.size,
2315
- reason: "upstream tools/list response had unexpected shape"
2694
+ reason: classifyPrimeFailure(result.response)
2316
2695
  };
2317
2696
  }
2318
2697
  return { success: true, toolsCached: this.annotationCache.size };
@@ -2913,6 +3292,27 @@ function hasJsonRpcError(result) {
2913
3292
  const body = result.response.body;
2914
3293
  return body?.["error"] !== void 0;
2915
3294
  }
3295
+ function classifyPrimeFailure(response) {
3296
+ if (response.status >= 400) {
3297
+ return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
3298
+ }
3299
+ const rawBody = response.body;
3300
+ if (typeof rawBody !== "object" || rawBody === null) {
3301
+ return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
3302
+ }
3303
+ const body = rawBody;
3304
+ const error = body["error"];
3305
+ if (typeof error === "string") {
3306
+ return `upstream tools/list returned a JSON-RPC error: ${error}`;
3307
+ }
3308
+ if (error !== null && typeof error === "object") {
3309
+ const message = error["message"];
3310
+ if (typeof message === "string") {
3311
+ return `upstream tools/list returned a JSON-RPC error: ${message}`;
3312
+ }
3313
+ }
3314
+ return "upstream tools/list response was missing result.tools";
3315
+ }
2916
3316
  function extractBlockReason(result) {
2917
3317
  const body = result.response.body;
2918
3318
  const error = body?.["error"];
package/dist/index.d.ts CHANGED
@@ -484,8 +484,7 @@ declare function startServer(app: Hono, config: HelioConfig): ServerHandle;
484
484
  */
485
485
  declare function startSidebandServer(app: Hono, port: number, host?: string): ServerHandle;
486
486
 
487
- /** Options for constructing an UpstreamForwarder. */
488
- interface UpstreamForwarderOptions {
487
+ interface StreamableHttpForwarderOptions {
489
488
  /** The upstream MCP server URL (e.g. "http://localhost:8080/mcp"). */
490
489
  url: string;
491
490
  /** Static headers to include on every upstream request (e.g. API keys). */
@@ -494,17 +493,44 @@ interface UpstreamForwarderOptions {
494
493
  requestTimeoutMs?: number;
495
494
  }
496
495
  /**
497
- * Forward MCP requests to an upstream server via HTTP.
496
+ * Spec-compliant upstream MCP Streamable HTTP client.
498
497
  *
499
- * Sends JSON-RPC POST requests to the configured URL, passes through
500
- * session IDs and per-request headers, and captures request timing.
498
+ * Parses both `application/json` and `text/event-stream` POST responses, sends
499
+ * the negotiated protocol version, relays the upstream session id back
500
+ * downstream, and — for Helio-internal requests with no downstream session —
501
+ * borrows an internally-managed session established via `initialize`.
501
502
  */
502
- declare class UpstreamForwarder implements McpForwarder {
503
+ declare class StreamableHttpForwarder implements McpForwarder {
503
504
  private readonly url;
504
505
  private readonly staticHeaders;
505
506
  private readonly requestTimeoutMs;
506
- constructor(options: UpstreamForwarderOptions);
507
+ private readonly sessions;
508
+ constructor(options: StreamableHttpForwarderOptions);
509
+ /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
510
+ connect(): Promise<void>;
511
+ /** Lifecycle parity with sse/stdio. */
512
+ close(): Promise<void>;
507
513
  forward(request: McpRequest): Promise<ForwardResult>;
514
+ /**
515
+ * Helio-internal execution path (startup prime / internal maintenance) that
516
+ * may borrow the proxy-managed internal session.
517
+ */
518
+ forwardInternal(request: McpRequest): Promise<ForwardResult>;
519
+ private send;
520
+ }
521
+
522
+ /** Options for constructing an UpstreamForwarder. */
523
+ type UpstreamForwarderOptions = StreamableHttpForwarderOptions;
524
+ /**
525
+ * @deprecated Use `StreamableHttpForwarder` directly for new code.
526
+ *
527
+ * Backward-compatible alias for older integrations that imported
528
+ * `UpstreamForwarder`. Kept to avoid a breaking API change.
529
+ *
530
+ * Behavior matches `StreamableHttpForwarder` (including Streamable HTTP SSE
531
+ * response parsing and managed internal session support).
532
+ */
533
+ declare class UpstreamForwarder extends StreamableHttpForwarder {
508
534
  }
509
535
 
510
536
  /** Options for constructing an SseUpstreamForwarder. */
@@ -1583,6 +1609,11 @@ declare class GovernedForwarder implements McpForwarder {
1583
1609
  *
1584
1610
  * This path is intended for startup warm-up and intentionally bypasses policy
1585
1611
  * and audit handling. Runtime tools/list requests still flow through forward().
1612
+ *
1613
+ * When the inner forwarder exposes `forwardInternal` (duck-typed), the prime
1614
+ * request is routed through it so session-enforcing servers (e.g. Streamable
1615
+ * HTTP upstreams) receive the request on the managed internal session rather
1616
+ * than as a sessionless call that they would reject with HTTP 400.
1586
1617
  */
1587
1618
  primeAnnotationCache(): Promise<AnnotationCachePrimeResult>;
1588
1619
  forward(request: McpRequest): Promise<ForwardResult>;
@@ -1872,4 +1903,4 @@ interface DashboardAppOptions {
1872
1903
  */
1873
1904
  declare function createDashboardApp(deps: DashboardAppDeps, options?: DashboardAppOptions): Hono;
1874
1905
 
1875
- export { type ApprovalAppOptions, type ApprovalChannel, type ApprovalOutcome, ApprovalQueue, type ApprovalQueueOptions, ApprovalRouter, type ApprovalRouterOptions, type ApprovalStatus, type ApprovalTicket, type AuditAggregateStats, type AuditListResult, type AuditPaginationOptions, type AuditQueryFilters, type AuditRecord, AuditStore, type AuditStoreOptions, type AuditTimeBucket, AuditWriter, type AuditWriterOptions, type CompilePoliciesResult, type CompiledPolicy, type CompiledPolicyRule, ConfigError, type CreateAppOptions, type DashboardAppDeps, type DashboardAppOptions, DashboardEventBus, type DashboardEventType, type DashboardEvents, type EvidenceEntry, EvidenceStore, type EvidenceStoreOptions, GovernedForwarder, type GovernedForwarderOptions, type HelioConfig, type MatchContext, type PolicyDecision, PolicyParseError, QueueChannel, type RateLimitCheckParams, type RateLimitKeyState, type RateLimitResult, RateLimiter, type RateLimiterOptions, type ServerHandle, type SessionState, type SlackActionAppOptions, SlackChannel, type SlackChannelOptions, type SpendLimitCheckParams, type SpendLimitKeyState, type SpendLimitResult, SpendLimiter, type SpendLimiterOptions, SseUpstreamForwarder, type SseUpstreamForwarderOptions, StdioForwarder, type StdioForwarderOptions, UpstreamForwarder, type UpstreamForwarderOptions, VERSION, WebhookChannel, type WebhookChannelOptions, compilePolicies, createApp, createApprovalApp, createChannels, createDashboardApp, createSidebandApp, createSlackActionApp, evaluatePolicy, loadConfig, matchRule, startServer, startSidebandServer };
1906
+ export { type ApprovalAppOptions, type ApprovalChannel, type ApprovalOutcome, ApprovalQueue, type ApprovalQueueOptions, ApprovalRouter, type ApprovalRouterOptions, type ApprovalStatus, type ApprovalTicket, type AuditAggregateStats, type AuditListResult, type AuditPaginationOptions, type AuditQueryFilters, type AuditRecord, AuditStore, type AuditStoreOptions, type AuditTimeBucket, AuditWriter, type AuditWriterOptions, type CompilePoliciesResult, type CompiledPolicy, type CompiledPolicyRule, ConfigError, type CreateAppOptions, type DashboardAppDeps, type DashboardAppOptions, DashboardEventBus, type DashboardEventType, type DashboardEvents, type EvidenceEntry, EvidenceStore, type EvidenceStoreOptions, GovernedForwarder, type GovernedForwarderOptions, type HelioConfig, type MatchContext, type PolicyDecision, PolicyParseError, QueueChannel, type RateLimitCheckParams, type RateLimitKeyState, type RateLimitResult, RateLimiter, type RateLimiterOptions, type ServerHandle, type SessionState, type SlackActionAppOptions, SlackChannel, type SlackChannelOptions, type SpendLimitCheckParams, type SpendLimitKeyState, type SpendLimitResult, SpendLimiter, type SpendLimiterOptions, SseUpstreamForwarder, type SseUpstreamForwarderOptions, StdioForwarder, type StdioForwarderOptions, StreamableHttpForwarder, type StreamableHttpForwarderOptions, UpstreamForwarder, type UpstreamForwarderOptions, VERSION, WebhookChannel, type WebhookChannelOptions, compilePolicies, createApp, createApprovalApp, createChannels, createDashboardApp, createSidebandApp, createSlackActionApp, evaluatePolicy, loadConfig, matchRule, startServer, startSidebandServer };
package/dist/index.js CHANGED
@@ -1109,6 +1109,93 @@ async function parseUpstreamResponse(res) {
1109
1109
  return { status: res.status, headers, body };
1110
1110
  }
1111
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
+
1112
1199
  // src/upstream/connection-error.ts
1113
1200
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1114
1201
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1148,31 +1235,314 @@ function describeUnreachableUpstream(error, url) {
1148
1235
  );
1149
1236
  }
1150
1237
 
1151
- // src/upstream/merge-headers.ts
1152
- function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1153
- const out = {};
1154
- const apply = (headers) => {
1155
- for (const [name, value] of Object.entries(headers)) {
1156
- out[name.toLowerCase()] = value;
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`);
1157
1274
  }
1158
- };
1159
- apply(base);
1160
- apply(forwarded);
1161
- apply(staticHeaders);
1162
- return out;
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;
1163
1478
  }
1164
1479
 
1165
- // src/upstream/forwarder.ts
1166
- var UpstreamForwarder = class {
1480
+ // src/upstream/streamable-http-forwarder.ts
1481
+ var StreamableHttpForwarder = class {
1167
1482
  url;
1168
1483
  staticHeaders;
1169
1484
  requestTimeoutMs;
1485
+ sessions;
1170
1486
  constructor(options) {
1171
1487
  this.url = options.url;
1172
1488
  this.staticHeaders = options.headers ?? {};
1173
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();
1174
1504
  }
1175
1505
  async forward(request) {
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) {
1176
1546
  const headers = mergeUpstreamHeaders(
1177
1547
  {
1178
1548
  "content-type": "application/json",
@@ -1181,8 +1551,9 @@ var UpstreamForwarder = class {
1181
1551
  request.headers ?? {},
1182
1552
  this.staticHeaders
1183
1553
  );
1184
- if (request.sessionId) {
1185
- headers["mcp-session-id"] = request.sessionId;
1554
+ if (sessionId) headers["mcp-session-id"] = sessionId;
1555
+ if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
1556
+ headers["mcp-protocol-version"] = protocolVersion;
1186
1557
  }
1187
1558
  const body = {
1188
1559
  jsonrpc: request.jsonrpc,
@@ -1196,33 +1567,52 @@ var UpstreamForwarder = class {
1196
1567
  const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal;
1197
1568
  let res;
1198
1569
  try {
1199
- res = await fetch(this.url, {
1200
- method: "POST",
1201
- headers,
1202
- body: JSON.stringify(body),
1203
- signal
1204
- });
1570
+ res = await fetch(this.url, { method: "POST", headers, body: JSON.stringify(body), signal });
1205
1571
  } catch (error) {
1206
1572
  const isTimeout = error instanceof Error && error.name === "TimeoutError";
1207
- if (requestSignal?.aborted) {
1208
- throw new Error("request aborted by downstream client");
1209
- }
1573
+ if (requestSignal?.aborted) throw new Error("request aborted by downstream client");
1210
1574
  if (isTimeout) {
1211
1575
  throw new Error(`upstream request timed out after ${String(this.requestTimeoutMs)}ms`);
1212
1576
  }
1213
1577
  throw describeUnreachableUpstream(error, this.url) ?? error;
1214
1578
  }
1215
- const durationMs = performance.now() - start;
1579
+ if (internalManaged && res.status === 404 && sessionId) {
1580
+ await res.text().catch(() => void 0);
1581
+ throw new UpstreamSessionExpiredError();
1582
+ }
1216
1583
  const contentType = res.headers.get("content-type") ?? "";
1217
1584
  if (contentType.includes("text/event-stream")) {
1218
- throw new Error(
1219
- "upstream returned text/event-stream; streamable-http passthrough is not supported in v0.1"
1220
- );
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 };
1221
1601
  }
1222
1602
  const response = await parseUpstreamResponse(res);
1223
- return { response, durationMs };
1603
+ return { response, durationMs: performance.now() - start };
1224
1604
  }
1225
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";
1610
+ }
1611
+ };
1612
+
1613
+ // src/upstream/forwarder.ts
1614
+ var UpstreamForwarder = class extends StreamableHttpForwarder {
1615
+ };
1226
1616
 
1227
1617
  // src/mcp/pending-requests.ts
1228
1618
  var DEFAULT_TIMEOUT_MS = 3e4;
@@ -1293,26 +1683,6 @@ function buildRequestSignal(request, timeoutMs) {
1293
1683
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
1294
1684
  return request.signal ? AbortSignal.any([request.signal, timeoutSignal]) : timeoutSignal;
1295
1685
  }
1296
- function parseSseChunk(chunk, state, onEvent) {
1297
- let { event, data, remainder } = state;
1298
- const text = remainder + chunk;
1299
- const lines = text.split("\n");
1300
- remainder = lines.pop() ?? "";
1301
- for (const line of lines) {
1302
- if (line === "") {
1303
- if (event || data) {
1304
- onEvent(event, data);
1305
- event = "";
1306
- data = "";
1307
- }
1308
- } else if (line.startsWith("event: ")) {
1309
- event = line.slice(7);
1310
- } else if (line.startsWith("data: ")) {
1311
- data = data ? data + "\n" + line.slice(6) : line.slice(6);
1312
- }
1313
- }
1314
- return { event, data, remainder };
1315
- }
1316
1686
  var SseUpstreamForwarder = class {
1317
1687
  url;
1318
1688
  staticHeaders;
@@ -2158,6 +2528,11 @@ var GovernedForwarder = class {
2158
2528
  *
2159
2529
  * This path is intended for startup warm-up and intentionally bypasses policy
2160
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.
2161
2536
  */
2162
2537
  async primeAnnotationCache() {
2163
2538
  const syntheticToolsList = {
@@ -2165,14 +2540,22 @@ var GovernedForwarder = class {
2165
2540
  id: "helio-prime-annotations",
2166
2541
  method: "tools/list"
2167
2542
  };
2543
+ const internal = this.inner;
2168
2544
  try {
2169
- 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
+ }
2170
2553
  const updated = this.annotationCache.update(result.response.body);
2171
2554
  if (!updated) {
2172
2555
  return {
2173
2556
  success: false,
2174
2557
  toolsCached: this.annotationCache.size,
2175
- reason: "upstream tools/list response had unexpected shape"
2558
+ reason: classifyPrimeFailure(result.response)
2176
2559
  };
2177
2560
  }
2178
2561
  return { success: true, toolsCached: this.annotationCache.size };
@@ -2773,6 +3156,27 @@ function hasJsonRpcError(result) {
2773
3156
  const body = result.response.body;
2774
3157
  return body?.["error"] !== void 0;
2775
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
+ }
2776
3180
  function extractBlockReason(result) {
2777
3181
  const body = result.response.body;
2778
3182
  const error = body?.["error"];
@@ -5725,6 +6129,7 @@ export {
5725
6129
  SpendLimiter,
5726
6130
  SseUpstreamForwarder,
5727
6131
  StdioForwarder,
6132
+ StreamableHttpForwarder,
5728
6133
  UpstreamForwarder,
5729
6134
  VERSION,
5730
6135
  WebhookChannel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethelio/proxy",
3
- "version": "0.2.0",
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",