@basein/runner 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +540 -350
  2. package/dist/auth/client.d.ts +65 -3
  3. package/dist/auth/client.js +305 -53
  4. package/dist/bin/bir-hooks.d.ts +72 -31
  5. package/dist/bin/bir-hooks.js +176 -284
  6. package/dist/bin/bir-proxy.d.ts +17 -2
  7. package/dist/bin/bir-proxy.js +222 -28
  8. package/dist/bin/bir-scenario.d.ts +61 -10
  9. package/dist/bin/bir-scenario.js +704 -135
  10. package/dist/bin/bir.d.ts +14 -8
  11. package/dist/bin/bir.js +2937 -488
  12. package/dist/bin/investigate.js +41 -16
  13. package/dist/bin/scenario-edit.d.ts +43 -10
  14. package/dist/bin/scenario-edit.js +281 -80
  15. package/dist/bin/scenario-liveness.d.ts +194 -0
  16. package/dist/bin/scenario-liveness.js +459 -0
  17. package/dist/bin/scenario-stderr.d.ts +43 -0
  18. package/dist/bin/scenario-stderr.js +76 -0
  19. package/dist/bin/service-read.d.ts +88 -0
  20. package/dist/bin/service-read.js +128 -0
  21. package/dist/bin/setup.d.ts +19 -0
  22. package/dist/bin/setup.js +146 -20
  23. package/dist/config/adapters/claude-code.d.ts +92 -14
  24. package/dist/config/adapters/claude-code.js +291 -101
  25. package/dist/config/adapters/generic.d.ts +4 -1
  26. package/dist/config/adapters/generic.js +13 -14
  27. package/dist/config/generate.d.ts +392 -14
  28. package/dist/config/generate.js +666 -19
  29. package/dist/config/mcp-approval.d.ts +76 -0
  30. package/dist/config/mcp-approval.js +210 -0
  31. package/dist/config/node-path.d.ts +42 -0
  32. package/dist/config/node-path.js +102 -0
  33. package/dist/config/overrides.d.ts +62 -0
  34. package/dist/config/overrides.js +136 -0
  35. package/dist/config/resolve.d.ts +70 -8
  36. package/dist/config/resolve.js +186 -15
  37. package/dist/control/client.d.ts +22 -0
  38. package/dist/control/client.js +26 -7
  39. package/dist/control/correlation.d.ts +23 -5
  40. package/dist/control/correlation.js +0 -0
  41. package/dist/control/daemon.d.ts +400 -18
  42. package/dist/control/daemon.js +1105 -102
  43. package/dist/control/discovery.d.ts +77 -5
  44. package/dist/control/discovery.js +186 -11
  45. package/dist/control/ensure-hook.d.ts +61 -0
  46. package/dist/control/ensure-hook.js +160 -8
  47. package/dist/control/paths.d.ts +26 -0
  48. package/dist/control/paths.js +34 -0
  49. package/dist/control/pin-guard.d.ts +83 -0
  50. package/dist/control/pin-guard.js +534 -0
  51. package/dist/control/recorder-keeper.d.ts +229 -0
  52. package/dist/control/recorder-keeper.js +721 -0
  53. package/dist/control/recorder-main.d.ts +73 -0
  54. package/dist/control/recorder-main.js +778 -0
  55. package/dist/control/server.d.ts +707 -31
  56. package/dist/control/server.js +2803 -306
  57. package/dist/control/transcript.d.ts +21 -1
  58. package/dist/control/transcript.js +378 -112
  59. package/dist/index.d.ts +5 -1
  60. package/dist/index.js +5 -1
  61. package/dist/jsonrpc/framing.d.ts +76 -12
  62. package/dist/jsonrpc/framing.js +216 -59
  63. package/dist/proxy/intercept.d.ts +49 -5
  64. package/dist/proxy/intercept.js +188 -59
  65. package/dist/proxy/relay.d.ts +16 -0
  66. package/dist/proxy/relay.js +77 -5
  67. package/dist/proxy/revive.d.ts +122 -0
  68. package/dist/proxy/revive.js +251 -0
  69. package/dist/proxy/session.d.ts +222 -14
  70. package/dist/proxy/session.js +708 -99
  71. package/dist/record/housekeeping.d.ts +71 -0
  72. package/dist/record/housekeeping.js +417 -0
  73. package/dist/record/recorder.d.ts +51 -3
  74. package/dist/record/recorder.js +3 -0
  75. package/dist/record/redact.d.ts +103 -9
  76. package/dist/record/redact.js +659 -30
  77. package/dist/record/remote-recorder.d.ts +327 -32
  78. package/dist/record/remote-recorder.js +1517 -187
  79. package/dist/record/switchable-recorder.d.ts +126 -0
  80. package/dist/record/switchable-recorder.js +351 -0
  81. package/dist/record/truncate.d.ts +40 -1
  82. package/dist/record/truncate.js +111 -4
  83. package/dist/replay/bash-channel.d.ts +35 -0
  84. package/dist/replay/bash-channel.js +133 -0
  85. package/dist/replay/bundle.d.ts +10 -0
  86. package/dist/replay/bundle.js +10 -0
  87. package/dist/replay/controller.d.ts +371 -20
  88. package/dist/replay/controller.js +1185 -111
  89. package/dist/replay/derive.d.ts +1 -0
  90. package/dist/replay/derive.js +81 -24
  91. package/dist/replay/executor.d.ts +51 -16
  92. package/dist/replay/executor.js +114 -41
  93. package/dist/replay/handover.d.ts +54 -1
  94. package/dist/replay/handover.js +55 -5
  95. package/dist/replay/logic.d.ts +62 -10
  96. package/dist/replay/logic.js +140 -22
  97. package/dist/replay/plan.d.ts +201 -27
  98. package/dist/replay/plan.js +581 -141
  99. package/dist/replay/safety.d.ts +72 -0
  100. package/dist/replay/safety.js +303 -0
  101. package/dist/replay/source-run.d.ts +10 -0
  102. package/dist/replay/source-run.js +80 -35
  103. package/dist/replay/targets.d.ts +15 -1
  104. package/dist/replay/targets.js +62 -4
  105. package/dist/replay/tool-error.d.ts +15 -1
  106. package/dist/replay/tool-error.js +33 -1
  107. package/dist/replay/types.d.ts +7 -4
  108. package/dist/upstream/client.d.ts +56 -3
  109. package/dist/upstream/client.js +61 -4
  110. package/dist/upstream/headers.d.ts +43 -0
  111. package/dist/upstream/headers.js +90 -0
  112. package/dist/upstream/http-client.d.ts +213 -22
  113. package/dist/upstream/http-client.js +1034 -168
  114. package/dist/upstream/http-transport.d.ts +143 -0
  115. package/dist/upstream/http-transport.js +391 -0
  116. package/dist/upstream/lazy-client.d.ts +8 -0
  117. package/dist/upstream/lazy-client.js +29 -1
  118. package/dist/upstream/sse.d.ts +78 -0
  119. package/dist/upstream/sse.js +189 -0
  120. package/dist/upstream/stdio-client.d.ts +61 -1
  121. package/dist/upstream/stdio-client.js +356 -33
  122. package/dist/util/atomic-write.d.ts +29 -0
  123. package/dist/util/atomic-write.js +96 -0
  124. package/dist/util/journal.d.ts +15 -0
  125. package/dist/util/journal.js +48 -3
  126. package/dist/util/log.d.ts +10 -0
  127. package/dist/util/log.js +28 -1
  128. package/dist/util/off-switch.d.ts +46 -0
  129. package/dist/util/off-switch.js +91 -0
  130. package/dist/util/service-health.d.ts +133 -0
  131. package/dist/util/service-health.js +217 -0
  132. package/docs/BaseInstRunner.md +694 -621
  133. package/docs/calculatedReplay.md +1625 -1244
  134. package/docs/calculatedReplayGuide.md +1212 -879
  135. package/docs/installRun.md +1013 -772
  136. package/docs/loginWeb.md +627 -607
  137. package/docs/mcpmark.md +49 -16
  138. package/docs/my-first-sample.md +569 -545
  139. package/docs/quickstart.md +384 -271
  140. package/docs/t-bench.md +406 -394
  141. package/package.json +58 -57
  142. package/production.md +1371 -0
  143. package/scripts/install.ps1 +97 -6
  144. package/scripts/install.sh +104 -7
@@ -11,243 +11,1109 @@
11
11
  * message is a POST to one endpoint. The reply comes back as a JSON body or
12
12
  * as an SSE stream carrying it. A session id handed out in the
