@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.
- package/README.md +540 -350
- package/dist/auth/client.d.ts +65 -3
- package/dist/auth/client.js +305 -53
- package/dist/bin/bir-hooks.d.ts +72 -31
- package/dist/bin/bir-hooks.js +176 -284
- package/dist/bin/bir-proxy.d.ts +17 -2
- package/dist/bin/bir-proxy.js +222 -28
- package/dist/bin/bir-scenario.d.ts +61 -10
- package/dist/bin/bir-scenario.js +704 -135
- package/dist/bin/bir.d.ts +14 -8
- package/dist/bin/bir.js +2937 -488
- package/dist/bin/investigate.js +41 -16
- package/dist/bin/scenario-edit.d.ts +43 -10
- package/dist/bin/scenario-edit.js +281 -80
- package/dist/bin/scenario-liveness.d.ts +194 -0
- package/dist/bin/scenario-liveness.js +459 -0
- package/dist/bin/scenario-stderr.d.ts +43 -0
- package/dist/bin/scenario-stderr.js +76 -0
- package/dist/bin/service-read.d.ts +88 -0
- package/dist/bin/service-read.js +128 -0
- package/dist/bin/setup.d.ts +19 -0
- package/dist/bin/setup.js +146 -20
- package/dist/config/adapters/claude-code.d.ts +92 -14
- package/dist/config/adapters/claude-code.js +291 -101
- package/dist/config/adapters/generic.d.ts +4 -1
- package/dist/config/adapters/generic.js +13 -14
- package/dist/config/generate.d.ts +392 -14
- package/dist/config/generate.js +666 -19
- package/dist/config/mcp-approval.d.ts +76 -0
- package/dist/config/mcp-approval.js +210 -0
- package/dist/config/node-path.d.ts +42 -0
- package/dist/config/node-path.js +102 -0
- package/dist/config/overrides.d.ts +62 -0
- package/dist/config/overrides.js +136 -0
- package/dist/config/resolve.d.ts +70 -8
- package/dist/config/resolve.js +186 -15
- package/dist/control/client.d.ts +22 -0
- package/dist/control/client.js +26 -7
- package/dist/control/correlation.d.ts +23 -5
- package/dist/control/correlation.js +0 -0
- package/dist/control/daemon.d.ts +400 -18
- package/dist/control/daemon.js +1105 -102
- package/dist/control/discovery.d.ts +77 -5
- package/dist/control/discovery.js +186 -11
- package/dist/control/ensure-hook.d.ts +61 -0
- package/dist/control/ensure-hook.js +160 -8
- package/dist/control/paths.d.ts +26 -0
- package/dist/control/paths.js +34 -0
- package/dist/control/pin-guard.d.ts +83 -0
- package/dist/control/pin-guard.js +534 -0
- package/dist/control/recorder-keeper.d.ts +229 -0
- package/dist/control/recorder-keeper.js +721 -0
- package/dist/control/recorder-main.d.ts +73 -0
- package/dist/control/recorder-main.js +778 -0
- package/dist/control/server.d.ts +707 -31
- package/dist/control/server.js +2803 -306
- package/dist/control/transcript.d.ts +21 -1
- package/dist/control/transcript.js +378 -112
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/jsonrpc/framing.d.ts +76 -12
- package/dist/jsonrpc/framing.js +216 -59
- package/dist/proxy/intercept.d.ts +49 -5
- package/dist/proxy/intercept.js +188 -59
- package/dist/proxy/relay.d.ts +16 -0
- package/dist/proxy/relay.js +77 -5
- package/dist/proxy/revive.d.ts +122 -0
- package/dist/proxy/revive.js +251 -0
- package/dist/proxy/session.d.ts +222 -14
- package/dist/proxy/session.js +708 -99
- package/dist/record/housekeeping.d.ts +71 -0
- package/dist/record/housekeeping.js +417 -0
- package/dist/record/recorder.d.ts +51 -3
- package/dist/record/recorder.js +3 -0
- package/dist/record/redact.d.ts +103 -9
- package/dist/record/redact.js +659 -30
- package/dist/record/remote-recorder.d.ts +327 -32
- package/dist/record/remote-recorder.js +1517 -187
- package/dist/record/switchable-recorder.d.ts +126 -0
- package/dist/record/switchable-recorder.js +351 -0
- package/dist/record/truncate.d.ts +40 -1
- package/dist/record/truncate.js +111 -4
- package/dist/replay/bash-channel.d.ts +35 -0
- package/dist/replay/bash-channel.js +133 -0
- package/dist/replay/bundle.d.ts +10 -0
- package/dist/replay/bundle.js +10 -0
- package/dist/replay/controller.d.ts +371 -20
- package/dist/replay/controller.js +1185 -111
- package/dist/replay/derive.d.ts +1 -0
- package/dist/replay/derive.js +81 -24
- package/dist/replay/executor.d.ts +51 -16
- package/dist/replay/executor.js +114 -41
- package/dist/replay/handover.d.ts +54 -1
- package/dist/replay/handover.js +55 -5
- package/dist/replay/logic.d.ts +62 -10
- package/dist/replay/logic.js +140 -22
- package/dist/replay/plan.d.ts +201 -27
- package/dist/replay/plan.js +581 -141
- package/dist/replay/safety.d.ts +72 -0
- package/dist/replay/safety.js +303 -0
- package/dist/replay/source-run.d.ts +10 -0
- package/dist/replay/source-run.js +80 -35
- package/dist/replay/targets.d.ts +15 -1
- package/dist/replay/targets.js +62 -4
- package/dist/replay/tool-error.d.ts +15 -1
- package/dist/replay/tool-error.js +33 -1
- package/dist/replay/types.d.ts +7 -4
- package/dist/upstream/client.d.ts +56 -3
- package/dist/upstream/client.js +61 -4
- package/dist/upstream/headers.d.ts +43 -0
- package/dist/upstream/headers.js +90 -0
- package/dist/upstream/http-client.d.ts +213 -22
- package/dist/upstream/http-client.js +1034 -168
- package/dist/upstream/http-transport.d.ts +143 -0
- package/dist/upstream/http-transport.js +391 -0
- package/dist/upstream/lazy-client.d.ts +8 -0
- package/dist/upstream/lazy-client.js +29 -1
- package/dist/upstream/sse.d.ts +78 -0
- package/dist/upstream/sse.js +189 -0
- package/dist/upstream/stdio-client.d.ts +61 -1
- package/dist/upstream/stdio-client.js +356 -33
- package/dist/util/atomic-write.d.ts +29 -0
- package/dist/util/atomic-write.js +96 -0
- package/dist/util/journal.d.ts +15 -0
- package/dist/util/journal.js +48 -3
- package/dist/util/log.d.ts +10 -0
- package/dist/util/log.js +28 -1
- package/dist/util/off-switch.d.ts +46 -0
- package/dist/util/off-switch.js +91 -0
- package/dist/util/service-health.d.ts +133 -0
- package/dist/util/service-health.js +217 -0
- package/docs/BaseInstRunner.md +694 -621
- package/docs/calculatedReplay.md +1625 -1244
- package/docs/calculatedReplayGuide.md +1212 -879
- package/docs/installRun.md +1013 -772
- package/docs/loginWeb.md +627 -607
- package/docs/mcpmark.md +49 -16
- package/docs/my-first-sample.md +569 -545
- package/docs/quickstart.md +384 -271
- package/docs/t-bench.md +406 -394
- package/package.json +58 -57
- package/production.md +1371 -0
- package/scripts/install.ps1 +97 -6
- 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
|
|
15
|
-
*
|
|
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 {
|
|
27
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 ??
|
|
50
|
-
this.
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
116
|
-
|
|
431
|
+
finally {
|
|
432
|
+
clearTimeout(abandonTimer);
|
|
433
|
+
if (!handedOff)
|
|
434
|
+
this.inflight.delete(controller);
|
|
117
435
|
}
|
|
118
436
|
}
|
|
119
|
-
/**
|
|
120
|
-
async
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
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
|
-
/**
|
|
148
|
-
|
|
149
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
571
|
+
noteProtocolVersion(result) {
|
|
572
|
+
const version = paramsObject(result).protocolVersion;
|
|
573
|
+
if (typeof version === "string" && version)
|
|
574
|
+
this.protocolVersion = version;
|
|
166
575
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
852
|
+
attempts: this.reconnectFailures,
|
|
853
|
+
error: errText(err),
|
|
206
854
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
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
|
/**
|