@gethelio/proxy 0.11.1 → 0.12.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 +75 -45
- package/dist/cli.js +2345 -957
- package/dist/dashboard-assets/assets/index-BBYXsIig.css +1 -0
- package/dist/dashboard-assets/assets/index-uJng9NyO.js +128 -0
- package/dist/dashboard-assets/index.html +2 -2
- package/dist/index.d.ts +257 -20
- package/dist/index.js +2199 -872
- package/package.json +7 -7
- package/dist/dashboard-assets/assets/index-CPoQ6xns.css +0 -1
- package/dist/dashboard-assets/assets/index-D19fYKEH.js +0 -128
package/dist/index.js
CHANGED
|
@@ -29,10 +29,25 @@ function parseDuration(duration) {
|
|
|
29
29
|
}
|
|
30
30
|
return value * multiplier;
|
|
31
31
|
}
|
|
32
|
+
var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
|
|
33
|
+
"mcp-session-id",
|
|
34
|
+
"mcp-protocol-version",
|
|
35
|
+
"content-type",
|
|
36
|
+
"content-length",
|
|
37
|
+
"host",
|
|
38
|
+
// Modern (2026-07-28) transport headers Helio owns on the wire for every
|
|
39
|
+
// Streamable HTTP POST it sends upstream — relayed client traffic and
|
|
40
|
+
// proxy-initiated requests (era probe, revalidation) alike — see
|
|
41
|
+
// upstream-session-manager.ts and streamable-http-forwarder.ts.
|
|
42
|
+
"mcp-method",
|
|
43
|
+
"mcp-name"
|
|
44
|
+
]);
|
|
32
45
|
var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
|
|
46
|
+
var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
|
|
33
47
|
var upstreamSchema = z.object({
|
|
34
48
|
url: z.string(),
|
|
35
49
|
transport: transportSchema.default("streamable-http"),
|
|
50
|
+
protocol_version: protocolVersionSchema.default("auto"),
|
|
36
51
|
command: z.string().optional(),
|
|
37
52
|
args: z.array(z.string()).optional(),
|
|
38
53
|
connect_timeout: durationSchema.default("10s"),
|
|
@@ -43,6 +58,13 @@ var upstreamSchema = z.object({
|
|
|
43
58
|
message: '"command" is required when transport is "stdio"',
|
|
44
59
|
path: ["command"]
|
|
45
60
|
}).superRefine((data, ctx) => {
|
|
61
|
+
if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
|
|
62
|
+
ctx.addIssue({
|
|
63
|
+
code: "custom",
|
|
64
|
+
path: ["protocol_version"],
|
|
65
|
+
message: data.transport === "stdio" ? 'protocol_version "2026-07-28" requires transport "streamable-http" \u2014 stdio modern-era support is tracked in #256.' : 'protocol_version "2026-07-28" requires transport "streamable-http" \u2014 the SSE upstream transport is the deprecated legacy transport.'
|
|
66
|
+
});
|
|
67
|
+
}
|
|
46
68
|
for (const [index, header] of data.forward_headers.entries()) {
|
|
47
69
|
if (!header.toLowerCase().startsWith("x-")) {
|
|
48
70
|
ctx.addIssue({
|
|
@@ -52,15 +74,8 @@ var upstreamSchema = z.object({
|
|
|
52
74
|
});
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
|
-
const reserved = /* @__PURE__ */ new Set([
|
|
56
|
-
"mcp-session-id",
|
|
57
|
-
"mcp-protocol-version",
|
|
58
|
-
"content-type",
|
|
59
|
-
"content-length",
|
|
60
|
-
"host"
|
|
61
|
-
]);
|
|
62
77
|
for (const name of Object.keys(data.headers)) {
|
|
63
|
-
if (
|
|
78
|
+
if (RESERVED_TRANSPORT_HEADERS.has(name.toLowerCase())) {
|
|
64
79
|
ctx.addIssue({
|
|
65
80
|
code: "custom",
|
|
66
81
|
path: ["headers", name],
|
|
@@ -71,8 +86,63 @@ var upstreamSchema = z.object({
|
|
|
71
86
|
});
|
|
72
87
|
var listenSchema = z.object({
|
|
73
88
|
port: z.number().int().min(1).max(65535).default(3e3),
|
|
74
|
-
host: z.string().default("127.0.0.1")
|
|
75
|
-
|
|
89
|
+
host: z.string().default("127.0.0.1"),
|
|
90
|
+
/**
|
|
91
|
+
* Origin allowlist for the MCP transports (issue #213). Requests to /mcp
|
|
92
|
+
* or /sse carrying an Origin header not in this list are refused with 403.
|
|
93
|
+
* Empty (the default) means every Origin is refused — MCP clients are
|
|
94
|
+
* non-browser processes and never send one. This is NOT CORS support: the
|
|
95
|
+
* proxy emits no CORS response headers, so a browser still cannot read
|
|
96
|
+
* responses. The list exists for deployments where a fronting proxy or
|
|
97
|
+
* embedding host injects an Origin the operator needs to name.
|
|
98
|
+
*/
|
|
99
|
+
allowed_origins: z.array(z.string().min(1)).default([])
|
|
100
|
+
}).strict().superRefine((data, ctx) => {
|
|
101
|
+
for (const [index, entry] of data.allowed_origins.entries()) {
|
|
102
|
+
if (entry === "*") {
|
|
103
|
+
ctx.addIssue({
|
|
104
|
+
code: "custom",
|
|
105
|
+
path: ["allowed_origins", index],
|
|
106
|
+
message: "listen.allowed_origins does not support wildcards \u2014 list each origin exactly."
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (entry === "null") {
|
|
111
|
+
ctx.addIssue({
|
|
112
|
+
code: "custom",
|
|
113
|
+
path: ["allowed_origins", index],
|
|
114
|
+
message: 'The literal "null" cannot be allowlisted: it is the opaque origin sent by sandboxed frames, data: documents, and file:// pages, so allowing it would admit all of them.'
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = new URL(entry);
|
|
121
|
+
} catch {
|
|
122
|
+
ctx.addIssue({
|
|
123
|
+
code: "custom",
|
|
124
|
+
path: ["allowed_origins", index],
|
|
125
|
+
message: `"${entry}" is not a serialized origin. Use scheme://host[:port], e.g. "http://localhost:5173".`
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
130
|
+
ctx.addIssue({
|
|
131
|
+
code: "custom",
|
|
132
|
+
path: ["allowed_origins", index],
|
|
133
|
+
message: `"${entry}" is not an http(s) origin. Allowlist entries must be serialized http(s) origins, e.g. "http://localhost:5173".`
|
|
134
|
+
});
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (parsed.origin !== entry) {
|
|
138
|
+
ctx.addIssue({
|
|
139
|
+
code: "custom",
|
|
140
|
+
path: ["allowed_origins", index],
|
|
141
|
+
message: `"${entry}" is not in serialized origin form and would never match a browser-sent Origin \u2014 did you mean "${parsed.origin}"?`
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
76
146
|
function isLoopbackHost(host) {
|
|
77
147
|
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
78
148
|
}
|
|
@@ -87,6 +157,56 @@ var dashboardSchema = z.object({
|
|
|
87
157
|
allow_open_mode: z.boolean().default(false),
|
|
88
158
|
sse_heartbeat_interval: durationSchema.default("30s")
|
|
89
159
|
}).strict();
|
|
160
|
+
var sessionHeaderSourceSchema = z.object({
|
|
161
|
+
source: z.literal("header"),
|
|
162
|
+
/** Lowercased on parse — HTTP header names are case-insensitive. */
|
|
163
|
+
name: z.string().min(1).default("x-helio-session-id").transform((name) => name.toLowerCase())
|
|
164
|
+
}).strict();
|
|
165
|
+
var sessionMetaSourceSchema = z.object({
|
|
166
|
+
source: z.literal("meta")
|
|
167
|
+
}).strict();
|
|
168
|
+
var sessionLegacyHeaderSourceSchema = z.object({
|
|
169
|
+
source: z.literal("legacy_header")
|
|
170
|
+
}).strict();
|
|
171
|
+
var sessionIdentitySourceSchema = z.discriminatedUnion("source", [
|
|
172
|
+
sessionHeaderSourceSchema,
|
|
173
|
+
sessionMetaSourceSchema,
|
|
174
|
+
sessionLegacyHeaderSourceSchema
|
|
175
|
+
]);
|
|
176
|
+
var sessionSchema = z.object({
|
|
177
|
+
/** Ordered identity sources; the first source that yields a value wins. */
|
|
178
|
+
identity: z.array(sessionIdentitySourceSchema).min(1, {
|
|
179
|
+
message: "session.identity cannot be empty \u2014 an empty chain would leave every request unresolved. Omit the section to use the defaults, or list at least one source."
|
|
180
|
+
}).default([{ source: "header", name: "x-helio-session-id" }, { source: "legacy_header" }]),
|
|
181
|
+
on_unresolved: z.enum(["deny", "anonymous"]).default("deny")
|
|
182
|
+
}).strict().superRefine((session, ctx) => {
|
|
183
|
+
const seen = /* @__PURE__ */ new Set();
|
|
184
|
+
for (const [index, entry] of session.identity.entries()) {
|
|
185
|
+
if (entry.source !== "header") continue;
|
|
186
|
+
if (!entry.name.startsWith("x-")) {
|
|
187
|
+
ctx.addIssue({
|
|
188
|
+
code: "custom",
|
|
189
|
+
path: ["identity", index, "name"],
|
|
190
|
+
message: 'Session identity header names must start with "x-" (for example "x-helio-session-id")'
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (RESERVED_TRANSPORT_HEADERS.has(entry.name)) {
|
|
194
|
+
ctx.addIssue({
|
|
195
|
+
code: "custom",
|
|
196
|
+
path: ["identity", index, "name"],
|
|
197
|
+
message: `session.identity must not read reserved transport header "${entry.name}" \u2014 the proxy owns it on the wire. Use source: legacy_header for Mcp-Session-Id, or a custom x- header.`
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (seen.has(entry.name)) {
|
|
201
|
+
ctx.addIssue({
|
|
202
|
+
code: "custom",
|
|
203
|
+
path: ["identity", index, "name"],
|
|
204
|
+
message: `Duplicate session identity header "${entry.name}" \u2014 the first entry always wins, so the duplicate is dead config. Remove it.`
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
seen.add(entry.name);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
90
210
|
var inputConditionSchema = z.object({
|
|
91
211
|
eq: z.unknown().optional(),
|
|
92
212
|
neq: z.unknown().optional(),
|
|
@@ -185,6 +305,21 @@ var installSchema = z.object({
|
|
|
185
305
|
default: z.enum(["allow", "deny"]).default("allow"),
|
|
186
306
|
rules: z.array(installRuleSchema).default([])
|
|
187
307
|
}).strict();
|
|
308
|
+
var toolRevalidationSchema = z.object({
|
|
309
|
+
enabled: z.boolean().default(true),
|
|
310
|
+
interval: durationSchema.default("5m"),
|
|
311
|
+
// Default: `interval`, applied at compile time (undefined here means
|
|
312
|
+
// "same as interval" — see PoliciesConfig compilation).
|
|
313
|
+
max_advertised_ttl: durationSchema.optional()
|
|
314
|
+
}).strict().superRefine((data, ctx) => {
|
|
315
|
+
if (parseDuration(data.interval) < 1e4) {
|
|
316
|
+
ctx.addIssue({
|
|
317
|
+
code: "custom",
|
|
318
|
+
path: ["interval"],
|
|
319
|
+
message: "tool_revalidation.interval must be at least 10s"
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
});
|
|
188
323
|
var policiesSchema = z.object({
|
|
189
324
|
default: z.enum(["allow", "deny"]).default("allow"),
|
|
190
325
|
flag_destructive: z.enum(["log", "require_approval"]).optional(),
|
|
@@ -205,6 +340,13 @@ var policiesSchema = z.object({
|
|
|
205
340
|
* don't need the field; undefined is treated as "block".
|
|
206
341
|
*/
|
|
207
342
|
on_tool_drift: z.enum(["block", "require_approval", "log"]).optional(),
|
|
343
|
+
/**
|
|
344
|
+
* Proxy-scheduled `tools/list` revalidation and `ttlMs` clamping (issue
|
|
345
|
+
* #221). Optional; absent ⇒ compiled defaults (enabled: true, interval:
|
|
346
|
+
* "5m") in `CompiledPolicy`, except in literal `CompiledPolicy` fixtures,
|
|
347
|
+
* which treat an absent field as disabled — see `compilePolicies`.
|
|
348
|
+
*/
|
|
349
|
+
tool_revalidation: toolRevalidationSchema.optional(),
|
|
208
350
|
/**
|
|
209
351
|
* Whether `helio start` should watch the config file for changes and
|
|
210
352
|
* reconcile policy state on every save. Defaults to `true` when omitted.
|
|
@@ -339,6 +481,10 @@ var helioConfigBaseSchema = z.object({
|
|
|
339
481
|
upstream: upstreamSchema,
|
|
340
482
|
listen: listenSchema.prefault({}),
|
|
341
483
|
environment: z.string().optional(),
|
|
484
|
+
// Session precedes policies deliberately: upstream/listen/environment say
|
|
485
|
+
// where and as-what Helio runs, session says who is calling, and
|
|
486
|
+
// policies/budgets then govern those calls (issue #218).
|
|
487
|
+
session: sessionSchema.prefault({}),
|
|
342
488
|
policies: policiesSchema.prefault({}),
|
|
343
489
|
// Budgets sit beside policies deliberately: they are the second half of the
|
|
344
490
|
// governance declaration (policy decision → budget gate), not plumbing.
|
|
@@ -681,11 +827,18 @@ var METADATA_OPERATORS = ["eq", "neq", "contains", "regex"];
|
|
|
681
827
|
function compilePolicies(config) {
|
|
682
828
|
const warnings = [];
|
|
683
829
|
const rules = config.rules.map((rule, index) => compileRule(rule, index, warnings));
|
|
830
|
+
const rv = config.tool_revalidation;
|
|
831
|
+
const toolRevalidation = {
|
|
832
|
+
enabled: rv?.enabled ?? true,
|
|
833
|
+
intervalMs: parseDuration(rv?.interval ?? "5m"),
|
|
834
|
+
maxAdvertisedTtlMs: parseDuration(rv?.max_advertised_ttl ?? rv?.interval ?? "5m")
|
|
835
|
+
};
|
|
684
836
|
const policy = {
|
|
685
837
|
defaultAction: config.default,
|
|
686
838
|
flagDestructive: config.flag_destructive,
|
|
687
839
|
...config.dry_run && { dryRun: true },
|
|
688
840
|
...config.on_tool_drift && { onToolDrift: config.on_tool_drift },
|
|
841
|
+
toolRevalidation,
|
|
689
842
|
rules,
|
|
690
843
|
...config.install && { install: compileInstallPolicy(config.install) }
|
|
691
844
|
};
|
|
@@ -970,10 +1123,17 @@ var PARSE_ERROR = -32700;
|
|
|
970
1123
|
var INVALID_REQUEST = -32600;
|
|
971
1124
|
var INVALID_PARAMS = -32602;
|
|
972
1125
|
var INTERNAL_ERROR = -32603;
|
|
1126
|
+
var HEADER_MISMATCH = -32020;
|
|
973
1127
|
function makeJsonRpcError(id, code, message) {
|
|
974
1128
|
return {
|
|
975
1129
|
jsonrpc: "2.0",
|
|
976
|
-
id
|
|
1130
|
+
id,
|
|
1131
|
+
error: { code, message }
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
function makeJsonRpcErrorWithoutId(code, message) {
|
|
1135
|
+
return {
|
|
1136
|
+
jsonrpc: "2.0",
|
|
977
1137
|
error: { code, message }
|
|
978
1138
|
};
|
|
979
1139
|
}
|
|
@@ -1042,6 +1202,73 @@ function parseJsonRpcRequest(body) {
|
|
|
1042
1202
|
};
|
|
1043
1203
|
}
|
|
1044
1204
|
|
|
1205
|
+
// src/mcp/session-resolver.ts
|
|
1206
|
+
var MAX_SESSION_ID_LENGTH = 256;
|
|
1207
|
+
var CLIENT_INFO_META_KEY = "io.modelcontextprotocol/clientInfo";
|
|
1208
|
+
function compileSessionIdentity(config) {
|
|
1209
|
+
const strategySummary = config.identity.map((entry) => entry.source === "header" ? `header "${entry.name}"` : entry.source).join(", ");
|
|
1210
|
+
return {
|
|
1211
|
+
sources: config.identity,
|
|
1212
|
+
onUnresolved: config.on_unresolved,
|
|
1213
|
+
strategySummary
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
var DEFAULT_SESSION_IDENTITY = compileSessionIdentity({
|
|
1217
|
+
identity: [{ source: "header", name: "x-helio-session-id" }, { source: "legacy_header" }],
|
|
1218
|
+
on_unresolved: "deny"
|
|
1219
|
+
});
|
|
1220
|
+
function paramsMeta(params) {
|
|
1221
|
+
if (params === null || typeof params !== "object" || Array.isArray(params)) return void 0;
|
|
1222
|
+
return params["_meta"];
|
|
1223
|
+
}
|
|
1224
|
+
var malformedValueWarned = false;
|
|
1225
|
+
function sanitizeCandidate(value, origin) {
|
|
1226
|
+
if (value === void 0) return void 0;
|
|
1227
|
+
if (value.trim() === "" || value.length > MAX_SESSION_ID_LENGTH) {
|
|
1228
|
+
if (!malformedValueWarned) {
|
|
1229
|
+
malformedValueWarned = true;
|
|
1230
|
+
console.error(
|
|
1231
|
+
`[helio] Warning: session identity value from ${origin} was skipped (empty or longer than ${String(MAX_SESSION_ID_LENGTH)} chars); continuing down the identity chain. Further skips will not be logged.`
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
return void 0;
|
|
1235
|
+
}
|
|
1236
|
+
return value;
|
|
1237
|
+
}
|
|
1238
|
+
function clientInfoId(meta) {
|
|
1239
|
+
if (meta === null || typeof meta !== "object") return void 0;
|
|
1240
|
+
const clientInfo = meta[CLIENT_INFO_META_KEY];
|
|
1241
|
+
if (clientInfo === null || typeof clientInfo !== "object") return void 0;
|
|
1242
|
+
const { name, version } = clientInfo;
|
|
1243
|
+
if (typeof name !== "string" || name.trim() === "") return void 0;
|
|
1244
|
+
return `clientinfo:${name}@${typeof version === "string" ? version : "unknown"}`;
|
|
1245
|
+
}
|
|
1246
|
+
function resolveSession(input, identity) {
|
|
1247
|
+
for (const strategy of identity.sources) {
|
|
1248
|
+
switch (strategy.source) {
|
|
1249
|
+
case "header": {
|
|
1250
|
+
const value = sanitizeCandidate(input.headers[strategy.name], `header "${strategy.name}"`);
|
|
1251
|
+
if (value !== void 0) return { id: value, source: "header" };
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
case "meta": {
|
|
1255
|
+
const id = sanitizeCandidate(clientInfoId(input.meta), "meta clientInfo");
|
|
1256
|
+
if (id !== void 0) return { id, source: "meta" };
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
case "legacy_header": {
|
|
1260
|
+
const value = sanitizeCandidate(input.transportSessionId, "legacy_header");
|
|
1261
|
+
if (value !== void 0) return { id: value, source: "legacy_header" };
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
if (input.transportMintedId !== void 0) {
|
|
1267
|
+
return { id: input.transportMintedId, source: "transport" };
|
|
1268
|
+
}
|
|
1269
|
+
return void 0;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1045
1272
|
// src/transport/content-type.ts
|
|
1046
1273
|
function isJsonContentType(header) {
|
|
1047
1274
|
const [essence = ""] = (header ?? "").split(";");
|
|
@@ -1065,804 +1292,1419 @@ function buildForwardHeaders(requestHeaders, allowlist) {
|
|
|
1065
1292
|
return Object.keys(forwardHeaders).length > 0 ? forwardHeaders : void 0;
|
|
1066
1293
|
}
|
|
1067
1294
|
|
|
1068
|
-
// src/
|
|
1069
|
-
|
|
1070
|
-
|
|
1295
|
+
// src/mcp/protocol-version.ts
|
|
1296
|
+
var HELIO_MCP_LEGACY_PROTOCOL_VERSION = "2025-06-18";
|
|
1297
|
+
var HELIO_MCP_MODERN_PROTOCOL_VERSION = "2026-07-28";
|
|
1298
|
+
function isModernProtocolClaim(rawValue) {
|
|
1299
|
+
if (rawValue === void 0) return false;
|
|
1300
|
+
const tokens = rawValue.split(",").map((token) => token.trim()).filter((token) => token.length > 0);
|
|
1301
|
+
return tokens.length > 0 && tokens.every((token) => token === HELIO_MCP_MODERN_PROTOCOL_VERSION);
|
|
1071
1302
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1303
|
+
|
|
1304
|
+
// src/upstream/standard-headers.ts
|
|
1305
|
+
var SENTINEL_PREFIX = "=?base64?";
|
|
1306
|
+
var SENTINEL_SUFFIX = "?=";
|
|
1307
|
+
var MCP_NAME_MAX_BYTES = 8192;
|
|
1308
|
+
var NAME_SOURCE_FIELD = /* @__PURE__ */ new Map([
|
|
1309
|
+
["tools/call", "name"],
|
|
1310
|
+
["prompts/get", "name"],
|
|
1311
|
+
["resources/read", "uri"]
|
|
1312
|
+
]);
|
|
1313
|
+
function needsSentinelEncoding(value) {
|
|
1314
|
+
const hasUnsafeChar = /[^\t\x20-\x7E]/.test(value);
|
|
1315
|
+
const hasEdgeWhitespace = /^[ \t]/.test(value) || /[ \t]$/.test(value);
|
|
1316
|
+
const looksLikeSentinel = value.startsWith(SENTINEL_PREFIX) && value.endsWith(SENTINEL_SUFFIX);
|
|
1317
|
+
return hasUnsafeChar || hasEdgeWhitespace || looksLikeSentinel;
|
|
1318
|
+
}
|
|
1319
|
+
function encodeSentinelValue(value) {
|
|
1320
|
+
return `${SENTINEL_PREFIX}${Buffer.from(value, "utf8").toString("base64")}${SENTINEL_SUFFIX}`;
|
|
1321
|
+
}
|
|
1322
|
+
var SENTINEL_DECODE_PATTERN = /^=\?base64\?([A-Za-z0-9+/]*={0,2})\?=$/;
|
|
1323
|
+
function decodeSentinelValue(value) {
|
|
1324
|
+
const match = SENTINEL_DECODE_PATTERN.exec(value);
|
|
1325
|
+
return match ? Buffer.from(match[1] ?? "", "base64").toString("utf8") : value;
|
|
1326
|
+
}
|
|
1327
|
+
function nameBearingField(method) {
|
|
1328
|
+
return NAME_SOURCE_FIELD.get(method);
|
|
1329
|
+
}
|
|
1330
|
+
function extractName(method, params) {
|
|
1331
|
+
const field = NAME_SOURCE_FIELD.get(method);
|
|
1332
|
+
if (!field || typeof params !== "object" || params === null || Array.isArray(params)) {
|
|
1333
|
+
return void 0;
|
|
1334
|
+
}
|
|
1335
|
+
let source = params;
|
|
1336
|
+
const maybeToJSON = params.toJSON;
|
|
1337
|
+
if (typeof maybeToJSON === "function") {
|
|
1338
|
+
try {
|
|
1339
|
+
source = maybeToJSON.call(params, "params");
|
|
1340
|
+
} catch {
|
|
1341
|
+
return void 0;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) {
|
|
1345
|
+
return void 0;
|
|
1346
|
+
}
|
|
1347
|
+
if (!Object.prototype.propertyIsEnumerable.call(source, field)) {
|
|
1348
|
+
return void 0;
|
|
1349
|
+
}
|
|
1350
|
+
const raw = source[field];
|
|
1351
|
+
return typeof raw === "string" ? raw : void 0;
|
|
1074
1352
|
}
|
|
1075
|
-
function
|
|
1076
|
-
|
|
1077
|
-
const id = value["id"];
|
|
1078
|
-
return isValidJsonRpcId(id) ? id : void 0;
|
|
1353
|
+
function isHeaderSafeMethod(method) {
|
|
1354
|
+
return /^[\x21-\x7E]+$/.test(method);
|
|
1079
1355
|
}
|
|
1080
|
-
function
|
|
1081
|
-
|
|
1082
|
-
|
|
1356
|
+
function encodedNameValue(method, params) {
|
|
1357
|
+
const name = extractName(method, params);
|
|
1358
|
+
if (name === void 0) return void 0;
|
|
1359
|
+
return needsSentinelEncoding(name) ? encodeSentinelValue(name) : name;
|
|
1083
1360
|
}
|
|
1084
|
-
function
|
|
1085
|
-
if (!
|
|
1086
|
-
|
|
1087
|
-
if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
|
|
1088
|
-
return false;
|
|
1361
|
+
function buildStandardRequestHeaders(method, params) {
|
|
1362
|
+
if (!isHeaderSafeMethod(method)) {
|
|
1363
|
+
return {};
|
|
1089
1364
|
}
|
|
1090
|
-
const
|
|
1091
|
-
const
|
|
1092
|
-
if (
|
|
1093
|
-
|
|
1094
|
-
|
|
1365
|
+
const headers = { "mcp-method": method };
|
|
1366
|
+
const value = encodedNameValue(method, params);
|
|
1367
|
+
if (value !== void 0 && Buffer.byteLength(value) <= MCP_NAME_MAX_BYTES) {
|
|
1368
|
+
headers["mcp-name"] = value;
|
|
1369
|
+
}
|
|
1370
|
+
return headers;
|
|
1095
1371
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
data
|
|
1372
|
+
|
|
1373
|
+
// src/upstream/merge-headers.ts
|
|
1374
|
+
function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
|
|
1375
|
+
const out = {};
|
|
1376
|
+
const apply = (headers) => {
|
|
1377
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1378
|
+
out[name.toLowerCase()] = value;
|
|
1104
1379
|
}
|
|
1105
1380
|
};
|
|
1381
|
+
apply(base);
|
|
1382
|
+
apply(forwarded);
|
|
1383
|
+
apply(staticHeaders);
|
|
1384
|
+
return out;
|
|
1106
1385
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
const upstream = args.upstreamResponse;
|
|
1129
|
-
const upstreamContentType = upstream.headers["content-type"] ?? null;
|
|
1130
|
-
if (!isValidJsonRpcResponse(upstream.body)) {
|
|
1131
|
-
return {
|
|
1132
|
-
httpStatus: 200,
|
|
1133
|
-
wrapped: true,
|
|
1134
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1135
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1136
|
-
upstream_http_status: upstream.status,
|
|
1137
|
-
upstream_content_type: upstreamContentType,
|
|
1138
|
-
upstream_body_type: typeof upstream.body
|
|
1139
|
-
})
|
|
1140
|
-
};
|
|
1141
|
-
}
|
|
1142
|
-
if (args.requestId !== void 0) {
|
|
1143
|
-
const upstreamId = getJsonRpcId(upstream.body);
|
|
1144
|
-
if (upstreamId === void 0) {
|
|
1145
|
-
return {
|
|
1146
|
-
httpStatus: 200,
|
|
1147
|
-
wrapped: true,
|
|
1148
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1149
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1150
|
-
upstream_http_status: upstream.status,
|
|
1151
|
-
upstream_content_type: upstreamContentType,
|
|
1152
|
-
upstream_body_type: typeof upstream.body,
|
|
1153
|
-
invalid_reason: "missing_response_id"
|
|
1154
|
-
})
|
|
1155
|
-
};
|
|
1156
|
-
}
|
|
1157
|
-
const expectedId = args.requestId ?? null;
|
|
1158
|
-
if (upstreamId !== expectedId) {
|
|
1159
|
-
return {
|
|
1160
|
-
httpStatus: 200,
|
|
1161
|
-
wrapped: true,
|
|
1162
|
-
body: makeWrappedError(args.requestId, "upstream response id mismatch", {
|
|
1163
|
-
failure_class: "upstream_id_mismatch",
|
|
1164
|
-
expected_request_id: expectedId,
|
|
1165
|
-
upstream_response_id: upstreamId
|
|
1166
|
-
})
|
|
1167
|
-
};
|
|
1386
|
+
|
|
1387
|
+
// src/upstream/connection-error.ts
|
|
1388
|
+
var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
|
|
1389
|
+
var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
|
|
1390
|
+
"ECONNREFUSED",
|
|
1391
|
+
"ENOTFOUND",
|
|
1392
|
+
"EAI_AGAIN",
|
|
1393
|
+
"ECONNRESET",
|
|
1394
|
+
"EHOSTUNREACH",
|
|
1395
|
+
"ENETUNREACH",
|
|
1396
|
+
"ETIMEDOUT",
|
|
1397
|
+
"EPIPE",
|
|
1398
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
1399
|
+
"UND_ERR_SOCKET"
|
|
1400
|
+
]);
|
|
1401
|
+
function extractErrorCode(error) {
|
|
1402
|
+
let current = error;
|
|
1403
|
+
for (let depth = 0; depth < 5 && current != null; depth += 1) {
|
|
1404
|
+
if (typeof current === "object" && "code" in current) {
|
|
1405
|
+
const code = current.code;
|
|
1406
|
+
if (typeof code === "string") return code;
|
|
1168
1407
|
}
|
|
1408
|
+
current = current.cause;
|
|
1169
1409
|
}
|
|
1170
|
-
return
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1410
|
+
return void 0;
|
|
1411
|
+
}
|
|
1412
|
+
function describeUnreachableUpstream(error, url) {
|
|
1413
|
+
const code = extractErrorCode(error);
|
|
1414
|
+
const isGenericFetchFailure = error instanceof TypeError && error.message === "fetch failed";
|
|
1415
|
+
if (code !== void 0) {
|
|
1416
|
+
if (!UNREACHABLE_CODES.has(code)) return null;
|
|
1417
|
+
} else if (!isGenericFetchFailure) {
|
|
1418
|
+
return null;
|
|
1419
|
+
}
|
|
1420
|
+
const codeSuffix = code ? ` (${code})` : "";
|
|
1421
|
+
return new Error(
|
|
1422
|
+
`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}`
|
|
1423
|
+
);
|
|
1175
1424
|
}
|
|
1176
1425
|
|
|
1177
|
-
// src/
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1426
|
+
// src/upstream/sse-parse.ts
|
|
1427
|
+
function parseSseChunk(chunk, state, onEvent) {
|
|
1428
|
+
let { event, data, remainder } = state;
|
|
1429
|
+
const text = remainder + chunk;
|
|
1430
|
+
const lines = text.split("\n");
|
|
1431
|
+
remainder = lines.pop() ?? "";
|
|
1432
|
+
for (const rawLine of lines) {
|
|
1433
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1434
|
+
if (line === "") {
|
|
1435
|
+
if (event || data) {
|
|
1436
|
+
onEvent(event, data);
|
|
1437
|
+
event = "";
|
|
1438
|
+
data = "";
|
|
1439
|
+
}
|
|
1440
|
+
} else if (line.startsWith("event:")) {
|
|
1441
|
+
const value = line.slice(6).replace(/^ /, "");
|
|
1442
|
+
event = value;
|
|
1443
|
+
} else if (line.startsWith("data:")) {
|
|
1444
|
+
const value = line.slice(5).replace(/^ /, "");
|
|
1445
|
+
data = data ? data + "\n" + value : value;
|
|
1189
1446
|
}
|
|
1190
|
-
|
|
1447
|
+
}
|
|
1448
|
+
return { event, data, remainder };
|
|
1449
|
+
}
|
|
1450
|
+
async function readSseJsonRpcResponse(res, requestId) {
|
|
1451
|
+
if (!res.body) {
|
|
1452
|
+
throw new Error("upstream SSE response had no body");
|
|
1453
|
+
}
|
|
1454
|
+
const reader = res.body.getReader();
|
|
1455
|
+
const decoder = new TextDecoder();
|
|
1456
|
+
let state = { event: "", data: "", remainder: "" };
|
|
1457
|
+
let found;
|
|
1458
|
+
const onEvent = (event, data) => {
|
|
1459
|
+
if (event && event !== "message") return;
|
|
1460
|
+
let parsed;
|
|
1191
1461
|
try {
|
|
1192
|
-
|
|
1462
|
+
parsed = JSON.parse(data);
|
|
1193
1463
|
} catch {
|
|
1194
|
-
return
|
|
1195
|
-
}
|
|
1196
|
-
const parsedRequest = parseJsonRpcRequest(body);
|
|
1197
|
-
if (!parsedRequest.success) {
|
|
1198
|
-
return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
|
|
1464
|
+
return;
|
|
1199
1465
|
}
|
|
1200
|
-
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
1205
|
-
const mcpRequest = {
|
|
1206
|
-
jsonrpc: "2.0",
|
|
1207
|
-
id,
|
|
1208
|
-
method,
|
|
1209
|
-
params,
|
|
1210
|
-
sessionId,
|
|
1211
|
-
headers: forwardHeaders,
|
|
1212
|
-
signal: c.req.raw.signal
|
|
1213
|
-
};
|
|
1214
|
-
if (id === void 0) {
|
|
1215
|
-
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
1216
|
-
void forwarder.forward(notificationRequest).catch((err) => {
|
|
1217
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1218
|
-
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
1219
|
-
});
|
|
1220
|
-
return c.body(null, 202);
|
|
1466
|
+
if (parsed === null || typeof parsed !== "object") return;
|
|
1467
|
+
const id = parsed["id"];
|
|
1468
|
+
if (id === requestId) {
|
|
1469
|
+
found = parsed;
|
|
1221
1470
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1471
|
+
};
|
|
1472
|
+
const processChunk = (chunk) => {
|
|
1473
|
+
state = parseSseChunk(chunk, state, onEvent);
|
|
1474
|
+
};
|
|
1475
|
+
for (; ; ) {
|
|
1476
|
+
const result = await reader.read();
|
|
1477
|
+
if (result.value !== void 0) {
|
|
1478
|
+
const chunk = result.value;
|
|
1479
|
+
processChunk(decoder.decode(chunk, { stream: true }));
|
|
1480
|
+
if (found) {
|
|
1481
|
+
await reader.cancel().catch(() => void 0);
|
|
1482
|
+
return found;
|
|
1483
|
+
}
|
|
1230
1484
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
if (
|
|
1234
|
-
|
|
1485
|
+
if (result.done) {
|
|
1486
|
+
const tail = decoder.decode();
|
|
1487
|
+
if (tail) {
|
|
1488
|
+
processChunk(tail);
|
|
1489
|
+
if (found) return found;
|
|
1235
1490
|
}
|
|
1491
|
+
break;
|
|
1236
1492
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1493
|
+
}
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1496
|
+
);
|
|
1241
1497
|
}
|
|
1242
1498
|
|
|
1243
|
-
// src/
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
var
|
|
1248
|
-
var
|
|
1249
|
-
var
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1499
|
+
// src/upstream/upstream-session-manager.ts
|
|
1500
|
+
var ERA_PROBE_BACKOFF_MS = 3e4;
|
|
1501
|
+
var MAX_SSE_SCAN_BYTES = 256 * 1024;
|
|
1502
|
+
var ERA_PROBE_REQUEST_ID = "helio-era-probe";
|
|
1503
|
+
var MCP_MISSING_CLIENT_CAPABILITY_CODE = -32021;
|
|
1504
|
+
var MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE = -32022;
|
|
1505
|
+
var MCP_MODERN_ONLY_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
1506
|
+
HEADER_MISMATCH,
|
|
1507
|
+
MCP_MISSING_CLIENT_CAPABILITY_CODE,
|
|
1508
|
+
MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE
|
|
1509
|
+
]);
|
|
1510
|
+
var MCP_META_PROTOCOL_VERSION_KEY = "io.modelcontextprotocol/protocolVersion";
|
|
1511
|
+
function buildInternalMeta() {
|
|
1512
|
+
return {
|
|
1513
|
+
[MCP_META_PROTOCOL_VERSION_KEY]: HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
1514
|
+
"io.modelcontextprotocol/clientCapabilities": {},
|
|
1515
|
+
"io.modelcontextprotocol/clientInfo": { name: "helio-proxy", version: "0" }
|
|
1516
|
+
};
|
|
1258
1517
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1518
|
+
var UpstreamSessionManager = class {
|
|
1519
|
+
url;
|
|
1520
|
+
staticHeaders;
|
|
1521
|
+
requestTimeoutMs;
|
|
1522
|
+
pin;
|
|
1523
|
+
internal;
|
|
1524
|
+
era;
|
|
1525
|
+
capture;
|
|
1526
|
+
inflight;
|
|
1527
|
+
inflightProbe;
|
|
1528
|
+
probeBackoffUntil = 0;
|
|
1529
|
+
constructor(options) {
|
|
1530
|
+
this.url = options.url;
|
|
1531
|
+
this.staticHeaders = options.staticHeaders;
|
|
1532
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
|
|
1533
|
+
this.pin = options.protocolVersion ?? "auto";
|
|
1534
|
+
}
|
|
1535
|
+
/** Return the internal session, establishing it once if needed. */
|
|
1536
|
+
ensureInternalSession() {
|
|
1537
|
+
if (this.internal) return Promise.resolve(this.internal);
|
|
1538
|
+
this.inflight ??= this.establish().then((session) => {
|
|
1539
|
+
this.internal = session;
|
|
1540
|
+
return session;
|
|
1541
|
+
}).finally(() => {
|
|
1542
|
+
this.inflight = void 0;
|
|
1271
1543
|
});
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1544
|
+
return this.inflight;
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Drop the cached internal session and era so the next call re-probes.
|
|
1548
|
+
* Does not cancel any in-flight establishment. Every era wipe drops the
|
|
1549
|
+
* probe-time DiscoverResult capture with it; invalidation is session
|
|
1550
|
+
* lifecycle, not falsification, so it must NOT arm the probe backoff.
|
|
1551
|
+
*/
|
|
1552
|
+
invalidateInternalSession() {
|
|
1553
|
+
this.internal = void 0;
|
|
1554
|
+
this.era = void 0;
|
|
1555
|
+
this.capture = void 0;
|
|
1556
|
+
}
|
|
1557
|
+
/**
|
|
1558
|
+
* Resolve the era a relayed request should be sent under. Evaluated in a
|
|
1559
|
+
* strict total order: pin, cached era, join an in-flight probe, backoff
|
|
1560
|
+
* presumption, start a probe. A probe failure never fails the relay — the
|
|
1561
|
+
* request proceeds under a per-request legacy presumption, preserving
|
|
1562
|
+
* today's behavior for deployments the probe cannot classify (for example
|
|
1563
|
+
* per-client Authorization pass-through, where the probe is refused
|
|
1564
|
+
* forever while relays carry the client's own credentials and succeed).
|
|
1565
|
+
*/
|
|
1566
|
+
async resolveRelayEra() {
|
|
1567
|
+
const pinned = this.pinnedEra();
|
|
1568
|
+
if (pinned) return pinned;
|
|
1569
|
+
if (this.era) return this.era;
|
|
1570
|
+
const joined = this.inflightProbe;
|
|
1571
|
+
if (joined) {
|
|
1572
|
+
try {
|
|
1573
|
+
const outcome = await joined;
|
|
1574
|
+
this.cacheRelayClassification(outcome);
|
|
1575
|
+
return outcome.era;
|
|
1576
|
+
} catch {
|
|
1577
|
+
return "legacy";
|
|
1280
1578
|
}
|
|
1281
1579
|
}
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
|
|
1290
|
-
writeSessionEvent(sessionId, endpointData);
|
|
1291
|
-
c.req.raw.signal.addEventListener("abort", () => {
|
|
1292
|
-
sessions.delete(sessionId);
|
|
1293
|
-
void writer.close().catch(() => {
|
|
1294
|
-
});
|
|
1295
|
-
});
|
|
1296
|
-
return new Response(readable, {
|
|
1297
|
-
headers: {
|
|
1298
|
-
"content-type": "text/event-stream",
|
|
1299
|
-
"cache-control": "no-cache",
|
|
1300
|
-
connection: "keep-alive"
|
|
1301
|
-
}
|
|
1302
|
-
});
|
|
1303
|
-
});
|
|
1304
|
-
app.post("/", async (c) => {
|
|
1305
|
-
const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
|
|
1306
|
-
if (!parsedQuery.success) {
|
|
1307
|
-
return c.json(
|
|
1308
|
-
makeJsonRpcError(null, INVALID_REQUEST, "missing sessionId query parameter"),
|
|
1309
|
-
400
|
|
1310
|
-
);
|
|
1580
|
+
if (Date.now() < this.probeBackoffUntil) return "legacy";
|
|
1581
|
+
try {
|
|
1582
|
+
const outcome = await this.sharedProbe();
|
|
1583
|
+
this.cacheRelayClassification(outcome);
|
|
1584
|
+
return outcome.era;
|
|
1585
|
+
} catch {
|
|
1586
|
+
return "legacy";
|
|
1311
1587
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1588
|
+
}
|
|
1589
|
+
/**
|
|
1590
|
+
* The falsification door, shared by both sides: a signal just
|
|
1591
|
+
* contradicted a cached LEGACY era — a relayed response only a modern
|
|
1592
|
+
* server gives (a modern-only JSON-RPC code on any method, a 404/-32601
|
|
1593
|
+
* answer to a relayed initialize), or the internal initialize failing
|
|
1594
|
+
* against the classification that promised it would work. No-ops unless
|
|
1595
|
+
* the cached era is 'legacy': a cached modern era is never cleared
|
|
1596
|
+
* automatically — no reliable legacy-rejection signal exists, recovery
|
|
1597
|
+
* from an upstream downgrade is pin-or-restart, and during the
|
|
1598
|
+
* probe-to-initialize window a fresher relay probe may already have
|
|
1599
|
+
* re-classified the upstream as modern, in which case an initialize
|
|
1600
|
+
* failure is evidence against the STALE legacy classification, not
|
|
1601
|
+
* against that newer conclusion.
|
|
1602
|
+
*/
|
|
1603
|
+
clearFalsifiedLegacyEra(door) {
|
|
1604
|
+
if (this.era !== "legacy") return;
|
|
1605
|
+
this.clearEraAndArmBackoff(door);
|
|
1606
|
+
}
|
|
1607
|
+
/** Probe-captured DiscoverResult fields for the relay initialize synthesis. */
|
|
1608
|
+
getDiscoverCapture() {
|
|
1609
|
+
return this.capture;
|
|
1610
|
+
}
|
|
1611
|
+
/** The era a non-auto pin dictates; undefined in auto mode. */
|
|
1612
|
+
pinnedEra() {
|
|
1613
|
+
if (this.pin === HELIO_MCP_MODERN_PROTOCOL_VERSION) return "modern";
|
|
1614
|
+
if (this.pin === HELIO_MCP_LEGACY_PROTOCOL_VERSION) return "legacy";
|
|
1615
|
+
return void 0;
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* One in-flight `server/discover` per manager, shared by `establish()` and
|
|
1619
|
+
* `resolveRelayEra()` — whichever asks first starts it, later callers
|
|
1620
|
+
* join. A failure notes its time, arming the relay-path backoff, then
|
|
1621
|
+
* rethrows for the consumer's own handling.
|
|
1622
|
+
*/
|
|
1623
|
+
sharedProbe() {
|
|
1624
|
+
this.inflightProbe ??= this.probeEra().catch((error) => {
|
|
1625
|
+
this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
|
|
1626
|
+
throw error;
|
|
1627
|
+
}).finally(() => {
|
|
1628
|
+
this.inflightProbe = void 0;
|
|
1629
|
+
});
|
|
1630
|
+
return this.inflightProbe;
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Relay-path caching: the probe classification alone settles the era.
|
|
1634
|
+
* Caching 'legacy' without a proven initialize is what makes
|
|
1635
|
+
* `establish()`'s legacy fast path live; the two-sided re-probe rule
|
|
1636
|
+
* (`clearFalsifiedLegacyEra()` and the internal initialize catch) heals a
|
|
1637
|
+
* wrong conclusion.
|
|
1638
|
+
*/
|
|
1639
|
+
cacheRelayClassification(outcome) {
|
|
1640
|
+
if (outcome.era === "modern") {
|
|
1641
|
+
this.cacheModernClassification(outcome);
|
|
1642
|
+
return;
|
|
1316
1643
|
}
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1644
|
+
this.setEra("legacy");
|
|
1645
|
+
}
|
|
1646
|
+
/** Every modern classification re-captures from its own fresh DiscoverResult. */
|
|
1647
|
+
cacheModernClassification(outcome) {
|
|
1648
|
+
this.capture = { capabilities: outcome.capabilities, instructions: outcome.instructions };
|
|
1649
|
+
this.setEra("modern");
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Falsified-classification clear: any cleared era means the classification
|
|
1653
|
+
* was just contradicted, so re-probing is throttled no matter which door
|
|
1654
|
+
* noticed. No-ops under a pin and on uncached eras; drops the probe-time
|
|
1655
|
+
* DiscoverResult capture with the era it came from; emits exactly one
|
|
1656
|
+
* operator line per clear.
|
|
1657
|
+
*/
|
|
1658
|
+
clearEraAndArmBackoff(door) {
|
|
1659
|
+
if (this.pinnedEra()) return;
|
|
1660
|
+
if (this.era === void 0) return;
|
|
1661
|
+
this.era = void 0;
|
|
1662
|
+
this.capture = void 0;
|
|
1663
|
+
this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
|
|
1664
|
+
console.error(
|
|
1665
|
+
`[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
/** Convert a fetch failure into an actionable error for the given step. */
|
|
1669
|
+
describeFetchFailure(error, step) {
|
|
1670
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
1671
|
+
return new Error(`upstream ${step} timed out after ${String(this.requestTimeoutMs)}ms`);
|
|
1322
1672
|
}
|
|
1323
|
-
|
|
1673
|
+
return describeUnreachableUpstream(error, this.url) ?? (error instanceof Error ? error : new Error(String(error)));
|
|
1674
|
+
}
|
|
1675
|
+
async establish() {
|
|
1676
|
+
const pinned = this.pinnedEra();
|
|
1677
|
+
if (pinned === "modern") return this.modernSession();
|
|
1678
|
+
if (pinned === "legacy") return this.legacyInitialize();
|
|
1679
|
+
if (this.era === "modern") return this.modernSession();
|
|
1680
|
+
if (this.era === "legacy") return this.legacyInitializeCachingEra(void 0);
|
|
1681
|
+
const probe = await this.sharedProbe();
|
|
1682
|
+
if (probe.era === "modern") {
|
|
1683
|
+
this.cacheModernClassification(probe);
|
|
1684
|
+
return this.modernSession();
|
|
1685
|
+
}
|
|
1686
|
+
return this.legacyInitializeCachingEra(probe.unsupportedModernVersions);
|
|
1687
|
+
}
|
|
1688
|
+
/** The only `initialize` call site: one handshake attempt per establishment. */
|
|
1689
|
+
async legacyInitializeCachingEra(unsupportedModernVersions) {
|
|
1324
1690
|
try {
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
return
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1691
|
+
const session = await this.legacyInitialize();
|
|
1692
|
+
this.setEra("legacy");
|
|
1693
|
+
return session;
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
this.clearFalsifiedLegacyEra("internal initialize failed against the cached legacy era");
|
|
1696
|
+
if (unsupportedModernVersions) {
|
|
1697
|
+
throw new Error(
|
|
1698
|
+
`upstream is a modern MCP server supporting [${unsupportedModernVersions.join(", ")}] (helio speaks ${HELIO_MCP_MODERN_PROTOCOL_VERSION} or legacy initialize); legacy fallback also failed: ${error instanceof Error ? error.message : String(error)}`
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
throw error;
|
|
1332
1702
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1703
|
+
}
|
|
1704
|
+
/** Single owner of era assignment and of the era-detected log line. */
|
|
1705
|
+
setEra(era) {
|
|
1706
|
+
if (this.era === era) return;
|
|
1707
|
+
this.era = era;
|
|
1708
|
+
console.error(
|
|
1709
|
+
era === "modern" ? `[helio] Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : "[helio] Upstream MCP era detected: legacy (initialize handshake)"
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
/** A modern upstream neither mints nor echoes session ids — nothing to hold. */
|
|
1713
|
+
modernSession() {
|
|
1714
|
+
return {
|
|
1715
|
+
sessionId: void 0,
|
|
1716
|
+
protocolVersion: HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
1717
|
+
era: "modern"
|
|
1345
1718
|
};
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1719
|
+
}
|
|
1720
|
+
/**
|
|
1721
|
+
* Classify the upstream's era with one `server/discover` request.
|
|
1722
|
+
*
|
|
1723
|
+
* A pure classifier: it performs no handshake, so the dual-era salvage cannot
|
|
1724
|
+
* double-initialize. It throws when no era conclusion is possible — a
|
|
1725
|
+
* transport failure, a status that says nothing about the era (401/403/5xx),
|
|
1726
|
+
* or a known-modern server refusing Helio's own probe — leaving the era
|
|
1727
|
+
* uncached so the next attempt re-probes.
|
|
1728
|
+
*/
|
|
1729
|
+
async probeEra() {
|
|
1730
|
+
const headers = mergeUpstreamHeaders(
|
|
1731
|
+
{
|
|
1732
|
+
"content-type": "application/json",
|
|
1733
|
+
accept: "application/json, text/event-stream",
|
|
1734
|
+
"mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
1735
|
+
"mcp-method": "server/discover"
|
|
1736
|
+
},
|
|
1737
|
+
{},
|
|
1738
|
+
this.staticHeaders
|
|
1739
|
+
);
|
|
1740
|
+
headers["mcp-method"] = "server/discover";
|
|
1741
|
+
delete headers["mcp-name"];
|
|
1742
|
+
const probeBody = {
|
|
1743
|
+
jsonrpc: "2.0",
|
|
1744
|
+
id: ERA_PROBE_REQUEST_ID,
|
|
1745
|
+
method: "server/discover",
|
|
1746
|
+
params: { _meta: buildInternalMeta() }
|
|
1747
|
+
};
|
|
1748
|
+
let res;
|
|
1749
|
+
try {
|
|
1750
|
+
res = await fetch(this.url, {
|
|
1751
|
+
method: "POST",
|
|
1752
|
+
headers,
|
|
1753
|
+
body: JSON.stringify(probeBody),
|
|
1754
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1351
1755
|
});
|
|
1352
|
-
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
throw this.describeFetchFailure(error, "server/discover probe");
|
|
1353
1758
|
}
|
|
1354
|
-
|
|
1759
|
+
if (!isClassifiableProbeStatus(res.status)) {
|
|
1760
|
+
throw new Error(`upstream server/discover probe failed: HTTP ${String(res.status)}`);
|
|
1761
|
+
}
|
|
1762
|
+
const body = await this.readProbeBody(res);
|
|
1763
|
+
if (body.kind === "stalled") {
|
|
1764
|
+
throw new Error(`upstream server/discover probe ${body.reason}`);
|
|
1765
|
+
}
|
|
1766
|
+
if (body.kind === "unparseable") {
|
|
1767
|
+
return { era: "legacy" };
|
|
1768
|
+
}
|
|
1769
|
+
if (body.kind === "error") {
|
|
1770
|
+
return classifyProbeError(body.envelope);
|
|
1771
|
+
}
|
|
1772
|
+
return classifyProbeResult(body.envelope);
|
|
1773
|
+
}
|
|
1774
|
+
async readProbeBody(res) {
|
|
1775
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1776
|
+
if (contentType.includes("text/event-stream")) {
|
|
1777
|
+
if (!res.body) return { kind: "unparseable" };
|
|
1778
|
+
const scan = await this.scanSseEvents(
|
|
1779
|
+
res.body,
|
|
1780
|
+
(payload) => payload["id"] === ERA_PROBE_REQUEST_ID ? payload : void 0
|
|
1781
|
+
);
|
|
1782
|
+
switch (scan.outcome) {
|
|
1783
|
+
case "found":
|
|
1784
|
+
return classifyEnvelopeShape(scan.value);
|
|
1785
|
+
case "closed":
|
|
1786
|
+
return { kind: "unparseable" };
|
|
1787
|
+
case "timed-out":
|
|
1788
|
+
return {
|
|
1789
|
+
kind: "stalled",
|
|
1790
|
+
reason: `SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1791
|
+
};
|
|
1792
|
+
case "too-large":
|
|
1793
|
+
return {
|
|
1794
|
+
kind: "stalled",
|
|
1795
|
+
reason: `SSE response exceeded ${String(MAX_SSE_SCAN_BYTES)} bytes`
|
|
1796
|
+
};
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
const raw = await res.text();
|
|
1800
|
+
if (!raw.trim()) return { kind: "unparseable" };
|
|
1801
|
+
let parsed;
|
|
1355
1802
|
try {
|
|
1356
|
-
|
|
1357
|
-
} catch
|
|
1358
|
-
|
|
1359
|
-
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
1360
|
-
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
1361
|
-
const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
|
|
1362
|
-
writeSessionEvent(sessionId, errorEvent);
|
|
1363
|
-
return c.body(null, 202);
|
|
1803
|
+
parsed = JSON.parse(raw);
|
|
1804
|
+
} catch {
|
|
1805
|
+
return { kind: "unparseable" };
|
|
1364
1806
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
const
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1807
|
+
if (typeof parsed !== "object" || parsed === null) return { kind: "unparseable" };
|
|
1808
|
+
return classifyEnvelopeShape(parsed);
|
|
1809
|
+
}
|
|
1810
|
+
async legacyInitialize() {
|
|
1811
|
+
const headers = mergeUpstreamHeaders(
|
|
1812
|
+
{
|
|
1813
|
+
"content-type": "application/json",
|
|
1814
|
+
accept: "application/json, text/event-stream"
|
|
1815
|
+
},
|
|
1816
|
+
{},
|
|
1817
|
+
this.staticHeaders
|
|
1818
|
+
);
|
|
1819
|
+
delete headers["mcp-method"];
|
|
1820
|
+
delete headers["mcp-name"];
|
|
1821
|
+
const initBody = {
|
|
1822
|
+
jsonrpc: "2.0",
|
|
1823
|
+
id: 0,
|
|
1824
|
+
method: "initialize",
|
|
1825
|
+
params: {
|
|
1826
|
+
protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION,
|
|
1827
|
+
capabilities: {},
|
|
1828
|
+
clientInfo: { name: "helio-proxy", version: "0" }
|
|
1829
|
+
}
|
|
1830
|
+
};
|
|
1831
|
+
let res;
|
|
1832
|
+
try {
|
|
1833
|
+
res = await fetch(this.url, {
|
|
1834
|
+
method: "POST",
|
|
1835
|
+
headers,
|
|
1836
|
+
body: JSON.stringify(initBody),
|
|
1837
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1838
|
+
});
|
|
1839
|
+
} catch (error) {
|
|
1840
|
+
throw this.describeFetchFailure(error, "initialize");
|
|
1841
|
+
}
|
|
1842
|
+
if (!res.ok) {
|
|
1843
|
+
throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
|
|
1844
|
+
}
|
|
1845
|
+
const sessionId = res.headers.get("mcp-session-id") ?? void 0;
|
|
1846
|
+
const initializeEnvelope = await this.readRequiredJsonRpcEnvelope(
|
|
1847
|
+
res,
|
|
1848
|
+
initBody.id,
|
|
1849
|
+
"initialize"
|
|
1850
|
+
);
|
|
1851
|
+
const initializeError = extractJsonRpcErrorMessage(initializeEnvelope);
|
|
1852
|
+
if (initializeError) {
|
|
1853
|
+
throw new Error(`upstream initialize returned JSON-RPC error: ${initializeError}`);
|
|
1854
|
+
}
|
|
1855
|
+
const negotiatedProtocolVersion = extractNegotiatedProtocolVersion(initializeEnvelope);
|
|
1856
|
+
const notifyHeaders = { ...headers };
|
|
1857
|
+
if (sessionId) notifyHeaders["mcp-session-id"] = sessionId;
|
|
1858
|
+
notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1859
|
+
const notifyRes = await fetch(this.url, {
|
|
1860
|
+
method: "POST",
|
|
1861
|
+
headers: notifyHeaders,
|
|
1862
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
1863
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1864
|
+
}).catch((error) => {
|
|
1865
|
+
throw this.describeFetchFailure(error, "notifications/initialized");
|
|
1389
1866
|
});
|
|
1390
|
-
|
|
1391
|
-
|
|
1867
|
+
if (!notifyRes.ok) {
|
|
1868
|
+
throw new Error(`upstream notifications/initialized failed: HTTP ${String(notifyRes.status)}`);
|
|
1869
|
+
}
|
|
1870
|
+
const notifyError = await this.readOptionalJsonRpcError(notifyRes);
|
|
1871
|
+
if (notifyError) {
|
|
1872
|
+
throw new Error(`upstream notifications/initialized returned JSON-RPC error: ${notifyError}`);
|
|
1873
|
+
}
|
|
1874
|
+
return { sessionId, protocolVersion: negotiatedProtocolVersion, era: "legacy" };
|
|
1875
|
+
}
|
|
1876
|
+
async readRequiredJsonRpcEnvelope(res, requestId, step) {
|
|
1877
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1878
|
+
if (contentType.includes("text/event-stream")) {
|
|
1879
|
+
const payload = await readSseJsonRpcResponse(res, requestId);
|
|
1880
|
+
return payload;
|
|
1881
|
+
}
|
|
1882
|
+
const raw = await res.text();
|
|
1883
|
+
if (!raw.trim()) {
|
|
1884
|
+
throw new Error(`upstream ${step} returned an empty body`);
|
|
1885
|
+
}
|
|
1886
|
+
let parsed;
|
|
1392
1887
|
try {
|
|
1393
|
-
|
|
1888
|
+
parsed = JSON.parse(raw);
|
|
1394
1889
|
} catch {
|
|
1890
|
+
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1395
1891
|
}
|
|
1396
|
-
if (
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1892
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
1893
|
+
throw new Error(`upstream ${step} returned non-object JSON`);
|
|
1894
|
+
}
|
|
1895
|
+
return parsed;
|
|
1896
|
+
}
|
|
1897
|
+
async readOptionalJsonRpcError(res) {
|
|
1898
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1899
|
+
if (contentType.includes("text/event-stream")) {
|
|
1900
|
+
if (!res.body) return void 0;
|
|
1901
|
+
const scan = await this.scanSseEvents(
|
|
1902
|
+
res.body,
|
|
1903
|
+
(payload) => extractJsonRpcErrorMessage(payload)
|
|
1904
|
+
);
|
|
1905
|
+
switch (scan.outcome) {
|
|
1906
|
+
case "found":
|
|
1907
|
+
return scan.value;
|
|
1908
|
+
case "closed":
|
|
1909
|
+
return void 0;
|
|
1910
|
+
case "timed-out":
|
|
1911
|
+
throw new Error(
|
|
1912
|
+
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1913
|
+
);
|
|
1914
|
+
case "too-large":
|
|
1915
|
+
throw new Error(
|
|
1916
|
+
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_SCAN_BYTES)} bytes`
|
|
1917
|
+
);
|
|
1400
1918
|
}
|
|
1401
|
-
return;
|
|
1402
1919
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1920
|
+
const raw = await res.text();
|
|
1921
|
+
if (!raw.trim()) return void 0;
|
|
1922
|
+
let parsed;
|
|
1923
|
+
try {
|
|
1924
|
+
parsed = JSON.parse(raw);
|
|
1925
|
+
} catch {
|
|
1926
|
+
return void 0;
|
|
1405
1927
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1928
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
1929
|
+
return extractJsonRpcErrorMessage(parsed);
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* Read an SSE POST response body under an explicit read deadline and byte
|
|
1933
|
+
* cap, returning the first `message` payload `select` accepts. The
|
|
1934
|
+
* fetch-level `AbortSignal.timeout` would eventually abort a stalled body
|
|
1935
|
+
* read; these bounds are the belt to its braces.
|
|
1936
|
+
*/
|
|
1937
|
+
async scanSseEvents(body, select) {
|
|
1938
|
+
const reader = body.getReader();
|
|
1939
|
+
const decoder = new TextDecoder();
|
|
1940
|
+
let state = { event: "", data: "", remainder: "" };
|
|
1941
|
+
let found;
|
|
1942
|
+
let scannedBytes = 0;
|
|
1943
|
+
const deadline = Date.now() + this.requestTimeoutMs;
|
|
1944
|
+
const onEvent = (event, data) => {
|
|
1945
|
+
if (found !== void 0) return;
|
|
1946
|
+
if (event && event !== "message") return;
|
|
1947
|
+
let parsed;
|
|
1425
1948
|
try {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
settle(err);
|
|
1429
|
-
return;
|
|
1430
|
-
}
|
|
1431
|
-
settle();
|
|
1432
|
-
});
|
|
1433
|
-
} catch (error) {
|
|
1434
|
-
settle(normalizeError(error));
|
|
1949
|
+
parsed = JSON.parse(data);
|
|
1950
|
+
} catch {
|
|
1435
1951
|
return;
|
|
1436
1952
|
}
|
|
1953
|
+
if (typeof parsed !== "object" || parsed === null) return;
|
|
1954
|
+
found = select(parsed);
|
|
1955
|
+
};
|
|
1956
|
+
for (; ; ) {
|
|
1957
|
+
const remainingMs = deadline - Date.now();
|
|
1958
|
+
if (remainingMs <= 0) {
|
|
1959
|
+
await reader.cancel().catch(() => void 0);
|
|
1960
|
+
return { outcome: "timed-out" };
|
|
1961
|
+
}
|
|
1962
|
+
let chunk;
|
|
1437
1963
|
try {
|
|
1438
|
-
|
|
1964
|
+
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1439
1965
|
} catch {
|
|
1966
|
+
await reader.cancel().catch(() => void 0);
|
|
1967
|
+
return { outcome: "timed-out" };
|
|
1440
1968
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1969
|
+
const { done, value } = chunk;
|
|
1970
|
+
if (value !== void 0) {
|
|
1971
|
+
scannedBytes += value.byteLength;
|
|
1972
|
+
if (scannedBytes > MAX_SSE_SCAN_BYTES) {
|
|
1973
|
+
await reader.cancel().catch(() => void 0);
|
|
1974
|
+
return { outcome: "too-large" };
|
|
1975
|
+
}
|
|
1976
|
+
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
1977
|
+
if (found !== void 0) {
|
|
1978
|
+
await reader.cancel().catch(() => void 0);
|
|
1979
|
+
return { outcome: "found", value: found };
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
if (done) {
|
|
1983
|
+
const tail = decoder.decode();
|
|
1984
|
+
if (tail) {
|
|
1985
|
+
state = parseSseChunk(tail, state, onEvent);
|
|
1986
|
+
}
|
|
1987
|
+
return found !== void 0 ? { outcome: "found", value: found } : { outcome: "closed" };
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
async function readSseChunkWithTimeout(reader, timeoutMs) {
|
|
1993
|
+
let timeoutHandle;
|
|
1994
|
+
try {
|
|
1995
|
+
const result = await Promise.race([
|
|
1996
|
+
reader.read(),
|
|
1997
|
+
new Promise((_, reject) => {
|
|
1998
|
+
timeoutHandle = setTimeout(() => {
|
|
1999
|
+
reject(new Error(`sse read timed out after ${String(timeoutMs)}ms`));
|
|
2000
|
+
}, timeoutMs);
|
|
2001
|
+
})
|
|
2002
|
+
]);
|
|
2003
|
+
if (!isSseReadChunk(result)) {
|
|
2004
|
+
throw new Error("upstream SSE response returned invalid chunk");
|
|
2005
|
+
}
|
|
2006
|
+
return result;
|
|
2007
|
+
} finally {
|
|
2008
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
1456
2009
|
}
|
|
1457
|
-
return app;
|
|
1458
|
-
}
|
|
1459
|
-
function startServer(app, config) {
|
|
1460
|
-
const server = serve({
|
|
1461
|
-
fetch: app.fetch,
|
|
1462
|
-
port: config.listen.port,
|
|
1463
|
-
hostname: config.listen.host
|
|
1464
|
-
});
|
|
1465
|
-
return createServerHandle(server);
|
|
1466
2010
|
}
|
|
1467
|
-
function
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
return createServerHandle(server);
|
|
2011
|
+
function isSseReadChunk(value) {
|
|
2012
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2013
|
+
const candidate = value;
|
|
2014
|
+
if (typeof candidate.done !== "boolean") return false;
|
|
2015
|
+
if (candidate.value === void 0) return true;
|
|
2016
|
+
return candidate.value instanceof Uint8Array;
|
|
1474
2017
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
2018
|
+
function isClassifiableProbeStatus(status) {
|
|
2019
|
+
if (status >= 200 && status < 300) return true;
|
|
2020
|
+
return status === 400 || status === 404 || status === 405;
|
|
2021
|
+
}
|
|
2022
|
+
function classifyEnvelopeShape(envelope) {
|
|
2023
|
+
if (envelope["error"] !== void 0) return { kind: "error", envelope };
|
|
2024
|
+
if (envelope["result"] !== void 0) return { kind: "result", envelope };
|
|
2025
|
+
return { kind: "unparseable" };
|
|
2026
|
+
}
|
|
2027
|
+
function classifyProbeError(envelope) {
|
|
2028
|
+
const error = envelope["error"];
|
|
2029
|
+
const code = typeof error === "object" && error !== null ? error["code"] : void 0;
|
|
2030
|
+
if (code === MCP_UNSUPPORTED_PROTOCOL_VERSION_CODE) {
|
|
2031
|
+
const data = error["data"];
|
|
2032
|
+
return {
|
|
2033
|
+
era: "legacy",
|
|
2034
|
+
unsupportedModernVersions: readStringArray(
|
|
2035
|
+
typeof data === "object" && data !== null ? data["supported"] : void 0
|
|
2036
|
+
)
|
|
2037
|
+
};
|
|
1493
2038
|
}
|
|
1494
|
-
|
|
2039
|
+
if (code === HEADER_MISMATCH || code === MCP_MISSING_CLIENT_CAPABILITY_CODE) {
|
|
2040
|
+
throw new Error(
|
|
2041
|
+
`upstream refused Helio's server/discover probe with modern MCP error ${String(code)}: ${extractJsonRpcErrorMessage(envelope) ?? "unknown JSON-RPC error"} (the upstream is a modern MCP server, so Helio does not fall back to initialize)`
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
return { era: "legacy" };
|
|
1495
2045
|
}
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
remainder = lines.pop() ?? "";
|
|
1503
|
-
for (const rawLine of lines) {
|
|
1504
|
-
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1505
|
-
if (line === "") {
|
|
1506
|
-
if (event || data) {
|
|
1507
|
-
onEvent(event, data);
|
|
1508
|
-
event = "";
|
|
1509
|
-
data = "";
|
|
1510
|
-
}
|
|
1511
|
-
} else if (line.startsWith("event:")) {
|
|
1512
|
-
const value = line.slice(6).replace(/^ /, "");
|
|
1513
|
-
event = value;
|
|
1514
|
-
} else if (line.startsWith("data:")) {
|
|
1515
|
-
const value = line.slice(5).replace(/^ /, "");
|
|
1516
|
-
data = data ? data + "\n" + value : value;
|
|
1517
|
-
}
|
|
2046
|
+
function classifyProbeResult(envelope) {
|
|
2047
|
+
const result = envelope["result"];
|
|
2048
|
+
if (typeof result !== "object" || result === null) return { era: "legacy" };
|
|
2049
|
+
const supportedVersions = result["supportedVersions"];
|
|
2050
|
+
if (!Array.isArray(supportedVersions)) {
|
|
2051
|
+
return { era: "legacy" };
|
|
1518
2052
|
}
|
|
1519
|
-
|
|
2053
|
+
const versions = readStringArray(supportedVersions);
|
|
2054
|
+
if (versions.includes(HELIO_MCP_MODERN_PROTOCOL_VERSION)) {
|
|
2055
|
+
const record = result;
|
|
2056
|
+
const capabilities = record["capabilities"];
|
|
2057
|
+
const instructions = record["instructions"];
|
|
2058
|
+
return {
|
|
2059
|
+
era: "modern",
|
|
2060
|
+
capabilities: typeof capabilities === "object" && capabilities !== null && !Array.isArray(capabilities) ? capabilities : void 0,
|
|
2061
|
+
instructions: typeof instructions === "string" ? instructions : void 0
|
|
2062
|
+
};
|
|
2063
|
+
}
|
|
2064
|
+
return { era: "legacy", unsupportedModernVersions: versions };
|
|
1520
2065
|
}
|
|
1521
|
-
|
|
1522
|
-
if (!
|
|
1523
|
-
|
|
2066
|
+
function readStringArray(value) {
|
|
2067
|
+
if (!Array.isArray(value)) return [];
|
|
2068
|
+
return value.filter((entry) => typeof entry === "string");
|
|
2069
|
+
}
|
|
2070
|
+
function extractJsonRpcErrorMessage(payload) {
|
|
2071
|
+
const error = payload["error"];
|
|
2072
|
+
if (typeof error === "string") return error;
|
|
2073
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
2074
|
+
const message = error["message"];
|
|
2075
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
2076
|
+
return "unknown JSON-RPC error";
|
|
2077
|
+
}
|
|
2078
|
+
function extractNegotiatedProtocolVersion(payload) {
|
|
2079
|
+
const result = payload["result"];
|
|
2080
|
+
if (typeof result !== "object" || result === null) {
|
|
2081
|
+
return HELIO_MCP_LEGACY_PROTOCOL_VERSION;
|
|
1524
2082
|
}
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2083
|
+
const protocolVersion = result["protocolVersion"];
|
|
2084
|
+
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_LEGACY_PROTOCOL_VERSION;
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// src/transport/header-body-agreement.ts
|
|
2088
|
+
var DISPLAY_CAP_CHARS = 256;
|
|
2089
|
+
function displayCap(value) {
|
|
2090
|
+
if (value.length <= DISPLAY_CAP_CHARS) return value;
|
|
2091
|
+
return `${value.slice(0, DISPLAY_CAP_CHARS)}\u2026 (truncated)`;
|
|
2092
|
+
}
|
|
2093
|
+
function readOwnField(source, field) {
|
|
2094
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) return void 0;
|
|
2095
|
+
if (!Object.prototype.hasOwnProperty.call(source, field)) return void 0;
|
|
2096
|
+
return source[field];
|
|
2097
|
+
}
|
|
2098
|
+
function validateHeaderBodyAgreement(input) {
|
|
2099
|
+
const { method, params } = input;
|
|
2100
|
+
const headerMethod = input.headers["mcp-method"];
|
|
2101
|
+
const headerName = input.headers["mcp-name"];
|
|
2102
|
+
const rawVersionClaim = input.headers["mcp-protocol-version"];
|
|
2103
|
+
const modern = isModernProtocolClaim(rawVersionClaim);
|
|
2104
|
+
const isNotification = input.id === void 0;
|
|
2105
|
+
const requiresPresence = modern && !isNotification;
|
|
2106
|
+
const field = nameBearingField(method);
|
|
2107
|
+
const rawFieldValue = field === void 0 ? void 0 : readOwnField(params, field);
|
|
2108
|
+
const bodyName = typeof rawFieldValue === "string" ? rawFieldValue : void 0;
|
|
2109
|
+
const presentHeaders = {};
|
|
2110
|
+
if (headerMethod !== void 0) presentHeaders["mcp-method"] = headerMethod;
|
|
2111
|
+
if (headerName !== void 0) presentHeaders["mcp-name"] = headerName;
|
|
2112
|
+
if (rawVersionClaim !== void 0) presentHeaders["mcp-protocol-version"] = rawVersionClaim;
|
|
2113
|
+
const reject = (reason) => ({
|
|
2114
|
+
ok: false,
|
|
2115
|
+
reason,
|
|
2116
|
+
evidence: {
|
|
2117
|
+
headers: presentHeaders,
|
|
2118
|
+
...bodyName !== void 0 && { bodyName }
|
|
1536
2119
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
if (
|
|
1540
|
-
|
|
2120
|
+
});
|
|
2121
|
+
if (headerMethod === void 0) {
|
|
2122
|
+
if (requiresPresence) {
|
|
2123
|
+
return reject(`missing mcp-method header (expected ${displayCap(method)})`);
|
|
1541
2124
|
}
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
if (
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
2125
|
+
} else if (headerMethod !== method) {
|
|
2126
|
+
return reject(
|
|
2127
|
+
`mismatched mcp-method header (expected ${displayCap(method)}, got ${displayCap(headerMethod)})`
|
|
2128
|
+
);
|
|
2129
|
+
}
|
|
2130
|
+
if (bodyName !== void 0) {
|
|
2131
|
+
if (headerName === void 0) {
|
|
2132
|
+
if (requiresPresence) {
|
|
2133
|
+
return reject(`missing mcp-name header (expected ${displayCap(bodyName)})`);
|
|
2134
|
+
}
|
|
2135
|
+
} else {
|
|
2136
|
+
const decoded = decodeSentinelValue(headerName);
|
|
2137
|
+
if (decoded !== bodyName) {
|
|
2138
|
+
return reject(
|
|
2139
|
+
`mismatched mcp-name header (expected ${displayCap(bodyName)}, got ${displayCap(decoded)})`
|
|
2140
|
+
);
|
|
1554
2141
|
}
|
|
1555
2142
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
2143
|
+
}
|
|
2144
|
+
if (modern) {
|
|
2145
|
+
const mirror = readOwnField(readOwnField(params, "_meta"), MCP_META_PROTOCOL_VERSION_KEY);
|
|
2146
|
+
if (mirror === void 0) {
|
|
2147
|
+
if (!isNotification) {
|
|
2148
|
+
return reject(
|
|
2149
|
+
`missing params._meta["${MCP_META_PROTOCOL_VERSION_KEY}"] mirror (expected ${HELIO_MCP_MODERN_PROTOCOL_VERSION})`
|
|
2150
|
+
);
|
|
1561
2151
|
}
|
|
1562
|
-
|
|
2152
|
+
} else if (mirror !== HELIO_MCP_MODERN_PROTOCOL_VERSION) {
|
|
2153
|
+
const display = typeof mirror === "string" ? mirror : JSON.stringify(mirror);
|
|
2154
|
+
return reject(
|
|
2155
|
+
`mismatched params._meta["${MCP_META_PROTOCOL_VERSION_KEY}"] mirror (expected ${HELIO_MCP_MODERN_PROTOCOL_VERSION}, got ${displayCap(display)})`
|
|
2156
|
+
);
|
|
1563
2157
|
}
|
|
1564
2158
|
}
|
|
1565
|
-
|
|
1566
|
-
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1567
|
-
);
|
|
2159
|
+
return { ok: true };
|
|
1568
2160
|
}
|
|
1569
2161
|
|
|
1570
|
-
// src/
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
2162
|
+
// src/transport/origin-guard.ts
|
|
2163
|
+
var MAX_UNIQUE_ORIGIN_WARNINGS = 20;
|
|
2164
|
+
var SUPPRESSED_WARNING_SUMMARY_INTERVAL = 50;
|
|
2165
|
+
var LOGGED_ORIGIN_MAX_LENGTH = 256;
|
|
2166
|
+
function createOriginGuard(allowedOrigins) {
|
|
2167
|
+
const allowed = new Set(allowedOrigins);
|
|
2168
|
+
const warnedOrigins = /* @__PURE__ */ new Set();
|
|
2169
|
+
let suppressedWarningCount = 0;
|
|
2170
|
+
const logRejection = (origin) => {
|
|
2171
|
+
const displayOrigin = origin.length > LOGGED_ORIGIN_MAX_LENGTH ? `${origin.slice(0, LOGGED_ORIGIN_MAX_LENGTH)}\u2026 (truncated)` : origin;
|
|
2172
|
+
if (warnedOrigins.has(displayOrigin)) return;
|
|
2173
|
+
if (warnedOrigins.size < MAX_UNIQUE_ORIGIN_WARNINGS) {
|
|
2174
|
+
warnedOrigins.add(displayOrigin);
|
|
2175
|
+
console.error(`[helio] Rejected request with disallowed Origin: ${displayOrigin}`);
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
suppressedWarningCount += 1;
|
|
2179
|
+
if (suppressedWarningCount === 1 || suppressedWarningCount % SUPPRESSED_WARNING_SUMMARY_INTERVAL === 0) {
|
|
2180
|
+
console.error(
|
|
2181
|
+
`[helio] Origin rejection warnings: logged ${String(MAX_UNIQUE_ORIGIN_WARNINGS)} distinct origins and are suppressing the rest (${String(suppressedWarningCount)} further rejections so far).`
|
|
2182
|
+
);
|
|
1576
2183
|
}
|
|
1577
2184
|
};
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
2185
|
+
return async (c, next) => {
|
|
2186
|
+
const origin = c.req.header("origin");
|
|
2187
|
+
if (origin !== void 0 && !allowed.has(origin)) {
|
|
2188
|
+
logRejection(origin);
|
|
2189
|
+
return c.json(makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Origin not allowed"), 403);
|
|
2190
|
+
}
|
|
2191
|
+
await next();
|
|
2192
|
+
};
|
|
1582
2193
|
}
|
|
1583
2194
|
|
|
1584
|
-
// src/
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
"
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
"
|
|
1593
|
-
"
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
current = current.cause;
|
|
2195
|
+
// src/transport/response-normalizer.ts
|
|
2196
|
+
function isObject(value) {
|
|
2197
|
+
return value !== null && typeof value === "object";
|
|
2198
|
+
}
|
|
2199
|
+
function isValidJsonRpcId(value) {
|
|
2200
|
+
return value === null || typeof value === "string" || typeof value === "number";
|
|
2201
|
+
}
|
|
2202
|
+
function getJsonRpcId(value) {
|
|
2203
|
+
if (!isObject(value) || !Object.prototype.hasOwnProperty.call(value, "id")) return void 0;
|
|
2204
|
+
const id = value["id"];
|
|
2205
|
+
return isValidJsonRpcId(id) ? id : void 0;
|
|
2206
|
+
}
|
|
2207
|
+
function isValidJsonRpcError(value) {
|
|
2208
|
+
if (!isObject(value)) return false;
|
|
2209
|
+
return typeof value["code"] === "number" && typeof value["message"] === "string";
|
|
2210
|
+
}
|
|
2211
|
+
function isValidJsonRpcResponse(value) {
|
|
2212
|
+
if (!isObject(value)) return false;
|
|
2213
|
+
if (value["jsonrpc"] !== "2.0") return false;
|
|
2214
|
+
if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
|
|
2215
|
+
return false;
|
|
1606
2216
|
}
|
|
1607
|
-
|
|
2217
|
+
const hasResult = Object.prototype.hasOwnProperty.call(value, "result");
|
|
2218
|
+
const hasError = Object.prototype.hasOwnProperty.call(value, "error");
|
|
2219
|
+
if (hasResult && hasError || !hasResult && !hasError) return false;
|
|
2220
|
+
if (hasError && !isValidJsonRpcError(value["error"])) return false;
|
|
2221
|
+
return true;
|
|
1608
2222
|
}
|
|
1609
|
-
function
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
2223
|
+
function makeWrappedError(requestId, message, data) {
|
|
2224
|
+
return {
|
|
2225
|
+
jsonrpc: "2.0",
|
|
2226
|
+
id: requestId ?? null,
|
|
2227
|
+
error: {
|
|
2228
|
+
code: INTERNAL_ERROR,
|
|
2229
|
+
message,
|
|
2230
|
+
data
|
|
2231
|
+
}
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
function normalizeUpstreamOutcome(args) {
|
|
2235
|
+
if (args.forwardingError) {
|
|
2236
|
+
return {
|
|
2237
|
+
httpStatus: 200,
|
|
2238
|
+
wrapped: true,
|
|
2239
|
+
body: makeWrappedError(args.requestId, "upstream forwarding failed", {
|
|
2240
|
+
failure_class: "upstream_forward_error",
|
|
2241
|
+
failure_reason: args.forwardingError.message
|
|
2242
|
+
})
|
|
2243
|
+
};
|
|
1616
2244
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2245
|
+
if (!args.upstreamResponse) {
|
|
2246
|
+
return {
|
|
2247
|
+
httpStatus: 200,
|
|
2248
|
+
wrapped: true,
|
|
2249
|
+
body: makeWrappedError(args.requestId, "upstream forwarding failed", {
|
|
2250
|
+
failure_class: "upstream_forward_error",
|
|
2251
|
+
failure_reason: "missing upstream response"
|
|
2252
|
+
})
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
const upstream = args.upstreamResponse;
|
|
2256
|
+
const upstreamContentType = upstream.headers["content-type"] ?? null;
|
|
2257
|
+
if (!isValidJsonRpcResponse(upstream.body)) {
|
|
2258
|
+
return {
|
|
2259
|
+
httpStatus: 200,
|
|
2260
|
+
wrapped: true,
|
|
2261
|
+
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
2262
|
+
failure_class: "upstream_invalid_jsonrpc",
|
|
2263
|
+
upstream_http_status: upstream.status,
|
|
2264
|
+
upstream_content_type: upstreamContentType,
|
|
2265
|
+
upstream_body_type: typeof upstream.body
|
|
2266
|
+
})
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
if (args.requestId !== void 0) {
|
|
2270
|
+
const upstreamId = getJsonRpcId(upstream.body);
|
|
2271
|
+
if (upstreamId === void 0) {
|
|
2272
|
+
return {
|
|
2273
|
+
httpStatus: 200,
|
|
2274
|
+
wrapped: true,
|
|
2275
|
+
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
2276
|
+
failure_class: "upstream_invalid_jsonrpc",
|
|
2277
|
+
upstream_http_status: upstream.status,
|
|
2278
|
+
upstream_content_type: upstreamContentType,
|
|
2279
|
+
upstream_body_type: typeof upstream.body,
|
|
2280
|
+
invalid_reason: "missing_response_id"
|
|
2281
|
+
})
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
const expectedId = args.requestId ?? null;
|
|
2285
|
+
if (upstreamId !== expectedId) {
|
|
2286
|
+
return {
|
|
2287
|
+
httpStatus: 200,
|
|
2288
|
+
wrapped: true,
|
|
2289
|
+
body: makeWrappedError(args.requestId, "upstream response id mismatch", {
|
|
2290
|
+
failure_class: "upstream_id_mismatch",
|
|
2291
|
+
expected_request_id: expectedId,
|
|
2292
|
+
upstream_response_id: upstreamId
|
|
2293
|
+
})
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
return {
|
|
2298
|
+
httpStatus: 200,
|
|
2299
|
+
wrapped: false,
|
|
2300
|
+
body: upstream.body
|
|
2301
|
+
};
|
|
1621
2302
|
}
|
|
1622
2303
|
|
|
1623
|
-
// src/
|
|
1624
|
-
var
|
|
1625
|
-
var
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
2304
|
+
// src/transport/streamable-http.ts
|
|
2305
|
+
var MCP_SESSION_HEADER = "mcp-session-id";
|
|
2306
|
+
var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set(["content-type", "mcp-session-id"]);
|
|
2307
|
+
function createStreamableHttpRoute(forwarder, options = {}) {
|
|
2308
|
+
const app = new Hono();
|
|
2309
|
+
const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
|
|
2310
|
+
const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
2311
|
+
app.use("*", createOriginGuard(options.allowedOrigins ?? []));
|
|
2312
|
+
app.post("/", async (c) => {
|
|
2313
|
+
const handlerStart = performance.now();
|
|
2314
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
2315
|
+
return c.json(
|
|
2316
|
+
makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Content-Type must be application/json"),
|
|
2317
|
+
415
|
|
2318
|
+
);
|
|
2319
|
+
}
|
|
2320
|
+
let body;
|
|
2321
|
+
try {
|
|
2322
|
+
body = await c.req.json();
|
|
2323
|
+
} catch {
|
|
2324
|
+
return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
|
|
2325
|
+
}
|
|
2326
|
+
const parsedRequest = parseJsonRpcRequest(body);
|
|
2327
|
+
if (!parsedRequest.success) {
|
|
2328
|
+
const errorBody = parsedRequest.id === null ? makeJsonRpcErrorWithoutId(INVALID_REQUEST, parsedRequest.message) : makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message);
|
|
2329
|
+
return c.json(errorBody, 400);
|
|
2330
|
+
}
|
|
2331
|
+
const id = parsedRequest.request.id;
|
|
2332
|
+
const method = parsedRequest.request.method;
|
|
2333
|
+
const params = parsedRequest.request.params;
|
|
2334
|
+
const transportSessionId = c.req.header(MCP_SESSION_HEADER);
|
|
2335
|
+
const session = resolveSession(
|
|
2336
|
+
{
|
|
2337
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
2338
|
+
meta: paramsMeta(params),
|
|
2339
|
+
transportSessionId
|
|
2340
|
+
},
|
|
2341
|
+
sessionIdentity
|
|
2342
|
+
);
|
|
2343
|
+
const protocolVersion = c.req.header("mcp-protocol-version");
|
|
2344
|
+
const agreement = validateHeaderBodyAgreement({
|
|
2345
|
+
method,
|
|
2346
|
+
id,
|
|
2347
|
+
params,
|
|
2348
|
+
headers: {
|
|
2349
|
+
"mcp-method": c.req.header("mcp-method"),
|
|
2350
|
+
"mcp-name": c.req.header("mcp-name"),
|
|
2351
|
+
"mcp-protocol-version": protocolVersion
|
|
2352
|
+
}
|
|
2353
|
+
});
|
|
2354
|
+
if (!agreement.ok) {
|
|
2355
|
+
options.onHeaderMismatch?.({
|
|
2356
|
+
reason: agreement.reason,
|
|
2357
|
+
method,
|
|
2358
|
+
params,
|
|
2359
|
+
...agreement.evidence.bodyName !== void 0 && { bodyName: agreement.evidence.bodyName },
|
|
2360
|
+
...protocolVersion !== void 0 && { protocolVersion },
|
|
2361
|
+
headers: agreement.evidence.headers,
|
|
2362
|
+
...session !== void 0 && { session },
|
|
2363
|
+
durationMs: performance.now() - handlerStart
|
|
2364
|
+
});
|
|
2365
|
+
const errorBody = id === void 0 || id === null ? makeJsonRpcErrorWithoutId(HEADER_MISMATCH, agreement.reason) : makeJsonRpcError(id, HEADER_MISMATCH, agreement.reason);
|
|
2366
|
+
return c.json(errorBody, 400);
|
|
2367
|
+
}
|
|
2368
|
+
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
2369
|
+
const mcpRequest = {
|
|
2370
|
+
jsonrpc: "2.0",
|
|
2371
|
+
id,
|
|
2372
|
+
method,
|
|
2373
|
+
params,
|
|
2374
|
+
session,
|
|
2375
|
+
transportSessionId,
|
|
2376
|
+
protocolVersion,
|
|
2377
|
+
headers: forwardHeaders,
|
|
2378
|
+
signal: c.req.raw.signal
|
|
2379
|
+
};
|
|
2380
|
+
if (id === void 0) {
|
|
2381
|
+
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
2382
|
+
void forwarder.forward(notificationRequest).catch((err) => {
|
|
2383
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2384
|
+
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
2385
|
+
});
|
|
2386
|
+
return c.body(null, 202);
|
|
2387
|
+
}
|
|
2388
|
+
let result;
|
|
2389
|
+
try {
|
|
2390
|
+
result = await forwarder.forward(mcpRequest);
|
|
2391
|
+
} catch (err) {
|
|
2392
|
+
const forwardingError = err instanceof Error ? err : new Error(String(err));
|
|
2393
|
+
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
2394
|
+
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
2395
|
+
return c.json(normalized2.body, normalized2.httpStatus);
|
|
2396
|
+
}
|
|
2397
|
+
const { response } = result;
|
|
2398
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
2399
|
+
if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
|
2400
|
+
c.header(key, value);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
const normalized = normalizeUpstreamOutcome({ requestId: id, upstreamResponse: response });
|
|
2404
|
+
return c.json(normalized.body, normalized.httpStatus);
|
|
2405
|
+
});
|
|
2406
|
+
return app;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// src/transport/sse.ts
|
|
2410
|
+
import { randomUUID } from "crypto";
|
|
2411
|
+
import { Hono as Hono2 } from "hono";
|
|
2412
|
+
import { z as z3 } from "zod";
|
|
2413
|
+
var encoder = new TextEncoder();
|
|
2414
|
+
var MCP_SESSION_HEADER2 = "mcp-session-id";
|
|
2415
|
+
var STALE_THRESHOLD_MS = 9e4;
|
|
2416
|
+
var SWEEP_INTERVAL_MS = 6e4;
|
|
2417
|
+
var MAX_CONCURRENT_SESSIONS = 1024;
|
|
2418
|
+
var REFUSAL_LOG_WINDOW_MS = 1e4;
|
|
2419
|
+
var ssePostQuerySchema = z3.object({
|
|
2420
|
+
sessionId: z3.string().min(1)
|
|
2421
|
+
});
|
|
2422
|
+
function sseEvent(event, data) {
|
|
2423
|
+
return `event: ${event}
|
|
2424
|
+
data: ${data}
|
|
2425
|
+
|
|
2426
|
+
`;
|
|
2427
|
+
}
|
|
2428
|
+
function createSseRoute(forwarder, options = {}) {
|
|
2429
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
2430
|
+
const app = new Hono2();
|
|
2431
|
+
const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
|
|
2432
|
+
const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
2433
|
+
const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
|
|
2434
|
+
let refusalCount = 0;
|
|
2435
|
+
let lastRefusalLogAt = null;
|
|
2436
|
+
const logRefusal = () => {
|
|
2437
|
+
refusalCount += 1;
|
|
2438
|
+
const now = Date.now();
|
|
2439
|
+
if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
|
|
2440
|
+
lastRefusalLogAt = now;
|
|
2441
|
+
console.error(
|
|
2442
|
+
`[helio] /sse at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
|
|
2443
|
+
);
|
|
2444
|
+
};
|
|
2445
|
+
app.use("*", createOriginGuard(options.allowedOrigins ?? []));
|
|
2446
|
+
const writeSessionEvent = (sessionId, eventPayload) => {
|
|
2447
|
+
const session = sessions.get(sessionId);
|
|
2448
|
+
if (!session) return;
|
|
2449
|
+
session.lastActivity = Date.now();
|
|
2450
|
+
void session.writer.write(encoder.encode(eventPayload)).catch(() => {
|
|
2451
|
+
sessions.delete(sessionId);
|
|
2452
|
+
void session.writer.close().catch(() => {
|
|
2453
|
+
});
|
|
2454
|
+
});
|
|
2455
|
+
};
|
|
2456
|
+
const sweepInterval = setInterval(() => {
|
|
2457
|
+
const now = Date.now();
|
|
2458
|
+
for (const [id, session] of sessions) {
|
|
2459
|
+
if (now - session.lastActivity > STALE_THRESHOLD_MS) {
|
|
2460
|
+
sessions.delete(id);
|
|
2461
|
+
void session.writer.close().catch(() => {
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
}, SWEEP_INTERVAL_MS);
|
|
2466
|
+
sweepInterval.unref();
|
|
2467
|
+
app.get("/", (c) => {
|
|
2468
|
+
if (sessions.size >= maxConcurrentSessions) {
|
|
2469
|
+
logRefusal();
|
|
2470
|
+
return c.json({ error: "session capacity reached" }, 503);
|
|
2471
|
+
}
|
|
2472
|
+
const sessionId = randomUUID();
|
|
2473
|
+
const { readable, writable } = new TransformStream();
|
|
2474
|
+
const writer = writable.getWriter();
|
|
2475
|
+
sessions.set(sessionId, { writer, lastActivity: Date.now() });
|
|
2476
|
+
const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
|
|
2477
|
+
writeSessionEvent(sessionId, endpointData);
|
|
2478
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
2479
|
+
sessions.delete(sessionId);
|
|
2480
|
+
void writer.close().catch(() => {
|
|
2481
|
+
});
|
|
1645
2482
|
});
|
|
1646
|
-
return
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
2483
|
+
return new Response(readable, {
|
|
2484
|
+
headers: {
|
|
2485
|
+
"content-type": "text/event-stream",
|
|
2486
|
+
"cache-control": "no-cache",
|
|
2487
|
+
connection: "keep-alive"
|
|
2488
|
+
}
|
|
2489
|
+
});
|
|
2490
|
+
});
|
|
2491
|
+
app.post("/", async (c) => {
|
|
2492
|
+
const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
|
|
2493
|
+
if (!parsedQuery.success) {
|
|
2494
|
+
return c.json(
|
|
2495
|
+
makeJsonRpcErrorWithoutId(INVALID_REQUEST, "missing sessionId query parameter"),
|
|
2496
|
+
400
|
|
2497
|
+
);
|
|
1659
2498
|
}
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
2499
|
+
const sessionId = parsedQuery.data.sessionId;
|
|
2500
|
+
const session = sessions.get(sessionId);
|
|
2501
|
+
if (!session) {
|
|
2502
|
+
return c.json(makeJsonRpcErrorWithoutId(INVALID_REQUEST, "unknown session"), 404);
|
|
2503
|
+
}
|
|
2504
|
+
if (!isJsonContentType(c.req.header("content-type"))) {
|
|
2505
|
+
return c.json(
|
|
2506
|
+
makeJsonRpcErrorWithoutId(INVALID_REQUEST, "Content-Type must be application/json"),
|
|
2507
|
+
415
|
|
2508
|
+
);
|
|
2509
|
+
}
|
|
2510
|
+
let body;
|
|
2511
|
+
try {
|
|
2512
|
+
body = await c.req.json();
|
|
2513
|
+
} catch {
|
|
2514
|
+
return c.json(makeJsonRpcErrorWithoutId(PARSE_ERROR, "invalid JSON"), 400);
|
|
2515
|
+
}
|
|
2516
|
+
const parsedRequest = parseJsonRpcRequest(body);
|
|
2517
|
+
if (!parsedRequest.success) {
|
|
2518
|
+
const errorBody = parsedRequest.id === null ? makeJsonRpcErrorWithoutId(INVALID_REQUEST, parsedRequest.message) : makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message);
|
|
2519
|
+
return c.json(errorBody, 400);
|
|
2520
|
+
}
|
|
2521
|
+
const id = parsedRequest.request.id;
|
|
2522
|
+
const method = parsedRequest.request.method;
|
|
2523
|
+
const params = parsedRequest.request.params;
|
|
2524
|
+
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
2525
|
+
const transportSessionId = c.req.header(MCP_SESSION_HEADER2);
|
|
2526
|
+
const resolvedSession = resolveSession(
|
|
1664
2527
|
{
|
|
1665
|
-
|
|
1666
|
-
|
|
2528
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
2529
|
+
meta: paramsMeta(params),
|
|
2530
|
+
transportSessionId,
|
|
2531
|
+
transportMintedId: sessionId
|
|
1667
2532
|
},
|
|
1668
|
-
|
|
1669
|
-
this.staticHeaders
|
|
2533
|
+
sessionIdentity
|
|
1670
2534
|
);
|
|
1671
|
-
const
|
|
2535
|
+
const mcpRequest = {
|
|
1672
2536
|
jsonrpc: "2.0",
|
|
1673
|
-
id
|
|
1674
|
-
method
|
|
1675
|
-
params
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
2537
|
+
id,
|
|
2538
|
+
method,
|
|
2539
|
+
params,
|
|
2540
|
+
session: resolvedSession,
|
|
2541
|
+
transportSessionId,
|
|
2542
|
+
headers: forwardHeaders,
|
|
2543
|
+
signal: c.req.raw.signal
|
|
1680
2544
|
};
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
body: JSON.stringify(initBody),
|
|
1687
|
-
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
2545
|
+
if (id === void 0) {
|
|
2546
|
+
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
2547
|
+
void forwarder.forward(notificationRequest).catch((err) => {
|
|
2548
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2549
|
+
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
1688
2550
|
});
|
|
1689
|
-
|
|
1690
|
-
throw this.describeFetchFailure(error, "initialize");
|
|
1691
|
-
}
|
|
1692
|
-
if (!res.ok) {
|
|
1693
|
-
throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
|
|
2551
|
+
return c.body(null, 202);
|
|
1694
2552
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
2553
|
+
let result;
|
|
2554
|
+
try {
|
|
2555
|
+
result = await forwarder.forward(mcpRequest);
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
const forwardingError = err instanceof Error ? err : new Error(String(err));
|
|
2558
|
+
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
2559
|
+
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
2560
|
+
const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
|
|
2561
|
+
writeSessionEvent(sessionId, errorEvent);
|
|
2562
|
+
return c.body(null, 202);
|
|
1704
2563
|
}
|
|
1705
|
-
const
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1709
|
-
const notifyRes = await fetch(this.url, {
|
|
1710
|
-
method: "POST",
|
|
1711
|
-
headers: notifyHeaders,
|
|
1712
|
-
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
1713
|
-
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1714
|
-
}).catch((error) => {
|
|
1715
|
-
throw this.describeFetchFailure(error, "notifications/initialized");
|
|
2564
|
+
const normalized = normalizeUpstreamOutcome({
|
|
2565
|
+
requestId: id,
|
|
2566
|
+
upstreamResponse: result.response
|
|
1716
2567
|
});
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
2568
|
+
const messageEvent = sseEvent("message", JSON.stringify(normalized.body));
|
|
2569
|
+
writeSessionEvent(sessionId, messageEvent);
|
|
2570
|
+
return c.body(null, 202);
|
|
2571
|
+
});
|
|
2572
|
+
return app;
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
// src/server.ts
|
|
2576
|
+
var FORCE_CONNECTION_CLOSE_GRACE_MS = 1500;
|
|
2577
|
+
function normalizeError(error) {
|
|
2578
|
+
if (error instanceof Error) return error;
|
|
2579
|
+
return new Error(String(error));
|
|
2580
|
+
}
|
|
2581
|
+
function createServerHandle(server) {
|
|
2582
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
2583
|
+
const nodeServer = server;
|
|
2584
|
+
nodeServer.on("connection", (socket) => {
|
|
2585
|
+
sockets.add(socket);
|
|
2586
|
+
socket.on("close", () => {
|
|
2587
|
+
sockets.delete(socket);
|
|
2588
|
+
});
|
|
2589
|
+
});
|
|
2590
|
+
const forceCloseConnections = () => {
|
|
1737
2591
|
try {
|
|
1738
|
-
|
|
2592
|
+
nodeServer.closeIdleConnections?.();
|
|
1739
2593
|
} catch {
|
|
1740
|
-
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1741
2594
|
}
|
|
1742
|
-
if (
|
|
1743
|
-
|
|
2595
|
+
if (nodeServer.closeAllConnections) {
|
|
2596
|
+
try {
|
|
2597
|
+
nodeServer.closeAllConnections();
|
|
2598
|
+
} catch {
|
|
2599
|
+
}
|
|
2600
|
+
return;
|
|
1744
2601
|
}
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
} catch {
|
|
2602
|
+
for (const socket of sockets) {
|
|
2603
|
+
socket.destroy();
|
|
2604
|
+
}
|
|
2605
|
+
};
|
|
2606
|
+
return {
|
|
2607
|
+
server,
|
|
2608
|
+
close: () => new Promise((resolve, reject) => {
|
|
2609
|
+
let settled = false;
|
|
2610
|
+
let forceTimer;
|
|
2611
|
+
const settle = (err) => {
|
|
2612
|
+
if (settled) return;
|
|
2613
|
+
settled = true;
|
|
2614
|
+
if (forceTimer) {
|
|
2615
|
+
clearTimeout(forceTimer);
|
|
2616
|
+
forceTimer = void 0;
|
|
2617
|
+
}
|
|
2618
|
+
if (err) {
|
|
2619
|
+
reject(err);
|
|
1764
2620
|
return;
|
|
1765
2621
|
}
|
|
1766
|
-
|
|
1767
|
-
errorMessage = extractJsonRpcErrorMessage(parsed2);
|
|
2622
|
+
resolve();
|
|
1768
2623
|
};
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1775
|
-
);
|
|
1776
|
-
}
|
|
1777
|
-
let chunk;
|
|
1778
|
-
try {
|
|
1779
|
-
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1780
|
-
} catch {
|
|
1781
|
-
await reader.cancel().catch(() => void 0);
|
|
1782
|
-
throw new Error(
|
|
1783
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1784
|
-
);
|
|
1785
|
-
}
|
|
1786
|
-
const { done, value } = chunk;
|
|
1787
|
-
if (value !== void 0) {
|
|
1788
|
-
scannedBytes += value.byteLength;
|
|
1789
|
-
if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
|
|
1790
|
-
await reader.cancel().catch(() => void 0);
|
|
1791
|
-
throw new Error(
|
|
1792
|
-
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
|
|
1793
|
-
);
|
|
1794
|
-
}
|
|
1795
|
-
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
1796
|
-
if (errorMessage) {
|
|
1797
|
-
await reader.cancel().catch(() => void 0);
|
|
1798
|
-
return errorMessage;
|
|
1799
|
-
}
|
|
1800
|
-
}
|
|
1801
|
-
if (done) {
|
|
1802
|
-
const tail = decoder.decode();
|
|
1803
|
-
if (tail) {
|
|
1804
|
-
state = parseSseChunk(tail, state, onEvent);
|
|
2624
|
+
try {
|
|
2625
|
+
nodeServer.close((err) => {
|
|
2626
|
+
if (err) {
|
|
2627
|
+
settle(err);
|
|
2628
|
+
return;
|
|
1805
2629
|
}
|
|
1806
|
-
|
|
1807
|
-
}
|
|
2630
|
+
settle();
|
|
2631
|
+
});
|
|
2632
|
+
} catch (error) {
|
|
2633
|
+
settle(normalizeError(error));
|
|
2634
|
+
return;
|
|
1808
2635
|
}
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
2636
|
+
try {
|
|
2637
|
+
nodeServer.closeIdleConnections?.();
|
|
2638
|
+
} catch {
|
|
2639
|
+
}
|
|
2640
|
+
forceTimer = setTimeout(() => {
|
|
2641
|
+
forceCloseConnections();
|
|
2642
|
+
}, FORCE_CONNECTION_CLOSE_GRACE_MS);
|
|
2643
|
+
forceTimer.unref();
|
|
2644
|
+
})
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
function createApp(config, forwarder, options) {
|
|
2648
|
+
const app = new Hono3();
|
|
2649
|
+
const forwardHeadersAllowlist = config.upstream.forward_headers;
|
|
2650
|
+
const allowedOrigins = config.listen.allowed_origins;
|
|
2651
|
+
const session = compileSessionIdentity(config.session);
|
|
2652
|
+
app.get("/healthz", (c) => c.json({ status: "ok" }));
|
|
2653
|
+
app.route(
|
|
2654
|
+
"/mcp",
|
|
2655
|
+
createStreamableHttpRoute(forwarder, {
|
|
2656
|
+
forwardHeadersAllowlist,
|
|
2657
|
+
allowedOrigins,
|
|
2658
|
+
session,
|
|
2659
|
+
onHeaderMismatch: options?.onHeaderMismatch
|
|
2660
|
+
})
|
|
2661
|
+
);
|
|
2662
|
+
app.route("/sse", createSseRoute(forwarder, { forwardHeadersAllowlist, allowedOrigins, session }));
|
|
2663
|
+
if (options?.slackActionApp) {
|
|
2664
|
+
app.route("/slack/actions", options.slackActionApp);
|
|
1839
2665
|
}
|
|
2666
|
+
return app;
|
|
1840
2667
|
}
|
|
1841
|
-
function
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
2668
|
+
function startServer(app, config) {
|
|
2669
|
+
const server = serve({
|
|
2670
|
+
fetch: app.fetch,
|
|
2671
|
+
port: config.listen.port,
|
|
2672
|
+
hostname: config.listen.host
|
|
2673
|
+
});
|
|
2674
|
+
return createServerHandle(server);
|
|
1847
2675
|
}
|
|
1848
|
-
function
|
|
1849
|
-
const
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
return
|
|
2676
|
+
function startSidebandServer(app, port, host = "127.0.0.1") {
|
|
2677
|
+
const server = serve({
|
|
2678
|
+
fetch: app.fetch,
|
|
2679
|
+
port,
|
|
2680
|
+
hostname: host
|
|
2681
|
+
});
|
|
2682
|
+
return createServerHandle(server);
|
|
1855
2683
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
2684
|
+
|
|
2685
|
+
// src/upstream/response.ts
|
|
2686
|
+
async function parseUpstreamResponse(res) {
|
|
2687
|
+
const headers = {};
|
|
2688
|
+
res.headers.forEach((value, key) => {
|
|
2689
|
+
headers[key] = value;
|
|
2690
|
+
});
|
|
2691
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
2692
|
+
let body;
|
|
2693
|
+
if (contentType.includes("application/json")) {
|
|
2694
|
+
const text = await res.text();
|
|
2695
|
+
try {
|
|
2696
|
+
body = JSON.parse(text);
|
|
2697
|
+
} catch {
|
|
2698
|
+
body = text;
|
|
2699
|
+
}
|
|
2700
|
+
} else {
|
|
2701
|
+
body = await res.text();
|
|
1860
2702
|
}
|
|
1861
|
-
|
|
1862
|
-
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
|
|
2703
|
+
return { status: res.status, headers, body };
|
|
1863
2704
|
}
|
|
1864
2705
|
|
|
1865
2706
|
// src/upstream/streamable-http-forwarder.ts
|
|
2707
|
+
var JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
1866
2708
|
var StreamableHttpForwarder = class {
|
|
1867
2709
|
url;
|
|
1868
2710
|
staticHeaders;
|
|
@@ -1875,7 +2717,8 @@ var StreamableHttpForwarder = class {
|
|
|
1875
2717
|
this.sessions = new UpstreamSessionManager({
|
|
1876
2718
|
url: this.url,
|
|
1877
2719
|
staticHeaders: this.staticHeaders,
|
|
1878
|
-
requestTimeoutMs: this.requestTimeoutMs
|
|
2720
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
2721
|
+
protocolVersion: options.protocolVersion
|
|
1879
2722
|
});
|
|
1880
2723
|
}
|
|
1881
2724
|
/** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
|
|
@@ -1888,15 +2731,91 @@ var StreamableHttpForwarder = class {
|
|
|
1888
2731
|
return Promise.resolve();
|
|
1889
2732
|
}
|
|
1890
2733
|
async forward(request) {
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
request
|
|
1895
|
-
|
|
1896
|
-
|
|
2734
|
+
const era = await this.sessions.resolveRelayEra();
|
|
2735
|
+
if (era === "modern") {
|
|
2736
|
+
if (request.method === "initialize") {
|
|
2737
|
+
return this.synthesizeInitializeResult(request);
|
|
2738
|
+
}
|
|
2739
|
+
if (request.method === "notifications/initialized") {
|
|
2740
|
+
return this.swallowInitializedNotification();
|
|
2741
|
+
}
|
|
2742
|
+
return this.send(request, {
|
|
2743
|
+
sessionId: void 0,
|
|
2744
|
+
protocolVersion: void 0,
|
|
2745
|
+
era: "modern"
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
const result = request.method === "initialize" ? await this.send(request, {
|
|
2749
|
+
sessionId: request.transportSessionId,
|
|
2750
|
+
protocolVersion: void 0
|
|
2751
|
+
}) : (
|
|
2752
|
+
// Downstream-driven and external sessionless callers alike are
|
|
2753
|
+
// transparent passthrough: forward whatever session the caller did
|
|
2754
|
+
// (or did not) supply.
|
|
2755
|
+
await this.send(request, {
|
|
2756
|
+
sessionId: request.transportSessionId,
|
|
2757
|
+
protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION
|
|
2758
|
+
})
|
|
2759
|
+
);
|
|
2760
|
+
this.inspectLegacyRelayOutcome(request, result.response);
|
|
2761
|
+
return result;
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* The dual-era bridge (relay leg, modern era): a modern-only server
|
|
2765
|
+
* answers the retired `initialize` handshake with 404/-32601, so Helio
|
|
2766
|
+
* synthesizes the legacy InitializeResult locally from the upstream's own
|
|
2767
|
+
* probe-time DiscoverResult. No `mcp-session-id` response header — the
|
|
2768
|
+
* legacy spec permits sessionless servers, and the downstream stays
|
|
2769
|
+
* sessionless. The synthesized protocolVersion is always the current
|
|
2770
|
+
* legacy revision, even for a client that offered an older one.
|
|
2771
|
+
*/
|
|
2772
|
+
synthesizeInitializeResult(request) {
|
|
2773
|
+
const capture = this.sessions.getDiscoverCapture();
|
|
2774
|
+
const result = {
|
|
2775
|
+
protocolVersion: HELIO_MCP_LEGACY_PROTOCOL_VERSION,
|
|
2776
|
+
capabilities: capture?.capabilities ?? { tools: {} },
|
|
2777
|
+
// NOT copied from upstream: the 2026-07-28 DiscoverResult has no
|
|
2778
|
+
// serverInfo field at all, and the bridge is Helio's own construct —
|
|
2779
|
+
// matching buildInternalMeta()'s identity.
|
|
2780
|
+
serverInfo: { name: "helio-proxy", version: "0" }
|
|
2781
|
+
};
|
|
2782
|
+
if (capture?.instructions !== void 0) {
|
|
2783
|
+
result["instructions"] = capture.instructions;
|
|
2784
|
+
}
|
|
2785
|
+
const response = {
|
|
2786
|
+
status: 200,
|
|
2787
|
+
headers: { "content-type": "application/json" },
|
|
2788
|
+
body: { jsonrpc: "2.0", id: request.id ?? null, result }
|
|
2789
|
+
};
|
|
2790
|
+
return { response, durationMs: 0 };
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* The modern upstream removed `notifications/initialized`; answer the
|
|
2794
|
+
* same minimal success envelope the SSE-notification path returns.
|
|
2795
|
+
*/
|
|
2796
|
+
swallowInitializedNotification() {
|
|
2797
|
+
const response = { status: 200, headers: {}, body: { jsonrpc: "2.0" } };
|
|
2798
|
+
return { response, durationMs: 0 };
|
|
2799
|
+
}
|
|
2800
|
+
/**
|
|
2801
|
+
* The relay-side era falsification door (issue #219): a legacy-leg relay whose answer only a
|
|
2802
|
+
* modern server gives clears the cached legacy era (the manager no-ops on
|
|
2803
|
+
* pins, uncached eras, and cached modern). The response still flows to the
|
|
2804
|
+
* client unchanged — no in-place retry.
|
|
2805
|
+
*/
|
|
2806
|
+
inspectLegacyRelayOutcome(request, response) {
|
|
2807
|
+
const errorCode = readJsonRpcErrorCode(response.body);
|
|
2808
|
+
if (errorCode !== void 0 && MCP_MODERN_ONLY_ERROR_CODES.has(errorCode)) {
|
|
2809
|
+
this.sessions.clearFalsifiedLegacyEra(
|
|
2810
|
+
`a relayed response carried the modern-only JSON-RPC error ${String(errorCode)}`
|
|
2811
|
+
);
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
if (request.method === "initialize" && (response.status === 404 || errorCode === JSON_RPC_METHOD_NOT_FOUND)) {
|
|
2815
|
+
this.sessions.clearFalsifiedLegacyEra(
|
|
2816
|
+
response.status === 404 ? "a relayed initialize was answered with HTTP 404" : "a relayed initialize was answered with JSON-RPC -32601"
|
|
1897
2817
|
);
|
|
1898
2818
|
}
|
|
1899
|
-
return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
|
|
1900
2819
|
}
|
|
1901
2820
|
/**
|
|
1902
2821
|
* Helio-internal execution path (startup prime / internal maintenance) that
|
|
@@ -1907,8 +2826,7 @@ var StreamableHttpForwarder = class {
|
|
|
1907
2826
|
try {
|
|
1908
2827
|
return await this.send(
|
|
1909
2828
|
request,
|
|
1910
|
-
session
|
|
1911
|
-
session.protocolVersion,
|
|
2829
|
+
session,
|
|
1912
2830
|
/* internalManaged */
|
|
1913
2831
|
true
|
|
1914
2832
|
);
|
|
@@ -1918,8 +2836,7 @@ var StreamableHttpForwarder = class {
|
|
|
1918
2836
|
const fresh = await this.sessions.ensureInternalSession();
|
|
1919
2837
|
return this.send(
|
|
1920
2838
|
request,
|
|
1921
|
-
fresh
|
|
1922
|
-
fresh.protocolVersion,
|
|
2839
|
+
fresh,
|
|
1923
2840
|
/* internalManaged */
|
|
1924
2841
|
true
|
|
1925
2842
|
);
|
|
@@ -1927,7 +2844,50 @@ var StreamableHttpForwarder = class {
|
|
|
1927
2844
|
throw error;
|
|
1928
2845
|
}
|
|
1929
2846
|
}
|
|
1930
|
-
|
|
2847
|
+
/** Drop the managed internal session AND the cached era; next internal call re-probes. */
|
|
2848
|
+
resetInternalSession() {
|
|
2849
|
+
this.sessions.invalidateInternalSession();
|
|
2850
|
+
}
|
|
2851
|
+
async send(request, session, internalManaged = false) {
|
|
2852
|
+
const modern = session.era === "modern";
|
|
2853
|
+
let outboundParams;
|
|
2854
|
+
if (modern) {
|
|
2855
|
+
if (request.params !== void 0 && !isPlainObject(request.params)) {
|
|
2856
|
+
throw new Error(
|
|
2857
|
+
"helio refused to forward: MCP 2026-07-28 requires the _meta mirror inside params, which cannot be attached to array or primitive params; send object params or none"
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
const params = request.params ?? {};
|
|
2861
|
+
const rawMeta = params["_meta"];
|
|
2862
|
+
const existingMeta = isPlainObject(rawMeta) ? rawMeta : {};
|
|
2863
|
+
const internalMeta = buildInternalMeta();
|
|
2864
|
+
const clientCapabilities = existingMeta["io.modelcontextprotocol/clientCapabilities"];
|
|
2865
|
+
const clientInfo = existingMeta["io.modelcontextprotocol/clientInfo"];
|
|
2866
|
+
outboundParams = {
|
|
2867
|
+
...params,
|
|
2868
|
+
_meta: {
|
|
2869
|
+
...existingMeta,
|
|
2870
|
+
[MCP_META_PROTOCOL_VERSION_KEY]: HELIO_MCP_MODERN_PROTOCOL_VERSION,
|
|
2871
|
+
"io.modelcontextprotocol/clientCapabilities": clientCapabilities !== void 0 ? clientCapabilities : internalMeta["io.modelcontextprotocol/clientCapabilities"],
|
|
2872
|
+
"io.modelcontextprotocol/clientInfo": clientInfo !== void 0 ? clientInfo : internalMeta["io.modelcontextprotocol/clientInfo"]
|
|
2873
|
+
}
|
|
2874
|
+
};
|
|
2875
|
+
} else {
|
|
2876
|
+
outboundParams = request.params;
|
|
2877
|
+
}
|
|
2878
|
+
if (modern) {
|
|
2879
|
+
if (!isHeaderSafeMethod(request.method)) {
|
|
2880
|
+
throw new Error(
|
|
2881
|
+
"helio refused to forward: the request method cannot be carried in the Mcp-Method header that MCP 2026-07-28 requires (it contains characters outside the visible-ASCII token set), so the upstream is guaranteed to reject the request"
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
const nameValue = encodedNameValue(request.method, outboundParams);
|
|
2885
|
+
if (nameValue !== void 0 && Buffer.byteLength(nameValue) > MCP_NAME_MAX_BYTES) {
|
|
2886
|
+
throw new Error(
|
|
2887
|
+
`helio refused to forward: params.name/uri exceeds the ${String(MCP_NAME_MAX_BYTES)}-byte Mcp-Name header cap after encoding, and MCP 2026-07-28 requires the header on this method; shorten the name or uri`
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
1931
2891
|
const headers = mergeUpstreamHeaders(
|
|
1932
2892
|
{
|
|
1933
2893
|
"content-type": "application/json",
|
|
@@ -1936,16 +2896,26 @@ var StreamableHttpForwarder = class {
|
|
|
1936
2896
|
request.headers ?? {},
|
|
1937
2897
|
this.staticHeaders
|
|
1938
2898
|
);
|
|
1939
|
-
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
1940
|
-
if (
|
|
1941
|
-
headers["mcp-
|
|
1942
|
-
|
|
2899
|
+
if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
|
|
2900
|
+
if (modern) {
|
|
2901
|
+
delete headers["mcp-session-id"];
|
|
2902
|
+
headers["mcp-protocol-version"] = HELIO_MCP_MODERN_PROTOCOL_VERSION;
|
|
2903
|
+
} else if (session.protocolVersion && headers["mcp-protocol-version"] === void 0) {
|
|
2904
|
+
headers["mcp-protocol-version"] = session.protocolVersion;
|
|
2905
|
+
}
|
|
2906
|
+
delete headers["mcp-method"];
|
|
2907
|
+
delete headers["mcp-name"];
|
|
2908
|
+
Object.assign(headers, buildStandardRequestHeaders(request.method, outboundParams));
|
|
1943
2909
|
const body = {
|
|
1944
2910
|
jsonrpc: request.jsonrpc,
|
|
1945
2911
|
method: request.method
|
|
1946
2912
|
};
|
|
1947
2913
|
if (request.id !== void 0) body["id"] = request.id;
|
|
1948
|
-
if (
|
|
2914
|
+
if (modern) {
|
|
2915
|
+
body["params"] = outboundParams;
|
|
2916
|
+
} else if (request.params !== void 0) {
|
|
2917
|
+
body["params"] = outboundParams;
|
|
2918
|
+
}
|
|
1949
2919
|
const start = performance.now();
|
|
1950
2920
|
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
1951
2921
|
const requestSignal = request.signal;
|
|
@@ -1961,7 +2931,7 @@ var StreamableHttpForwarder = class {
|
|
|
1961
2931
|
}
|
|
1962
2932
|
throw describeUnreachableUpstream(error, this.url) ?? error;
|
|
1963
2933
|
}
|
|
1964
|
-
if (internalManaged && res.status === 404 && sessionId) {
|
|
2934
|
+
if (internalManaged && res.status === 404 && session.sessionId) {
|
|
1965
2935
|
await res.text().catch(() => void 0);
|
|
1966
2936
|
throw new UpstreamSessionExpiredError();
|
|
1967
2937
|
}
|
|
@@ -1971,6 +2941,7 @@ var StreamableHttpForwarder = class {
|
|
|
1971
2941
|
res.headers.forEach((value, key) => {
|
|
1972
2942
|
responseHeaders[key] = value;
|
|
1973
2943
|
});
|
|
2944
|
+
if (modern) delete responseHeaders["mcp-session-id"];
|
|
1974
2945
|
if (request.id === void 0) {
|
|
1975
2946
|
await res.body?.cancel().catch(() => void 0);
|
|
1976
2947
|
const response3 = {
|
|
@@ -1985,6 +2956,7 @@ var StreamableHttpForwarder = class {
|
|
|
1985
2956
|
return { response: response2, durationMs: performance.now() - start };
|
|
1986
2957
|
}
|
|
1987
2958
|
const response = await parseUpstreamResponse(res);
|
|
2959
|
+
if (modern) delete response.headers["mcp-session-id"];
|
|
1988
2960
|
return { response, durationMs: performance.now() - start };
|
|
1989
2961
|
}
|
|
1990
2962
|
};
|
|
@@ -1994,6 +2966,16 @@ var UpstreamSessionExpiredError = class extends Error {
|
|
|
1994
2966
|
this.name = "UpstreamSessionExpiredError";
|
|
1995
2967
|
}
|
|
1996
2968
|
};
|
|
2969
|
+
function isPlainObject(value) {
|
|
2970
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2971
|
+
}
|
|
2972
|
+
function readJsonRpcErrorCode(body) {
|
|
2973
|
+
if (typeof body !== "object" || body === null) return void 0;
|
|
2974
|
+
const error = body["error"];
|
|
2975
|
+
if (typeof error !== "object" || error === null) return void 0;
|
|
2976
|
+
const code = error["code"];
|
|
2977
|
+
return typeof code === "number" ? code : void 0;
|
|
2978
|
+
}
|
|
1997
2979
|
|
|
1998
2980
|
// src/upstream/forwarder.ts
|
|
1999
2981
|
var UpstreamForwarder = class extends StreamableHttpForwarder {
|
|
@@ -2146,8 +3128,11 @@ var SseUpstreamForwarder = class {
|
|
|
2146
3128
|
request.headers ?? {},
|
|
2147
3129
|
this.staticHeaders
|
|
2148
3130
|
);
|
|
2149
|
-
|
|
2150
|
-
|
|
3131
|
+
delete headers["mcp-method"];
|
|
3132
|
+
delete headers["mcp-name"];
|
|
3133
|
+
delete headers["mcp-session-id"];
|
|
3134
|
+
if (request.transportSessionId) {
|
|
3135
|
+
headers["mcp-session-id"] = request.transportSessionId;
|
|
2151
3136
|
}
|
|
2152
3137
|
const start = performance.now();
|
|
2153
3138
|
const signal = buildRequestSignal(request, this.requestTimeoutMs);
|
|
@@ -2576,6 +3561,79 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2576
3561
|
// src/policy/governed-forwarder.ts
|
|
2577
3562
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2578
3563
|
|
|
3564
|
+
// src/policy/session-gate.ts
|
|
3565
|
+
function isWellFormedSessionId(id) {
|
|
3566
|
+
return id != null && id.trim() !== "";
|
|
3567
|
+
}
|
|
3568
|
+
function gateSession(sessionId, onUnresolved) {
|
|
3569
|
+
if (isWellFormedSessionId(sessionId)) {
|
|
3570
|
+
return { ok: true, session: sessionId, anonymous: false };
|
|
3571
|
+
}
|
|
3572
|
+
if (onUnresolved === "anonymous") {
|
|
3573
|
+
return { ok: true, session: "unknown", anonymous: true };
|
|
3574
|
+
}
|
|
3575
|
+
return { ok: false };
|
|
3576
|
+
}
|
|
3577
|
+
function sessionLimitKey(session) {
|
|
3578
|
+
return `session:${session}`;
|
|
3579
|
+
}
|
|
3580
|
+
function gateBudgetCharges(resolved, gate) {
|
|
3581
|
+
const sessionEngaged = resolved.charges.some((charge) => charge.budget.key === "session") || resolved.failures.some((failure) => failure.budget.key === "session");
|
|
3582
|
+
if (!gate.ok) {
|
|
3583
|
+
if (sessionEngaged) return { ok: false, unresolvedEngaged: true };
|
|
3584
|
+
return { ok: true, charges: resolved.charges };
|
|
3585
|
+
}
|
|
3586
|
+
if (gate.anonymous && sessionEngaged) warnAnonymousPoolingOnce();
|
|
3587
|
+
return { ok: true, charges: resolved.charges };
|
|
3588
|
+
}
|
|
3589
|
+
function freezeGatedPlans(charges, breached) {
|
|
3590
|
+
if (breached.length !== charges.length) {
|
|
3591
|
+
throw new Error(
|
|
3592
|
+
`freezeGatedPlans: breach markers must pair positionally with charges (${String(breached.length)} markers for ${String(charges.length)} charges)`
|
|
3593
|
+
);
|
|
3594
|
+
}
|
|
3595
|
+
return charges.map(
|
|
3596
|
+
(charge, index) => ({
|
|
3597
|
+
kind: "budget",
|
|
3598
|
+
budget: charge.budget,
|
|
3599
|
+
bucketKey: charge.bucketKey,
|
|
3600
|
+
amount: charge.amount,
|
|
3601
|
+
generation: charge.generation,
|
|
3602
|
+
breached: breached[index] === true
|
|
3603
|
+
})
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
3606
|
+
function remintDeferredCharges(frozen, actualAmount) {
|
|
3607
|
+
return frozen.map((plan) => ({
|
|
3608
|
+
budget: plan.budget,
|
|
3609
|
+
bucketKey: plan.bucketKey,
|
|
3610
|
+
amount: actualAmount ?? plan.amount,
|
|
3611
|
+
generation: plan.generation
|
|
3612
|
+
}));
|
|
3613
|
+
}
|
|
3614
|
+
function sessionUnresolvedControlMessage(tried) {
|
|
3615
|
+
return `No session identity resolved (tried: ${tried}) \u2014 a session-keyed limit or budget requires one. See session.identity in helio.yaml.`;
|
|
3616
|
+
}
|
|
3617
|
+
function sessionRequiredForGroundingMessage(tried) {
|
|
3618
|
+
return `No session identity resolved (tried: ${tried}) \u2014 rules using evidence.requires or requires need one. See session.identity in helio.yaml.`;
|
|
3619
|
+
}
|
|
3620
|
+
var unresolvedEngagementWarned = false;
|
|
3621
|
+
var anonymousPoolingWarned = false;
|
|
3622
|
+
function warnSessionUnresolvedEngagementOnce(tried) {
|
|
3623
|
+
if (unresolvedEngagementWarned) return;
|
|
3624
|
+
unresolvedEngagementWarned = true;
|
|
3625
|
+
console.error(
|
|
3626
|
+
`[helio] Warning: a session-keyed control was engaged with no resolved session identity (tried: ${tried}); session.on_unresolved: deny denies such requests (dry-run reports them). Send an identity the chain can read (e.g. the x-helio-session-id header), or set session.on_unresolved: anonymous to restore pre-0.12 shared pooling.`
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
function warnAnonymousPoolingOnce() {
|
|
3630
|
+
if (anonymousPoolingWarned) return;
|
|
3631
|
+
anonymousPoolingWarned = true;
|
|
3632
|
+
console.error(
|
|
3633
|
+
'[helio] Warning: session identity unresolved; session-keyed limits and budgets are pooling into the shared "unknown" bucket (session.on_unresolved: anonymous). Have callers send session identity to isolate them from each other.'
|
|
3634
|
+
);
|
|
3635
|
+
}
|
|
3636
|
+
|
|
2579
3637
|
// src/evidence/grounding.ts
|
|
2580
3638
|
function checkEvidence(store, sessionId, requirements) {
|
|
2581
3639
|
if (requirements.length === 0) {
|
|
@@ -2621,7 +3679,8 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
|
2621
3679
|
|
|
2622
3680
|
// src/policy/decision-pipeline.ts
|
|
2623
3681
|
function decide(input) {
|
|
2624
|
-
const { toolName, toolArguments,
|
|
3682
|
+
const { toolName, toolArguments, policy, environment, evidenceStore } = input;
|
|
3683
|
+
const sessionId = isWellFormedSessionId(input.sessionId) ? input.sessionId : void 0;
|
|
2625
3684
|
const annotations = input.baselineAnnotations;
|
|
2626
3685
|
const driftEvent = input.driftEvent;
|
|
2627
3686
|
const driftMode = policy.onToolDrift ?? "block";
|
|
@@ -2680,7 +3739,9 @@ function decide(input) {
|
|
|
2680
3739
|
decision = {
|
|
2681
3740
|
action: "deny",
|
|
2682
3741
|
matchedRule: decision.matchedRule,
|
|
2683
|
-
reason:
|
|
3742
|
+
reason: sessionRequiredForGroundingMessage(
|
|
3743
|
+
input.sessionStrategySummary ?? "the configured session.identity chain"
|
|
3744
|
+
)
|
|
2684
3745
|
};
|
|
2685
3746
|
}
|
|
2686
3747
|
if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
|
|
@@ -3131,6 +4192,17 @@ function buildToolDriftFeedback(drift, action) {
|
|
|
3131
4192
|
retry_allowed: false
|
|
3132
4193
|
};
|
|
3133
4194
|
}
|
|
4195
|
+
function buildSessionUnresolvedFeedback(decision, control, tried) {
|
|
4196
|
+
return {
|
|
4197
|
+
blocked: true,
|
|
4198
|
+
reason: "session_unresolved",
|
|
4199
|
+
...ruleInfo(decision.matchedRule),
|
|
4200
|
+
control,
|
|
4201
|
+
tried,
|
|
4202
|
+
suggestion: `No session identity resolved (tried: ${tried}). Send an identity the chain can read \u2014 for example set the x-helio-session-id header once per agent run \u2014 or set session.on_unresolved: anonymous to restore shared pooling.`,
|
|
4203
|
+
retry_allowed: true
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
3134
4206
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
3135
4207
|
const info = ruleInfo(decision.matchedRule);
|
|
3136
4208
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
@@ -3588,6 +4660,7 @@ var GovernedForwarder = class {
|
|
|
3588
4660
|
inner;
|
|
3589
4661
|
policy;
|
|
3590
4662
|
environment;
|
|
4663
|
+
session;
|
|
3591
4664
|
auditWriter;
|
|
3592
4665
|
evidenceStore;
|
|
3593
4666
|
approvalRouter;
|
|
@@ -3607,6 +4680,7 @@ var GovernedForwarder = class {
|
|
|
3607
4680
|
this.rateLimiter = options?.rateLimiter;
|
|
3608
4681
|
this.spendLimiter = options?.spendLimiter;
|
|
3609
4682
|
this.budgetEngine = options?.budgetEngine;
|
|
4683
|
+
this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
|
|
3610
4684
|
if (this.evidenceStore) {
|
|
3611
4685
|
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
3612
4686
|
}
|
|
@@ -3680,6 +4754,7 @@ var GovernedForwarder = class {
|
|
|
3680
4754
|
try {
|
|
3681
4755
|
const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
|
|
3682
4756
|
if (result.response.status >= 400) {
|
|
4757
|
+
internal.resetInternalSession?.();
|
|
3683
4758
|
return {
|
|
3684
4759
|
success: false,
|
|
3685
4760
|
toolsCached: this.annotationCache.size,
|
|
@@ -3688,6 +4763,7 @@ var GovernedForwarder = class {
|
|
|
3688
4763
|
}
|
|
3689
4764
|
const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
|
|
3690
4765
|
if (!update.updated) {
|
|
4766
|
+
internal.resetInternalSession?.();
|
|
3691
4767
|
return {
|
|
3692
4768
|
success: false,
|
|
3693
4769
|
toolsCached: this.annotationCache.size,
|
|
@@ -3696,6 +4772,7 @@ var GovernedForwarder = class {
|
|
|
3696
4772
|
}
|
|
3697
4773
|
return { success: true, toolsCached: this.annotationCache.size };
|
|
3698
4774
|
} catch (error) {
|
|
4775
|
+
internal.resetInternalSession?.();
|
|
3699
4776
|
return {
|
|
3700
4777
|
success: false,
|
|
3701
4778
|
toolsCached: this.annotationCache.size,
|
|
@@ -3709,17 +4786,42 @@ var GovernedForwarder = class {
|
|
|
3709
4786
|
}
|
|
3710
4787
|
const result = await this.inner.forward(request);
|
|
3711
4788
|
if (request.method === "tools/list") {
|
|
3712
|
-
this.applyToolDefinitionUpdate(result.response.body, request.
|
|
4789
|
+
this.applyToolDefinitionUpdate(result.response.body, request.session);
|
|
4790
|
+
this.clampCacheHints(result.response.body);
|
|
3713
4791
|
}
|
|
3714
4792
|
return result;
|
|
3715
4793
|
}
|
|
4794
|
+
/**
|
|
4795
|
+
* Clamp an over-long `result.ttlMs` on a `tools/list` response to
|
|
4796
|
+
* `policies.tool_revalidation.max_advertised_ttl` (issue #221, D7).
|
|
4797
|
+
*
|
|
4798
|
+
* Downward-only: a `ttlMs` at or below the cap is left untouched, and a
|
|
4799
|
+
* response with no `ttlMs` never gains one — Helio does not manufacture a
|
|
4800
|
+
* cache hint the upstream never advertised. Non-numeric values are left
|
|
4801
|
+
* alone rather than coerced. `cacheScope` passes through untouched: Helio
|
|
4802
|
+
* baselines and vouches for tool *definitions* only, and its own
|
|
4803
|
+
* `tools/list` view is not caller-varying, so it has no basis to alter a
|
|
4804
|
+
* scope hint the upstream set. No-op when tool revalidation is disabled
|
|
4805
|
+
* (including hand-built `CompiledPolicy` fixtures that omit the field).
|
|
4806
|
+
*/
|
|
4807
|
+
clampCacheHints(responseBody) {
|
|
4808
|
+
const rv = this.policy.toolRevalidation;
|
|
4809
|
+
if (!rv?.enabled) return;
|
|
4810
|
+
if (typeof responseBody !== "object" || responseBody === null) return;
|
|
4811
|
+
const result = responseBody["result"];
|
|
4812
|
+
if (typeof result !== "object" || result === null) return;
|
|
4813
|
+
const r = result;
|
|
4814
|
+
if (typeof r["ttlMs"] === "number" && r["ttlMs"] > rv.maxAdvertisedTtlMs) {
|
|
4815
|
+
r["ttlMs"] = rv.maxAdvertisedTtlMs;
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
3716
4818
|
/**
|
|
3717
4819
|
* Apply a tools/list response to the definition cache and surface any
|
|
3718
4820
|
* drift: console warning + immediate audit record per event. Single entry
|
|
3719
4821
|
* point for both runtime tools/list responses and startup priming, so the
|
|
3720
4822
|
* cache is updated exactly once per response.
|
|
3721
4823
|
*/
|
|
3722
|
-
applyToolDefinitionUpdate(responseBody,
|
|
4824
|
+
applyToolDefinitionUpdate(responseBody, session) {
|
|
3723
4825
|
const update = this.annotationCache.update(responseBody);
|
|
3724
4826
|
if (!update.updated) return update;
|
|
3725
4827
|
for (const drift of update.drifted) {
|
|
@@ -3727,22 +4829,23 @@ var GovernedForwarder = class {
|
|
|
3727
4829
|
console.error(
|
|
3728
4830
|
`[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
|
|
3729
4831
|
);
|
|
3730
|
-
this.writeDriftAuditRecord(drift,
|
|
4832
|
+
this.writeDriftAuditRecord(drift, session, "tool_drift");
|
|
3731
4833
|
}
|
|
3732
4834
|
for (const toolName of update.reverted) {
|
|
3733
4835
|
console.error(
|
|
3734
4836
|
`[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
|
|
3735
4837
|
);
|
|
3736
|
-
this.writeDriftAuditRecord({ toolName, changes: [] },
|
|
4838
|
+
this.writeDriftAuditRecord({ toolName, changes: [] }, session, "tool_drift_reverted");
|
|
3737
4839
|
}
|
|
3738
4840
|
return update;
|
|
3739
4841
|
}
|
|
3740
4842
|
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
3741
|
-
writeDriftAuditRecord(drift,
|
|
4843
|
+
writeDriftAuditRecord(drift, session, decision) {
|
|
3742
4844
|
if (!this.auditWriter) return;
|
|
3743
4845
|
this.auditWriter.pushImmediate({
|
|
3744
4846
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3745
|
-
session_id:
|
|
4847
|
+
session_id: session?.id ?? null,
|
|
4848
|
+
session_source: session?.source ?? null,
|
|
3746
4849
|
agent_id: null,
|
|
3747
4850
|
environment: this.environment ?? null,
|
|
3748
4851
|
tool_name: drift.toolName,
|
|
@@ -3765,7 +4868,9 @@ var GovernedForwarder = class {
|
|
|
3765
4868
|
dry_run: false,
|
|
3766
4869
|
record_kind: "drift_event",
|
|
3767
4870
|
origin: "mcp",
|
|
3768
|
-
metadata: null
|
|
4871
|
+
metadata: null,
|
|
4872
|
+
// Drift is a cache event, not a request: no protocol claim exists.
|
|
4873
|
+
protocol_version: null
|
|
3769
4874
|
});
|
|
3770
4875
|
}
|
|
3771
4876
|
async handleToolsCall(original) {
|
|
@@ -3802,7 +4907,8 @@ var GovernedForwarder = class {
|
|
|
3802
4907
|
} = decide({
|
|
3803
4908
|
toolName,
|
|
3804
4909
|
toolArguments,
|
|
3805
|
-
sessionId: request.
|
|
4910
|
+
sessionId: request.session?.id,
|
|
4911
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
3806
4912
|
policy: this.policy,
|
|
3807
4913
|
environment: this.environment,
|
|
3808
4914
|
evidenceStore: this.evidenceStore,
|
|
@@ -3895,9 +5001,10 @@ var GovernedForwarder = class {
|
|
|
3895
5001
|
});
|
|
3896
5002
|
}
|
|
3897
5003
|
try {
|
|
3898
|
-
|
|
5004
|
+
const dependencySessionId = request.session?.id;
|
|
5005
|
+
if (forwarded && !isDryRun && this.evidenceStore && isWellFormedSessionId(dependencySessionId) && toolName) {
|
|
3899
5006
|
const succeeded = !hasJsonRpcError(result);
|
|
3900
|
-
this.evidenceStore.recordToolCall(
|
|
5007
|
+
this.evidenceStore.recordToolCall(dependencySessionId, toolName, succeeded);
|
|
3901
5008
|
}
|
|
3902
5009
|
} catch (err) {
|
|
3903
5010
|
console.error("[helio] dependency tracking failed after forward:", err);
|
|
@@ -3918,6 +5025,7 @@ var GovernedForwarder = class {
|
|
|
3918
5025
|
evidenceResult,
|
|
3919
5026
|
dependencyResult,
|
|
3920
5027
|
evidenceBlocked,
|
|
5028
|
+
sessionBlocked,
|
|
3921
5029
|
approvalOutcome,
|
|
3922
5030
|
approvalContext,
|
|
3923
5031
|
rateLimitResult,
|
|
@@ -3948,7 +5056,7 @@ var GovernedForwarder = class {
|
|
|
3948
5056
|
tool_name: toolName,
|
|
3949
5057
|
tool_input: toolArguments ?? {},
|
|
3950
5058
|
matched_rule: decision.matchedRule,
|
|
3951
|
-
session_id: request.
|
|
5059
|
+
session_id: request.session?.id ?? null,
|
|
3952
5060
|
breached_budgets: gate.breachContexts,
|
|
3953
5061
|
approval: gate.approval
|
|
3954
5062
|
},
|
|
@@ -4091,15 +5199,25 @@ var GovernedForwarder = class {
|
|
|
4091
5199
|
gateBudgets(request, decision, toolName, toolArguments) {
|
|
4092
5200
|
const engine = this.budgetEngine;
|
|
4093
5201
|
if (!engine) return { kind: "proceed" };
|
|
5202
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4094
5203
|
const { charges, failures } = engine.resolveCharges({
|
|
4095
5204
|
toolName,
|
|
4096
5205
|
toolArguments,
|
|
4097
|
-
sessionId:
|
|
5206
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4098
5207
|
senderId: null
|
|
4099
5208
|
// adapter context; absent on the MCP path
|
|
4100
5209
|
});
|
|
4101
5210
|
if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
|
|
4102
|
-
const
|
|
5211
|
+
const gated = gateBudgetCharges({ charges, failures }, sessionGate);
|
|
5212
|
+
if (!gated.ok) {
|
|
5213
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
5214
|
+
return {
|
|
5215
|
+
kind: "blocked",
|
|
5216
|
+
result: this.makeSessionUnresolvedResult(request, decision, "budget"),
|
|
5217
|
+
chain: []
|
|
5218
|
+
};
|
|
5219
|
+
}
|
|
5220
|
+
const peek = charges.length > 0 ? engine.peekAll(gated.charges) : { allowed: true, entries: [] };
|
|
4103
5221
|
const breaches = peek.entries.filter((entry) => !entry.allowed);
|
|
4104
5222
|
const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
|
|
4105
5223
|
if (breaches.length > 0) engine.reportBreaches(breaches);
|
|
@@ -4132,7 +5250,7 @@ var GovernedForwarder = class {
|
|
|
4132
5250
|
const peekBlockByName = new Map(
|
|
4133
5251
|
peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
|
|
4134
5252
|
);
|
|
4135
|
-
const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
|
|
5253
|
+
const commit = (auditRecordId, kinds) => engine.recordAll(gated.charges, {
|
|
4136
5254
|
kind: "spend",
|
|
4137
5255
|
...kinds ? { kinds } : {},
|
|
4138
5256
|
auditRecordId,
|
|
@@ -4208,7 +5326,8 @@ var GovernedForwarder = class {
|
|
|
4208
5326
|
const toolInput = { raw_params: params ?? null };
|
|
4209
5327
|
this.auditWriter.pushImmediate({
|
|
4210
5328
|
timestamp,
|
|
4211
|
-
session_id: request.
|
|
5329
|
+
session_id: request.session?.id ?? null,
|
|
5330
|
+
session_source: request.session?.source ?? null,
|
|
4212
5331
|
agent_id: null,
|
|
4213
5332
|
environment: this.environment ?? null,
|
|
4214
5333
|
tool_name: "<nameless>",
|
|
@@ -4231,7 +5350,8 @@ var GovernedForwarder = class {
|
|
|
4231
5350
|
dry_run: false,
|
|
4232
5351
|
record_kind: "tool_call",
|
|
4233
5352
|
origin: "mcp",
|
|
4234
|
-
metadata: null
|
|
5353
|
+
metadata: null,
|
|
5354
|
+
protocol_version: request.protocolVersion ?? null
|
|
4235
5355
|
});
|
|
4236
5356
|
}
|
|
4237
5357
|
return result;
|
|
@@ -4244,7 +5364,7 @@ var GovernedForwarder = class {
|
|
|
4244
5364
|
tool_name: toolName,
|
|
4245
5365
|
tool_input: toolArguments ?? {},
|
|
4246
5366
|
matched_rule: decision.matchedRule,
|
|
4247
|
-
session_id: request.
|
|
5367
|
+
session_id: request.session?.id ?? null
|
|
4248
5368
|
},
|
|
4249
5369
|
request.signal
|
|
4250
5370
|
);
|
|
@@ -4340,7 +5460,20 @@ var GovernedForwarder = class {
|
|
|
4340
5460
|
rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4341
5461
|
};
|
|
4342
5462
|
}
|
|
4343
|
-
|
|
5463
|
+
let key;
|
|
5464
|
+
if (limits.key === "session") {
|
|
5465
|
+
const sessionKey = this.gateSessionLimitKey(request);
|
|
5466
|
+
if (sessionKey === null) {
|
|
5467
|
+
return {
|
|
5468
|
+
proceed: false,
|
|
5469
|
+
result: this.makeSessionUnresolvedResult(request, decision, "rate_limit"),
|
|
5470
|
+
approvalWaitMs: 0
|
|
5471
|
+
};
|
|
5472
|
+
}
|
|
5473
|
+
key = sessionKey;
|
|
5474
|
+
} else {
|
|
5475
|
+
key = this.buildLimitKey(limits.key, toolName);
|
|
5476
|
+
}
|
|
4344
5477
|
const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
|
|
4345
5478
|
const rateLimitResult = limiter.peek(params);
|
|
4346
5479
|
if (!rateLimitResult.allowed) {
|
|
@@ -4378,7 +5511,21 @@ var GovernedForwarder = class {
|
|
|
4378
5511
|
spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4379
5512
|
};
|
|
4380
5513
|
}
|
|
4381
|
-
|
|
5514
|
+
let baseKey;
|
|
5515
|
+
if (maxSpend.key === "session") {
|
|
5516
|
+
const sessionKey = this.gateSessionLimitKey(request);
|
|
5517
|
+
if (sessionKey === null) {
|
|
5518
|
+
return {
|
|
5519
|
+
proceed: false,
|
|
5520
|
+
result: this.makeSessionUnresolvedResult(request, decision, "spend_limit"),
|
|
5521
|
+
approvalWaitMs: 0
|
|
5522
|
+
};
|
|
5523
|
+
}
|
|
5524
|
+
baseKey = sessionKey;
|
|
5525
|
+
} else {
|
|
5526
|
+
baseKey = this.buildLimitKey(maxSpend.key, toolName);
|
|
5527
|
+
}
|
|
5528
|
+
const key = spendBucketKey(baseKey, decision.matchedRule.index);
|
|
4382
5529
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
4383
5530
|
if (typeof rawAmount !== "number") {
|
|
4384
5531
|
console.error(
|
|
@@ -4442,6 +5589,7 @@ var GovernedForwarder = class {
|
|
|
4442
5589
|
const evidenceSatisfied = !evidenceBlocked;
|
|
4443
5590
|
let wouldForward = false;
|
|
4444
5591
|
let limitsOk = true;
|
|
5592
|
+
let sessionUnresolved = false;
|
|
4445
5593
|
if (!evidenceBlocked) {
|
|
4446
5594
|
switch (decision.action) {
|
|
4447
5595
|
case "allow":
|
|
@@ -4449,14 +5597,21 @@ var GovernedForwarder = class {
|
|
|
4449
5597
|
break;
|
|
4450
5598
|
case "rate_limit":
|
|
4451
5599
|
if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
|
|
4452
|
-
const
|
|
4453
|
-
const
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
5600
|
+
const limits = decision.matchedRule.limits;
|
|
5601
|
+
const key = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
|
|
5602
|
+
if (key === null) {
|
|
5603
|
+
wouldForward = false;
|
|
5604
|
+
limitsOk = false;
|
|
5605
|
+
sessionUnresolved = true;
|
|
5606
|
+
} else {
|
|
5607
|
+
const peekResult = this.rateLimiter.peek({
|
|
5608
|
+
key,
|
|
5609
|
+
maxCalls: decision.matchedRule.limits.maxCalls,
|
|
5610
|
+
windowMs: decision.matchedRule.limits.windowMs
|
|
5611
|
+
});
|
|
5612
|
+
wouldForward = peekResult.allowed;
|
|
5613
|
+
limitsOk = peekResult.allowed;
|
|
5614
|
+
}
|
|
4460
5615
|
}
|
|
4461
5616
|
break;
|
|
4462
5617
|
case "spend_limit":
|
|
@@ -4476,20 +5631,21 @@ var GovernedForwarder = class {
|
|
|
4476
5631
|
wouldForward = false;
|
|
4477
5632
|
limitsOk = false;
|
|
4478
5633
|
} else {
|
|
4479
|
-
const
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
5634
|
+
const baseKey = maxSpend.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(maxSpend.key, toolName);
|
|
5635
|
+
if (baseKey === null) {
|
|
5636
|
+
wouldForward = false;
|
|
5637
|
+
limitsOk = false;
|
|
5638
|
+
sessionUnresolved = true;
|
|
5639
|
+
} else {
|
|
5640
|
+
const peekResult = this.spendLimiter.peek({
|
|
5641
|
+
key: spendBucketKey(baseKey, decision.matchedRule.index),
|
|
5642
|
+
amount: rawAmount,
|
|
5643
|
+
limit: maxSpend.limit,
|
|
5644
|
+
windowMs: maxSpend.windowMs
|
|
5645
|
+
});
|
|
5646
|
+
wouldForward = peekResult.allowed;
|
|
5647
|
+
limitsOk = peekResult.allowed;
|
|
5648
|
+
}
|
|
4493
5649
|
}
|
|
4494
5650
|
}
|
|
4495
5651
|
break;
|
|
@@ -4497,30 +5653,39 @@ var GovernedForwarder = class {
|
|
|
4497
5653
|
}
|
|
4498
5654
|
let budgets;
|
|
4499
5655
|
if (wouldForward && this.budgetEngine) {
|
|
5656
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4500
5657
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
4501
5658
|
toolName,
|
|
4502
5659
|
toolArguments,
|
|
4503
|
-
sessionId:
|
|
5660
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4504
5661
|
senderId: null
|
|
4505
5662
|
});
|
|
4506
5663
|
if (failures.length > 0 || charges.length > 0) {
|
|
4507
|
-
const
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
4523
|
-
|
|
5664
|
+
const gated = gateBudgetCharges({ charges, failures }, sessionGate);
|
|
5665
|
+
if (!gated.ok) {
|
|
5666
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
5667
|
+
wouldForward = false;
|
|
5668
|
+
limitsOk = false;
|
|
5669
|
+
sessionUnresolved = true;
|
|
5670
|
+
} else {
|
|
5671
|
+
const peek = charges.length > 0 ? this.budgetEngine.peekAll(gated.charges) : { allowed: true, entries: [] };
|
|
5672
|
+
const ok = failures.length === 0 && peek.allowed;
|
|
5673
|
+
wouldForward &&= ok;
|
|
5674
|
+
limitsOk &&= ok;
|
|
5675
|
+
budgets = [
|
|
5676
|
+
...peek.entries.map((entry) => budgetChainBlock(entry)),
|
|
5677
|
+
...failures.map((failure) => ({
|
|
5678
|
+
name: failure.budget.name,
|
|
5679
|
+
bucket_key: failure.bucketKey,
|
|
5680
|
+
allowed: false,
|
|
5681
|
+
reason: failure.reason,
|
|
5682
|
+
spent: failure.spent,
|
|
5683
|
+
limit: failure.budget.limit,
|
|
5684
|
+
remaining: failure.remaining,
|
|
5685
|
+
currency: failure.budget.currency
|
|
5686
|
+
}))
|
|
5687
|
+
];
|
|
5688
|
+
}
|
|
4524
5689
|
}
|
|
4525
5690
|
}
|
|
4526
5691
|
return this.makeDryRunResult(
|
|
@@ -4529,14 +5694,18 @@ var GovernedForwarder = class {
|
|
|
4529
5694
|
wouldForward,
|
|
4530
5695
|
evidenceSatisfied,
|
|
4531
5696
|
limitsOk,
|
|
4532
|
-
budgets
|
|
5697
|
+
budgets,
|
|
5698
|
+
sessionUnresolved
|
|
4533
5699
|
);
|
|
4534
5700
|
}
|
|
4535
|
-
/**
|
|
4536
|
-
|
|
5701
|
+
/**
|
|
5702
|
+
* Construct a non-session limit bucket key. Session keys are deliberately
|
|
5703
|
+
* NOT built here: they come only from the gate module's `sessionLimitKey`,
|
|
5704
|
+
* whose `GatedSession` parameter makes skipping the identity gate a
|
|
5705
|
+
* compile error (issue #218) — call sites branch on `key === 'session'`.
|
|
5706
|
+
*/
|
|
5707
|
+
buildLimitKey(keyType, toolName) {
|
|
4537
5708
|
switch (keyType) {
|
|
4538
|
-
case "session":
|
|
4539
|
-
return `session:${request.sessionId ?? "unknown"}`;
|
|
4540
5709
|
case "agent":
|
|
4541
5710
|
if (!this.agentKeyWarned) {
|
|
4542
5711
|
this.agentKeyWarned = true;
|
|
@@ -4559,14 +5728,29 @@ var GovernedForwarder = class {
|
|
|
4559
5728
|
}
|
|
4560
5729
|
}
|
|
4561
5730
|
/**
|
|
4562
|
-
*
|
|
4563
|
-
*
|
|
4564
|
-
*
|
|
5731
|
+
* Gate a session-keyed limit at its key-build site (issue #218). Returns
|
|
5732
|
+
* the bucket key, or null when identity is unresolved under deny mode —
|
|
5733
|
+
* the caller denies (enforce) or reports the marker (dry-run).
|
|
4565
5734
|
*/
|
|
4566
|
-
|
|
4567
|
-
|
|
5735
|
+
gateSessionLimitKey(request) {
|
|
5736
|
+
const gate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
5737
|
+
if (!gate.ok) {
|
|
5738
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
5739
|
+
return null;
|
|
5740
|
+
}
|
|
5741
|
+
if (gate.anonymous) warnAnonymousPoolingOnce();
|
|
5742
|
+
return sessionLimitKey(gate.session);
|
|
5743
|
+
}
|
|
5744
|
+
makeSessionUnresolvedResult(request, decision, control) {
|
|
5745
|
+
const feedback = buildSessionUnresolvedFeedback(decision, control, this.session.strategySummary);
|
|
5746
|
+
return makeErrorResult(
|
|
5747
|
+
request,
|
|
5748
|
+
POLICY_DENIED,
|
|
5749
|
+
sessionUnresolvedControlMessage(this.session.strategySummary),
|
|
5750
|
+
{ ...feedback }
|
|
5751
|
+
);
|
|
4568
5752
|
}
|
|
4569
|
-
writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
|
|
5753
|
+
writeAuditRecord(request, auditRecordId, timestamp, toolName, toolArguments, decision, result, totalDurationMs, approvalWaitMs, flaggedDestructive, forwarded, evidenceResult, dependencyResult, evidenceBlocked, sessionBlocked, approvalOutcome, approvalContext, rateLimitResult, spendLimitResult, budgetsChain, budgetApproval, isDryRun, forwardingError, drift) {
|
|
4570
5754
|
if (!this.auditWriter) return;
|
|
4571
5755
|
const actuallyForwarded = forwarded && !isDryRun;
|
|
4572
5756
|
const hadForwardingError = forwardingError !== void 0;
|
|
@@ -4659,9 +5843,16 @@ var GovernedForwarder = class {
|
|
|
4659
5843
|
};
|
|
4660
5844
|
}
|
|
4661
5845
|
const blockReason = extractBlockReason(result);
|
|
5846
|
+
if (sessionBlocked || blockReason === "session_unresolved") {
|
|
5847
|
+
evidenceChain = {
|
|
5848
|
+
...evidenceChain ?? {},
|
|
5849
|
+
session: { unresolved: true, tried: this.session.strategySummary }
|
|
5850
|
+
};
|
|
5851
|
+
}
|
|
4662
5852
|
const record = {
|
|
4663
5853
|
timestamp,
|
|
4664
|
-
session_id: request.
|
|
5854
|
+
session_id: request.session?.id ?? null,
|
|
5855
|
+
session_source: request.session?.source ?? null,
|
|
4665
5856
|
agent_id: null,
|
|
4666
5857
|
environment: this.environment ?? null,
|
|
4667
5858
|
tool_name: toolName,
|
|
@@ -4687,7 +5878,8 @@ var GovernedForwarder = class {
|
|
|
4687
5878
|
dry_run: isDryRun ?? false,
|
|
4688
5879
|
record_kind: "tool_call",
|
|
4689
5880
|
origin: "mcp",
|
|
4690
|
-
metadata: null
|
|
5881
|
+
metadata: null,
|
|
5882
|
+
protocol_version: request.protocolVersion ?? null
|
|
4691
5883
|
};
|
|
4692
5884
|
const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
|
|
4693
5885
|
if (isEnforcementDecision) {
|
|
@@ -4730,7 +5922,7 @@ var GovernedForwarder = class {
|
|
|
4730
5922
|
unsupported: true
|
|
4731
5923
|
});
|
|
4732
5924
|
}
|
|
4733
|
-
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
|
|
5925
|
+
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets, sessionUnresolved) {
|
|
4734
5926
|
const payload = {
|
|
4735
5927
|
dry_run: true,
|
|
4736
5928
|
would_forward: wouldForward,
|
|
@@ -4738,13 +5930,19 @@ var GovernedForwarder = class {
|
|
|
4738
5930
|
matched_rule: decision.matchedRule?.name ?? null,
|
|
4739
5931
|
evidence_satisfied: evidenceSatisfied,
|
|
4740
5932
|
limits_ok: limitsOk,
|
|
4741
|
-
...budgets ? { budgets } : {}
|
|
5933
|
+
...budgets ? { budgets } : {},
|
|
5934
|
+
...sessionUnresolved ? { session_unresolved: true } : {}
|
|
4742
5935
|
};
|
|
4743
5936
|
const body = {
|
|
4744
5937
|
jsonrpc: "2.0",
|
|
4745
5938
|
id: request.id ?? null,
|
|
4746
5939
|
result: {
|
|
4747
|
-
content: [{ type: "text", text: JSON.stringify(payload) }]
|
|
5940
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
5941
|
+
// `resultType` is REQUIRED on every 2026-07-28 result; earlier
|
|
5942
|
+
// revisions never defined it, so the field rides on the client's
|
|
5943
|
+
// validated wire claim — the same tokenizer the #226 door uses for
|
|
5944
|
+
// its tier decision, keeping the two verdicts in agreement.
|
|
5945
|
+
...isModernProtocolClaim(request.protocolVersion) ? { resultType: "complete" } : {}
|
|
4748
5946
|
}
|
|
4749
5947
|
};
|
|
4750
5948
|
const response = {
|
|
@@ -4767,15 +5965,10 @@ var GovernedForwarder = class {
|
|
|
4767
5965
|
}
|
|
4768
5966
|
makeSessionRequiredBlockResult(request, decision) {
|
|
4769
5967
|
const feedback = buildPolicyDeniedFeedback(decision);
|
|
4770
|
-
return makeErrorResult(
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
{
|
|
4775
|
-
...feedback,
|
|
4776
|
-
retry_allowed: true
|
|
4777
|
-
}
|
|
4778
|
-
);
|
|
5968
|
+
return makeErrorResult(request, POLICY_DENIED, decision.reason, {
|
|
5969
|
+
...feedback,
|
|
5970
|
+
retry_allowed: true
|
|
5971
|
+
});
|
|
4779
5972
|
}
|
|
4780
5973
|
makeClientDisconnectedBlockResult(request, decision) {
|
|
4781
5974
|
const feedback = buildClientDisconnectedFeedback(decision);
|
|
@@ -5205,7 +6398,11 @@ var BudgetEngine = class {
|
|
|
5205
6398
|
}
|
|
5206
6399
|
return { charges, failures };
|
|
5207
6400
|
}
|
|
5208
|
-
/**
|
|
6401
|
+
/**
|
|
6402
|
+
* Check every charge without mutating. All-or-nothing: one deny flips
|
|
6403
|
+
* `allowed`. Accepts only gate-branded charges (issue #218) — a caller
|
|
6404
|
+
* cannot peek budget state without having run the session engagement check.
|
|
6405
|
+
*/
|
|
5209
6406
|
peekAll(charges) {
|
|
5210
6407
|
const entries = charges.map((charge) => this.snapshot(charge));
|
|
5211
6408
|
return { allowed: entries.every((entry) => entry.allowed), entries };
|
|
@@ -5711,6 +6908,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5711
6908
|
id TEXT PRIMARY KEY,
|
|
5712
6909
|
timestamp TEXT NOT NULL,
|
|
5713
6910
|
session_id TEXT,
|
|
6911
|
+
session_source TEXT,
|
|
5714
6912
|
agent_id TEXT,
|
|
5715
6913
|
environment TEXT,
|
|
5716
6914
|
tool_name TEXT NOT NULL,
|
|
@@ -5734,6 +6932,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5734
6932
|
record_kind TEXT NOT NULL DEFAULT 'tool_call',
|
|
5735
6933
|
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
5736
6934
|
metadata TEXT,
|
|
6935
|
+
protocol_version TEXT,
|
|
5737
6936
|
created_at TEXT NOT NULL
|
|
5738
6937
|
);
|
|
5739
6938
|
`;
|
|
@@ -5749,19 +6948,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
|
5749
6948
|
`;
|
|
5750
6949
|
var INSERT_SQL = `
|
|
5751
6950
|
INSERT INTO audit_records (
|
|
5752
|
-
id, timestamp, session_id, agent_id, environment, tool_name, tool_input,
|
|
6951
|
+
id, timestamp, session_id, session_source, agent_id, environment, tool_name, tool_input,
|
|
5753
6952
|
policy_decision, block_reason, matched_rule, matched_rule_index, evidence_chain, approval_status,
|
|
5754
6953
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
5755
6954
|
upstream_http_status,
|
|
5756
6955
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
5757
|
-
flagged_destructive, dry_run, record_kind, origin, metadata, created_at
|
|
6956
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
|
|
5758
6957
|
) VALUES (
|
|
5759
|
-
@id, @timestamp, @session_id, @agent_id, @environment, @tool_name, @tool_input,
|
|
6958
|
+
@id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
|
|
5760
6959
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
5761
6960
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
5762
6961
|
@upstream_http_status,
|
|
5763
6962
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
5764
|
-
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
|
|
6963
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
|
|
5765
6964
|
)
|
|
5766
6965
|
`;
|
|
5767
6966
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -5774,13 +6973,20 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
5774
6973
|
"upstream_http_status",
|
|
5775
6974
|
"record_kind",
|
|
5776
6975
|
"origin",
|
|
5777
|
-
"metadata"
|
|
6976
|
+
"metadata",
|
|
6977
|
+
// Deliberately listed (issue #218): pre-0.12 local DBs fail fast with the
|
|
6978
|
+
// documented delete-these-files message — the pre-1.0 clean-break policy.
|
|
6979
|
+
"session_source",
|
|
6980
|
+
// Same clean break, same unreleased cycle (issue #219): released users see
|
|
6981
|
+
// ONE break, at v0.12.0.
|
|
6982
|
+
"protocol_version"
|
|
5778
6983
|
];
|
|
5779
6984
|
function deserializeRow(row) {
|
|
5780
6985
|
return {
|
|
5781
6986
|
id: row.id,
|
|
5782
6987
|
timestamp: row.timestamp,
|
|
5783
6988
|
session_id: row.session_id,
|
|
6989
|
+
session_source: row.session_source,
|
|
5784
6990
|
agent_id: row.agent_id,
|
|
5785
6991
|
environment: row.environment,
|
|
5786
6992
|
tool_name: row.tool_name,
|
|
@@ -5804,6 +7010,7 @@ function deserializeRow(row) {
|
|
|
5804
7010
|
record_kind: row.record_kind,
|
|
5805
7011
|
origin: row.origin,
|
|
5806
7012
|
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
7013
|
+
protocol_version: row.protocol_version,
|
|
5807
7014
|
created_at: row.created_at
|
|
5808
7015
|
};
|
|
5809
7016
|
}
|
|
@@ -5987,6 +7194,7 @@ var AuditStore = class {
|
|
|
5987
7194
|
id: resolvedId,
|
|
5988
7195
|
timestamp: record.timestamp,
|
|
5989
7196
|
session_id: record.session_id,
|
|
7197
|
+
session_source: record.session_source,
|
|
5990
7198
|
agent_id: record.agent_id,
|
|
5991
7199
|
environment: record.environment,
|
|
5992
7200
|
tool_name: record.tool_name,
|
|
@@ -6010,6 +7218,7 @@ var AuditStore = class {
|
|
|
6010
7218
|
record_kind: record.record_kind,
|
|
6011
7219
|
origin: record.origin,
|
|
6012
7220
|
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
7221
|
+
protocol_version: record.protocol_version,
|
|
6013
7222
|
created_at: now
|
|
6014
7223
|
});
|
|
6015
7224
|
return resolvedId;
|
|
@@ -6198,7 +7407,11 @@ var CSV_HEADERS = [
|
|
|
6198
7407
|
"matched_rule_index",
|
|
6199
7408
|
"record_kind",
|
|
6200
7409
|
"origin",
|
|
6201
|
-
"metadata"
|
|
7410
|
+
"metadata",
|
|
7411
|
+
// Appended LAST (issues #218, #219): positional consumers of the existing
|
|
7412
|
+
// columns keep working — new columns always go at the end.
|
|
7413
|
+
"session_source",
|
|
7414
|
+
"protocol_version"
|
|
6202
7415
|
];
|
|
6203
7416
|
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
6204
7417
|
function csvEscape(value) {
|
|
@@ -6742,15 +7955,18 @@ function asStatus(status) {
|
|
|
6742
7955
|
|
|
6743
7956
|
// src/evidence/api.ts
|
|
6744
7957
|
var SIDEBAND_BODY_LIMIT_BYTES = 1 * 1024 * 1024;
|
|
7958
|
+
var sessionIdSchema = z5.string().min(1).refine((value) => value.trim() !== "", {
|
|
7959
|
+
message: "session_id must not be whitespace-only"
|
|
7960
|
+
});
|
|
6745
7961
|
var postEvidenceBody = z5.object({
|
|
6746
|
-
session_id:
|
|
7962
|
+
session_id: sessionIdSchema,
|
|
6747
7963
|
tool_name: z5.string().min(1),
|
|
6748
7964
|
evidence_key: z5.string().min(1),
|
|
6749
7965
|
evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
6750
7966
|
ttl_seconds: z5.number().int().positive().optional()
|
|
6751
7967
|
});
|
|
6752
7968
|
var postContextBody = z5.object({
|
|
6753
|
-
session_id:
|
|
7969
|
+
session_id: sessionIdSchema,
|
|
6754
7970
|
key: z5.string().min(1),
|
|
6755
7971
|
value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
|
|
6756
7972
|
});
|
|
@@ -6880,6 +8096,7 @@ var SWEEP_INTERVAL_MS2 = 3e4;
|
|
|
6880
8096
|
var GovernanceService = class {
|
|
6881
8097
|
policy;
|
|
6882
8098
|
environment;
|
|
8099
|
+
session;
|
|
6883
8100
|
evidenceStore;
|
|
6884
8101
|
approvalRouter;
|
|
6885
8102
|
rateLimiter;
|
|
@@ -6913,6 +8130,7 @@ var GovernanceService = class {
|
|
|
6913
8130
|
constructor(options) {
|
|
6914
8131
|
this.policy = options.policy;
|
|
6915
8132
|
this.environment = options.environment;
|
|
8133
|
+
this.session = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
6916
8134
|
this.evidenceStore = options.evidenceStore;
|
|
6917
8135
|
this.approvalRouter = options.approvalRouter;
|
|
6918
8136
|
this.rateLimiter = options.rateLimiter;
|
|
@@ -6947,6 +8165,7 @@ var GovernanceService = class {
|
|
|
6947
8165
|
if (reserved) {
|
|
6948
8166
|
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
6949
8167
|
}
|
|
8168
|
+
const sessionId = isWellFormedSessionId(req.session_id) ? req.session_id : null;
|
|
6950
8169
|
const inputBytes = byteLength(req.arguments ?? {});
|
|
6951
8170
|
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
6952
8171
|
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
@@ -6954,7 +8173,7 @@ var GovernanceService = class {
|
|
|
6954
8173
|
const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
|
|
6955
8174
|
tool: req.tool.name,
|
|
6956
8175
|
agent_id: req.agent_id,
|
|
6957
|
-
session_id:
|
|
8176
|
+
session_id: sessionId,
|
|
6958
8177
|
origin: req.origin
|
|
6959
8178
|
});
|
|
6960
8179
|
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
@@ -6976,7 +8195,8 @@ var GovernanceService = class {
|
|
|
6976
8195
|
const pipeline = decide({
|
|
6977
8196
|
toolName,
|
|
6978
8197
|
toolArguments: req.arguments,
|
|
6979
|
-
sessionId:
|
|
8198
|
+
sessionId: sessionId ?? void 0,
|
|
8199
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
6980
8200
|
policy: this.policy,
|
|
6981
8201
|
environment: this.environment,
|
|
6982
8202
|
evidenceStore: this.evidenceStore,
|
|
@@ -6993,6 +8213,8 @@ var GovernanceService = class {
|
|
|
6993
8213
|
const plans = [];
|
|
6994
8214
|
let limitsBlock;
|
|
6995
8215
|
let ruleLimitOk = true;
|
|
8216
|
+
let sessionUnresolvedDeny = false;
|
|
8217
|
+
let dryRunSessionUnresolved = false;
|
|
6996
8218
|
const reservedThisCall = [];
|
|
6997
8219
|
const reserve = (key) => {
|
|
6998
8220
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -7007,12 +8229,14 @@ var GovernanceService = class {
|
|
|
7007
8229
|
if (pipeline.isDryRun) {
|
|
7008
8230
|
wire = "dry_run";
|
|
7009
8231
|
if (decision.action === "rate_limit") {
|
|
7010
|
-
const planned = this.planRate(decision, toolName,
|
|
8232
|
+
const planned = this.planRate(decision, toolName, sessionId, senderId);
|
|
7011
8233
|
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
8234
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
7012
8235
|
ruleLimitOk = planned?.allowed ?? true;
|
|
7013
8236
|
} else if (decision.action === "spend_limit") {
|
|
7014
|
-
const planned = this.planSpend(decision, toolName,
|
|
8237
|
+
const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
|
|
7015
8238
|
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
8239
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
7016
8240
|
ruleLimitOk = planned?.allowed ?? true;
|
|
7017
8241
|
}
|
|
7018
8242
|
} else if (decision.action === "deny") {
|
|
@@ -7020,21 +8244,31 @@ var GovernanceService = class {
|
|
|
7020
8244
|
} else if (decision.action === "require_approval") {
|
|
7021
8245
|
wire = "require_approval";
|
|
7022
8246
|
} else if (decision.action === "rate_limit") {
|
|
7023
|
-
const planned = this.planRate(decision, toolName,
|
|
7024
|
-
if (planned?.
|
|
7025
|
-
|
|
8247
|
+
const planned = this.planRate(decision, toolName, sessionId, senderId);
|
|
8248
|
+
if (planned?.sessionUnresolved) {
|
|
8249
|
+
wire = "deny";
|
|
8250
|
+
sessionUnresolvedDeny = true;
|
|
8251
|
+
} else {
|
|
8252
|
+
if (planned?.plan && !reserve(planned.plan.key)) {
|
|
8253
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
8254
|
+
}
|
|
8255
|
+
if (planned?.plan) plans.push(planned.plan);
|
|
8256
|
+
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
8257
|
+
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
7026
8258
|
}
|
|
7027
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
7028
|
-
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
7029
|
-
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
7030
8259
|
} else if (decision.action === "spend_limit") {
|
|
7031
|
-
const planned = this.planSpend(decision, toolName,
|
|
7032
|
-
if (planned?.
|
|
7033
|
-
|
|
8260
|
+
const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
|
|
8261
|
+
if (planned?.sessionUnresolved) {
|
|
8262
|
+
wire = "deny";
|
|
8263
|
+
sessionUnresolvedDeny = true;
|
|
8264
|
+
} else {
|
|
8265
|
+
if (planned?.plan && !reserve(planned.plan.key)) {
|
|
8266
|
+
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
8267
|
+
}
|
|
8268
|
+
if (planned?.plan) plans.push(planned.plan);
|
|
8269
|
+
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
8270
|
+
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
7034
8271
|
}
|
|
7035
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
7036
|
-
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
7037
|
-
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
7038
8272
|
} else {
|
|
7039
8273
|
wire = "allow";
|
|
7040
8274
|
}
|
|
@@ -7046,14 +8280,27 @@ var GovernanceService = class {
|
|
|
7046
8280
|
let budgetTicketTimeoutMs;
|
|
7047
8281
|
let budgetTriggeredApproval = false;
|
|
7048
8282
|
if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
|
|
8283
|
+
const budgetSessionGate = gateSession(sessionId, this.session.onUnresolved);
|
|
7049
8284
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
7050
8285
|
toolName,
|
|
7051
8286
|
toolArguments: req.arguments,
|
|
7052
|
-
sessionId:
|
|
8287
|
+
sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
|
|
7053
8288
|
senderId
|
|
7054
8289
|
});
|
|
7055
|
-
|
|
7056
|
-
|
|
8290
|
+
const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
|
|
8291
|
+
if (gatedCharges && !gatedCharges.ok) {
|
|
8292
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
8293
|
+
if (wire === "dry_run") {
|
|
8294
|
+
budgetDryRunOk = false;
|
|
8295
|
+
dryRunSessionUnresolved = true;
|
|
8296
|
+
} else {
|
|
8297
|
+
releaseReservations();
|
|
8298
|
+
plans.length = 0;
|
|
8299
|
+
wire = "deny";
|
|
8300
|
+
sessionUnresolvedDeny = true;
|
|
8301
|
+
}
|
|
8302
|
+
} else if (gatedCharges) {
|
|
8303
|
+
const peek = charges.length > 0 ? this.budgetEngine.peekAll(gatedCharges.charges) : { allowed: true, entries: [] };
|
|
7057
8304
|
budgetsBlock = [
|
|
7058
8305
|
...peek.entries.map((entry2) => budgetWireBlock(entry2)),
|
|
7059
8306
|
...failures.map((failure) => budgetFailureBlock(failure))
|
|
@@ -7075,19 +8322,16 @@ var GovernanceService = class {
|
|
|
7075
8322
|
} else {
|
|
7076
8323
|
if (breaches.length > 0) budgetDryRunOk = false;
|
|
7077
8324
|
if (wire !== "dry_run") {
|
|
7078
|
-
|
|
7079
|
-
|
|
8325
|
+
const frozen = freezeGatedPlans(
|
|
8326
|
+
gatedCharges.charges,
|
|
8327
|
+
charges.map((_, index) => peek.entries[index]?.allowed === false)
|
|
8328
|
+
);
|
|
8329
|
+
for (const plan of frozen) {
|
|
8330
|
+
if (!reserve(plan.bucketKey)) {
|
|
7080
8331
|
releaseReservations();
|
|
7081
8332
|
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
7082
8333
|
}
|
|
7083
|
-
plans.push(
|
|
7084
|
-
kind: "budget",
|
|
7085
|
-
budget: charge.budget,
|
|
7086
|
-
bucketKey: charge.bucketKey,
|
|
7087
|
-
amount: charge.amount,
|
|
7088
|
-
generation: charge.generation,
|
|
7089
|
-
breached: peek.entries[index]?.allowed === false
|
|
7090
|
-
});
|
|
8334
|
+
plans.push(plan);
|
|
7091
8335
|
}
|
|
7092
8336
|
if (breaches.length > 0) {
|
|
7093
8337
|
budgetBreachEntries = breaches;
|
|
@@ -7136,12 +8380,21 @@ var GovernanceService = class {
|
|
|
7136
8380
|
suggestion: budgetDenial.invalid.length > 0 ? "Retry with a non-negative finite amount in the expected field." : "Wait for the window to reset or reduce the amount."
|
|
7137
8381
|
};
|
|
7138
8382
|
}
|
|
8383
|
+
if (sessionUnresolvedDeny) {
|
|
8384
|
+
const message = sessionUnresolvedControlMessage(this.session.strategySummary);
|
|
8385
|
+
responseBody["reason"] = message;
|
|
8386
|
+
responseBody["feedback"] = {
|
|
8387
|
+
message,
|
|
8388
|
+
suggestion: "Send a session_id the identity policy accepts, or set session.on_unresolved: anonymous to restore shared pooling."
|
|
8389
|
+
};
|
|
8390
|
+
}
|
|
7139
8391
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
7140
8392
|
if (wire === "dry_run") {
|
|
7141
8393
|
responseBody["dry_run"] = {
|
|
7142
8394
|
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
7143
8395
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
7144
|
-
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
8396
|
+
limits_ok: ruleLimitOk && budgetDryRunOk,
|
|
8397
|
+
...dryRunSessionUnresolved ? { session_unresolved: true } : {}
|
|
7145
8398
|
};
|
|
7146
8399
|
}
|
|
7147
8400
|
if (pipeline.driftEvent) {
|
|
@@ -7152,7 +8405,7 @@ var GovernanceService = class {
|
|
|
7152
8405
|
timestampIso,
|
|
7153
8406
|
origin: req.origin,
|
|
7154
8407
|
agentId: req.agent_id,
|
|
7155
|
-
sessionId
|
|
8408
|
+
sessionId,
|
|
7156
8409
|
toolName,
|
|
7157
8410
|
toolInput: req.arguments ?? {},
|
|
7158
8411
|
metadata: req.metadata,
|
|
@@ -7167,7 +8420,9 @@ var GovernanceService = class {
|
|
|
7167
8420
|
// limitsBlock is also the response's `limits`, and the audit writer
|
|
7168
8421
|
// buffers records by reference until flush — a direct embedder
|
|
7169
8422
|
// editing the returned body must not be able to rewrite evidence.
|
|
7170
|
-
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
8423
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0,
|
|
8424
|
+
sessionUnresolved: sessionUnresolvedDeny,
|
|
8425
|
+
sessionChain: pipeline.sessionBlocked
|
|
7171
8426
|
});
|
|
7172
8427
|
this.tombstones.set(evaluationId, {
|
|
7173
8428
|
auditRecordId: auditId,
|
|
@@ -7200,7 +8455,7 @@ var GovernanceService = class {
|
|
|
7200
8455
|
// rewrite it (same guard as the pending entry's evidence below).
|
|
7201
8456
|
tool_input: structuredClone(req.arguments ?? {}),
|
|
7202
8457
|
matched_rule: decision.matchedRule,
|
|
7203
|
-
session_id:
|
|
8458
|
+
session_id: sessionId,
|
|
7204
8459
|
origin: req.origin,
|
|
7205
8460
|
timeout_ms: timeoutMs,
|
|
7206
8461
|
breached_budgets: budgetBreachContexts
|
|
@@ -7218,7 +8473,7 @@ var GovernanceService = class {
|
|
|
7218
8473
|
evaluationId,
|
|
7219
8474
|
origin: req.origin,
|
|
7220
8475
|
agentId: req.agent_id,
|
|
7221
|
-
sessionId
|
|
8476
|
+
sessionId,
|
|
7222
8477
|
toolName,
|
|
7223
8478
|
// Cloned: direct embedders share these references and could otherwise
|
|
7224
8479
|
// mutate the audit evidence (and desync the byte accounting) after
|
|
@@ -7437,7 +8692,9 @@ var GovernanceService = class {
|
|
|
7437
8692
|
timestampIso: new Date(this.now()).toISOString(),
|
|
7438
8693
|
origin: req.origin,
|
|
7439
8694
|
agentId: req.agent_id,
|
|
7440
|
-
|
|
8695
|
+
// Same trim-empty normalization as /evaluate: a whitespace-only id
|
|
8696
|
+
// must not land in the audit row as attributed sideband identity.
|
|
8697
|
+
sessionId: isWellFormedSessionId(req.session_id) ? req.session_id : null,
|
|
7441
8698
|
toolName,
|
|
7442
8699
|
toolInput: { ...req.package },
|
|
7443
8700
|
metadata: req.metadata,
|
|
@@ -7754,7 +9011,18 @@ var GovernanceService = class {
|
|
|
7754
9011
|
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
7755
9012
|
return { allowed: true };
|
|
7756
9013
|
}
|
|
7757
|
-
|
|
9014
|
+
let key;
|
|
9015
|
+
if (limits.key === "session") {
|
|
9016
|
+
const gate = gateSession(sessionId, this.session.onUnresolved);
|
|
9017
|
+
if (!gate.ok) {
|
|
9018
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
9019
|
+
return { allowed: false, sessionUnresolved: true };
|
|
9020
|
+
}
|
|
9021
|
+
if (gate.anonymous) warnAnonymousPoolingOnce();
|
|
9022
|
+
key = sessionLimitKey(gate.session);
|
|
9023
|
+
} else {
|
|
9024
|
+
key = buildLimitKey(limits.key, toolName, senderId);
|
|
9025
|
+
}
|
|
7758
9026
|
const peek = this.rateLimiter.peek({
|
|
7759
9027
|
key,
|
|
7760
9028
|
maxCalls: limits.maxCalls,
|
|
@@ -7774,10 +9042,19 @@ var GovernanceService = class {
|
|
|
7774
9042
|
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
7775
9043
|
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
7776
9044
|
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
9045
|
+
let baseKey;
|
|
9046
|
+
if (maxSpend.key === "session") {
|
|
9047
|
+
const gate = gateSession(sessionId, this.session.onUnresolved);
|
|
9048
|
+
if (!gate.ok) {
|
|
9049
|
+
warnSessionUnresolvedEngagementOnce(this.session.strategySummary);
|
|
9050
|
+
return { allowed: false, sessionUnresolved: true };
|
|
9051
|
+
}
|
|
9052
|
+
if (gate.anonymous) warnAnonymousPoolingOnce();
|
|
9053
|
+
baseKey = sessionLimitKey(gate.session);
|
|
9054
|
+
} else {
|
|
9055
|
+
baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
|
|
9056
|
+
}
|
|
9057
|
+
const key = spendBucketKey(baseKey, decision.matchedRule.index);
|
|
7781
9058
|
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
7782
9059
|
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
7783
9060
|
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
@@ -7809,18 +9086,15 @@ var GovernanceService = class {
|
|
|
7809
9086
|
/** Commit every plan of one call at /audit time; returns the chain blocks. */
|
|
7810
9087
|
commitPlans(entry, actualAmount, auditId, approvalStatus) {
|
|
7811
9088
|
let chain;
|
|
7812
|
-
const budgetPlans = entry.plans.filter(
|
|
9089
|
+
const budgetPlans = entry.plans.filter(
|
|
9090
|
+
(plan) => plan.kind === "budget"
|
|
9091
|
+
);
|
|
7813
9092
|
if (budgetPlans.length > 0 && this.budgetEngine) {
|
|
7814
9093
|
const kinds = new Map(
|
|
7815
9094
|
budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
|
|
7816
9095
|
);
|
|
7817
9096
|
const snapshots = this.budgetEngine.recordAll(
|
|
7818
|
-
budgetPlans
|
|
7819
|
-
budget: plan.budget,
|
|
7820
|
-
bucketKey: plan.bucketKey,
|
|
7821
|
-
amount: actualAmount ?? plan.amount,
|
|
7822
|
-
generation: plan.generation
|
|
7823
|
-
})),
|
|
9097
|
+
remintDeferredCharges(budgetPlans, actualAmount),
|
|
7824
9098
|
{
|
|
7825
9099
|
kind: "spend",
|
|
7826
9100
|
...kinds.size > 0 ? { kinds } : {},
|
|
@@ -7891,6 +9165,12 @@ var GovernanceService = class {
|
|
|
7891
9165
|
if (!this.auditWriter) return id;
|
|
7892
9166
|
const blockReason = deriveBlockReason(args);
|
|
7893
9167
|
let evidenceChain = args.limitsChain ?? null;
|
|
9168
|
+
if (args.sessionUnresolved || args.sessionChain) {
|
|
9169
|
+
evidenceChain = {
|
|
9170
|
+
...evidenceChain ?? {},
|
|
9171
|
+
session: { unresolved: true, tried: this.session.strategySummary }
|
|
9172
|
+
};
|
|
9173
|
+
}
|
|
7894
9174
|
if (args.sidebandUnreported) {
|
|
7895
9175
|
evidenceChain = {
|
|
7896
9176
|
...evidenceChain ?? {},
|
|
@@ -7906,6 +9186,9 @@ var GovernanceService = class {
|
|
|
7906
9186
|
const record = {
|
|
7907
9187
|
timestamp: args.timestampIso,
|
|
7908
9188
|
session_id: args.sessionId,
|
|
9189
|
+
// Adapter-supplied ids are attributed to the sideband door itself —
|
|
9190
|
+
// the MCP resolver's source vocabulary does not apply here.
|
|
9191
|
+
session_source: args.sessionId != null ? "sideband" : null,
|
|
7909
9192
|
agent_id: args.agentId,
|
|
7910
9193
|
environment: this.environment ?? null,
|
|
7911
9194
|
tool_name: args.toolName,
|
|
@@ -7928,7 +9211,9 @@ var GovernanceService = class {
|
|
|
7928
9211
|
dry_run: args.dryRun,
|
|
7929
9212
|
record_kind: args.recordKind,
|
|
7930
9213
|
origin: args.origin,
|
|
7931
|
-
metadata: args.metadata
|
|
9214
|
+
metadata: args.metadata,
|
|
9215
|
+
// The sideband has no MCP wire, so no protocol claim exists.
|
|
9216
|
+
protocol_version: null
|
|
7932
9217
|
};
|
|
7933
9218
|
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
7934
9219
|
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
@@ -7947,6 +9232,7 @@ function deriveBlockReason(args) {
|
|
|
7947
9232
|
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
7948
9233
|
if (args.dryRun) return null;
|
|
7949
9234
|
if (args.budgetBreachBlocked) return "budget_exceeded";
|
|
9235
|
+
if (args.sessionUnresolved) return "session_unresolved";
|
|
7950
9236
|
if (args.approvalStatus === "denied") return "approval_denied";
|
|
7951
9237
|
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
7952
9238
|
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
@@ -7985,10 +9271,8 @@ function policyCanRequireApproval(policy) {
|
|
|
7985
9271
|
}
|
|
7986
9272
|
return policy.rules.some((rule) => rule.action === "require_approval");
|
|
7987
9273
|
}
|
|
7988
|
-
function buildLimitKey(keyType, toolName,
|
|
9274
|
+
function buildLimitKey(keyType, toolName, senderId) {
|
|
7989
9275
|
switch (keyType) {
|
|
7990
|
-
case "session":
|
|
7991
|
-
return `session:${sessionId ?? "unknown"}`;
|
|
7992
9276
|
case "sender_id":
|
|
7993
9277
|
return `sender:${senderId ?? "unknown"}`;
|
|
7994
9278
|
case "agent":
|
|
@@ -8192,6 +9476,48 @@ var AuditWriter = class {
|
|
|
8192
9476
|
}
|
|
8193
9477
|
};
|
|
8194
9478
|
|
|
9479
|
+
// src/audit/header-mismatch.ts
|
|
9480
|
+
function buildHeaderMismatchAuditRecord(rejection, environment) {
|
|
9481
|
+
return {
|
|
9482
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
9483
|
+
session_id: rejection.session?.id ?? null,
|
|
9484
|
+
session_source: rejection.session?.source ?? null,
|
|
9485
|
+
agent_id: null,
|
|
9486
|
+
environment: environment ?? null,
|
|
9487
|
+
tool_name: rejection.bodyName ?? "<header_mismatch>",
|
|
9488
|
+
// Wrap parity with the nameless precedent: the wire params always nest
|
|
9489
|
+
// under `raw_params`, so a wrapped scalar can never be confused with an
|
|
9490
|
+
// object that happens to contain a `raw_params` key. Headers are the
|
|
9491
|
+
// present markers only, verbatim as received.
|
|
9492
|
+
tool_input: {
|
|
9493
|
+
raw_params: rejection.params ?? null,
|
|
9494
|
+
body_method: rejection.method,
|
|
9495
|
+
mismatch_reason: rejection.reason,
|
|
9496
|
+
headers: { ...rejection.headers }
|
|
9497
|
+
},
|
|
9498
|
+
policy_decision: "rejected",
|
|
9499
|
+
block_reason: "header_mismatch",
|
|
9500
|
+
matched_rule: null,
|
|
9501
|
+
matched_rule_index: null,
|
|
9502
|
+
evidence_chain: null,
|
|
9503
|
+
approval_status: null,
|
|
9504
|
+
approved_by: null,
|
|
9505
|
+
upstream_response: null,
|
|
9506
|
+
upstream_error: null,
|
|
9507
|
+
upstream_http_status: null,
|
|
9508
|
+
upstream_latency_ms: null,
|
|
9509
|
+
total_duration_ms: rejection.durationMs,
|
|
9510
|
+
approval_wait_ms: 0,
|
|
9511
|
+
proxy_compute_ms: rejection.durationMs,
|
|
9512
|
+
flagged_destructive: false,
|
|
9513
|
+
dry_run: false,
|
|
9514
|
+
record_kind: "tool_call",
|
|
9515
|
+
origin: "mcp",
|
|
9516
|
+
metadata: null,
|
|
9517
|
+
protocol_version: rejection.protocolVersion ?? null
|
|
9518
|
+
};
|
|
9519
|
+
}
|
|
9520
|
+
|
|
8195
9521
|
// src/approval/queue.ts
|
|
8196
9522
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
8197
9523
|
var ApprovalQueue = class {
|
|
@@ -9904,6 +11230,7 @@ export {
|
|
|
9904
11230
|
UpstreamForwarder,
|
|
9905
11231
|
VERSION,
|
|
9906
11232
|
WebhookChannel,
|
|
11233
|
+
buildHeaderMismatchAuditRecord,
|
|
9907
11234
|
compileBudgets,
|
|
9908
11235
|
compilePolicies,
|
|
9909
11236
|
createApp,
|