@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/cli.js CHANGED
@@ -47,7 +47,8 @@ var upstreamSchema = z.object({
47
47
  args: z.array(z.string()).optional(),
48
48
  connect_timeout: durationSchema.default("10s"),
49
49
  request_timeout: durationSchema.default("30s"),
50
- forward_headers: z.array(z.string().min(1)).default([])
50
+ forward_headers: z.array(z.string().min(1)).default([]),
51
+ headers: z.record(z.string(), z.string()).default({})
51
52
  }).refine((data) => data.transport !== "stdio" || data.command !== void 0, {
52
53
  message: '"command" is required when transport is "stdio"',
53
54
  path: ["command"]
@@ -61,6 +62,22 @@ var upstreamSchema = z.object({
61
62
  });
62
63
  }
63
64
  }
65
+ const reserved = /* @__PURE__ */ new Set([
66
+ "mcp-session-id",
67
+ "mcp-protocol-version",
68
+ "content-type",
69
+ "content-length",
70
+ "host"
71
+ ]);
72
+ for (const name of Object.keys(data.headers)) {
73
+ if (reserved.has(name.toLowerCase())) {
74
+ ctx.addIssue({
75
+ code: "custom",
76
+ path: ["headers", name],
77
+ message: `upstream.headers must not set reserved header "${name}"`
78
+ });
79
+ }
80
+ }
64
81
  });
