@gethelio/proxy 0.11.0 → 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 +2351 -959
- 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 +2205 -874
- 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,79 @@ 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
|
+
|
|
1272
|
+
// src/transport/content-type.ts
|
|
1273
|
+
function isJsonContentType(header) {
|
|
1274
|
+
const [essence = ""] = (header ?? "").split(";");
|
|
1275
|
+
return essence.trim().toLowerCase() === "application/json";
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1045
1278
|
// src/transport/forward-headers.ts
|
|
1046
1279
|
function buildForwardHeaders(requestHeaders, allowlist) {
|
|
1047
1280
|
const forwardHeaders = {};
|
|
@@ -1059,806 +1292,1419 @@ function buildForwardHeaders(requestHeaders, allowlist) {
|
|
|
1059
1292
|
return Object.keys(forwardHeaders).length > 0 ? forwardHeaders : void 0;
|
|
1060
1293
|
}
|
|
1061
1294
|
|
|
1062
|
-
// src/
|
|
1063
|
-
|
|
1064
|
-
|
|
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);
|
|
1065
1302
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
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;
|
|
1068
1352
|
}
|
|
1069
|
-
function
|
|
1070
|
-
|
|
1071
|
-
const id = value["id"];
|
|
1072
|
-
return isValidJsonRpcId(id) ? id : void 0;
|
|
1353
|
+
function isHeaderSafeMethod(method) {
|
|
1354
|
+
return /^[\x21-\x7E]+$/.test(method);
|
|
1073
1355
|
}
|
|
1074
|
-
function
|
|
1075
|
-
|
|
1076
|
-
|
|
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;
|
|
1077
1360
|
}
|
|
1078
|
-
function
|
|
1079
|
-
if (!
|
|
1080
|
-
|
|
1081
|
-
if (Object.prototype.hasOwnProperty.call(value, "id") && !isValidJsonRpcId(value["id"])) {
|
|
1082
|
-
return false;
|
|
1361
|
+
function buildStandardRequestHeaders(method, params) {
|
|
1362
|
+
if (!isHeaderSafeMethod(method)) {
|
|
1363
|
+
return {};
|
|
1083
1364
|
}
|
|
1084
|
-
const
|
|
1085
|
-
const
|
|
1086
|
-
if (
|
|
1087
|
-
|
|
1088
|
-
|
|
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;
|
|
1089
1371
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
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;
|
|
1098
1379
|
}
|
|
1099
1380
|
};
|
|
1381
|
+
apply(base);
|
|
1382
|
+
apply(forwarded);
|
|
1383
|
+
apply(staticHeaders);
|
|
1384
|
+
return out;
|
|
1100
1385
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const upstream = args.upstreamResponse;
|
|
1123
|
-
const upstreamContentType = upstream.headers["content-type"] ?? null;
|
|
1124
|
-
if (!isValidJsonRpcResponse(upstream.body)) {
|
|
1125
|
-
return {
|
|
1126
|
-
httpStatus: 200,
|
|
1127
|
-
wrapped: true,
|
|
1128
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1129
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1130
|
-
upstream_http_status: upstream.status,
|
|
1131
|
-
upstream_content_type: upstreamContentType,
|
|
1132
|
-
upstream_body_type: typeof upstream.body
|
|
1133
|
-
})
|
|
1134
|
-
};
|
|
1135
|
-
}
|
|
1136
|
-
if (args.requestId !== void 0) {
|
|
1137
|
-
const upstreamId = getJsonRpcId(upstream.body);
|
|
1138
|
-
if (upstreamId === void 0) {
|
|
1139
|
-
return {
|
|
1140
|
-
httpStatus: 200,
|
|
1141
|
-
wrapped: true,
|
|
1142
|
-
body: makeWrappedError(args.requestId, "upstream returned invalid JSON-RPC response", {
|
|
1143
|
-
failure_class: "upstream_invalid_jsonrpc",
|
|
1144
|
-
upstream_http_status: upstream.status,
|
|
1145
|
-
upstream_content_type: upstreamContentType,
|
|
1146
|
-
upstream_body_type: typeof upstream.body,
|
|
1147
|
-
invalid_reason: "missing_response_id"
|
|
1148
|
-
})
|
|
1149
|
-
};
|
|
1150
|
-
}
|
|
1151
|
-
const expectedId = args.requestId ?? null;
|
|
1152
|
-
if (upstreamId !== expectedId) {
|
|
1153
|
-
return {
|
|
1154
|
-
httpStatus: 200,
|
|
1155
|
-
wrapped: true,
|
|
1156
|
-
body: makeWrappedError(args.requestId, "upstream response id mismatch", {
|
|
1157
|
-
failure_class: "upstream_id_mismatch",
|
|
1158
|
-
expected_request_id: expectedId,
|
|
1159
|
-
upstream_response_id: upstreamId
|
|
1160
|
-
})
|
|
1161
|
-
};
|
|
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;
|
|
1162
1407
|
}
|
|
1408
|
+
current = current.cause;
|
|
1163
1409
|
}
|
|
1164
|
-
return
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
+
);
|
|
1169
1424
|
}
|
|
1170
1425
|
|
|
1171
|
-
// src/
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
const
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
const
|
|
1179
|
-
if (
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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;
|
|
1184
1446
|
}
|
|
1185
|
-
|
|
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;
|
|
1186
1461
|
try {
|
|
1187
|
-
|
|
1462
|
+
parsed = JSON.parse(data);
|
|
1188
1463
|
} catch {
|
|
1189
|
-
return
|
|
1190
|
-
}
|
|
1191
|
-
const parsedRequest = parseJsonRpcRequest(body);
|
|
1192
|
-
if (!parsedRequest.success) {
|
|
1193
|
-
return c.json(makeJsonRpcError(parsedRequest.id, INVALID_REQUEST, parsedRequest.message), 400);
|
|
1464
|
+
return;
|
|
1194
1465
|
}
|
|
1195
|
-
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
const forwardHeaders = buildForwardHeaders(c.req.raw.headers, forwardHeaderAllowlist);
|
|
1200
|
-
const mcpRequest = {
|
|
1201
|
-
jsonrpc: "2.0",
|
|
1202
|
-
id,
|
|
1203
|
-
method,
|
|
1204
|
-
params,
|
|
1205
|
-
sessionId,
|
|
1206
|
-
headers: forwardHeaders,
|
|
1207
|
-
signal: c.req.raw.signal
|
|
1208
|
-
};
|
|
1209
|
-
if (id === void 0) {
|
|
1210
|
-
const notificationRequest = { ...mcpRequest, signal: void 0 };
|
|
1211
|
-
void forwarder.forward(notificationRequest).catch((err) => {
|
|
1212
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
1213
|
-
console.error(`[helio] Upstream notification forward failed (${method}): ${message}`);
|
|
1214
|
-
});
|
|
1215
|
-
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;
|
|
1216
1470
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
+
}
|
|
1225
1484
|
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
if (
|
|
1229
|
-
|
|
1485
|
+
if (result.done) {
|
|
1486
|
+
const tail = decoder.decode();
|
|
1487
|
+
if (tail) {
|
|
1488
|
+
processChunk(tail);
|
|
1489
|
+
if (found) return found;
|
|
1230
1490
|
}
|
|
1491
|
+
break;
|
|
1231
1492
|
}
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1493
|
+
}
|
|
1494
|
+
throw new Error(
|
|
1495
|
+
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1496
|
+
);
|
|
1236
1497
|
}
|
|
1237
1498
|
|
|
1238
|
-
// src/
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
var
|
|
1243
|
-
var
|
|
1244
|
-
var
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
+
};
|
|
1253
1517
|
}
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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;
|
|
1266
1543
|
});
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
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";
|
|
1275
1578
|
}
|
|
1276
1579
|
}
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const endpointData = sseEvent("endpoint", `?sessionId=${sessionId}`);
|
|
1285
|
-
writeSessionEvent(sessionId, endpointData);
|
|
1286
|
-
c.req.raw.signal.addEventListener("abort", () => {
|
|
1287
|
-
sessions.delete(sessionId);
|
|
1288
|
-
void writer.close().catch(() => {
|
|
1289
|
-
});
|
|
1290
|
-
});
|
|
1291
|
-
return new Response(readable, {
|
|
1292
|
-
headers: {
|
|
1293
|
-
"content-type": "text/event-stream",
|
|
1294
|
-
"cache-control": "no-cache",
|
|
1295
|
-
connection: "keep-alive"
|
|
1296
|
-
}
|
|
1297
|
-
});
|
|
1298
|
-
});
|
|
1299
|
-
app.post("/", async (c) => {
|
|
1300
|
-
const parsedQuery = ssePostQuerySchema.safeParse(c.req.query());
|
|
1301
|
-
if (!parsedQuery.success) {
|
|
1302
|
-
return c.json(
|
|
1303
|
-
makeJsonRpcError(null, INVALID_REQUEST, "missing sessionId query parameter"),
|
|
1304
|
-
400
|
|
1305
|
-
);
|
|
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";
|
|
1306
1587
|
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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;
|
|
1311
1643
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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`);
|
|
1318
1672
|
}
|
|
1319
|
-
|
|
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) {
|
|
1320
1690
|
try {
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
return
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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;
|
|
1328
1702
|
}
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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"
|
|
1341
1718
|
};
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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)
|
|
1347
1755
|
});
|
|
1348
|
-
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
throw this.describeFetchFailure(error, "server/discover probe");
|
|
1349
1758
|
}
|
|
1350
|
-
|
|
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;
|
|
1351
1802
|
try {
|
|
1352
|
-
|
|
1353
|
-
} catch
|
|
1354
|
-
|
|
1355
|
-
console.error("[helio] Upstream forwarding failed:", forwardingError.message);
|
|
1356
|
-
const normalized2 = normalizeUpstreamOutcome({ requestId: id, forwardingError });
|
|
1357
|
-
const errorEvent = sseEvent("message", JSON.stringify(normalized2.body));
|
|
1358
|
-
writeSessionEvent(sessionId, errorEvent);
|
|
1359
|
-
return c.body(null, 202);
|
|
1803
|
+
parsed = JSON.parse(raw);
|
|
1804
|
+
} catch {
|
|
1805
|
+
return { kind: "unparseable" };
|
|
1360
1806
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
const
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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");
|
|
1385
1866
|
});
|
|
1386
|
-
|
|
1387
|
-
|
|
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;
|
|
1388
1887
|
try {
|
|
1389
|
-
|
|
1888
|
+
parsed = JSON.parse(raw);
|
|
1390
1889
|
} catch {
|
|
1890
|
+
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1391
1891
|
}
|
|
1392
|
-
if (
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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
|
+
);
|
|
1396
1918
|
}
|
|
1397
|
-
return;
|
|
1398
1919
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
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;
|
|
1401
1927
|
}
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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;
|
|
1421
1948
|
try {
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
settle(err);
|
|
1425
|
-
return;
|
|
1426
|
-
}
|
|
1427
|
-
settle();
|
|
1428
|
-
});
|
|
1429
|
-
} catch (error) {
|
|
1430
|
-
settle(normalizeError(error));
|
|
1949
|
+
parsed = JSON.parse(data);
|
|
1950
|
+
} catch {
|
|
1431
1951
|
return;
|
|
1432
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;
|
|
1433
1963
|
try {
|
|
1434
|
-
|
|
1964
|
+
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1435
1965
|
} catch {
|
|
1966
|
+
await reader.cancel().catch(() => void 0);
|
|
1967
|
+
return { outcome: "timed-out" };
|
|
1436
1968
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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);
|
|
1452
2009
|
}
|
|
1453
|
-
return app;
|
|
1454
|
-
}
|
|
1455
|
-
function startServer(app, config) {
|
|
1456
|
-
const server = serve({
|
|
1457
|
-
fetch: app.fetch,
|
|
1458
|
-
port: config.listen.port,
|
|
1459
|
-
hostname: config.listen.host
|
|
1460
|
-
});
|
|
1461
|
-
return createServerHandle(server);
|
|
1462
2010
|
}
|
|
1463
|
-
function
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
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;
|
|
1470
2017
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
+
};
|
|
1489
2038
|
}
|
|
1490
|
-
|
|
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" };
|
|
1491
2045
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
remainder = lines.pop() ?? "";
|
|
1499
|
-
for (const rawLine of lines) {
|
|
1500
|
-
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
1501
|
-
if (line === "") {
|
|
1502
|
-
if (event || data) {
|
|
1503
|
-
onEvent(event, data);
|
|
1504
|
-
event = "";
|
|
1505
|
-
data = "";
|
|
1506
|
-
}
|
|
1507
|
-
} else if (line.startsWith("event:")) {
|
|
1508
|
-
const value = line.slice(6).replace(/^ /, "");
|
|
1509
|
-
event = value;
|
|
1510
|
-
} else if (line.startsWith("data:")) {
|
|
1511
|
-
const value = line.slice(5).replace(/^ /, "");
|
|
1512
|
-
data = data ? data + "\n" + value : value;
|
|
1513
|
-
}
|
|
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" };
|
|
1514
2052
|
}
|
|
1515
|
-
|
|
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 };
|
|
1516
2065
|
}
|
|
1517
|
-
|
|
1518
|
-
if (!
|
|
1519
|
-
|
|
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;
|
|
1520
2082
|
}
|
|
1521
|
-
const
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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 }
|
|
1532
2119
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
if (
|
|
1536
|
-
|
|
2120
|
+
});
|
|
2121
|
+
if (headerMethod === void 0) {
|
|
2122
|
+
if (requiresPresence) {
|
|
2123
|
+
return reject(`missing mcp-method header (expected ${displayCap(method)})`);
|
|
1537
2124
|
}
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
if (
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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
|
+
);
|
|
1550
2141
|
}
|
|
1551
2142
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
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
|
+
);
|
|
1557
2151
|
}
|
|
1558
|
-
|
|
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
|
+
);
|
|
1559
2157
|
}
|
|
1560
2158
|
}
|
|
1561
|
-
|
|
1562
|
-
`upstream SSE stream closed with no JSON-RPC response for id ${String(requestId)}`
|
|
1563
|
-
);
|
|
2159
|
+
return { ok: true };
|
|
1564
2160
|
}
|
|
1565
2161
|
|
|
1566
|
-
// src/
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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
|
+
);
|
|
1572
2183
|
}
|
|
1573
2184
|
};
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
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
|
+
};
|
|
1578
2193
|
}
|
|
1579
2194
|
|
|
1580
|
-
// src/
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
"
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
"
|
|
1589
|
-
"
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
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;
|
|
1602
2216
|
}
|
|
1603
|
-
|
|
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;
|
|
1604
2222
|
}
|
|
1605
|
-
function
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
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
|
+
};
|
|
1612
2244
|
}
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
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
|
+
};
|
|
1617
2302
|
}
|
|
1618
2303
|
|
|
1619
|
-
// src/
|
|
1620
|
-
var
|
|
1621
|
-
var
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
+
});
|
|
1641
2482
|
});
|
|
1642
|
-
return
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
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
|
+
);
|
|
1655
2498
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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(
|
|
1660
2527
|
{
|
|
1661
|
-
|
|
1662
|
-
|
|
2528
|
+
headers: Object.fromEntries(c.req.raw.headers),
|
|
2529
|
+
meta: paramsMeta(params),
|
|
2530
|
+
transportSessionId,
|
|
2531
|
+
transportMintedId: sessionId
|
|
1663
2532
|
},
|
|
1664
|
-
|
|
1665
|
-
this.staticHeaders
|
|
2533
|
+
sessionIdentity
|
|
1666
2534
|
);
|
|
1667
|
-
const
|
|
2535
|
+
const mcpRequest = {
|
|
1668
2536
|
jsonrpc: "2.0",
|
|
1669
|
-
id
|
|
1670
|
-
method
|
|
1671
|
-
params
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
2537
|
+
id,
|
|
2538
|
+
method,
|
|
2539
|
+
params,
|
|
2540
|
+
session: resolvedSession,
|
|
2541
|
+
transportSessionId,
|
|
2542
|
+
headers: forwardHeaders,
|
|
2543
|
+
signal: c.req.raw.signal
|
|
1676
2544
|
};
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
body: JSON.stringify(initBody),
|
|
1683
|
-
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}`);
|
|
1684
2550
|
});
|
|
1685
|
-
|
|
1686
|
-
throw this.describeFetchFailure(error, "initialize");
|
|
1687
|
-
}
|
|
1688
|
-
if (!res.ok) {
|
|
1689
|
-
throw new Error(`upstream initialize failed: HTTP ${String(res.status)}`);
|
|
2551
|
+
return c.body(null, 202);
|
|
1690
2552
|
}
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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);
|
|
1700
2563
|
}
|
|
1701
|
-
const
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
notifyHeaders["mcp-protocol-version"] = negotiatedProtocolVersion;
|
|
1705
|
-
const notifyRes = await fetch(this.url, {
|
|
1706
|
-
method: "POST",
|
|
1707
|
-
headers: notifyHeaders,
|
|
1708
|
-
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
1709
|
-
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
1710
|
-
}).catch((error) => {
|
|
1711
|
-
throw this.describeFetchFailure(error, "notifications/initialized");
|
|
2564
|
+
const normalized = normalizeUpstreamOutcome({
|
|
2565
|
+
requestId: id,
|
|
2566
|
+
upstreamResponse: result.response
|
|
1712
2567
|
});
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
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 = () => {
|
|
1733
2591
|
try {
|
|
1734
|
-
|
|
2592
|
+
nodeServer.closeIdleConnections?.();
|
|
1735
2593
|
} catch {
|
|
1736
|
-
throw new Error(`upstream ${step} returned non-JSON body`);
|
|
1737
2594
|
}
|
|
1738
|
-
if (
|
|
1739
|
-
|
|
2595
|
+
if (nodeServer.closeAllConnections) {
|
|
2596
|
+
try {
|
|
2597
|
+
nodeServer.closeAllConnections();
|
|
2598
|
+
} catch {
|
|
2599
|
+
}
|
|
2600
|
+
return;
|
|
1740
2601
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
} 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);
|
|
1760
2620
|
return;
|
|
1761
2621
|
}
|
|
1762
|
-
|
|
1763
|
-
errorMessage = extractJsonRpcErrorMessage(parsed2);
|
|
2622
|
+
resolve();
|
|
1764
2623
|
};
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1771
|
-
);
|
|
1772
|
-
}
|
|
1773
|
-
let chunk;
|
|
1774
|
-
try {
|
|
1775
|
-
chunk = await readSseChunkWithTimeout(reader, remainingMs);
|
|
1776
|
-
} catch {
|
|
1777
|
-
await reader.cancel().catch(() => void 0);
|
|
1778
|
-
throw new Error(
|
|
1779
|
-
`upstream notifications/initialized SSE response timed out after ${String(this.requestTimeoutMs)}ms`
|
|
1780
|
-
);
|
|
1781
|
-
}
|
|
1782
|
-
const { done, value } = chunk;
|
|
1783
|
-
if (value !== void 0) {
|
|
1784
|
-
scannedBytes += value.byteLength;
|
|
1785
|
-
if (scannedBytes > MAX_SSE_ERROR_SCAN_BYTES) {
|
|
1786
|
-
await reader.cancel().catch(() => void 0);
|
|
1787
|
-
throw new Error(
|
|
1788
|
-
`upstream notifications/initialized SSE response exceeded ${String(MAX_SSE_ERROR_SCAN_BYTES)} bytes`
|
|
1789
|
-
);
|
|
1790
|
-
}
|
|
1791
|
-
state = parseSseChunk(decoder.decode(value, { stream: true }), state, onEvent);
|
|
1792
|
-
if (errorMessage) {
|
|
1793
|
-
await reader.cancel().catch(() => void 0);
|
|
1794
|
-
return errorMessage;
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
if (done) {
|
|
1798
|
-
const tail = decoder.decode();
|
|
1799
|
-
if (tail) {
|
|
1800
|
-
state = parseSseChunk(tail, state, onEvent);
|
|
2624
|
+
try {
|
|
2625
|
+
nodeServer.close((err) => {
|
|
2626
|
+
if (err) {
|
|
2627
|
+
settle(err);
|
|
2628
|
+
return;
|
|
1801
2629
|
}
|
|
1802
|
-
|
|
1803
|
-
}
|
|
2630
|
+
settle();
|
|
2631
|
+
});
|
|
2632
|
+
} catch (error) {
|
|
2633
|
+
settle(normalizeError(error));
|
|
2634
|
+
return;
|
|
1804
2635
|
}
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
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
|
-
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);
|
|
1835
2665
|
}
|
|
2666
|
+
return app;
|
|
1836
2667
|
}
|
|
1837
|
-
function
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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);
|
|
1843
2675
|
}
|
|
1844
|
-
function
|
|
1845
|
-
const
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
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);
|
|
1851
2683
|
}
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
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();
|
|
1856
2702
|
}
|
|
1857
|
-
|
|
1858
|
-
return typeof protocolVersion === "string" && protocolVersion.trim() ? protocolVersion : HELIO_MCP_PROTOCOL_VERSION;
|
|
2703
|
+
return { status: res.status, headers, body };
|
|
1859
2704
|
}
|
|
1860
2705
|
|
|
1861
2706
|
// src/upstream/streamable-http-forwarder.ts
|
|
2707
|
+
var JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
1862
2708
|
var StreamableHttpForwarder = class {
|
|
1863
2709
|
url;
|
|
1864
2710
|
staticHeaders;
|
|
@@ -1871,7 +2717,8 @@ var StreamableHttpForwarder = class {
|
|
|
1871
2717
|
this.sessions = new UpstreamSessionManager({
|
|
1872
2718
|
url: this.url,
|
|
1873
2719
|
staticHeaders: this.staticHeaders,
|
|
1874
|
-
requestTimeoutMs: this.requestTimeoutMs
|
|
2720
|
+
requestTimeoutMs: this.requestTimeoutMs,
|
|
2721
|
+
protocolVersion: options.protocolVersion
|
|
1875
2722
|
});
|
|
1876
2723
|
}
|
|
1877
2724
|
/** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
|
|
@@ -1884,15 +2731,91 @@ var StreamableHttpForwarder = class {
|
|
|
1884
2731
|
return Promise.resolve();
|
|
1885
2732
|
}
|
|
1886
2733
|
async forward(request) {
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
request
|
|
1891
|
-
|
|
1892
|
-
|
|
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"
|
|
1893
2817
|
);
|
|
1894
2818
|
}
|
|
1895
|
-
return this.send(request, request.sessionId, HELIO_MCP_PROTOCOL_VERSION);
|
|
1896
2819
|
}
|
|
1897
2820
|
/**
|
|
1898
2821
|
* Helio-internal execution path (startup prime / internal maintenance) that
|
|
@@ -1903,8 +2826,7 @@ var StreamableHttpForwarder = class {
|
|
|
1903
2826
|
try {
|
|
1904
2827
|
return await this.send(
|
|
1905
2828
|
request,
|
|
1906
|
-
session
|
|
1907
|
-
session.protocolVersion,
|
|
2829
|
+
session,
|
|
1908
2830
|
/* internalManaged */
|
|
1909
2831
|
true
|
|
1910
2832
|
);
|
|
@@ -1914,8 +2836,7 @@ var StreamableHttpForwarder = class {
|
|
|
1914
2836
|
const fresh = await this.sessions.ensureInternalSession();
|
|
1915
2837
|
return this.send(
|
|
1916
2838
|
request,
|
|
1917
|
-
fresh
|
|
1918
|
-
fresh.protocolVersion,
|
|
2839
|
+
fresh,
|
|
1919
2840
|
/* internalManaged */
|
|
1920
2841
|
true
|
|
1921
2842
|
);
|
|
@@ -1923,7 +2844,50 @@ var StreamableHttpForwarder = class {
|
|
|
1923
2844
|
throw error;
|
|
1924
2845
|
}
|
|
1925
2846
|
}
|
|
1926
|
-
|
|
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
|
+
}
|
|
1927
2891
|
const headers = mergeUpstreamHeaders(
|
|
1928
2892
|
{
|
|
1929
2893
|
"content-type": "application/json",
|
|
@@ -1932,16 +2896,26 @@ var StreamableHttpForwarder = class {
|
|
|
1932
2896
|
request.headers ?? {},
|
|
1933
2897
|
this.staticHeaders
|
|
1934
2898
|
);
|
|
1935
|
-
if (sessionId) headers["mcp-session-id"] = sessionId;
|
|
1936
|
-
if (
|
|
1937
|
-
headers["mcp-
|
|
1938
|
-
|
|
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));
|
|
1939
2909
|
const body = {
|
|
1940
2910
|
jsonrpc: request.jsonrpc,
|
|
1941
2911
|
method: request.method
|
|
1942
2912
|
};
|
|
1943
2913
|
if (request.id !== void 0) body["id"] = request.id;
|
|
1944
|
-
if (
|
|
2914
|
+
if (modern) {
|
|
2915
|
+
body["params"] = outboundParams;
|
|
2916
|
+
} else if (request.params !== void 0) {
|
|
2917
|
+
body["params"] = outboundParams;
|
|
2918
|
+
}
|
|
1945
2919
|
const start = performance.now();
|
|
1946
2920
|
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs);
|
|
1947
2921
|
const requestSignal = request.signal;
|
|
@@ -1957,7 +2931,7 @@ var StreamableHttpForwarder = class {
|
|
|
1957
2931
|
}
|
|
1958
2932
|
throw describeUnreachableUpstream(error, this.url) ?? error;
|
|
1959
2933
|
}
|
|
1960
|
-
if (internalManaged && res.status === 404 && sessionId) {
|
|
2934
|
+
if (internalManaged && res.status === 404 && session.sessionId) {
|
|
1961
2935
|
await res.text().catch(() => void 0);
|
|
1962
2936
|
throw new UpstreamSessionExpiredError();
|
|
1963
2937
|
}
|
|
@@ -1967,6 +2941,7 @@ var StreamableHttpForwarder = class {
|
|
|
1967
2941
|
res.headers.forEach((value, key) => {
|
|
1968
2942
|
responseHeaders[key] = value;
|
|
1969
2943
|
});
|
|
2944
|
+
if (modern) delete responseHeaders["mcp-session-id"];
|
|
1970
2945
|
if (request.id === void 0) {
|
|
1971
2946
|
await res.body?.cancel().catch(() => void 0);
|
|
1972
2947
|
const response3 = {
|
|
@@ -1981,6 +2956,7 @@ var StreamableHttpForwarder = class {
|
|
|
1981
2956
|
return { response: response2, durationMs: performance.now() - start };
|
|
1982
2957
|
}
|
|
1983
2958
|
const response = await parseUpstreamResponse(res);
|
|
2959
|
+
if (modern) delete response.headers["mcp-session-id"];
|
|
1984
2960
|
return { response, durationMs: performance.now() - start };
|
|
1985
2961
|
}
|
|
1986
2962
|
};
|
|
@@ -1990,6 +2966,16 @@ var UpstreamSessionExpiredError = class extends Error {
|
|
|
1990
2966
|
this.name = "UpstreamSessionExpiredError";
|
|
1991
2967
|
}
|
|
1992
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
|
+
}
|
|
1993
2979
|
|
|
1994
2980
|
// src/upstream/forwarder.ts
|
|
1995
2981
|
var UpstreamForwarder = class extends StreamableHttpForwarder {
|
|
@@ -2142,8 +3128,11 @@ var SseUpstreamForwarder = class {
|
|
|
2142
3128
|
request.headers ?? {},
|
|
2143
3129
|
this.staticHeaders
|
|
2144
3130
|
);
|
|
2145
|
-
|
|
2146
|
-
|
|
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;
|
|
2147
3136
|
}
|
|
2148
3137
|
const start = performance.now();
|
|
2149
3138
|
const signal = buildRequestSignal(request, this.requestTimeoutMs);
|
|
@@ -2572,6 +3561,79 @@ function evaluatePolicy(policy, ctx) {
|
|
|
2572
3561
|
// src/policy/governed-forwarder.ts
|
|
2573
3562
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
2574
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
|
+
|
|
2575
3637
|
// src/evidence/grounding.ts
|
|
2576
3638
|
function checkEvidence(store, sessionId, requirements) {
|
|
2577
3639
|
if (requirements.length === 0) {
|
|
@@ -2617,7 +3679,8 @@ function checkDependencies(store, sessionId, requirements, options = {}) {
|
|
|
2617
3679
|
|
|
2618
3680
|
// src/policy/decision-pipeline.ts
|
|
2619
3681
|
function decide(input) {
|
|
2620
|
-
const { toolName, toolArguments,
|
|
3682
|
+
const { toolName, toolArguments, policy, environment, evidenceStore } = input;
|
|
3683
|
+
const sessionId = isWellFormedSessionId(input.sessionId) ? input.sessionId : void 0;
|
|
2621
3684
|
const annotations = input.baselineAnnotations;
|
|
2622
3685
|
const driftEvent = input.driftEvent;
|
|
2623
3686
|
const driftMode = policy.onToolDrift ?? "block";
|
|
@@ -2676,7 +3739,9 @@ function decide(input) {
|
|
|
2676
3739
|
decision = {
|
|
2677
3740
|
action: "deny",
|
|
2678
3741
|
matchedRule: decision.matchedRule,
|
|
2679
|
-
reason:
|
|
3742
|
+
reason: sessionRequiredForGroundingMessage(
|
|
3743
|
+
input.sessionStrategySummary ?? "the configured session.identity chain"
|
|
3744
|
+
)
|
|
2680
3745
|
};
|
|
2681
3746
|
}
|
|
2682
3747
|
if (decision.action !== "deny" && evidenceStore && sessionId && decision.matchedRule) {
|
|
@@ -3127,6 +4192,17 @@ function buildToolDriftFeedback(drift, action) {
|
|
|
3127
4192
|
retry_allowed: false
|
|
3128
4193
|
};
|
|
3129
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
|
+
}
|
|
3130
4206
|
function buildSpendLimitedFeedback(decision, result, currency) {
|
|
3131
4207
|
const info = ruleInfo(decision.matchedRule);
|
|
3132
4208
|
const windowSeconds = Math.round(result.windowMs / 1e3);
|
|
@@ -3584,6 +4660,7 @@ var GovernedForwarder = class {
|
|
|
3584
4660
|
inner;
|
|
3585
4661
|
policy;
|
|
3586
4662
|
environment;
|
|
4663
|
+
session;
|
|
3587
4664
|
auditWriter;
|
|
3588
4665
|
evidenceStore;
|
|
3589
4666
|
approvalRouter;
|
|
@@ -3603,6 +4680,7 @@ var GovernedForwarder = class {
|
|
|
3603
4680
|
this.rateLimiter = options?.rateLimiter;
|
|
3604
4681
|
this.spendLimiter = options?.spendLimiter;
|
|
3605
4682
|
this.budgetEngine = options?.budgetEngine;
|
|
4683
|
+
this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
|
|
3606
4684
|
if (this.evidenceStore) {
|
|
3607
4685
|
this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
|
|
3608
4686
|
}
|
|
@@ -3676,6 +4754,7 @@ var GovernedForwarder = class {
|
|
|
3676
4754
|
try {
|
|
3677
4755
|
const result = typeof internal.forwardInternal === "function" ? await internal.forwardInternal(syntheticToolsList) : await this.inner.forward(syntheticToolsList);
|
|
3678
4756
|
if (result.response.status >= 400) {
|
|
4757
|
+
internal.resetInternalSession?.();
|
|
3679
4758
|
return {
|
|
3680
4759
|
success: false,
|
|
3681
4760
|
toolsCached: this.annotationCache.size,
|
|
@@ -3684,6 +4763,7 @@ var GovernedForwarder = class {
|
|
|
3684
4763
|
}
|
|
3685
4764
|
const update = this.applyToolDefinitionUpdate(result.response.body, void 0);
|
|
3686
4765
|
if (!update.updated) {
|
|
4766
|
+
internal.resetInternalSession?.();
|
|
3687
4767
|
return {
|
|
3688
4768
|
success: false,
|
|
3689
4769
|
toolsCached: this.annotationCache.size,
|
|
@@ -3692,6 +4772,7 @@ var GovernedForwarder = class {
|
|
|
3692
4772
|
}
|
|
3693
4773
|
return { success: true, toolsCached: this.annotationCache.size };
|
|
3694
4774
|
} catch (error) {
|
|
4775
|
+
internal.resetInternalSession?.();
|
|
3695
4776
|
return {
|
|
3696
4777
|
success: false,
|
|
3697
4778
|
toolsCached: this.annotationCache.size,
|
|
@@ -3705,17 +4786,42 @@ var GovernedForwarder = class {
|
|
|
3705
4786
|
}
|
|
3706
4787
|
const result = await this.inner.forward(request);
|
|
3707
4788
|
if (request.method === "tools/list") {
|
|
3708
|
-
this.applyToolDefinitionUpdate(result.response.body, request.
|
|
4789
|
+
this.applyToolDefinitionUpdate(result.response.body, request.session);
|
|
4790
|
+
this.clampCacheHints(result.response.body);
|
|
3709
4791
|
}
|
|
3710
4792
|
return result;
|
|
3711
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
|
+
}
|
|
3712
4818
|
/**
|
|
3713
4819
|
* Apply a tools/list response to the definition cache and surface any
|
|
3714
4820
|
* drift: console warning + immediate audit record per event. Single entry
|
|
3715
4821
|
* point for both runtime tools/list responses and startup priming, so the
|
|
3716
4822
|
* cache is updated exactly once per response.
|
|
3717
4823
|
*/
|
|
3718
|
-
applyToolDefinitionUpdate(responseBody,
|
|
4824
|
+
applyToolDefinitionUpdate(responseBody, session) {
|
|
3719
4825
|
const update = this.annotationCache.update(responseBody);
|
|
3720
4826
|
if (!update.updated) return update;
|
|
3721
4827
|
for (const drift of update.drifted) {
|
|
@@ -3723,22 +4829,23 @@ var GovernedForwarder = class {
|
|
|
3723
4829
|
console.error(
|
|
3724
4830
|
`[helio] Tool definition drift detected: "${drift.toolName}" changed (${aspects}) after baseline \u2014 calls governed by policies.on_tool_drift (${this.policy.onToolDrift ?? "block"})`
|
|
3725
4831
|
);
|
|
3726
|
-
this.writeDriftAuditRecord(drift,
|
|
4832
|
+
this.writeDriftAuditRecord(drift, session, "tool_drift");
|
|
3727
4833
|
}
|
|
3728
4834
|
for (const toolName of update.reverted) {
|
|
3729
4835
|
console.error(
|
|
3730
4836
|
`[helio] Tool definition drift cleared: "${toolName}" returned to its baseline definition`
|
|
3731
4837
|
);
|
|
3732
|
-
this.writeDriftAuditRecord({ toolName, changes: [] },
|
|
4838
|
+
this.writeDriftAuditRecord({ toolName, changes: [] }, session, "tool_drift_reverted");
|
|
3733
4839
|
}
|
|
3734
4840
|
return update;
|
|
3735
4841
|
}
|
|
3736
4842
|
/** Write an immediate audit record for a drift event (not a tool call). */
|
|
3737
|
-
writeDriftAuditRecord(drift,
|
|
4843
|
+
writeDriftAuditRecord(drift, session, decision) {
|
|
3738
4844
|
if (!this.auditWriter) return;
|
|
3739
4845
|
this.auditWriter.pushImmediate({
|
|
3740
4846
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3741
|
-
session_id:
|
|
4847
|
+
session_id: session?.id ?? null,
|
|
4848
|
+
session_source: session?.source ?? null,
|
|
3742
4849
|
agent_id: null,
|
|
3743
4850
|
environment: this.environment ?? null,
|
|
3744
4851
|
tool_name: drift.toolName,
|
|
@@ -3761,7 +4868,9 @@ var GovernedForwarder = class {
|
|
|
3761
4868
|
dry_run: false,
|
|
3762
4869
|
record_kind: "drift_event",
|
|
3763
4870
|
origin: "mcp",
|
|
3764
|
-
metadata: null
|
|
4871
|
+
metadata: null,
|
|
4872
|
+
// Drift is a cache event, not a request: no protocol claim exists.
|
|
4873
|
+
protocol_version: null
|
|
3765
4874
|
});
|
|
3766
4875
|
}
|
|
3767
4876
|
async handleToolsCall(original) {
|
|
@@ -3798,7 +4907,8 @@ var GovernedForwarder = class {
|
|
|
3798
4907
|
} = decide({
|
|
3799
4908
|
toolName,
|
|
3800
4909
|
toolArguments,
|
|
3801
|
-
sessionId: request.
|
|
4910
|
+
sessionId: request.session?.id,
|
|
4911
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
3802
4912
|
policy: this.policy,
|
|
3803
4913
|
environment: this.environment,
|
|
3804
4914
|
evidenceStore: this.evidenceStore,
|
|
@@ -3891,9 +5001,10 @@ var GovernedForwarder = class {
|
|
|
3891
5001
|
});
|
|
3892
5002
|
}
|
|
3893
5003
|
try {
|
|
3894
|
-
|
|
5004
|
+
const dependencySessionId = request.session?.id;
|
|
5005
|
+
if (forwarded && !isDryRun && this.evidenceStore && isWellFormedSessionId(dependencySessionId) && toolName) {
|
|
3895
5006
|
const succeeded = !hasJsonRpcError(result);
|
|
3896
|
-
this.evidenceStore.recordToolCall(
|
|
5007
|
+
this.evidenceStore.recordToolCall(dependencySessionId, toolName, succeeded);
|
|
3897
5008
|
}
|
|
3898
5009
|
} catch (err) {
|
|
3899
5010
|
console.error("[helio] dependency tracking failed after forward:", err);
|
|
@@ -3914,6 +5025,7 @@ var GovernedForwarder = class {
|
|
|
3914
5025
|
evidenceResult,
|
|
3915
5026
|
dependencyResult,
|
|
3916
5027
|
evidenceBlocked,
|
|
5028
|
+
sessionBlocked,
|
|
3917
5029
|
approvalOutcome,
|
|
3918
5030
|
approvalContext,
|
|
3919
5031
|
rateLimitResult,
|
|
@@ -3944,7 +5056,7 @@ var GovernedForwarder = class {
|
|
|
3944
5056
|
tool_name: toolName,
|
|
3945
5057
|
tool_input: toolArguments ?? {},
|
|
3946
5058
|
matched_rule: decision.matchedRule,
|
|
3947
|
-
session_id: request.
|
|
5059
|
+
session_id: request.session?.id ?? null,
|
|
3948
5060
|
breached_budgets: gate.breachContexts,
|
|
3949
5061
|
approval: gate.approval
|
|
3950
5062
|
},
|
|
@@ -4087,15 +5199,25 @@ var GovernedForwarder = class {
|
|
|
4087
5199
|
gateBudgets(request, decision, toolName, toolArguments) {
|
|
4088
5200
|
const engine = this.budgetEngine;
|
|
4089
5201
|
if (!engine) return { kind: "proceed" };
|
|
5202
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4090
5203
|
const { charges, failures } = engine.resolveCharges({
|
|
4091
5204
|
toolName,
|
|
4092
5205
|
toolArguments,
|
|
4093
|
-
sessionId:
|
|
5206
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4094
5207
|
senderId: null
|
|
4095
5208
|
// adapter context; absent on the MCP path
|
|
4096
5209
|
});
|
|
4097
5210
|
if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
|
|
4098
|
-
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: [] };
|
|
4099
5221
|
const breaches = peek.entries.filter((entry) => !entry.allowed);
|
|
4100
5222
|
const anyHardDeny = failures.length > 0 || breaches.some((entry) => entry.budget.onExceed === "deny");
|
|
4101
5223
|
if (breaches.length > 0) engine.reportBreaches(breaches);
|
|
@@ -4128,7 +5250,7 @@ var GovernedForwarder = class {
|
|
|
4128
5250
|
const peekBlockByName = new Map(
|
|
4129
5251
|
peek.entries.map((entry) => [entry.budget.name, budgetChainBlock(entry)])
|
|
4130
5252
|
);
|
|
4131
|
-
const commit = (auditRecordId, kinds) => engine.recordAll(charges, {
|
|
5253
|
+
const commit = (auditRecordId, kinds) => engine.recordAll(gated.charges, {
|
|
4132
5254
|
kind: "spend",
|
|
4133
5255
|
...kinds ? { kinds } : {},
|
|
4134
5256
|
auditRecordId,
|
|
@@ -4204,7 +5326,8 @@ var GovernedForwarder = class {
|
|
|
4204
5326
|
const toolInput = { raw_params: params ?? null };
|
|
4205
5327
|
this.auditWriter.pushImmediate({
|
|
4206
5328
|
timestamp,
|
|
4207
|
-
session_id: request.
|
|
5329
|
+
session_id: request.session?.id ?? null,
|
|
5330
|
+
session_source: request.session?.source ?? null,
|
|
4208
5331
|
agent_id: null,
|
|
4209
5332
|
environment: this.environment ?? null,
|
|
4210
5333
|
tool_name: "<nameless>",
|
|
@@ -4227,7 +5350,8 @@ var GovernedForwarder = class {
|
|
|
4227
5350
|
dry_run: false,
|
|
4228
5351
|
record_kind: "tool_call",
|
|
4229
5352
|
origin: "mcp",
|
|
4230
|
-
metadata: null
|
|
5353
|
+
metadata: null,
|
|
5354
|
+
protocol_version: request.protocolVersion ?? null
|
|
4231
5355
|
});
|
|
4232
5356
|
}
|
|
4233
5357
|
return result;
|
|
@@ -4240,7 +5364,7 @@ var GovernedForwarder = class {
|
|
|
4240
5364
|
tool_name: toolName,
|
|
4241
5365
|
tool_input: toolArguments ?? {},
|
|
4242
5366
|
matched_rule: decision.matchedRule,
|
|
4243
|
-
session_id: request.
|
|
5367
|
+
session_id: request.session?.id ?? null
|
|
4244
5368
|
},
|
|
4245
5369
|
request.signal
|
|
4246
5370
|
);
|
|
@@ -4336,7 +5460,20 @@ var GovernedForwarder = class {
|
|
|
4336
5460
|
rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4337
5461
|
};
|
|
4338
5462
|
}
|
|
4339
|
-
|
|
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
|
+
}
|
|
4340
5477
|
const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
|
|
4341
5478
|
const rateLimitResult = limiter.peek(params);
|
|
4342
5479
|
if (!rateLimitResult.allowed) {
|
|
@@ -4374,7 +5511,21 @@ var GovernedForwarder = class {
|
|
|
4374
5511
|
spendLimitResult: { allowed: false, currentSpend: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
|
|
4375
5512
|
};
|
|
4376
5513
|
}
|
|
4377
|
-
|
|
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);
|
|
4378
5529
|
const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
|
|
4379
5530
|
if (typeof rawAmount !== "number") {
|
|
4380
5531
|
console.error(
|
|
@@ -4438,6 +5589,7 @@ var GovernedForwarder = class {
|
|
|
4438
5589
|
const evidenceSatisfied = !evidenceBlocked;
|
|
4439
5590
|
let wouldForward = false;
|
|
4440
5591
|
let limitsOk = true;
|
|
5592
|
+
let sessionUnresolved = false;
|
|
4441
5593
|
if (!evidenceBlocked) {
|
|
4442
5594
|
switch (decision.action) {
|
|
4443
5595
|
case "allow":
|
|
@@ -4445,14 +5597,21 @@ var GovernedForwarder = class {
|
|
|
4445
5597
|
break;
|
|
4446
5598
|
case "rate_limit":
|
|
4447
5599
|
if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
|
|
4448
|
-
const
|
|
4449
|
-
const
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
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
|
+
}
|
|
4456
5615
|
}
|
|
4457
5616
|
break;
|
|
4458
5617
|
case "spend_limit":
|
|
@@ -4472,20 +5631,21 @@ var GovernedForwarder = class {
|
|
|
4472
5631
|
wouldForward = false;
|
|
4473
5632
|
limitsOk = false;
|
|
4474
5633
|
} else {
|
|
4475
|
-
const
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
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
|
+
}
|
|
4489
5649
|
}
|
|
4490
5650
|
}
|
|
4491
5651
|
break;
|
|
@@ -4493,30 +5653,39 @@ var GovernedForwarder = class {
|
|
|
4493
5653
|
}
|
|
4494
5654
|
let budgets;
|
|
4495
5655
|
if (wouldForward && this.budgetEngine) {
|
|
5656
|
+
const sessionGate = gateSession(request.session?.id, this.session.onUnresolved);
|
|
4496
5657
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
4497
5658
|
toolName,
|
|
4498
5659
|
toolArguments,
|
|
4499
|
-
sessionId:
|
|
5660
|
+
sessionId: sessionGate.ok ? sessionGate.session : null,
|
|
4500
5661
|
senderId: null
|
|
4501
5662
|
});
|
|
4502
5663
|
if (failures.length > 0 || charges.length > 0) {
|
|
4503
|
-
const
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
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
|
+
}
|
|
4520
5689
|
}
|
|
4521
5690
|
}
|
|
4522
5691
|
return this.makeDryRunResult(
|
|
@@ -4525,14 +5694,18 @@ var GovernedForwarder = class {
|
|
|
4525
5694
|
wouldForward,
|
|
4526
5695
|
evidenceSatisfied,
|
|
4527
5696
|
limitsOk,
|
|
4528
|
-
budgets
|
|
5697
|
+
budgets,
|
|
5698
|
+
sessionUnresolved
|
|
4529
5699
|
);
|
|
4530
5700
|
}
|
|
4531
|
-
/**
|
|
4532
|
-
|
|
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) {
|
|
4533
5708
|
switch (keyType) {
|
|
4534
|
-
case "session":
|
|
4535
|
-
return `session:${request.sessionId ?? "unknown"}`;
|
|
4536
5709
|
case "agent":
|
|
4537
5710
|
if (!this.agentKeyWarned) {
|
|
4538
5711
|
this.agentKeyWarned = true;
|
|
@@ -4555,14 +5728,29 @@ var GovernedForwarder = class {
|
|
|
4555
5728
|
}
|
|
4556
5729
|
}
|
|
4557
5730
|
/**
|
|
4558
|
-
*
|
|
4559
|
-
*
|
|
4560
|
-
*
|
|
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).
|
|
4561
5734
|
*/
|
|
4562
|
-
|
|
4563
|
-
|
|
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
|
+
);
|
|
4564
5752
|
}
|
|
4565
|
-
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) {
|
|
4566
5754
|
if (!this.auditWriter) return;
|
|
4567
5755
|
const actuallyForwarded = forwarded && !isDryRun;
|
|
4568
5756
|
const hadForwardingError = forwardingError !== void 0;
|
|
@@ -4655,9 +5843,16 @@ var GovernedForwarder = class {
|
|
|
4655
5843
|
};
|
|
4656
5844
|
}
|
|
4657
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
|
+
}
|
|
4658
5852
|
const record = {
|
|
4659
5853
|
timestamp,
|
|
4660
|
-
session_id: request.
|
|
5854
|
+
session_id: request.session?.id ?? null,
|
|
5855
|
+
session_source: request.session?.source ?? null,
|
|
4661
5856
|
agent_id: null,
|
|
4662
5857
|
environment: this.environment ?? null,
|
|
4663
5858
|
tool_name: toolName,
|
|
@@ -4683,7 +5878,8 @@ var GovernedForwarder = class {
|
|
|
4683
5878
|
dry_run: isDryRun ?? false,
|
|
4684
5879
|
record_kind: "tool_call",
|
|
4685
5880
|
origin: "mcp",
|
|
4686
|
-
metadata: null
|
|
5881
|
+
metadata: null,
|
|
5882
|
+
protocol_version: request.protocolVersion ?? null
|
|
4687
5883
|
};
|
|
4688
5884
|
const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
|
|
4689
5885
|
if (isEnforcementDecision) {
|
|
@@ -4726,7 +5922,7 @@ var GovernedForwarder = class {
|
|
|
4726
5922
|
unsupported: true
|
|
4727
5923
|
});
|
|
4728
5924
|
}
|
|
4729
|
-
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets) {
|
|
5925
|
+
makeDryRunResult(request, decision, wouldForward, evidenceSatisfied, limitsOk, budgets, sessionUnresolved) {
|
|
4730
5926
|
const payload = {
|
|
4731
5927
|
dry_run: true,
|
|
4732
5928
|
would_forward: wouldForward,
|
|
@@ -4734,13 +5930,19 @@ var GovernedForwarder = class {
|
|
|
4734
5930
|
matched_rule: decision.matchedRule?.name ?? null,
|
|
4735
5931
|
evidence_satisfied: evidenceSatisfied,
|
|
4736
5932
|
limits_ok: limitsOk,
|
|
4737
|
-
...budgets ? { budgets } : {}
|
|
5933
|
+
...budgets ? { budgets } : {},
|
|
5934
|
+
...sessionUnresolved ? { session_unresolved: true } : {}
|
|
4738
5935
|
};
|
|
4739
5936
|
const body = {
|
|
4740
5937
|
jsonrpc: "2.0",
|
|
4741
5938
|
id: request.id ?? null,
|
|
4742
5939
|
result: {
|
|
4743
|
-
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" } : {}
|
|
4744
5946
|
}
|
|
4745
5947
|
};
|
|
4746
5948
|
const response = {
|
|
@@ -4763,15 +5965,10 @@ var GovernedForwarder = class {
|
|
|
4763
5965
|
}
|
|
4764
5966
|
makeSessionRequiredBlockResult(request, decision) {
|
|
4765
5967
|
const feedback = buildPolicyDeniedFeedback(decision);
|
|
4766
|
-
return makeErrorResult(
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
{
|
|
4771
|
-
...feedback,
|
|
4772
|
-
retry_allowed: true
|
|
4773
|
-
}
|
|
4774
|
-
);
|
|
5968
|
+
return makeErrorResult(request, POLICY_DENIED, decision.reason, {
|
|
5969
|
+
...feedback,
|
|
5970
|
+
retry_allowed: true
|
|
5971
|
+
});
|
|
4775
5972
|
}
|
|
4776
5973
|
makeClientDisconnectedBlockResult(request, decision) {
|
|
4777
5974
|
const feedback = buildClientDisconnectedFeedback(decision);
|
|
@@ -5201,7 +6398,11 @@ var BudgetEngine = class {
|
|
|
5201
6398
|
}
|
|
5202
6399
|
return { charges, failures };
|
|
5203
6400
|
}
|
|
5204
|
-
/**
|
|
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
|
+
*/
|
|
5205
6406
|
peekAll(charges) {
|
|
5206
6407
|
const entries = charges.map((charge) => this.snapshot(charge));
|
|
5207
6408
|
return { allowed: entries.every((entry) => entry.allowed), entries };
|
|
@@ -5707,6 +6908,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5707
6908
|
id TEXT PRIMARY KEY,
|
|
5708
6909
|
timestamp TEXT NOT NULL,
|
|
5709
6910
|
session_id TEXT,
|
|
6911
|
+
session_source TEXT,
|
|
5710
6912
|
agent_id TEXT,
|
|
5711
6913
|
environment TEXT,
|
|
5712
6914
|
tool_name TEXT NOT NULL,
|
|
@@ -5730,6 +6932,7 @@ CREATE TABLE IF NOT EXISTS audit_records (
|
|
|
5730
6932
|
record_kind TEXT NOT NULL DEFAULT 'tool_call',
|
|
5731
6933
|
origin TEXT NOT NULL DEFAULT 'mcp',
|
|
5732
6934
|
metadata TEXT,
|
|
6935
|
+
protocol_version TEXT,
|
|
5733
6936
|
created_at TEXT NOT NULL
|
|
5734
6937
|
);
|
|
5735
6938
|
`;
|
|
@@ -5745,19 +6948,19 @@ CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
|
|
|
5745
6948
|
`;
|
|
5746
6949
|
var INSERT_SQL = `
|
|
5747
6950
|
INSERT INTO audit_records (
|
|
5748
|
-
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,
|
|
5749
6952
|
policy_decision, block_reason, matched_rule, matched_rule_index, evidence_chain, approval_status,
|
|
5750
6953
|
approved_by, upstream_response, upstream_error, upstream_latency_ms,
|
|
5751
6954
|
upstream_http_status,
|
|
5752
6955
|
total_duration_ms, approval_wait_ms, proxy_compute_ms,
|
|
5753
|
-
flagged_destructive, dry_run, record_kind, origin, metadata, created_at
|
|
6956
|
+
flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
|
|
5754
6957
|
) VALUES (
|
|
5755
|
-
@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,
|
|
5756
6959
|
@policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
|
|
5757
6960
|
@approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
|
|
5758
6961
|
@upstream_http_status,
|
|
5759
6962
|
@total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
|
|
5760
|
-
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @created_at
|
|
6963
|
+
@flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
|
|
5761
6964
|
)
|
|
5762
6965
|
`;
|
|
5763
6966
|
var REQUIRED_AUDIT_COLUMNS = [
|
|
@@ -5770,13 +6973,20 @@ var REQUIRED_AUDIT_COLUMNS = [
|
|
|
5770
6973
|
"upstream_http_status",
|
|
5771
6974
|
"record_kind",
|
|
5772
6975
|
"origin",
|
|
5773
|
-
"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"
|
|
5774
6983
|
];
|
|
5775
6984
|
function deserializeRow(row) {
|
|
5776
6985
|
return {
|
|
5777
6986
|
id: row.id,
|
|
5778
6987
|
timestamp: row.timestamp,
|
|
5779
6988
|
session_id: row.session_id,
|
|
6989
|
+
session_source: row.session_source,
|
|
5780
6990
|
agent_id: row.agent_id,
|
|
5781
6991
|
environment: row.environment,
|
|
5782
6992
|
tool_name: row.tool_name,
|
|
@@ -5800,6 +7010,7 @@ function deserializeRow(row) {
|
|
|
5800
7010
|
record_kind: row.record_kind,
|
|
5801
7011
|
origin: row.origin,
|
|
5802
7012
|
metadata: row.metadata ? JSON.parse(row.metadata) : null,
|
|
7013
|
+
protocol_version: row.protocol_version,
|
|
5803
7014
|
created_at: row.created_at
|
|
5804
7015
|
};
|
|
5805
7016
|
}
|
|
@@ -5983,6 +7194,7 @@ var AuditStore = class {
|
|
|
5983
7194
|
id: resolvedId,
|
|
5984
7195
|
timestamp: record.timestamp,
|
|
5985
7196
|
session_id: record.session_id,
|
|
7197
|
+
session_source: record.session_source,
|
|
5986
7198
|
agent_id: record.agent_id,
|
|
5987
7199
|
environment: record.environment,
|
|
5988
7200
|
tool_name: record.tool_name,
|
|
@@ -6006,6 +7218,7 @@ var AuditStore = class {
|
|
|
6006
7218
|
record_kind: record.record_kind,
|
|
6007
7219
|
origin: record.origin,
|
|
6008
7220
|
metadata: record.metadata ? JSON.stringify(record.metadata) : null,
|
|
7221
|
+
protocol_version: record.protocol_version,
|
|
6009
7222
|
created_at: now
|
|
6010
7223
|
});
|
|
6011
7224
|
return resolvedId;
|
|
@@ -6194,7 +7407,11 @@ var CSV_HEADERS = [
|
|
|
6194
7407
|
"matched_rule_index",
|
|
6195
7408
|
"record_kind",
|
|
6196
7409
|
"origin",
|
|
6197
|
-
"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"
|
|
6198
7415
|
];
|
|
6199
7416
|
var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
|
|
6200
7417
|
function csvEscape(value) {
|
|
@@ -6738,15 +7955,18 @@ function asStatus(status) {
|
|
|
6738
7955
|
|
|
6739
7956
|
// src/evidence/api.ts
|
|
6740
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
|
+
});
|
|
6741
7961
|
var postEvidenceBody = z5.object({
|
|
6742
|
-
session_id:
|
|
7962
|
+
session_id: sessionIdSchema,
|
|
6743
7963
|
tool_name: z5.string().min(1),
|
|
6744
7964
|
evidence_key: z5.string().min(1),
|
|
6745
7965
|
evidence_data: z5.unknown().refine((v) => v !== void 0, { message: "Required" }),
|
|
6746
7966
|
ttl_seconds: z5.number().int().positive().optional()
|
|
6747
7967
|
});
|
|
6748
7968
|
var postContextBody = z5.object({
|
|
6749
|
-
session_id:
|
|
7969
|
+
session_id: sessionIdSchema,
|
|
6750
7970
|
key: z5.string().min(1),
|
|
6751
7971
|
value: z5.unknown().refine((v) => v !== void 0, { message: "Required" })
|
|
6752
7972
|
});
|
|
@@ -6876,6 +8096,7 @@ var SWEEP_INTERVAL_MS2 = 3e4;
|
|
|
6876
8096
|
var GovernanceService = class {
|
|
6877
8097
|
policy;
|
|
6878
8098
|
environment;
|
|
8099
|
+
session;
|
|
6879
8100
|
evidenceStore;
|
|
6880
8101
|
approvalRouter;
|
|
6881
8102
|
rateLimiter;
|
|
@@ -6909,6 +8130,7 @@ var GovernanceService = class {
|
|
|
6909
8130
|
constructor(options) {
|
|
6910
8131
|
this.policy = options.policy;
|
|
6911
8132
|
this.environment = options.environment;
|
|
8133
|
+
this.session = options.session ?? DEFAULT_SESSION_IDENTITY;
|
|
6912
8134
|
this.evidenceStore = options.evidenceStore;
|
|
6913
8135
|
this.approvalRouter = options.approvalRouter;
|
|
6914
8136
|
this.rateLimiter = options.rateLimiter;
|
|
@@ -6943,6 +8165,7 @@ var GovernanceService = class {
|
|
|
6943
8165
|
if (reserved) {
|
|
6944
8166
|
return { status: 400, body: { error: "reserved_metadata_key", key: reserved } };
|
|
6945
8167
|
}
|
|
8168
|
+
const sessionId = isWellFormedSessionId(req.session_id) ? req.session_id : null;
|
|
6946
8169
|
const inputBytes = byteLength(req.arguments ?? {});
|
|
6947
8170
|
if (inputBytes > MAX_TOOL_INPUT_BYTES) {
|
|
6948
8171
|
return { status: 413, body: { error: "tool_input_too_large" } };
|
|
@@ -6950,7 +8173,7 @@ var GovernanceService = class {
|
|
|
6950
8173
|
const entryBytes = inputBytes + byteLength(req.metadata ?? {}) + byteLength({
|
|
6951
8174
|
tool: req.tool.name,
|
|
6952
8175
|
agent_id: req.agent_id,
|
|
6953
|
-
session_id:
|
|
8176
|
+
session_id: sessionId,
|
|
6954
8177
|
origin: req.origin
|
|
6955
8178
|
});
|
|
6956
8179
|
if (!this.caches.has(req.origin) && this.caches.size >= MAX_ORIGINS) {
|
|
@@ -6972,7 +8195,8 @@ var GovernanceService = class {
|
|
|
6972
8195
|
const pipeline = decide({
|
|
6973
8196
|
toolName,
|
|
6974
8197
|
toolArguments: req.arguments,
|
|
6975
|
-
sessionId:
|
|
8198
|
+
sessionId: sessionId ?? void 0,
|
|
8199
|
+
sessionStrategySummary: this.session.strategySummary,
|
|
6976
8200
|
policy: this.policy,
|
|
6977
8201
|
environment: this.environment,
|
|
6978
8202
|
evidenceStore: this.evidenceStore,
|
|
@@ -6989,6 +8213,8 @@ var GovernanceService = class {
|
|
|
6989
8213
|
const plans = [];
|
|
6990
8214
|
let limitsBlock;
|
|
6991
8215
|
let ruleLimitOk = true;
|
|
8216
|
+
let sessionUnresolvedDeny = false;
|
|
8217
|
+
let dryRunSessionUnresolved = false;
|
|
6992
8218
|
const reservedThisCall = [];
|
|
6993
8219
|
const reserve = (key) => {
|
|
6994
8220
|
const preexisting = this.senderKeys.has(key);
|
|
@@ -7003,12 +8229,14 @@ var GovernanceService = class {
|
|
|
7003
8229
|
if (pipeline.isDryRun) {
|
|
7004
8230
|
wire = "dry_run";
|
|
7005
8231
|
if (decision.action === "rate_limit") {
|
|
7006
|
-
const planned = this.planRate(decision, toolName,
|
|
8232
|
+
const planned = this.planRate(decision, toolName, sessionId, senderId);
|
|
7007
8233
|
if (planned?.block) limitsBlock = { rate: planned.block };
|
|
8234
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
7008
8235
|
ruleLimitOk = planned?.allowed ?? true;
|
|
7009
8236
|
} else if (decision.action === "spend_limit") {
|
|
7010
|
-
const planned = this.planSpend(decision, toolName,
|
|
8237
|
+
const planned = this.planSpend(decision, toolName, sessionId, req.arguments, senderId);
|
|
7011
8238
|
if (planned?.block) limitsBlock = { spend: planned.block };
|
|
8239
|
+
if (planned?.sessionUnresolved) dryRunSessionUnresolved = true;
|
|
7012
8240
|
ruleLimitOk = planned?.allowed ?? true;
|
|
7013
8241
|
}
|
|
7014
8242
|
} else if (decision.action === "deny") {
|
|
@@ -7016,21 +8244,31 @@ var GovernanceService = class {
|
|
|
7016
8244
|
} else if (decision.action === "require_approval") {
|
|
7017
8245
|
wire = "require_approval";
|
|
7018
8246
|
} else if (decision.action === "rate_limit") {
|
|
7019
|
-
const planned = this.planRate(decision, toolName,
|
|
7020
|
-
if (planned?.
|
|
7021
|
-
|
|
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";
|
|
7022
8258
|
}
|
|
7023
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
7024
|
-
limitsBlock = planned?.block ? { rate: planned.block } : void 0;
|
|
7025
|
-
wire = planned?.allowed ? "allow" : "rate_limited";
|
|
7026
8259
|
} else if (decision.action === "spend_limit") {
|
|
7027
|
-
const planned = this.planSpend(decision, toolName,
|
|
7028
|
-
if (planned?.
|
|
7029
|
-
|
|
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";
|
|
7030
8271
|
}
|
|
7031
|
-
if (planned?.plan) plans.push(planned.plan);
|
|
7032
|
-
limitsBlock = planned?.block ? { spend: planned.block } : void 0;
|
|
7033
|
-
wire = planned?.allowed ? "allow" : "spend_limited";
|
|
7034
8272
|
} else {
|
|
7035
8273
|
wire = "allow";
|
|
7036
8274
|
}
|
|
@@ -7042,14 +8280,27 @@ var GovernanceService = class {
|
|
|
7042
8280
|
let budgetTicketTimeoutMs;
|
|
7043
8281
|
let budgetTriggeredApproval = false;
|
|
7044
8282
|
if (this.budgetEngine && (wire === "allow" || wire === "require_approval" || wire === "dry_run")) {
|
|
8283
|
+
const budgetSessionGate = gateSession(sessionId, this.session.onUnresolved);
|
|
7045
8284
|
const { charges, failures } = this.budgetEngine.resolveCharges({
|
|
7046
8285
|
toolName,
|
|
7047
8286
|
toolArguments: req.arguments,
|
|
7048
|
-
sessionId:
|
|
8287
|
+
sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
|
|
7049
8288
|
senderId
|
|
7050
8289
|
});
|
|
7051
|
-
|
|
7052
|
-
|
|
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: [] };
|
|
7053
8304
|
budgetsBlock = [
|
|
7054
8305
|
...peek.entries.map((entry2) => budgetWireBlock(entry2)),
|
|
7055
8306
|
...failures.map((failure) => budgetFailureBlock(failure))
|
|
@@ -7071,19 +8322,16 @@ var GovernanceService = class {
|
|
|
7071
8322
|
} else {
|
|
7072
8323
|
if (breaches.length > 0) budgetDryRunOk = false;
|
|
7073
8324
|
if (wire !== "dry_run") {
|
|
7074
|
-
|
|
7075
|
-
|
|
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)) {
|
|
7076
8331
|
releaseReservations();
|
|
7077
8332
|
return { status: 503, body: { error: "limit_capacity_exhausted" } };
|
|
7078
8333
|
}
|
|
7079
|
-
plans.push(
|
|
7080
|
-
kind: "budget",
|
|
7081
|
-
budget: charge.budget,
|
|
7082
|
-
bucketKey: charge.bucketKey,
|
|
7083
|
-
amount: charge.amount,
|
|
7084
|
-
generation: charge.generation,
|
|
7085
|
-
breached: peek.entries[index]?.allowed === false
|
|
7086
|
-
});
|
|
8334
|
+
plans.push(plan);
|
|
7087
8335
|
}
|
|
7088
8336
|
if (breaches.length > 0) {
|
|
7089
8337
|
budgetBreachEntries = breaches;
|
|
@@ -7132,12 +8380,21 @@ var GovernanceService = class {
|
|
|
7132
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."
|
|
7133
8381
|
};
|
|
7134
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
|
+
}
|
|
7135
8391
|
if (limitsBlock) responseBody["limits"] = limitsBlock;
|
|
7136
8392
|
if (wire === "dry_run") {
|
|
7137
8393
|
responseBody["dry_run"] = {
|
|
7138
8394
|
would_forward: (decision.action === "allow" || (decision.action === "rate_limit" || decision.action === "spend_limit") && ruleLimitOk) && !pipeline.evidenceBlocked && budgetDryRunOk,
|
|
7139
8395
|
evidence_satisfied: !pipeline.evidenceBlocked,
|
|
7140
|
-
limits_ok: ruleLimitOk && budgetDryRunOk
|
|
8396
|
+
limits_ok: ruleLimitOk && budgetDryRunOk,
|
|
8397
|
+
...dryRunSessionUnresolved ? { session_unresolved: true } : {}
|
|
7141
8398
|
};
|
|
7142
8399
|
}
|
|
7143
8400
|
if (pipeline.driftEvent) {
|
|
@@ -7148,7 +8405,7 @@ var GovernanceService = class {
|
|
|
7148
8405
|
timestampIso,
|
|
7149
8406
|
origin: req.origin,
|
|
7150
8407
|
agentId: req.agent_id,
|
|
7151
|
-
sessionId
|
|
8408
|
+
sessionId,
|
|
7152
8409
|
toolName,
|
|
7153
8410
|
toolInput: req.arguments ?? {},
|
|
7154
8411
|
metadata: req.metadata,
|
|
@@ -7163,7 +8420,9 @@ var GovernanceService = class {
|
|
|
7163
8420
|
// limitsBlock is also the response's `limits`, and the audit writer
|
|
7164
8421
|
// buffers records by reference until flush — a direct embedder
|
|
7165
8422
|
// editing the returned body must not be able to rewrite evidence.
|
|
7166
|
-
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0
|
|
8423
|
+
limitsChain: limitsBlock ? structuredClone(limitsBlock) : void 0,
|
|
8424
|
+
sessionUnresolved: sessionUnresolvedDeny,
|
|
8425
|
+
sessionChain: pipeline.sessionBlocked
|
|
7167
8426
|
});
|
|
7168
8427
|
this.tombstones.set(evaluationId, {
|
|
7169
8428
|
auditRecordId: auditId,
|
|
@@ -7196,7 +8455,7 @@ var GovernanceService = class {
|
|
|
7196
8455
|
// rewrite it (same guard as the pending entry's evidence below).
|
|
7197
8456
|
tool_input: structuredClone(req.arguments ?? {}),
|
|
7198
8457
|
matched_rule: decision.matchedRule,
|
|
7199
|
-
session_id:
|
|
8458
|
+
session_id: sessionId,
|
|
7200
8459
|
origin: req.origin,
|
|
7201
8460
|
timeout_ms: timeoutMs,
|
|
7202
8461
|
breached_budgets: budgetBreachContexts
|
|
@@ -7214,7 +8473,7 @@ var GovernanceService = class {
|
|
|
7214
8473
|
evaluationId,
|
|
7215
8474
|
origin: req.origin,
|
|
7216
8475
|
agentId: req.agent_id,
|
|
7217
|
-
sessionId
|
|
8476
|
+
sessionId,
|
|
7218
8477
|
toolName,
|
|
7219
8478
|
// Cloned: direct embedders share these references and could otherwise
|
|
7220
8479
|
// mutate the audit evidence (and desync the byte accounting) after
|
|
@@ -7433,7 +8692,9 @@ var GovernanceService = class {
|
|
|
7433
8692
|
timestampIso: new Date(this.now()).toISOString(),
|
|
7434
8693
|
origin: req.origin,
|
|
7435
8694
|
agentId: req.agent_id,
|
|
7436
|
-
|
|
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,
|
|
7437
8698
|
toolName,
|
|
7438
8699
|
toolInput: { ...req.package },
|
|
7439
8700
|
metadata: req.metadata,
|
|
@@ -7750,7 +9011,18 @@ var GovernanceService = class {
|
|
|
7750
9011
|
if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
|
|
7751
9012
|
return { allowed: true };
|
|
7752
9013
|
}
|
|
7753
|
-
|
|
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
|
+
}
|
|
7754
9026
|
const peek = this.rateLimiter.peek({
|
|
7755
9027
|
key,
|
|
7756
9028
|
maxCalls: limits.maxCalls,
|
|
@@ -7770,10 +9042,19 @@ var GovernanceService = class {
|
|
|
7770
9042
|
planSpend(decision, toolName, sessionId, args, senderId) {
|
|
7771
9043
|
const maxSpend = decision.matchedRule?.limits?.maxSpend;
|
|
7772
9044
|
if (!this.spendLimiter || !maxSpend) return { allowed: true };
|
|
7773
|
-
|
|
7774
|
-
|
|
7775
|
-
|
|
7776
|
-
|
|
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);
|
|
7777
9058
|
const rawAmount = resolvePath(maxSpend.field, args ?? {});
|
|
7778
9059
|
if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
|
|
7779
9060
|
return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
|
|
@@ -7805,18 +9086,15 @@ var GovernanceService = class {
|
|
|
7805
9086
|
/** Commit every plan of one call at /audit time; returns the chain blocks. */
|
|
7806
9087
|
commitPlans(entry, actualAmount, auditId, approvalStatus) {
|
|
7807
9088
|
let chain;
|
|
7808
|
-
const budgetPlans = entry.plans.filter(
|
|
9089
|
+
const budgetPlans = entry.plans.filter(
|
|
9090
|
+
(plan) => plan.kind === "budget"
|
|
9091
|
+
);
|
|
7809
9092
|
if (budgetPlans.length > 0 && this.budgetEngine) {
|
|
7810
9093
|
const kinds = new Map(
|
|
7811
9094
|
budgetPlans.filter((plan) => plan.breached && approvalStatus === "approved").map((plan) => [plan.budget.name, "approved_overage"])
|
|
7812
9095
|
);
|
|
7813
9096
|
const snapshots = this.budgetEngine.recordAll(
|
|
7814
|
-
budgetPlans
|
|
7815
|
-
budget: plan.budget,
|
|
7816
|
-
bucketKey: plan.bucketKey,
|
|
7817
|
-
amount: actualAmount ?? plan.amount,
|
|
7818
|
-
generation: plan.generation
|
|
7819
|
-
})),
|
|
9097
|
+
remintDeferredCharges(budgetPlans, actualAmount),
|
|
7820
9098
|
{
|
|
7821
9099
|
kind: "spend",
|
|
7822
9100
|
...kinds.size > 0 ? { kinds } : {},
|
|
@@ -7887,6 +9165,12 @@ var GovernanceService = class {
|
|
|
7887
9165
|
if (!this.auditWriter) return id;
|
|
7888
9166
|
const blockReason = deriveBlockReason(args);
|
|
7889
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
|
+
}
|
|
7890
9174
|
if (args.sidebandUnreported) {
|
|
7891
9175
|
evidenceChain = {
|
|
7892
9176
|
...evidenceChain ?? {},
|
|
@@ -7902,6 +9186,9 @@ var GovernanceService = class {
|
|
|
7902
9186
|
const record = {
|
|
7903
9187
|
timestamp: args.timestampIso,
|
|
7904
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,
|
|
7905
9192
|
agent_id: args.agentId,
|
|
7906
9193
|
environment: this.environment ?? null,
|
|
7907
9194
|
tool_name: args.toolName,
|
|
@@ -7924,7 +9211,9 @@ var GovernanceService = class {
|
|
|
7924
9211
|
dry_run: args.dryRun,
|
|
7925
9212
|
record_kind: args.recordKind,
|
|
7926
9213
|
origin: args.origin,
|
|
7927
|
-
metadata: args.metadata
|
|
9214
|
+
metadata: args.metadata,
|
|
9215
|
+
// The sideband has no MCP wire, so no protocol claim exists.
|
|
9216
|
+
protocol_version: null
|
|
7928
9217
|
};
|
|
7929
9218
|
const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
|
|
7930
9219
|
if (isEnforcement) this.auditWriter.pushImmediate(record, id);
|
|
@@ -7943,6 +9232,7 @@ function deriveBlockReason(args) {
|
|
|
7943
9232
|
if (args.recordKind === "install_scan") return args.wire === "deny" ? "install_denied" : null;
|
|
7944
9233
|
if (args.dryRun) return null;
|
|
7945
9234
|
if (args.budgetBreachBlocked) return "budget_exceeded";
|
|
9235
|
+
if (args.sessionUnresolved) return "session_unresolved";
|
|
7946
9236
|
if (args.approvalStatus === "denied") return "approval_denied";
|
|
7947
9237
|
if (args.approvalStatus === "timeout") return "approval_timeout";
|
|
7948
9238
|
if (args.approvalStatus === "cancelled") return "cancelled";
|
|
@@ -7981,10 +9271,8 @@ function policyCanRequireApproval(policy) {
|
|
|
7981
9271
|
}
|
|
7982
9272
|
return policy.rules.some((rule) => rule.action === "require_approval");
|
|
7983
9273
|
}
|
|
7984
|
-
function buildLimitKey(keyType, toolName,
|
|
9274
|
+
function buildLimitKey(keyType, toolName, senderId) {
|
|
7985
9275
|
switch (keyType) {
|
|
7986
|
-
case "session":
|
|
7987
|
-
return `session:${sessionId ?? "unknown"}`;
|
|
7988
9276
|
case "sender_id":
|
|
7989
9277
|
return `sender:${senderId ?? "unknown"}`;
|
|
7990
9278
|
case "agent":
|
|
@@ -8188,6 +9476,48 @@ var AuditWriter = class {
|
|
|
8188
9476
|
}
|
|
8189
9477
|
};
|
|
8190
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
|
+
|
|
8191
9521
|
// src/approval/queue.ts
|
|
8192
9522
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
8193
9523
|
var ApprovalQueue = class {
|
|
@@ -9900,6 +11230,7 @@ export {
|
|
|
9900
11230
|
UpstreamForwarder,
|
|
9901
11231
|
VERSION,
|
|
9902
11232
|
WebhookChannel,
|
|
11233
|
+
buildHeaderMismatchAuditRecord,
|
|
9903
11234
|
compileBudgets,
|
|
9904
11235
|
compilePolicies,
|
|
9905
11236
|
createApp,
|