@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 +522 -91
- package/dist/index.d.ts +40 -8
- package/dist/index.js +490 -54
- package/package.json +1 -1
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/
|
|
1241
|
-
var
|
|
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
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
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
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
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:
|
|
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
|
-
|
|
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)}`;
|