@gethelio/proxy 0.1.0 → 0.2.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/README.md +13 -14
- package/dist/cli.js +126 -55
- package/dist/dashboard-assets/assets/{index-Yyo92yXC.js → index-CAgnN6wV.js} +28 -28
- package/dist/dashboard-assets/index.html +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +89 -18
- package/package.json +16 -15
package/README.md
CHANGED
|
@@ -249,20 +249,19 @@ Every tool call recorded: timestamp, agent identity, tool name, inputs, policy d
|
|
|
249
249
|
|
|
250
250
|
## How Helio Compares
|
|
251
251
|
|
|
252
|
-
|
|
|
253
|
-
|
|
|
254
|
-
| **
|
|
255
|
-
| **
|
|
256
|
-
| **
|
|
257
|
-
| **
|
|
258
|
-
| **
|
|
259
|
-
| **
|
|
260
|
-
| **
|
|
261
|
-
| **
|
|
262
|
-
| **
|
|
263
|
-
| **
|
|
264
|
-
| **
|
|
265
|
-
| **Language agnostic** | ✅ (proxy) | ✅ | ✅ | ✅ | ❌ Python only |
|
|
252
|
+
| | Helio | Obot | Cerbos | Built-in (Anthropic / OpenAI) | Framework (LangChain / CrewAI) |
|
|
253
|
+
| -------------------------------------------- | -------------------------------------- | ------------------------------ | --------------------------------- | ------------------------------------- | ----------------------------------- |
|
|
254
|
+
| **What it governs** | Per-call actions with cross-call state | Which tools/MCPs are reachable | App-level authorization decisions | Agent permissions inside one platform | Agent behavior inside one framework |
|
|
255
|
+
| **Architecture** | Out-of-process MCP proxy | Out-of-process MCP gateway | Sidecar / library | In-platform | In-framework |
|
|
256
|
+
| **Open source** | ✅ Apache 2.0 | ✅ Apache 2.0 | ✅ Apache 2.0 | ❌ | Varies |
|
|
257
|
+
| **Time to value** | 5 minutes | Setup-dependent | Hours | Built-in | Built-in |
|
|
258
|
+
| **No agent code changes** | ✅ | ✅ | ❌ | ✅ (within platform) | ❌ |
|
|
259
|
+
| **Governs agents you didn't build** | ✅ Any MCP agent | ✅ Any MCP agent | ✅ (any app) | ❌ One platform only | ❌ One framework only |
|
|
260
|
+
| **Evidence grounding** | ✅ Cumulative across calls | ❌ | ❌ | ❌ | Limited |
|
|
261
|
+
| **Self-repair feedback** | ✅ Structured retry hints | ❌ | ❌ | ❌ | Limited |
|
|
262
|
+
| **Stateful spend / rate limits** | ✅ Per-tool, per-session¹ | Basic | ❌ | ❌ | Limited |
|
|
263
|
+
| **Approval workflows** | ✅ Slack, webhook, dashboard | ✅ | ❌ | Limited | Limited |
|
|
264
|
+
| **Audit trail (incl. downstream responses)** | ✅ Captures upstream MCP responses | Decision logs | Decision logs | Platform telemetry | Framework logs |
|
|
266
265
|
|
|
267
266
|
\* Per-tool and per-session spend limits ship in v0.1. Cross-tool spend aggregation is planned for v0.2.
|
|
268
267
|
|
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,59 @@ async function parseUpstreamResponse(res) {
|
|
|
1198
1215
|
return { status: res.status, headers, body };
|
|
1199
1216
|
}
|
|
1200
1217
|
|
|
1218
|
+
// src/upstream/connection-error.ts
|
|
1219
|
+
var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
|
|
1220
|
+
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
1221
|
+
"ECONNREFUSED",
|
|
1222
|
+
"ENOTFOUND",
|
|
1223
|
+
"EAI_AGAIN",
|
|
1224
|
+
"ECONNRESET",
|
|
1225
|
+
"EHOSTUNREACH",
|
|
1226
|
+
"ENETUNREACH",
|
|
1227
|
+
"ETIMEDOUT",
|
|
1228
|
+
"EPIPE",
|
|
1229
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
1230
|
+
"UND_ERR_SOCKET"
|
|
1231
|
+
]);
|
|
1232
|
+
function extractErrorCode(error) {
|
|
1233
|
+
let current = error;
|
|
1234
|
+
for (let depth = 0; depth < 5 && current != null; depth += 1) {
|
|
1235
|
+
if (typeof current === "object" && "code" in current) {
|
|
1236
|
+
const code = current.code;
|
|
1237
|
+
if (typeof code === "string") return code;
|
|
1238
|
+
}
|
|
1239
|
+
current = current.cause;
|
|
1240
|
+
}
|
|
1241
|
+
return void 0;
|
|
1242
|
+
}
|
|
1243
|
+
function describeUnreachableUpstream(error, url) {
|
|
1244
|
+
const code = extractErrorCode(error);
|
|
1245
|
+
const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
|
|
1246
|
+
if (code !== void 0) {
|
|
1247
|
+
if (!UNREACHABLE_CODES.has(code)) return null;
|
|
1248
|
+
} else if (!isGenericFetchFailure) {
|
|
1249
|
+
return null;
|
|
1250
|
+
}
|
|
1251
|
+
const codeSuffix = code ? ` (${code})` : "";
|
|
1252
|
+
return new Error(
|
|
1253
|
+
`Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// src/upstream/merge-headers.ts
|
|
1258
|
+
function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
|
|
1259
|
+
const out = {};
|
|
1260
|
+
const apply = (headers) => {
|
|
1261
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1262
|
+
out[name.toLowerCase()] = value;
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
apply(base);
|
|
1266
|
+
apply(forwarded);
|
|
1267
|
+
apply(staticHeaders);
|
|
1268
|
+
return out;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1201
1271
|
// src/upstream/forwarder.ts
|
|
1202
1272
|
var UpstreamForwarder = class {
|
|
1203
1273
|
url;
|
|
@@ -1209,13 +1279,14 @@ var UpstreamForwarder = class {
|
|
|
1209
1279
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1210
1280
|
}
|
|
1211
1281
|
async forward(request) {
|
|
1212
|
-
const
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1282
|
+
const headers = mergeUpstreamHeaders(
|
|
1283
|
+
{
|
|
1284
|
+
"content-type": "application/json",
|
|
1285
|
+
accept: "application/json, text/event-stream"
|
|
1286
|
+
},
|
|
1287
|
+
request.headers ?? {},
|
|
1288
|
+
this.staticHeaders
|
|
1289
|
+
);
|
|
1219
1290
|
if (request.sessionId) {
|
|
1220
1291
|
headers["mcp-session-id"] = request.sessionId;
|
|
1221
1292
|
}
|
|
@@ -1245,7 +1316,7 @@ var UpstreamForwarder = class {
|
|
|
1245
1316
|
if (isTimeout) {
|
|
1246
1317
|
throw new Error(`upstream request timed out after ${String(this.requestTimeoutMs)}ms`);
|
|
1247
1318
|
}
|
|
1248
|
-
throw error;
|
|
1319
|
+
throw describeUnreachableUpstream(error, this.url) ?? error;
|
|
1249
1320
|
}
|
|
1250
1321
|
const durationMs = performance.now() - start;
|
|
1251
1322
|
const contentType = res.headers.get("content-type") ?? "";
|
|
@@ -1406,7 +1477,7 @@ var SseUpstreamForwarder = class {
|
|
|
1406
1477
|
);
|
|
1407
1478
|
return;
|
|
1408
1479
|
}
|
|
1409
|
-
reject(asError);
|
|
1480
|
+
reject(describeUnreachableUpstream(err, this.url) ?? asError);
|
|
1410
1481
|
}
|
|
1411
1482
|
});
|
|
1412
1483
|
});
|
|
@@ -1421,12 +1492,11 @@ var SseUpstreamForwarder = class {
|
|
|
1421
1492
|
};
|
|
1422
1493
|
if (request.id !== void 0) body["id"] = request.id;
|
|
1423
1494
|
if (request.params !== void 0) body["params"] = request.params;
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
};
|
|
1495
|
+
const headers = mergeUpstreamHeaders(
|
|
1496
|
+
{ "content-type": "application/json" },
|
|
1497
|
+
request.headers ?? {},
|
|
1498
|
+
this.staticHeaders
|
|
1499
|
+
);
|
|
1430
1500
|
if (request.sessionId) {
|
|
1431
1501
|
headers["mcp-session-id"] = request.sessionId;
|
|
1432
1502
|
}
|
|
@@ -1452,7 +1522,7 @@ var SseUpstreamForwarder = class {
|
|
|
1452
1522
|
`upstream notification POST timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1453
1523
|
);
|
|
1454
1524
|
}
|
|
1455
|
-
throw asError;
|
|
1525
|
+
throw describeUnreachableUpstream(error, this.postUrl) ?? asError;
|
|
1456
1526
|
}
|
|
1457
1527
|
if (!res.ok) {
|
|
1458
1528
|
throw new Error(`upstream notification POST failed: HTTP ${String(res.status)}`);
|
|
@@ -1499,7 +1569,8 @@ var SseUpstreamForwarder = class {
|
|
|
1499
1569
|
const asError = error instanceof Error ? error : new Error(String(error));
|
|
1500
1570
|
const isTimeout = asError.name === "TimeoutError";
|
|
1501
1571
|
const isAborted = request.signal?.aborted === true;
|
|
1502
|
-
const
|
|
1572
|
+
const networkFailure = describeUnreachableUpstream(error, this.postUrl) ?? asError;
|
|
1573
|
+
const postFailure = isTimeout ? new Error(`upstream request POST timed out after ${String(this.requestTimeoutMs)}ms`) : networkFailure;
|
|
1503
1574
|
this.pending.reject(
|
|
1504
1575
|
requestId,
|
|
1505
1576
|
isAborted ? new Error("request aborted by downstream client") : postFailure
|
|
@@ -1737,6 +1808,40 @@ var StdioForwarder = class {
|
|
|
1737
1808
|
}
|
|
1738
1809
|
};
|
|
1739
1810
|
|
|
1811
|
+
// src/cli-forwarder.ts
|
|
1812
|
+
async function createForwarderFromConfig(config) {
|
|
1813
|
+
switch (config.upstream.transport) {
|
|
1814
|
+
case "streamable-http": {
|
|
1815
|
+
return {
|
|
1816
|
+
forwarder: new UpstreamForwarder({
|
|
1817
|
+
url: config.upstream.url,
|
|
1818
|
+
headers: config.upstream.headers,
|
|
1819
|
+
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
1820
|
+
})
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
case "sse": {
|
|
1824
|
+
const sse = new SseUpstreamForwarder({
|
|
1825
|
+
url: config.upstream.url,
|
|
1826
|
+
headers: config.upstream.headers,
|
|
1827
|
+
connectTimeoutMs: parseDuration(config.upstream.connect_timeout),
|
|
1828
|
+
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
1829
|
+
});
|
|
1830
|
+
await sse.connect();
|
|
1831
|
+
return { forwarder: sse, close: () => sse.close() };
|
|
1832
|
+
}
|
|
1833
|
+
case "stdio": {
|
|
1834
|
+
const stdio = new StdioForwarder({
|
|
1835
|
+
command: config.upstream.command,
|
|
1836
|
+
args: config.upstream.args,
|
|
1837
|
+
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
1838
|
+
});
|
|
1839
|
+
await stdio.start();
|
|
1840
|
+
return { forwarder: stdio, close: () => stdio.close() };
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1740
1845
|
// src/policy/matchers.ts
|
|
1741
1846
|
var ANNOTATION_DEFAULTS = {
|
|
1742
1847
|
readOnlyHint: false,
|
|
@@ -5840,6 +5945,8 @@ upstream:
|
|
|
5840
5945
|
url: "http://localhost:8080/mcp"
|
|
5841
5946
|
# Transport: streamable-http (default), sse, or stdio
|
|
5842
5947
|
transport: streamable-http
|
|
5948
|
+
# headers:
|
|
5949
|
+
# Authorization: "Bearer \${UPSTREAM_TOKEN}"
|
|
5843
5950
|
|
|
5844
5951
|
# Operator dashboard + approval REST API. Bound to 127.0.0.1 by default \u2014 do
|
|
5845
5952
|
# not change to 0.0.0.0 without putting an authenticating reverse proxy in
|
|
@@ -5987,43 +6094,7 @@ async function startCommand(configPath, options) {
|
|
|
5987
6094
|
);
|
|
5988
6095
|
process.exit(1);
|
|
5989
6096
|
}
|
|
5990
|
-
|
|
5991
|
-
let closeForwarder;
|
|
5992
|
-
switch (config.upstream.transport) {
|
|
5993
|
-
case "streamable-http": {
|
|
5994
|
-
forwarder = new UpstreamForwarder({
|
|
5995
|
-
url: config.upstream.url,
|
|
5996
|
-
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
5997
|
-
});
|
|
5998
|
-
break;
|
|
5999
|
-
}
|
|
6000
|
-
case "stdio": {
|
|
6001
|
-
if (!config.upstream.command) {
|
|
6002
|
-
console.error('Error: "command" is required for stdio transport');
|
|
6003
|
-
process.exit(1);
|
|
6004
|
-
}
|
|
6005
|
-
const stdio = new StdioForwarder({
|
|
6006
|
-
command: config.upstream.command,
|
|
6007
|
-
args: config.upstream.args,
|
|
6008
|
-
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
6009
|
-
});
|
|
6010
|
-
await stdio.start();
|
|
6011
|
-
forwarder = stdio;
|
|
6012
|
-
closeForwarder = () => stdio.close();
|
|
6013
|
-
break;
|
|
6014
|
-
}
|
|
6015
|
-
case "sse": {
|
|
6016
|
-
const sse = new SseUpstreamForwarder({
|
|
6017
|
-
url: config.upstream.url,
|
|
6018
|
-
connectTimeoutMs: parseDuration(config.upstream.connect_timeout),
|
|
6019
|
-
requestTimeoutMs: parseDuration(config.upstream.request_timeout)
|
|
6020
|
-
});
|
|
6021
|
-
await sse.connect();
|
|
6022
|
-
forwarder = sse;
|
|
6023
|
-
closeForwarder = () => sse.close();
|
|
6024
|
-
break;
|
|
6025
|
-
}
|
|
6026
|
-
}
|
|
6097
|
+
const { forwarder, close: closeForwarder } = await createForwarderFromConfig(config);
|
|
6027
6098
|
const { policy, warnings } = compilePolicies(config.policies);
|
|
6028
6099
|
for (const w of warnings) {
|
|
6029
6100
|
const label = w.ruleName ? `rule "${w.ruleName}"` : `rule ${String(w.ruleIndex)}`;
|