@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 +458 -58
- package/dist/index.d.ts +39 -8
- package/dist/index.js +456 -51
- package/package.json +1 -1
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/
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
|
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 (
|
|
1291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
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
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
*
|
|
496
|
+
* Spec-compliant upstream MCP Streamable HTTP client.
|
|
498
497
|
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
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
|
|
503
|
+
declare class StreamableHttpForwarder implements McpForwarder {
|
|
503
504
|
private readonly url;
|
|
504
505
|
private readonly staticHeaders;
|
|
505
506
|
private readonly requestTimeoutMs;
|
|
506
|
-
|
|
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/
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
|
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 (
|
|
1185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
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:
|
|
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,
|