65
82
  var listenSchema = z.object({
66
83
  port: z.number().int().min(1).max(65535).default(3e3),
@@ -1198,6 +1215,93 @@ async function parseUpstreamResponse(res) {
1198
1215
  return { status: res.status, headers, body };
1199
1216
  }
1200
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
+
1201
1305
  // src/upstream/connection-error.ts
1202
1306
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1203
1307
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1237,26 +1341,325 @@ function describeUnreachableUpstream(error, url) {
1237
1341
  );
1238
1342
  }
1239
1343
 
1240
- // src/upstream/forwarder.ts
1241
- var UpstreamForwarder = class {
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 {
1242
1348
  url;
1243
1349
  staticHeaders;
1244
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`);
1380
+ }
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;
1584
+ }
1585
+
1586
+ // src/upstream/streamable-http-forwarder.ts
1587
+ var StreamableHttpForwarder = class {
1588
+ url;
1589
+ staticHeaders;
1590
+ requestTimeoutMs;
1591
+ sessions;
1245
1592
  constructor(options) {
1246
1593
  this.url = options.url;
1247
1594
  this.staticHeaders = options.headers ?? {};
1248
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();
1249
1610
  }
1250
1611
  async forward(request) {
1251
- const requestHeaders = request.headers ?? {};
1252
- const headers = {
1253
- "content-type": "application/json",
1254
- accept: "application/json, text/event-stream",
1255
- ...this.staticHeaders,
1256
- ...requestHeaders
1257
- };
1258
- if (request.sessionId) {
1259
- headers["mcp-session-id"] = request.sessionId;
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) {
1652
+ const headers = mergeUpstreamHeaders(
1653
+ {
1654
+ "content-type": "application/json",
1655
+ accept: "application/json, text/event-stream"
1656
+ },
1657
+ request.headers ?? {},
1658
+ this.staticHeaders
1659
+ );
1660
+ if (sessionId) headers["mcp-session-id"] = sessionId;
1661
+ if (protocolVersion && headers["mcp-protocol-version"] === void 0) {
1662
+ headers["mcp-protocol-version"] = protocolVersion;
1260
1663
  }
1261
1664
  const body = {
1262
1665
  jsonrpc: request.jsonrpc,
@@ -1270,31 +1673,46 @@ var UpstreamForwarder = class {
1270
1673
  const signal = requestSignal ? AbortSignal.any([requestSignal, timeoutSignal]) : timeoutSignal;
1271
1674
  let res;
1272
1675
  try {
1273
- res = await fetch(this.url, {
1274
- method: "POST",
1275
- headers,
1276
- body: JSON.stringify(body),
1277
- signal
1278
- });
1676
+ res = await fetch(this.url, { method: "POST", headers, body: JSON.stringify(body), signal });
1279
1677
  } catch (error) {
1280
1678
  const isTimeout = error instanceof Error && error.name === "TimeoutError";
1281
- if (requestSignal?.aborted) {
1282
- throw new Error("request aborted by downstream client");
1283
- }
1679
+ if (requestSignal?.aborted) throw new Error("request aborted by downstream client");
1284
1680
  if (isTimeout) {
1285
1681
  throw new Error(`upstream request timed out after ${String(this.requestTimeoutMs)}ms`);
1286
1682
  }
1287
1683
  throw describeUnreachableUpstream(error, this.url) ?? error;
1288
1684
  }
1289
- const durationMs = performance.now() - start;
1685
+ if (internalManaged && res.status === 404 && sessionId) {
1686
+ await res.text().catch(() => void 0);
1687
+ throw new UpstreamSessionExpiredError();
1688
+ }
1290
1689
  const contentType = res.headers.get("content-type") ?? "";
1291
1690
  if (contentType.includes("text/event-stream")) {
1292
- throw new Error(
1293
- "upstream returned text/event-stream; streamable-http passthrough is not supported in v0.1"
1294
- );
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 };
1295
1707
  }
1296
1708
  const response = await parseUpstreamResponse(res);
1297
- 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";
1298
1716
  }
1299
1717
  };
1300
1718
 
@@ -1367,26 +1785,6 @@ function buildRequestSignal(request, timeoutMs) {
1367
1785
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
1368
1786
  return request.signal ? AbortSignal.any([request.signal, timeoutSignal]) : timeoutSignal;
1369
1787
  }
1370
- function parseSseChunk(chunk, state, onEvent) {
1371
- let { event, data, remainder } = state;
1372
- const text = remainder + chunk;
1373
- const lines = text.split("\n");
1374
- remainder = lines.pop() ?? "";
1375
- for (const line of lines) {
1376
- if (line === "") {
1377
- if (event || data) {
1378
- onEvent(event, data);
1379
- event = "";
1380
- data = "";
1381
- }
1382
- } else if (line.startsWith("event: ")) {
1383
- event = line.slice(7);
1384
- } else if (line.startsWith("data: ")) {
1385
- data = data ? data + "\n" + line.slice(6) : line.slice(6);
1386
- }
1387
- }
1388
- return { event, data, remainder };
1389
- }
1390
1788
  var SseUpstreamForwarder = class {
1391
1789
  url;
1392
1790
  staticHeaders;
@@ -1460,12 +1858,11 @@ var SseUpstreamForwarder = class {
1460
1858
  };
1461
1859
  if (request.id !== void 0) body["id"] = request.id;
1462
1860
  if (request.params !== void 0) body["params"] = request.params;
1463
- const requestHeaders = request.headers ?? {};
1464
- const headers = {
1465
- "content-type": "application/json",
1466
- ...this.staticHeaders,
1467
- ...requestHeaders
1468
- };
1861
+ const headers = mergeUpstreamHeaders(
1862
+ { "content-type": "application/json" },
1863
+ request.headers ?? {},
1864
+ this.staticHeaders
1865
+ );
1469
1866
  if (request.sessionId) {
1470
1867
  headers["mcp-session-id"] = request.sessionId;
1471
1868
  }
@@ -1777,6 +2174,40 @@ var StdioForwarder = class {
1777
2174
  }
1778
2175
  };
1779
2176
 
2177
+ // src/cli-forwarder.ts
2178
+ async function createForwarderFromConfig(config) {
2179
+ switch (config.upstream.transport) {
2180
+ case "streamable-http": {
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() };
2188
+ }
2189
+ case "sse": {
2190
+ const sse = new SseUpstreamForwarder({
2191
+ url: config.upstream.url,
2192
+ headers: config.upstream.headers,
2193
+ connectTimeoutMs: parseDuration(config.upstream.connect_timeout),
2194
+ requestTimeoutMs: parseDuration(config.upstream.request_timeout)
2195
+ });
2196
+ await sse.connect();
2197
+ return { forwarder: sse, close: () => sse.close() };
2198
+ }
2199
+ case "stdio": {
2200
+ const stdio = new StdioForwarder({
2201
+ command: config.upstream.command,
2202
+ args: config.upstream.args,
2203
+ requestTimeoutMs: parseDuration(config.upstream.request_timeout)
2204
+ });
2205
+ await stdio.start();
2206
+ return { forwarder: stdio, close: () => stdio.close() };
2207
+ }
2208
+ }
2209
+ }
2210
+
1780
2211
  // src/policy/matchers.ts
1781
2212
  var ANNOTATION_DEFAULTS = {
1782
2213
  readOnlyHint: false,
@@ -2233,6 +2664,11 @@ var GovernedForwarder = class {
2233
2664
  *
2234
2665
  * This path is intended for startup warm-up and intentionally bypasses policy
2235
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.
2236
2672
  */
2237
2673
  async primeAnnotationCache() {
2238
2674
  const syntheticToolsList = {
@@ -2240,14 +2676,22 @@ var GovernedForwarder = class {
2240
2676
  id: "helio-prime-annotations",
2241
2677
  method: "tools/list"
2242
2678
  };
2679
+ const internal = this.inner;
2243
2680
  try {
2244
- 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
+ }
2245
2689
  const updated = this.annotationCache.update(result.response.body);
2246
2690
  if (!updated) {
2247
2691
  return {
2248
2692
  success: false,
2249
2693
  toolsCached: this.annotationCache.size,
2250
- reason: "upstream tools/list response had unexpected shape"
2694
+ reason: classifyPrimeFailure(result.response)
2251
2695
  };
2252
2696
  }
2253
2697
  return { success: true, toolsCached: this.annotationCache.size };
@@ -2848,6 +3292,27 @@ function hasJsonRpcError(result) {
2848
3292
  const body = result.response.body;
2849
3293
  return body?.["error"] !== void 0;
2850
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
+ }
2851
3316
  function extractBlockReason(result) {
2852
3317
  const body = result.response.body;
2853
3318
  const error = body?.["error"];
@@ -5880,6 +6345,8 @@ upstream:
5880
6345
  url: "http://localhost:8080/mcp"
5881
6346
  # Transport: streamable-http (default), sse, or stdio
5882
6347
  transport: streamable-http
6348
+ # headers:
6349
+ # Authorization: "Bearer \${UPSTREAM_TOKEN}"
5883
6350
 
5884
6351
  # Operator dashboard + approval REST API. Bound to 127.0.0.1 by default \u2014 do
5885
6352
  # not change to 0.0.0.0 without putting an authenticating reverse proxy in
@@ -6027,43 +6494,7 @@ async function startCommand(configPath, options) {
6027
6494
  );
6028
6495
  process.exit(1);
6029
6496
  }
6030
- let forwarder;
6031
- let closeForwarder;
6032
- switch (config.upstream.transport) {
6033
- case "streamable-http": {
6034
- forwarder = new UpstreamForwarder({
6035
- url: config.upstream.url,
6036
- requestTimeoutMs: parseDuration(config.upstream.request_timeout)
6037
- });
6038
- break;
6039
- }
6040
- case "stdio": {
6041
- if (!config.upstream.command) {
6042
- console.error('Error: "command" is required for stdio transport');
6043
- process.exit(1);
6044
- }
6045
- const stdio = new StdioForwarder({
6046
- command: config.upstream.command,
6047
- args: config.upstream.args,
6048
- requestTimeoutMs: parseDuration(config.upstream.request_timeout)
6049
- });
6050
- await stdio.start();
6051
- forwarder = stdio;
6052
- closeForwarder = () => stdio.close();
6053
- break;
6054
- }
6055
- case "sse": {
6056
- const sse = new SseUpstreamForwarder({
6057
- url: config.upstream.url,
6058
- connectTimeoutMs: parseDuration(config.upstream.connect_timeout),
6059
- requestTimeoutMs: parseDuration(config.upstream.request_timeout)
6060
- });
6061
- await sse.connect();
6062
- forwarder = sse;
6063
- closeForwarder = () => sse.close();
6064
- break;
6065
- }
6066
- }
6497
+ const { forwarder, close: closeForwarder } = await createForwarderFromConfig(config);
6067
6498
  const { policy, warnings } = compilePolicies(config.policies);
6068
6499
  for (const w of warnings) {
6069
6500
  const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;