@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 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
- | | Helio | Guild.ai | JetStream | Cerbos | Salus |
253
- | ----------------------------------- | --------------------- | ----------------- | --------------- | --------------- | -------------- |
254
- | **Approach** | Proxy + thin SDK | Platform runtime | Enterprise SaaS | Sidecar library | In-process SDK |
255
- | **Requires migration** | No | Yes | No | No | Code changes |
256
- | **Time to value** | 5 minutes | Weeks | Weeks | Hours | Minutes |
257
- | **Open source** | Apache 2.0 | | | ✅ Apache 2.0 | |
258
- | **Evidence grounding** | ✅ | | ❌ | | |
259
- | **Self-repair feedback** | ✅ | | | ❌ | |
260
- | **Approval workflows** | ✅ | (in runtime) | | ❌ | ❌ |
261
- | **Transaction controls** | ✅ \* | Basic | Cost tracking | ❌ | ❌ |
262
- | **Audit trail** | (incl. downstream) | ✅ (runtime only) | | Decision logs | ❌ |
263
- | **Cross-platform** | ✅ Any MCP agent | ❌ Guild only | | | ✅ Python only |
264
- | **Governs agents you didn't build** | ✅ | | | | |
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 requestHeaders = request.headers ?? {};
1213
- const headers = {
1214
- "content-type": "application/json",
1215
- accept: "application/json, text/event-stream",
1216
- ...this.staticHeaders,
1217
- ...requestHeaders
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 requestHeaders = request.headers ?? {};
1425
- const headers = {
1426
- "content-type": "application/json",
1427
- ...this.staticHeaders,
1428
- ...requestHeaders
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 postFailure = isTimeout ? new Error(`upstream request POST timed out after ${String(this.requestTimeoutMs)}ms`) : asError;
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
- let forwarder;
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)}`;