13
13
  * `Mcp-Session-Id` response header must be echoed on every later request.
14
- * Server-initiated messages arrive on a standing GET stream, opened lazily
15
- * after `initialize` and skipped when the server answers 405.
14
+ * Server-initiated messages arrive on a standing GET stream, opened once the
15
+ * handshake is done and skipped when the server answers 405.
16
16
  * - **legacy SSE** (`--transport sse`): a long-lived GET carries every reply
17
17
  * and announces the POST endpoint with an `endpoint` event, so the handshake
18
18
  * is inverted — you must be listening before you can send.
19
19
  *
20
+ * THE RULES THIS FILE KEEPS, because a wrapped server must behave at least as
21
+ * well as the same server connected directly:
22
+ *
23
+ * 1. **Every request the host sends is answered.** A network error, a non-2xx,
24
+ * an HTML page from an SSO or captive proxy, a body that is not the
25
+ * JSON-RPC answer, an SSE reply that ends or resets before the answer, an
26
+ * answer over the size cap, a legacy event stream that drops with requests
27
+ * in flight — each becomes a short, fixed JSON-RPC error for that id. The
28
+ * upstream's body is never echoed (it can be a login page or carry gateway
29
+ * internals), and 401/403 say "requires sign-in" rather than dumping HTML.
30
+ * 2. **Only the handshake is ordered.** `initialize` and then
31
+ * `notifications/initialized` go one after the other (the session id comes
32
+ * back on the first, and Python-SDK servers refuse traffic before the
33
+ * second). Every later POST is independent, as in the MCP SDK the host
34
+ * would otherwise use: parallel tool calls run in parallel, a
35
+ * `notifications/cancelled` is not queued behind the call it cancels, and
36
+ * the host's answer to a server-initiated request (roots, elicitation,
37
+ * sampling) is never stuck behind the very tool call waiting for it —
38
+ * which, serialised, was a deadlock.
39
+ * 3. **Legacy SSE sends wait for the endpoint.** Nothing is POSTed until the
40
+ * stream has announced where to POST (sent to the GET URL, the host's
41
+ * `initialize` got a 405 and the server showed as failed every session).
42
+ * A stream that ends — cleanly or not — is noticed, the requests riding it
43
+ * are answered, and it is re-established with backoff; the new session is
44
+ * re-initialised with the host's own `initialize` before anything else.
45
+ * 4. **Session expiry is survivable.** A 404 to a request that carried a
46
+ * session means the server forgot us (a redeploy, an idle timeout). The
47
+ * spec says start a new session; the proxy does it for the host — replays
48
+ * the host's cached `initialize` under a `bir:` id, sends `initialized`,
49
+ * and retries the request once.
50
+ * 5. **No timeout shorter than the host's.** The transport (http-transport.ts)
51
+ * bounds the connect only; how long a tool may take is Claude Code's call,
52
+ * and its cancellation reaches the server immediately (rule 2). What no
53
+ * host waits on is bounded here instead, so nothing holds a connection
54
+ * forever: the proxy's own calls (a replayed scenario step) are cancelled
55
+ * upstream when their caller gives up, and a notification or response the
56
+ * server has not answered in 60 s is dropped.
57
+ *
20
58
  * AUTH (§9). The host's own OAuth authenticates the server it is *configured*
21
59
  * with, which is now the proxy — so a remote upstream's credentials have to
22
60
  * reach us some other way. They come from `--header` / the environment at spawn
23
- * time and are held in memory only; nothing is written to disk here.
61
+ * time and are held in memory only; nothing is written to disk here. They are
62
+ * sent only to the configured origin: a legacy `endpoint` elsewhere is refused
63
+ * (as the MCP SDK does), a redirect off-origin drops every one of them, and a
64
+ * redirect from https to plain http is refused (http-transport.ts).
65
+ *
66
+ * STDERR IS FOR NEWS. Claude Code files every stderr line of bir-proxy as a
67
+ * "Server stderr" error, so what a remote server does routinely — a load
68
+ * balancer cutting an idle event stream every minute or two (60 s on an AWS
69
+ * ALB, 100 s on Cloudflare), and the reconnect and re-initialise that follow —
70
+ * is `BIR_VERBOSE` detail. What stays on stderr: requests answered with an
71
+ * error because a stream was lost, the first failure of an outage and its
72
+ * recovery, a session the server forgot, and a stream that keeps dropping as
73
+ * soon as it opens (once an hour per server at most).
24
74
  */
25
75
  import { readFrames } from "../jsonrpc/framing.js";
26
- import { logDetail, logLine, errText } from "../util/log.js";
27
- import { BaseUpstreamClient } from "./client.js";
76
+ import { paramsObject } from "../jsonrpc/types.js";
77
+ import { logDetail, logEvery, logLine, logOnce, errText } from "../util/log.js";
78
+ import { BaseUpstreamClient, BIR_ID_PREFIX, isBirId } from "./client.js";
28
79
  import { Readable } from "node:stream";
