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