80
+ import { BodyTooLargeError, configuredProxy, createAgents, destroyAgents, httpRequest, nodeSupportsProxyEnv, proxyEnvEnabled, readBodyText, } from "./http-transport.js";
81
+ import { readSseStream, SseTooLargeError } from "./sse.js";
82
+ /** Legacy SSE connect budget (headers + `endpoint`), and the replayed-`initialize` wait. */
83
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
84
+ /** DNS + TCP + TLS. A blackholed host fails in this, not the OS's 21-75 s. */
85
+ const DEFAULT_SOCKET_CONNECT_TIMEOUT_MS = 10_000;
86
+ const DEFAULT_RECONNECT_BASE_MS = 500;
87
+ const MAX_RECONNECT_DELAY_MS = 30_000;
88
+ /** A standing stream that stayed up this long was healthy: its drop restarts the backoff. */
89
+ const HEALTHY_STREAM_MS = 60_000;
90
+ /**
91
+ * A stream that keeps dropping as soon as it opens is said on stderr at most
92
+ * this often per server; the drops in between are `BIR_VERBOSE` detail.
93
+ */
94
+ const STREAM_NEWS_INTERVAL_MS = 60 * 60_000;
95
+ /** close(): the session DELETE is best effort and must never hold up an exit. */
96
+ const DELETE_TIMEOUT_MS = 1_000;
97
+ /**
98
+ * The longest one handshake message holds up the traffic behind it. The POST
99
+ * itself is not cut off — a server that never answers `initialized` just stops
100
+ * being waited for, rather than wedging every later request with it.
101
+ */
102
+ const HANDSHAKE_GATE_MS = 10_000;
103
+ /**
104
+ * The largest answer relayed. A bound against runaway memory, not a judgement
105
+ * on size: a directly connected server has none, so it is set well above any
106
+ * real tool result (a base64 screenshot or file is a few MiB to a few tens).
107
+ */
108
+ const DEFAULT_MAX_MESSAGE_BYTES = 128 * 1024 * 1024;
109
+ /**
110
+ * A notification or a response is answered with a bare 202 at once; one still
111
+ * unanswered after this is hung, and holding its socket forever helps nobody.
112
+ */
113
+ const DEFAULT_UNANSWERED_POST_TIMEOUT_MS = 60_000;
114
+ /** What the host is told. Short and fixed: never the upstream's body. */
115
+ const ANSWER = {
116
+ tooLarge: "upstream answer too large",
117
+ closedEarly: "upstream closed the connection before it answered",
118
+ lost: "upstream connection was lost before it answered",
119
+ streamDown: "upstream is not reachable (its event stream is down)",
120
+ };
121
+ function statusAnswer(status, statusText) {
122
+ if (status === 401 || status === 403)
123
+ return `upstream requires sign-in (HTTP ${status})`;
124
+ return `upstream HTTP ${status}${statusText ? ` ${statusText}` : ""}`;
125
+ }
126
+ /** `text/html; charset=utf-8` → `text/html`. */
127
+ function mediaType(contentType) {
128
+ return contentType.split(";")[0].trim();
129
+ }
130
+ function socketConnectTimeoutFromEnv() {
131
+ return positiveFromEnv("BIR_UPSTREAM_CONNECT_TIMEOUT_MS");
132
+ }
133
+ function positiveFromEnv(name) {
134
+ const raw = process.env[name];
135
+ if (raw === undefined || raw.trim() === "")
136
+ return undefined;
137
+ const n = Number(raw);
138
+ return Number.isFinite(n) && n > 0 ? n : undefined;
139
+ }
140
+ const noop = () => undefined;
141
+ /** Settles when `promise` does or after `ms`, whichever is first; never rejects. */
142
+ function settledWithin(promise, ms) {
143
+ return new Promise((resolve) => {
144
+ const timer = setTimeout(resolve, ms);
145
+ timer.unref?.();
146
+ promise.then(() => {
147
+ clearTimeout(timer);
148
+ resolve();
149
+ }, () => {
150
+ clearTimeout(timer);
151
+ resolve();
152
+ });
153
+ });
154
+ }
29
155
  export class HttpUpstreamClient extends BaseUpstreamClient {
30
156
  ready;
31
157
  url;
158
+ origin;
32
159
  transport;
33
160
  headers;
34
161
  label;
35
162
  connectTimeoutMs;
163
+ socketConnectTimeoutMs;
164
+ reconnectBaseMs;
165
+ maxMessageBytes;
166
+ unansweredPostTimeoutMs;
167
+ healthyStreamMs;
168
+ agents;
36
169
  sessionId;
37
- /** Legacy SSE only: where to POST, announced by the stream's `endpoint` event. */
170
+ /** Negotiated in `initialize`, echoed as `MCP-Protocol-Version` afterwards. */
171
+ protocolVersion;
172
+ /** The host's `initialize` params, replayed when the server loses our session. */
173
+ initParams;
174
+ hostInitId;
175
+ reinitCount = 0;
176
+ /**
177
+ * The handshake gate. `initialize` and `notifications/initialized` chain onto
178
+ * it; every other message waits for it and then goes on its own (rule 2).
179
+ */
180
+ gate;
181
+ recovery;
182
+ outstanding = new Map();
183
+ /** Every in-flight POST's aborter, so close() can drop them all. */
184
+ inflight = new Set();
185
+ /** Answers to this client's own replayed `initialize`, consumed before the relay. */
186
+ internalWaiters = new Map();
187
+ // ── legacy SSE ──
188
+ /** Where to POST, announced by the stream's `endpoint` event. Unset while down. */
38
189
  postUrl;
39
- abort = new AbortController();
40
- /** Serialises POSTs so messages leave in the order the host sent them. */
41
- chain = Promise.resolve();
42
- standingStreamOpened = false;
190
+ /** Bumped per announced stream; a request's answer can only ride the stream it was posted on. */
191
+ generation = 0;
192
+ streamAbort;
193
+ connecting;
194
+ reconnectTimer;
195
+ reconnectFailures = 0;
196
+ /** Streams that ended soon after connecting, in a row: they back off like failures. */
197
+ streamDrops = 0;
198
+ streamOpenedAt = 0;
199
+ // ── streamable HTTP's standing GET ──
200
+ standing = "idle";
201
+ standingAbort;
202
+ standingTimer;
203
+ standingDrops = 0;
204
+ standingLastEventId;
205
+ standingRetryMs;
206
+ standingParser;
43
207
  constructor(opts) {
44
208
  super();
45
209
  this.url = opts.url;
210
+ this.origin = new URL(opts.url).origin;
46
211
  this.transport = opts.transport ?? "http";
47
212
  this.headers = opts.headers ?? {};
48
213
  this.label = opts.label ?? new URL(opts.url).host;
49
- this.connectTimeoutMs = opts.connectTimeoutMs ?? 15_000;
50
- this.ready = this.connect();
214
+ this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
215
+ this.socketConnectTimeoutMs =
216
+ opts.socketConnectTimeoutMs ?? socketConnectTimeoutFromEnv() ?? DEFAULT_SOCKET_CONNECT_TIMEOUT_MS;
217
+ this.reconnectBaseMs = opts.reconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS;
218
+ this.maxMessageBytes =
219
+ opts.maxMessageBytes ?? positiveFromEnv("BIR_UPSTREAM_MAX_MESSAGE_BYTES") ?? DEFAULT_MAX_MESSAGE_BYTES;
220
+ this.unansweredPostTimeoutMs = opts.unansweredPostTimeoutMs ?? DEFAULT_UNANSWERED_POST_TIMEOUT_MS;
221
+ this.healthyStreamMs = opts.healthyStreamMs ?? HEALTHY_STREAM_MS;
222
+ this.agents = createAgents({ freeSocketTimeoutMs: opts.idleSocketTimeoutMs });
223
+ if (this.transport === "sse") {
224
+ this.ready = this.openSseEndpointStream();
225
+ // A server that cannot even be connected is dead, not "alive and silent":
226
+ // marking it so lets the relay answer every request at once (§10) instead
227
+ // of queueing them behind a stream that will never come.
228
+ this.ready.catch((err) => {
229
+ if (this.dead)
230
+ return;
231
+ this.closed({ error: err instanceof Error ? err : new Error(String(err)) });
232
+ this.teardown();
233
+ });
234
+ }
235
+ else {
236
+ // Streamable HTTP needs no connect step: the first POST is the handshake,
237
+ // and that POST is the host's own `initialize` relayed verbatim.
238
+ this.ready = Promise.resolve();
239
+ }
240
+ this.gate = this.ready.then(noop, noop);
51
241
  }
52
- async connect() {
53
- if (this.transport === "sse")
54
- await this.openSseEndpointStream();
55
- // Streamable HTTP needs no connect step: the first POST is the handshake,
56
- // and that POST is the host's own `initialize` relayed verbatim.
242
+ // ── sending ───────────────────────────────────────────────────────────────
243
+ send(msg) {
244
+ if (this.dead)
245
+ return;
246
+ const method = msg.method;
247
+ const id = msg.id;
248
+ logDetail("upstream.send", { server: this.label, method });
249
+ let entry;
250
+ if (typeof method === "string" && (typeof id === "string" || typeof id === "number")) {
251
+ entry = { id, method, abort: new AbortController() };
252
+ this.outstanding.set(id, entry);
253
+ }
254
+ if (method === "initialize" || method === "notifications/initialized") {
255
+ if (method === "initialize" && entry)
256
+ this.hostInitId = entry.id;
257
+ const previous = this.gate;
258
+ const sent = previous.then(() => this.postMessage(msg, entry));
259
+ this.gate = previous.then(() => settledWithin(sent, HANDSHAKE_GATE_MS));
260
+ return;
261
+ }
262
+ if (method === "notifications/cancelled") {
263
+ const target = paramsObject(msg.params).requestId;
264
+ const cancelled = typeof target === "string" || typeof target === "number" ? this.outstanding.get(target) : undefined;
265
+ if (cancelled)
266
+ cancelled.cancelled = true;
267
+ void this.gate
268
+ .then(() => this.postMessage(msg, undefined))
269
+ .finally(() => {
270
+ // The server has been told. Stop holding the connection that was
271
+ // carrying an answer nobody is waiting for any more.
272
+ if (cancelled && this.outstanding.get(cancelled.id) === cancelled) {
273
+ this.outstanding.delete(cancelled.id);
274
+ cancelled.abort.abort();
275
+ }
276
+ });
277
+ return;
278
+ }
279
+ void this.gate.then(() => this.postMessage(msg, entry));
57
280
  }
58
- // ── legacy SSE: listen first, then learn where to POST ────────────────────
59
- async openSseEndpointStream() {
60
- const res = await fetch(this.url, {
61
- method: "GET",
62
- headers: { accept: "text/event-stream", ...this.headers },
63
- signal: this.abort.signal,
64
- });
65
- if (!res.ok || !res.body) {
66
- throw new Error(`SSE connect failed: ${res.status} ${res.statusText}`);
281
+ /*
282
+ * The proxy's own calls (`bir:` ids — a replayed scenario step) need no
283
+ * override here. When the caller's signal fires, BaseUpstreamClient.request
284
+ * rejects locally and sends `notifications/cancelled` through send(), and the
285
+ * cancel path above releases the abandoned POST's connection once the server
286
+ * has been told — so a timed-out step leaks neither work nor a socket. An
287
+ * already-aborted signal is refused before anything is sent.
288
+ */
289
+ /** Never rejects: whatever goes wrong, a request still gets its answer. */
290
+ async postMessage(msg, entry, opts = {}) {
291
+ try {
292
+ await this.postOnce(msg, entry, opts);
67
293
  }
68
- let announce;
69
- const announced = new Promise((resolve) => {
70
- announce = resolve;
71
- });
72
- void this.pumpSse(res.body, (event, data) => {
73
- if (event === "endpoint") {
74
- this.postUrl = new URL(data, this.url).toString();
75
- announce?.();
294
+ catch (err) {
295
+ logLine("upstream.send_failed", { server: this.label, error: errText(err) });
296
+ this.answerError(entry, `upstream unreachable: ${errText(err)}`);
297
+ }
298
+ }
299
+ async postOnce(msg, entry, opts) {
300
+ if (this.dead || entry?.cancelled)
301
+ return;
302
+ const method = msg.method;
303
+ if (this.transport === "sse") {
304
+ // The replayed handshake runs inside a reconnect, so it must not wait for
305
+ // that reconnect; everything else waits for a live endpoint (rule 3).
306
+ const up = opts.internal ? Boolean(this.postUrl) : await this.ensureStream();
307
+ if (this.dead || entry?.cancelled)
308
+ return;
309
+ if (!up) {
310
+ this.answerError(entry, ANSWER.streamDown);
76
311
  return;
77
312
  }
78
- this.dispatch(data);
79
- }).catch((err) => {
80
- if (!this.dead) {
81
- logLine("upstream.stream_ended", { server: this.label, error: errText(err) });
82
- this.closed({ error: err instanceof Error ? err : new Error(String(err)) });
83
- }
84
- });
85
- const timer = setTimeout(() => announce?.(), this.connectTimeoutMs);
86
- await announced;
87
- clearTimeout(timer);
88
- if (!this.postUrl)
89
- throw new Error("SSE server never announced its endpoint");
90
- }
91
- /**
92
- * Streamable HTTP: open the standing GET stream that carries server-initiated
93
- * requests (`sampling/createMessage`, `elicitation/create`, list-changed
94
- * notifications). A 405 means this server does not offer one — normal, and not
95
- * an error: everything it sends will ride the POST responses instead.
96
- */
97
- async openStandingStream() {
98
- if (this.standingStreamOpened || this.transport !== "http")
313
+ }
314
+ const target = this.transport === "sse" ? this.postUrl : this.url;
315
+ if (!target) {
316
+ this.answerError(entry, ANSWER.streamDown);
99
317
  return;
100
- this.standingStreamOpened = true;
318
+ }
319
+ // Which session this POST speaks for: a 404 only means "expired" if the
320
+ // session it carried is still the current one (see recoverSession).
321
+ const sentOn = this.sessionKey();
322
+ const generation = this.generation;
323
+ const controller = entry?.abort ?? new AbortController();
324
+ this.inflight.add(controller);
325
+ // A request is bounded by the host (its timeout, its cancel). A message
326
+ // with no id has no one to do that: a server that never answers it would
327
+ // hold a socket for the life of the proxy. Its reply stream (below) is the
328
+ // exception — a server that streams is live, not hung.
329
+ let abandonTimer;
330
+ if (!entry) {
331
+ abandonTimer = setTimeout(() => {
332
+ logDetail("upstream.post_abandoned", { server: this.label, method: method ?? "response" });
333
+ controller.abort();
334
+ }, this.unansweredPostTimeoutMs);
335
+ abandonTimer.unref?.();
336
+ }
337
+ let res;
101
338
  try {
102
- const res = await fetch(this.url, {
103
- method: "GET",
104
- headers: { accept: "text/event-stream", ...this.sessionHeaders(), ...this.headers },
105
- signal: this.abort.signal,
106
- });
107
- if (!res.ok || !res.body) {
108
- logDetail("upstream.no_standing_stream", { server: this.label, status: res.status });
339
+ res = await this.call("POST", target, {
340
+ "content-type": "application/json",
341
+ accept: "application/json, text/event-stream",
342
+ ...this.sessionHeaders(method === "initialize"),
343
+ ...this.headers,
344
+ }, JSON.stringify(msg), controller.signal);
345
+ }
346
+ catch (err) {
347
+ clearTimeout(abandonTimer);
348
+ this.inflight.delete(controller);
349
+ if (this.dead || controller.signal.aborted)
350
+ return;
351
+ logLine("upstream.send_failed", { server: this.label, method, error: errText(err) });
352
+ this.proxyHint();
353
+ this.answerError(entry, `upstream unreachable: ${errText(err)}`);
354
+ return;
355
+ }
356
+ let handedOff = false;
357
+ try {
358
+ if (this.dead || controller.signal.aborted) {
359
+ res.body.destroy();
109
360
  return;
110
361
  }
111
- void this.pumpSse(res.body, (_event, data) => this.dispatch(data)).catch(() => {
112
- logDetail("upstream.standing_stream_ended", { server: this.label });
113
- });
362
+ if (this.transport === "http") {
363
+ // The server assigns the session at `initialize`. Adopting an id from
364
+ // any other answer would let a slow reply from a session that has since
365
+ // been replaced (see recoverSession) put the old one back.
366
+ const sid = res.header("mcp-session-id");
367
+ if (sid && (method === "initialize" || !this.sessionId))
368
+ this.sessionId = sid;
369
+ }
370
+ // Kept only once the server has taken it: replaying a handshake the host
371
+ // never completed would give a fresh session two `initialize`s.
372
+ if (res.ok && method === "initialize" && !opts.internal) {
373
+ this.initParams = msg.params ?? {};
374
+ }
375
+ if (!res.ok) {
376
+ res.body.destroy();
377
+ logLine("upstream.http_error", { server: this.label, status: res.status, method });
378
+ if (res.status === 401 || res.status === 403) {
379
+ if (method === "initialize")
380
+ this.noteAuthRequired(res.status);
381
+ else {
382
+ logOnce(`upstream.needs_sign_in:${this.label}`, "upstream.needs_sign_in", {
383
+ server: this.label,
384
+ status: res.status,
385
+ });
386
+ }
387
+ }
388
+ // Only a 404 to a request that carried a session says "forgotten": from
389
+ // a stateless server (no session id) it is just a 404, and recovering
390
+ // would add three round trips to every such request for nothing.
391
+ const expired = res.status === 404 &&
392
+ entry !== undefined &&
393
+ !opts.retried &&
394
+ method !== "initialize" &&
395
+ this.initParams !== undefined &&
396
+ (this.transport === "sse" || sentOn !== "");
397
+ if (expired && (await this.recoverSession(sentOn)) && !this.dead && !entry.cancelled) {
398
+ handedOff = true; // the retry owns the aborter now
399
+ this.inflight.delete(controller);
400
+ await this.postOnce(msg, entry, { retried: true });
401
+ return;
402
+ }
403
+ this.answerError(entry, statusAnswer(res.status, res.statusText));
404
+ return;
405
+ }
406
+ if (this.transport === "sse") {
407
+ // Legacy: the answer rides the event stream; this body is an
408
+ // acknowledgement. If that stream went down while we were posting, the
409
+ // answer can never arrive — say so now rather than never.
410
+ res.body.resume();
411
+ if (entry && this.outstanding.get(entry.id) === entry) {
412
+ if (generation !== this.generation || !this.postUrl)
413
+ this.answerError(entry, ANSWER.lost);
414
+ else
415
+ entry.postedOn = generation;
416
+ }
417
+ return;
418
+ }
419
+ const contentType = (res.header("content-type") ?? "").toLowerCase();
420
+ if (contentType.includes("text/event-stream")) {
421
+ handedOff = true;
422
+ void this.pumpReply(res, entry, controller);
423
+ }
424
+ else {
425
+ await this.readReply(res, contentType, entry, controller);
426
+ }
427
+ // As the MCP SDK does: the standing stream opens once the handshake is done.
428
+ if (method === "notifications/initialized")
429
+ void this.openStandingStream();
114
430
  }
115
- catch {
116
- // No standing stream. Not fatal — see the doc comment above.
431
+ finally {
432
+ clearTimeout(abandonTimer);
433
+ if (!handedOff)
434
+ this.inflight.delete(controller);
117
435
  }
118
436
  }
119
- /** Read an SSE body, calling `onEvent(eventName, data)` per complete event. */
120
- async pumpSse(body, onEvent) {
121
- const reader = body.getReader();
122
- const decoder = new TextDecoder();
123
- let buf = "";
124
- for (;;) {
125
- const { done, value } = await reader.read();
126
- if (done)
437
+ /** A JSON (or unexpected) reply body. Whatever it holds, the request gets an answer. */
438
+ async readReply(res, contentType, entry, controller) {
439
+ let text;
440
+ try {
441
+ text = await readBodyText(res.body, this.maxMessageBytes);
442
+ }
443
+ catch (err) {
444
+ if (this.dead || controller.signal.aborted)
445
+ return;
446
+ logLine("upstream.reply_failed", { server: this.label, method: entry?.method, error: errText(err) });
447
+ this.answerError(entry, err instanceof BodyTooLargeError ? ANSWER.tooLarge : ANSWER.closedEarly);
448
+ return;
449
+ }
450
+ const empty = text.trim().length === 0;
451
+ const parsed = empty ? true : this.dispatchData(text);
452
+ if (!entry || this.outstanding.get(entry.id) !== entry)
453
+ return; // answered (or a notification)
454
+ if (empty) {
455
+ // A bare 202 for a request is outside the spec, but the MCP SDK tolerates
456
+ // it when a standing stream could carry the answer, so we do too.
457
+ if (this.standing === "open" || this.standing === "opening") {
458
+ entry.onStandingStream = true;
127
459
  return;
128
- buf += decoder.decode(value, { stream: true });
129
- let sep;
130
- // Events are separated by a blank line; \r\n is legal too.
131
- while ((sep = buf.search(/\r?\n\r?\n/)) !== -1) {
132
- const raw = buf.slice(0, sep);
133
- buf = buf.slice(sep + (buf[sep] === "\r" ? 4 : 2));
134
- let event = "message";
135
- const data = [];
136
- for (const line of raw.split(/\r?\n/)) {
137
- if (line.startsWith("event:"))
138
- event = line.slice(6).trim();
139
- else if (line.startsWith("data:"))
140
- data.push(line.slice(5).replace(/^ /, ""));
141
- }
142
- if (data.length)
143
- onEvent(event, data.join("\n"));
144
460
  }
461
+ logLine("upstream.unanswered", { server: this.label, method: entry.method, status: res.status });
462
+ this.answerError(entry, `upstream accepted the request but sent no answer (HTTP ${res.status})`);
463
+ return;
145
464
  }
465
+ logLine("upstream.unexpected_answer", {
466
+ server: this.label,
467
+ method: entry.method,
468
+ status: res.status,
469
+ contentType: contentType ? mediaType(contentType) : "none",
470
+ json: parsed,
471
+ });
472
+ this.answerError(entry, `upstream returned an unexpected answer (HTTP ${res.status}${contentType ? `, ${mediaType(contentType)}` : ""})`);
146
473
  }
147
- /** Hand one inbound JSON-RPC payload to the relay (or to a `bir:` waiter). */
148
- dispatch(raw) {
149
- let msg;
474
+ /** A reply delivered as an SSE body. If it ends before the answer, answer anyway. */
475
+ async pumpReply(res, entry, controller) {
476
+ let failure;
150
477
  try {
151
- msg = JSON.parse(raw);
478
+ await readSseStream(res.body, (event) => void this.dispatchData(event.data), {
479
+ maxEventBytes: this.maxMessageBytes,
480
+ onHandlerError: (err) => this.handlerFailed(err),
481
+ });
482
+ }
483
+ catch (err) {
484
+ failure = err instanceof SseTooLargeError ? ANSWER.tooLarge : ANSWER.closedEarly;
485
+ if (!this.dead && !controller.signal.aborted) {
486
+ logLine("upstream.reply_stream_failed", {
487
+ server: this.label,
488
+ method: entry?.method,
489
+ error: errText(err),
490
+ });
491
+ }
492
+ }
493
+ finally {
494
+ this.inflight.delete(controller);
495
+ }
496
+ if (this.dead || controller.signal.aborted || !entry)
497
+ return;
498
+ if (this.outstanding.get(entry.id) !== entry)
499
+ return;
500
+ if (!failure)
501
+ logLine("upstream.unanswered", { server: this.label, method: entry.method, why: "stream ended" });
502
+ this.answerError(entry, failure ?? "upstream ended its reply stream without answering");
503
+ }
504
+ // ── receiving ─────────────────────────────────────────────────────────────
505
+ /** Parse one inbound payload (a body or an SSE event). False if it was not JSON. */
506
+ dispatchData(raw) {
507
+ let parsed;
508
+ try {
509
+ parsed = JSON.parse(raw);
152
510
  }
153
511
  catch {
154
512
  logDetail("upstream.unparseable", { server: this.label, sample: raw.slice(0, 120) });
155
- return;
513
+ return false;
156
514
  }
157
- if (!msg || typeof msg !== "object" || Array.isArray(msg))
515
+ // A JSON-RPC batch is legal on the wire; the host only ever sees single messages.
516
+ if (Array.isArray(parsed))
517
+ for (const item of parsed)
518
+ this.deliver(item);
519
+ else
520
+ this.deliver(parsed);
521
+ return true;
522
+ }
523
+ /** Hand one inbound JSON-RPC message to the relay (or to a `bir:` waiter). */
524
+ deliver(value) {
525
+ if (!value || typeof value !== "object" || Array.isArray(value))
158
526
  return;
527
+ const msg = value;
528
+ const hasMethod = typeof msg.method === "string";
529
+ const isAnswer = !hasMethod && "id" in msg && ("result" in msg || "error" in msg);
530
+ if (!hasMethod && !isAnswer) {
531
+ // `{"status":"ok"}` from a health route is not JSON-RPC, and the host's
532
+ // stream must only ever carry JSON-RPC.
533
+ logDetail("upstream.not_jsonrpc", { server: this.label });
534
+ return;
535
+ }
536
+ if (isAnswer) {
537
+ const id = msg.id;
538
+ if (typeof id === "string") {
539
+ const waiter = this.internalWaiters.get(id);
540
+ if (waiter) {
541
+ this.internalWaiters.delete(id);
542
+ this.outstanding.delete(id);
543
+ waiter(msg);
544
+ return;
545
+ }
546
+ }
547
+ if (typeof id === "string" || typeof id === "number") {
548
+ this.outstanding.delete(id);
549
+ if (id === this.hostInitId)
550
+ this.noteProtocolVersion(msg.result);
551
+ }
552
+ }
159
553
  this.accept(msg);
160
554
  }
161
- target() {
162
- return this.transport === "sse" ? (this.postUrl ?? this.url) : this.url;
555
+ /** Answer a request ourselves, once, unless the host no longer wants it. */
556
+ answerError(entry, message) {
557
+ if (!entry || this.outstanding.get(entry.id) !== entry)
558
+ return;
559
+ this.outstanding.delete(entry.id);
560
+ const waiter = typeof entry.id === "string" ? this.internalWaiters.get(entry.id) : undefined;
561
+ if (waiter) {
562
+ this.internalWaiters.delete(entry.id);
563
+ waiter({ jsonrpc: "2.0", id: entry.id, error: { code: -32000, message } });
564
+ return;
565
+ }
566
+ // Cancelled: the host stopped waiting. Dead: the relay has answered it.
567
+ if (entry.cancelled || this.dead)
568
+ return;
569
+ this.accept({ jsonrpc: "2.0", id: entry.id, error: { code: -32000, message } });
163
570
  }
164
- sessionHeaders() {
165
- return this.sessionId ? { "mcp-session-id": this.sessionId } : {};
571
+ noteProtocolVersion(result) {
572
+ const version = paramsObject(result).protocolVersion;
573
+ if (typeof version === "string" && version)
574
+ this.protocolVersion = version;
166
575
  }
167
- send(msg) {
168
- if (this.dead)
169
- return;
170
- logDetail("upstream.send", {
171
- server: this.label,
172
- method: msg.method,
576
+ handlerFailed(err) {
577
+ logLine("upstream.dispatch_failed", { server: this.label, error: errText(err) });
578
+ }
579
+ /**
580
+ * The server refused the handshake for want of credentials. Said once per
581
+ * server, as `upstream.auth_required`, so `bir doctor` can explain a remote
582
+ * server that was wrapped without the headers it needs (an opt-in remote
583
+ * entry whose sign-in only the host could do) instead of "it fails".
584
+ */
585
+ noteAuthRequired(status) {
586
+ logOnce(`upstream.auth_required:${this.label}`, "upstream.auth_required", { server: this.label, status });
587
+ }
588
+ // ── session expiry (rule 4) ───────────────────────────────────────────────
589
+ /**
590
+ * The server answered 404 to a request sent on session `staleKey`. Start a
591
+ * new session once, however many requests noticed at the same moment, and
592
+ * hold new traffic until it exists. Resolves true when a retry makes sense.
593
+ */
594
+ recoverSession(staleKey) {
595
+ if (this.recovery?.key === staleKey)
596
+ return this.recovery.promise;
597
+ if (this.sessionKey() !== staleKey)
598
+ return Promise.resolve(true); // already recovered: just retry
599
+ const promise = (async () => {
600
+ logLine("upstream.session_expired", { server: this.label, transport: this.transport });
601
+ if (this.transport === "http") {
602
+ this.sessionId = undefined;
603
+ this.resetStandingStream();
604
+ return this.reinitialize();
605
+ }
606
+ // Legacy: the session *is* the event stream. Drop it and open a new one;
607
+ // reconnect() replays the handshake. A reconnect already under way (the
608
+ // stream dropped, and the generation moves only once the new one
609
+ // announces itself) is opening that new session: join it. Aborting
610
+ // `streamAbort` then would kill it — mid-connect it is the new stream's.
611
+ if (this.connecting)
612
+ return this.connecting;
613
+ if (this.postUrl) {
614
+ // `session_expired` above is the line; this drop is its consequence.
615
+ this.onSseStreamLost(this.generation, "session expired", true);
616
+ this.streamAbort?.abort();
617
+ }
618
+ return this.reconnect(true);
619
+ })();
620
+ this.recovery = { key: staleKey, promise };
621
+ // Settled, it no longer stands for the live session: a later 404 on the
622
+ // same key (a recovery that failed) gets a fresh attempt, one per request.
623
+ void promise.finally(() => {
624
+ if (this.recovery?.promise === promise)
625
+ this.recovery = undefined;
173
626
  });
174
- // Serialised: MCP is order-sensitive (`initialize` before anything else, and
175
- // `notifications/initialized` before the first call), and parallel fetches
176
- // would let a later POST overtake an earlier one.
177
- this.chain = this.chain.then(() => this.post(msg)).catch(() => undefined);
627
+ this.gate = Promise.all([this.gate, promise]).then(noop, noop);
628
+ return promise;
178
629
  }
179
- async post(msg) {
180
- const isInitialize = msg.method === "initialize";
181
- try {
182
- const res = await fetch(this.target(), {
183
- method: "POST",
184
- headers: {
185
- "content-type": "application/json",
186
- accept: "application/json, text/event-stream",
187
- ...this.sessionHeaders(),
188
- ...this.headers,
189
- },
190
- body: JSON.stringify(msg),
191
- signal: this.abort.signal,
630
+ /** Which session a POST speaks for: the session id, or the legacy stream generation. */
631
+ sessionKey() {
632
+ return this.transport === "http" ? (this.sessionId ?? "") : String(this.generation);
633
+ }
634
+ /**
635
+ * Replay the host's `initialize` (under a `bir:` id) and `initialized`.
636
+ * `news`: say it on stderr when it worked — after a forgotten session or an
637
+ * outage; after a routine reconnect it is detail. A failure is always said.
638
+ */
639
+ async reinitialize(news = true) {
640
+ if (this.initParams === undefined || this.dead)
641
+ return false;
642
+ this.reinitCount += 1;
643
+ const id = `${BIR_ID_PREFIX}reinit:${this.reinitCount}`;
644
+ const entry = { id, method: "initialize", abort: new AbortController() };
645
+ this.outstanding.set(id, entry);
646
+ const answered = new Promise((resolve) => {
647
+ const timer = setTimeout(() => {
648
+ this.internalWaiters.delete(id);
649
+ if (this.outstanding.get(id) === entry)
650
+ this.outstanding.delete(id);
651
+ entry.abort.abort();
652
+ resolve(undefined);
653
+ }, this.connectTimeoutMs);
654
+ timer.unref?.();
655
+ this.internalWaiters.set(id, (msg) => {
656
+ clearTimeout(timer);
657
+ resolve(msg);
192
658
  });
193
- const sid = res.headers.get("mcp-session-id");
194
- if (sid)
195
- this.sessionId = sid;
659
+ });
660
+ await this.postMessage({ jsonrpc: "2.0", id, method: "initialize", params: this.initParams }, entry, { retried: true, internal: true });
661
+ const reply = await answered;
662
+ if (this.dead)
663
+ return false;
664
+ if (!reply || !("result" in reply)) {
665
+ const error = reply ? paramsObject(reply.error).message : "no answer";
666
+ logLine("upstream.reinitialize_failed", { server: this.label, error: String(error ?? "error") });
667
+ return false;
668
+ }
669
+ this.noteProtocolVersion(reply.result);
670
+ await settledWithin(this.postMessage({ jsonrpc: "2.0", method: "notifications/initialized" }, undefined, {
671
+ retried: true,
672
+ internal: true,
673
+ }), HANDSHAKE_GATE_MS);
674
+ (news ? logLine : logDetail)("upstream.session_reinitialized", { server: this.label, transport: this.transport });
675
+ return true;
676
+ }
677
+ // ── legacy SSE: listen first, then learn where to POST (rule 3) ────────────
678
+ /**
679
+ * Open the event stream and resolve once it has announced its endpoint. The
680
+ * whole connect — response headers included — is bounded by
681
+ * `connectTimeoutMs`: a server that never sent headers used to hold `ready`
682
+ * for fetch's 300 s, far past the host's own MCP connect timeout.
683
+ */
684
+ async openSseEndpointStream() {
685
+ const controller = new AbortController();
686
+ this.streamAbort = controller;
687
+ let timedOut = false;
688
+ const timer = setTimeout(() => {
689
+ timedOut = true;
690
+ controller.abort();
691
+ }, this.connectTimeoutMs);
692
+ timer.unref?.();
693
+ try {
694
+ let res;
695
+ try {
696
+ res = await this.call("GET", this.url, { accept: "text/event-stream", ...this.headers }, undefined, controller.signal);
697
+ }
698
+ catch (err) {
699
+ throw timedOut ? new Error(`SSE connect timed out after ${this.connectTimeoutMs} ms`) : err;
700
+ }
196
701
  if (!res.ok) {
197
- // A transport-level failure toward a request needs an answer, or the
198
- // host waits forever. The relay owns that decision, so surface it as a
199
- // JSON-RPC error response on the inbound path.
200
- const id = msg.id;
201
- const text = await res.text().catch(() => "");
202
- logLine("upstream.http_error", {
702
+ res.body.destroy();
703
+ // Legacy SSE's handshake starts here, with the GET: a 401/403 now is
704
+ // the same "no credentials" as one to a streamable `initialize`.
705
+ if (res.status === 401 || res.status === 403)
706
+ this.noteAuthRequired(res.status);
707
+ throw new Error(`SSE connect failed: ${statusAnswer(res.status, res.statusText)}`);
708
+ }
709
+ const contentType = (res.header("content-type") ?? "").toLowerCase();
710
+ if (contentType && !contentType.includes("text/event-stream")) {
711
+ res.body.destroy();
712
+ throw new Error(`SSE connect failed: the server answered ${mediaType(contentType)}, not an event stream`);
713
+ }
714
+ const generation = this.generation + 1;
715
+ await new Promise((resolve, reject) => {
716
+ let announced = false;
717
+ const onAbort = () => {
718
+ if (announced)
719
+ return;
720
+ reject(new Error(timedOut
721
+ ? `SSE server did not announce its endpoint within ${this.connectTimeoutMs} ms`
722
+ : "SSE connect aborted"));
723
+ };
724
+ controller.signal.addEventListener("abort", onAbort, { once: true });
725
+ const onEvent = (event) => {
726
+ if (event.event.trim() !== "endpoint") {
727
+ this.dispatchData(event.data);
728
+ return;
729
+ }
730
+ let next;
731
+ try {
732
+ next = new URL(event.data.trim(), this.url);
733
+ }
734
+ catch {
735
+ next = undefined;
736
+ }
737
+ if (!next || next.origin !== this.origin) {
738
+ // The endpoint decides where every POST — and the user's credential
739
+ // headers — go. The MCP SDK refuses a foreign origin; so do we.
740
+ logLine("upstream.endpoint_rejected", {
741
+ server: this.label,
742
+ reason: next ? "different origin" : "invalid URL",
743
+ });
744
+ if (!announced) {
745
+ reject(new Error("SSE server announced an endpoint on a different origin"));
746
+ controller.abort();
747
+ }
748
+ return;
749
+ }
750
+ this.postUrl = next.toString();
751
+ if (!announced) {
752
+ announced = true;
753
+ this.generation = generation;
754
+ this.streamOpenedAt = Date.now();
755
+ controller.signal.removeEventListener("abort", onAbort);
756
+ resolve();
757
+ }
758
+ };
759
+ const ended = (why) => {
760
+ if (!announced) {
761
+ reject(new Error(`SSE stream ${why === "ended" ? "ended" : `failed (${why})`} before announcing its endpoint`));
762
+ return;
763
+ }
764
+ // Closed, or dropped on purpose by a recovery that handles it itself.
765
+ if (this.dead || controller.signal.aborted)
766
+ return;
767
+ // A server that accepts and then drops every stream at once must not
768
+ // be reconnected (and re-initialised) twice a second forever.
769
+ const healthy = Date.now() - this.streamOpenedAt >= this.healthyStreamMs;
770
+ this.streamDrops = healthy ? 0 : this.streamDrops + 1;
771
+ // A healthy stream's end is a load balancer's idle cut, and so
772
+ // routine; the first quick drop of a run is news.
773
+ this.onSseStreamLost(generation, why, healthy || this.streamDrops > 1);
774
+ this.scheduleReconnect();
775
+ };
776
+ readSseStream(res.body, onEvent, {
777
+ maxEventBytes: this.maxMessageBytes,
778
+ onHandlerError: (err) => this.handlerFailed(err),
779
+ }).then(() => ended("ended"), (err) => ended(errText(err)));
780
+ });
781
+ }
782
+ finally {
783
+ clearTimeout(timer);
784
+ }
785
+ }
786
+ /**
787
+ * The stream a set of requests was answered on is gone. Their answers can
788
+ * never arrive (a new stream is a new session), so answer them now.
789
+ *
790
+ * Said on stderr when that cost the host an answer. Otherwise `routine`
791
+ * (a healthy stream's idle cut, a drop a recovery already said) is detail,
792
+ * and anything else — a stream that dropped soon after it opened — is said
793
+ * once an hour per server at most.
794
+ */
795
+ onSseStreamLost(generation, why, routine = false) {
796
+ if (generation !== this.generation || !this.postUrl)
797
+ return; // already handled
798
+ this.postUrl = undefined;
799
+ let lost = 0;
800
+ for (const entry of [...this.outstanding.values()]) {
801
+ if (entry.postedOn !== generation)
802
+ continue;
803
+ if (!entry.cancelled)
804
+ lost += 1;
805
+ this.answerError(entry, ANSWER.lost);
806
+ }
807
+ const fields = { server: this.label, why, lost: lost || undefined };
808
+ if (lost > 0)
809
+ logLine("upstream.stream_ended", fields);
810
+ else if (routine || !logEvery(`upstream.stream_ended:${this.label}`, STREAM_NEWS_INTERVAL_MS, "upstream.stream_ended", fields)) {
811
+ logDetail("upstream.stream_ended", fields);
812
+ }
813
+ }
814
+ /** Legacy: is there an endpoint to POST to? Waits for one reconnect attempt at most. */
815
+ async ensureStream() {
816
+ if (this.connecting)
817
+ return this.connecting;
818
+ if (this.postUrl)
819
+ return true;
820
+ if (this.dead)
821
+ return false;
822
+ return this.reconnect();
823
+ }
824
+ /**
825
+ * One reconnect attempt (shared by everyone who asks meanwhile). A host
826
+ * request that arrives while the stream is down pulls the next attempt
827
+ * forward rather than waiting out the backoff, and gets an error — not a
828
+ * hang — if that attempt fails.
829
+ *
830
+ * `afterExpiry`: the server forgot the session (a 404), which is worth a
831
+ * line when the new one is initialised. A routine reconnect that works at
832
+ * the first try is detail; one that ends an outage is said.
833
+ */
834
+ reconnect(afterExpiry = false) {
835
+ if (this.connecting)
836
+ return this.connecting;
837
+ if (this.dead)
838
+ return Promise.resolve(false);
839
+ clearTimeout(this.reconnectTimer);
840
+ this.reconnectTimer = undefined;
841
+ const attempt = (async () => {
842
+ try {
843
+ await this.openSseEndpointStream();
844
+ }
845
+ catch (err) {
846
+ if (this.dead)
847
+ return false;
848
+ this.reconnectFailures += 1;
849
+ // The first failure is news; the rest of an outage is noise.
850
+ (this.reconnectFailures === 1 ? logLine : logDetail)("upstream.reconnect_failed", {
203
851
  server: this.label,
204
- status: res.status,
205
- method: msg.method,
852
+ attempts: this.reconnectFailures,
853
+ error: errText(err),
206
854
  });
207
- if (id !== undefined && id !== null) {
208
- this.accept({
209
- jsonrpc: "2.0",
210
- id: id,
211
- error: {
212
- code: -32000,
213
- message: `upstream HTTP ${res.status} ${res.statusText}${text ? ` — ${text.slice(0, 500)}` : ""}`,
214
- },
215
- });
216
- }
855
+ this.scheduleReconnect();
856
+ return false;
857
+ }
858
+ const recovered = this.reconnectFailures > 0;
859
+ (recovered ? logLine : logDetail)("upstream.stream_reconnected", {
860
+ server: this.label,
861
+ failedAttempts: this.reconnectFailures,
862
+ });
863
+ this.reconnectFailures = 0;
864
+ // A new event stream is a new, uninitialised session: replay the host's
865
+ // handshake before anything else is let through.
866
+ if (this.initParams !== undefined)
867
+ await this.reinitialize(recovered || afterExpiry);
868
+ return true;
869
+ })();
870
+ this.connecting = attempt;
871
+ void attempt.finally(() => {
872
+ if (this.connecting === attempt)
873
+ this.connecting = undefined;
874
+ });
875
+ return attempt;
876
+ }
877
+ scheduleReconnect() {
878
+ if (this.dead || this.reconnectTimer)
879
+ return;
880
+ const setbacks = Math.max(this.reconnectFailures, this.streamDrops - 1);
881
+ const delay = Math.min(MAX_RECONNECT_DELAY_MS, this.reconnectBaseMs * 2 ** Math.min(setbacks, 10));
882
+ this.reconnectTimer = setTimeout(() => {
883
+ this.reconnectTimer = undefined;
884
+ void this.reconnect();
885
+ }, delay);
886
+ this.reconnectTimer.unref?.();
887
+ }
888
+ // ── streamable HTTP: the standing GET ─────────────────────────────────────
889
+ /**
890
+ * Open the standing GET stream that carries server-initiated requests
891
+ * (`sampling/createMessage`, `elicitation/create`, list-changed
892
+ * notifications). A 405 means this server does not offer one — normal, and
893
+ * not an error: everything it sends will ride the POST responses instead.
894
+ * When it drops it is re-opened with backoff (with `Last-Event-ID` when the
895
+ * server numbers its events), so a load balancer's idle cut does not silently
896
+ * lose every later list-changed or elicitation.
897
+ */
898
+ async openStandingStream() {
899
+ if (this.transport !== "http" || this.dead || this.standing !== "idle")
900
+ return;
901
+ clearTimeout(this.standingTimer);
902
+ this.standingTimer = undefined;
903
+ this.standing = "opening";
904
+ const controller = new AbortController();
905
+ this.standingAbort = controller;
906
+ const session = this.sessionId;
907
+ let res;
908
+ try {
909
+ res = await this.call("GET", this.url, {
910
+ accept: "text/event-stream",
911
+ ...(this.standingLastEventId ? { "last-event-id": this.standingLastEventId } : {}),
912
+ ...this.sessionHeaders(false),
913
+ ...this.headers,
914
+ }, undefined, controller.signal);
915
+ }
916
+ catch (err) {
917
+ if (this.dead || controller.signal.aborted)
217
918
  return;
919
+ this.standingDown();
920
+ this.standingDrops += 1;
921
+ // The first failed re-open of a run is news — until the stream is back,
922
+ // what the server sends on its own (list changes, elicitations) is not
923
+ // heard — once an hour per server at most; the retries are detail.
924
+ const fields = { server: this.label, error: errText(err), drops: this.standingDrops };
925
+ if (this.standingDrops > 1 ||
926
+ !logEvery(`upstream.standing_stream_failed:${this.label}`, STREAM_NEWS_INTERVAL_MS, "upstream.standing_stream_failed", fields)) {
927
+ logDetail("upstream.standing_stream_failed", fields);
218
928
  }
219
- const contentType = res.headers.get("content-type") ?? "";
220
- if (contentType.includes("text/event-stream") && res.body) {
221
- void this.pumpSse(res.body, (_event, data) => this.dispatch(data)).catch(() => undefined);
929
+ this.scheduleStandingReopen();
930
+ return;
931
+ }
932
+ if (this.dead || controller.signal.aborted) {
933
+ res.body.destroy();
934
+ return;
935
+ }
936
+ const contentType = (res.header("content-type") ?? "").toLowerCase();
937
+ if (!res.ok || !contentType.includes("text/event-stream")) {
938
+ res.body.destroy();
939
+ logDetail("upstream.no_standing_stream", { server: this.label, status: res.status });
940
+ if (res.ok || res.status === 405) {
941
+ this.standingDown("unsupported");
222
942
  }
223
- else if (this.transport !== "sse") {
224
- const text = await res.text();
225
- if (text.trim())
226
- this.dispatch(text);
227
- // An empty body is a 202-style acknowledgement — normal for notifications.
943
+ else if (res.status >= 500 || res.status === 409 || res.status === 429) {
944
+ // Busy or briefly broken: ask again later.
945
+ this.standingDown();
946
+ this.standingDrops += 1;
947
+ this.scheduleStandingReopen();
228
948
  }
229
- if (isInitialize)
230
- void this.openStandingStream();
949
+ else {
950
+ // 4xx (a forgotten session among them): the next request's recovery
951
+ // re-initialises, and the new handshake opens it again.
952
+ this.standingDown();
953
+ }
954
+ return;
955
+ }
956
+ this.standing = "open";
957
+ const openedAt = Date.now();
958
+ let why = "ended";
959
+ try {
960
+ await readSseStream(res.body, (event) => {
961
+ if (event.id)
962
+ this.standingLastEventId = event.id;
963
+ this.dispatchData(event.data);
964
+ }, {
965
+ maxEventBytes: this.maxMessageBytes,
966
+ onHandlerError: (err) => this.handlerFailed(err),
967
+ parser: (parser) => {
968
+ this.standingParser = parser;
969
+ },
970
+ });
231
971
  }
232
972
  catch (err) {
233
- if (this.abort.signal.aborted)
234
- return;
235
- const id = msg.id;
236
- logLine("upstream.send_failed", { server: this.label, error: errText(err) });
237
- if (id !== undefined && id !== null) {
238
- this.accept({
239
- jsonrpc: "2.0",
240
- id: id,
241
- error: { code: -32000, message: `upstream unreachable: ${errText(err)}` },
242
- });
243
- }
973
+ why = errText(err);
974
+ }
975
+ const retryHint = this.standingParser?.retryMs;
976
+ this.standingParser = undefined;
977
+ if (this.dead || controller.signal.aborted)
978
+ return;
979
+ if (retryHint !== undefined)
980
+ this.standingRetryMs = Math.max(100, retryHint);
981
+ this.standingDown();
982
+ const lived = Date.now() - openedAt;
983
+ const healthy = lived >= this.healthyStreamMs;
984
+ this.standingDrops = healthy ? 0 : this.standingDrops + 1;
985
+ // A stream that stayed up and is simply re-opened is routine: a load
986
+ // balancer's idle cut ends it every minute or two for the whole session,
987
+ // and a stderr line each time is an error in the host's MCP log every
988
+ // minute. Detail. The first quick drop of a run is news, but once an hour
989
+ // per server at most: a server that closes every GET at once (some
990
+ // stateless deployments do) is re-opened every 30 s, and a cut that lands
991
+ // right on the threshold alternates between "healthy" and "quick".
992
+ const fields = { server: this.label, why, drops: this.standingDrops, livedMs: lived };
993
+ if (healthy ||
994
+ this.standingDrops > 1 ||
995
+ !logEvery(`upstream.standing_stream_ended:${this.label}`, STREAM_NEWS_INTERVAL_MS, "upstream.standing_stream_ended", fields)) {
996
+ logDetail("upstream.standing_stream_ended", fields);
244
997
  }
998
+ // After a recovery the new handshake has opened (or will open) its own.
999
+ if (this.sessionId === session)
1000
+ this.scheduleStandingReopen();
1001
+ }
1002
+ /** The standing stream is not open: anything that was promised an answer on it gets one now. */
1003
+ standingDown(state = "idle") {
1004
+ this.standing = state;
1005
+ for (const entry of [...this.outstanding.values()]) {
1006
+ if (entry.onStandingStream)
1007
+ this.answerError(entry, ANSWER.lost);
1008
+ }
1009
+ }
1010
+ scheduleStandingReopen() {
1011
+ if (this.dead || this.standingTimer)
1012
+ return;
1013
+ const base = this.standingRetryMs ?? this.reconnectBaseMs * 2;
1014
+ const delay = Math.min(MAX_RECONNECT_DELAY_MS, base * 2 ** Math.min(this.standingDrops, 10));
1015
+ this.standingTimer = setTimeout(() => {
1016
+ this.standingTimer = undefined;
1017
+ void this.openStandingStream();
1018
+ }, delay);
1019
+ this.standingTimer.unref?.();
1020
+ }
1021
+ /** A new session is starting: the old standing stream belongs to the old one. */
1022
+ resetStandingStream() {
1023
+ this.standingAbort?.abort();
1024
+ clearTimeout(this.standingTimer);
1025
+ this.standingTimer = undefined;
1026
+ this.standingLastEventId = undefined;
1027
+ this.standingDrops = 0;
1028
+ // A server that has no standing stream will not grow one with a new session.
1029
+ this.standingDown(this.standing === "unsupported" ? "unsupported" : "idle");
1030
+ }
1031
+ // ── plumbing ──────────────────────────────────────────────────────────────
1032
+ call(method, url, headers, body, signal) {
1033
+ return httpRequest(url, {
1034
+ method,
1035
+ headers,
1036
+ body,
1037
+ signal,
1038
+ connectTimeoutMs: this.socketConnectTimeoutMs,
1039
+ agents: this.agents,
1040
+ });
1041
+ }
1042
+ sessionHeaders(isInitialize) {
1043
+ if (this.transport !== "http")
1044
+ return {};
1045
+ const out = {};
1046
+ if (this.sessionId)
1047
+ out["mcp-session-id"] = this.sessionId;
1048
+ // The spec (2025-06-18) asks for the negotiated version on every request
1049
+ // after the handshake; the MCP SDK sends it, so a server may rely on it.
1050
+ if (this.protocolVersion && !isInitialize)
1051
+ out["mcp-protocol-version"] = this.protocolVersion;
1052
+ return out;
1053
+ }
1054
+ /**
1055
+ * A network failure with a proxy configured that this process cannot use is
1056
+ * the likeliest cause; say so once in the audit log (`bir doctor` reads it).
1057
+ */
1058
+ proxyHint() {
1059
+ if (!configuredProxy())
1060
+ return;
1061
+ const enabled = proxyEnvEnabled();
1062
+ if (enabled && nodeSupportsProxyEnv())
1063
+ return;
1064
+ logOnce("upstream.proxy_env_ignored", "upstream.proxy_env_ignored", {
1065
+ server: this.label,
1066
+ node: process.versions.node,
1067
+ reason: enabled
1068
+ ? "HTTPS_PROXY is set but this Node cannot route through it (needs Node 24.5+ or 22.21+)"
1069
+ : "HTTPS_PROXY is set but BIR_UPSTREAM_PROXY_ENV=0",
1070
+ });
245
1071
  }
246
1072
  close() {
247
1073
  if (this.dead)
248
1074
  return;
1075
+ const sessionId = this.transport === "http" ? this.sessionId : undefined;
249
1076
  this.closed({});
250
- this.abort.abort();
1077
+ this.teardown(sessionId);
1078
+ }
1079
+ /** Stop every timer, stream and request; end the server-side session if there is one. */
1080
+ teardown(sessionId) {
1081
+ clearTimeout(this.reconnectTimer);
1082
+ clearTimeout(this.standingTimer);
1083
+ this.reconnectTimer = undefined;
1084
+ this.standingTimer = undefined;
1085
+ for (const controller of this.inflight)
1086
+ controller.abort();
1087
+ for (const entry of this.outstanding.values())
1088
+ entry.abort.abort();
1089
+ this.inflight.clear();
1090
+ this.outstanding.clear();
1091
+ this.streamAbort?.abort();
1092
+ this.standingAbort?.abort();
1093
+ for (const waiter of this.internalWaiters.values())
1094
+ waiter(undefined);
1095
+ this.internalWaiters.clear();
1096
+ if (!sessionId) {
1097
+ destroyAgents(this.agents);
1098
+ return;
1099
+ }
1100
+ // The spec's session DELETE lets the server free our session now rather
1101
+ // than at its own idle timeout. Best effort, one second, never an error.
1102
+ const headers = { "mcp-session-id": sessionId, ...this.headers };
1103
+ if (this.protocolVersion)
1104
+ headers["mcp-protocol-version"] = this.protocolVersion;
1105
+ httpRequest(this.url, {
1106
+ method: "DELETE",
1107
+ headers,
1108
+ signal: AbortSignal.timeout(DELETE_TIMEOUT_MS),
1109
+ connectTimeoutMs: DELETE_TIMEOUT_MS,
1110
+ agents: this.agents,
1111
+ })
1112
+ .then((res) => {
1113
+ res.body.resume();
1114
+ logDetail("upstream.session_deleted", { server: this.label, status: res.status });
1115
+ }, () => undefined)
1116
+ .finally(() => destroyAgents(this.agents));
251
1117
  }
252
1118
  }
253
1119
  /**