@basein/runner 0.1.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/LICENSE +201 -0
- package/README.md +276 -0
- package/dist/auth/client.d.ts +85 -0
- package/dist/auth/client.js +284 -0
- package/dist/bin/bir-hooks.d.ts +48 -0
- package/dist/bin/bir-hooks.js +201 -0
- package/dist/bin/bir-proxy.d.ts +45 -0
- package/dist/bin/bir-proxy.js +207 -0
- package/dist/bin/bir-scenario.d.ts +24 -0
- package/dist/bin/bir-scenario.js +177 -0
- package/dist/bin/bir.d.ts +21 -0
- package/dist/bin/bir.js +876 -0
- package/dist/config/adapters/claude-code.d.ts +76 -0
- package/dist/config/adapters/claude-code.js +181 -0
- package/dist/config/adapters/generic.d.ts +17 -0
- package/dist/config/adapters/generic.js +36 -0
- package/dist/config/generate.d.ts +127 -0
- package/dist/config/generate.js +114 -0
- package/dist/config/resolve.d.ts +68 -0
- package/dist/config/resolve.js +132 -0
- package/dist/control/client.d.ts +56 -0
- package/dist/control/client.js +86 -0
- package/dist/control/correlation.d.ts +86 -0
- package/dist/control/correlation.js +0 -0
- package/dist/control/discovery.d.ts +50 -0
- package/dist/control/discovery.js +123 -0
- package/dist/control/ordering.d.ts +38 -0
- package/dist/control/ordering.js +44 -0
- package/dist/control/paths.d.ts +32 -0
- package/dist/control/paths.js +56 -0
- package/dist/control/server.d.ts +272 -0
- package/dist/control/server.js +1131 -0
- package/dist/control/transcript.d.ts +75 -0
- package/dist/control/transcript.js +241 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +32 -0
- package/dist/jsonrpc/framing.d.ts +49 -0
- package/dist/jsonrpc/framing.js +143 -0
- package/dist/jsonrpc/types.d.ts +52 -0
- package/dist/jsonrpc/types.js +46 -0
- package/dist/proxy/intercept.d.ts +55 -0
- package/dist/proxy/intercept.js +147 -0
- package/dist/proxy/relay.d.ts +97 -0
- package/dist/proxy/relay.js +166 -0
- package/dist/proxy/session.d.ts +116 -0
- package/dist/proxy/session.js +319 -0
- package/dist/record/housekeeping.d.ts +34 -0
- package/dist/record/housekeeping.js +39 -0
- package/dist/record/queue.d.ts +48 -0
- package/dist/record/queue.js +96 -0
- package/dist/record/recorder.d.ts +111 -0
- package/dist/record/recorder.js +39 -0
- package/dist/record/redact.d.ts +37 -0
- package/dist/record/redact.js +119 -0
- package/dist/record/remote-recorder.d.ts +110 -0
- package/dist/record/remote-recorder.js +301 -0
- package/dist/record/truncate.d.ts +36 -0
- package/dist/record/truncate.js +85 -0
- package/dist/replay/bundle.d.ts +36 -0
- package/dist/replay/bundle.js +89 -0
- package/dist/replay/controller.d.ts +300 -0
- package/dist/replay/controller.js +807 -0
- package/dist/replay/coverage.d.ts +41 -0
- package/dist/replay/coverage.js +56 -0
- package/dist/replay/derive.d.ts +58 -0
- package/dist/replay/derive.js +166 -0
- package/dist/replay/executor.d.ts +78 -0
- package/dist/replay/executor.js +233 -0
- package/dist/replay/logic.d.ts +31 -0
- package/dist/replay/logic.js +50 -0
- package/dist/replay/plan.d.ts +181 -0
- package/dist/replay/plan.js +397 -0
- package/dist/replay/pricing.d.ts +41 -0
- package/dist/replay/pricing.js +76 -0
- package/dist/replay/source-run.d.ts +50 -0
- package/dist/replay/source-run.js +98 -0
- package/dist/replay/tool-error.d.ts +22 -0
- package/dist/replay/tool-error.js +60 -0
- package/dist/replay/types.d.ts +116 -0
- package/dist/replay/types.js +35 -0
- package/dist/upstream/client.d.ts +78 -0
- package/dist/upstream/client.js +114 -0
- package/dist/upstream/http-client.d.ts +78 -0
- package/dist/upstream/http-client.js +261 -0
- package/dist/upstream/lazy-client.d.ts +31 -0
- package/dist/upstream/lazy-client.js +53 -0
- package/dist/upstream/stdio-client.d.ts +57 -0
- package/dist/upstream/stdio-client.js +203 -0
- package/dist/util/log.d.ts +27 -0
- package/dist/util/log.js +51 -0
- package/dist/util/version.d.ts +2 -0
- package/dist/util/version.js +40 -0
- package/docs/BaseInstRunner.md +621 -0
- package/docs/calculatedReplay.md +1185 -0
- package/docs/calculatedReplayGuide.md +448 -0
- package/docs/installRun.md +413 -0
- package/docs/mcpmark.md +752 -0
- package/docs/quickstart.md +201 -0
- package/docs/t-bench.md +394 -0
- package/package.json +56 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-client — a remote upstream over streamable HTTP or legacy SSE (Phase 2.2).
|
|
3
|
+
*
|
|
4
|
+
* Ported in shape from RRepeat's `mcp-http-client.ts`, but message-level rather
|
|
5
|
+
* than call-level: this transport must carry *any* frame in either direction,
|
|
6
|
+
* responses included (see the deviation note in `upstream/client.ts`).
|
|
7
|
+
*
|
|
8
|
+
* TWO TRANSPORTS, because both are in the wild:
|
|
9
|
+
*
|
|
10
|
+
* - **streamable HTTP** (`--transport http`, current): every client→server
|
|
11
|
+
* message is a POST to one endpoint. The reply comes back as a JSON body or
|
|
12
|
+
* as an SSE stream carrying it. A session id handed out in the
|
|
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.
|
|
16
|
+
* - **legacy SSE** (`--transport sse`): a long-lived GET carries every reply
|
|
17
|
+
* and announces the POST endpoint with an `endpoint` event, so the handshake
|
|
18
|
+
* is inverted — you must be listening before you can send.
|
|
19
|
+
*
|
|
20
|
+
* AUTH (§9). The host's own OAuth authenticates the server it is *configured*
|
|
21
|
+
* with, which is now the proxy — so a remote upstream's credentials have to
|
|
22
|
+
* 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.
|
|
24
|
+
*/
|
|
25
|
+
import type { JsonRpcMessage } from "../jsonrpc/types.js";
|
|
26
|
+
import { BaseUpstreamClient } from "./client.js";
|
|
27
|
+
import { Readable } from "node:stream";
|
|
28
|
+
export interface HttpUpstreamOptions {
|
|
29
|
+
url: string;
|
|
30
|
+
/** `"http"` = streamable HTTP (default); `"sse"` = the legacy two-endpoint flow. */
|
|
31
|
+
transport?: "http" | "sse";
|
|
32
|
+
/** Extra headers (auth, tenancy) sent on every request. */
|
|
33
|
+
headers?: Record<string, string>;
|
|
34
|
+
label?: string;
|
|
35
|
+
/** How long to wait for the SSE `endpoint` announcement. Default 15s. */
|
|
36
|
+
connectTimeoutMs?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare class HttpUpstreamClient extends BaseUpstreamClient {
|
|
39
|
+
readonly ready: Promise<void>;
|
|
40
|
+
private readonly url;
|
|
41
|
+
private readonly transport;
|
|
42
|
+
private readonly headers;
|
|
43
|
+
private readonly label;
|
|
44
|
+
private readonly connectTimeoutMs;
|
|
45
|
+
private sessionId?;
|
|
46
|
+
/** Legacy SSE only: where to POST, announced by the stream's `endpoint` event. */
|
|
47
|
+
private postUrl?;
|
|
48
|
+
private readonly abort;
|
|
49
|
+
/** Serialises POSTs so messages leave in the order the host sent them. */
|
|
50
|
+
private chain;
|
|
51
|
+
private standingStreamOpened;
|
|
52
|
+
constructor(opts: HttpUpstreamOptions);
|
|
53
|
+
private connect;
|
|
54
|
+
private openSseEndpointStream;
|
|
55
|
+
/**
|
|
56
|
+
* Streamable HTTP: open the standing GET stream that carries server-initiated
|
|
57
|
+
* requests (`sampling/createMessage`, `elicitation/create`, list-changed
|
|
58
|
+
* notifications). A 405 means this server does not offer one — normal, and not
|
|
59
|
+
* an error: everything it sends will ride the POST responses instead.
|
|
60
|
+
*/
|
|
61
|
+
private openStandingStream;
|
|
62
|
+
/** Read an SSE body, calling `onEvent(eventName, data)` per complete event. */
|
|
63
|
+
private pumpSse;
|
|
64
|
+
/** Hand one inbound JSON-RPC payload to the relay (or to a `bir:` waiter). */
|
|
65
|
+
private dispatch;
|
|
66
|
+
private target;
|
|
67
|
+
private sessionHeaders;
|
|
68
|
+
send(msg: JsonRpcMessage): void;
|
|
69
|
+
private post;
|
|
70
|
+
close(): void;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Adapt a Node stream of newline-delimited JSON into the message callback shape,
|
|
74
|
+
* used by the tests' in-process fakes. Exported here rather than in a test file
|
|
75
|
+
* so the production framing is what the fakes exercise.
|
|
76
|
+
*/
|
|
77
|
+
export declare function readFramesFrom(stream: Readable, onMessage: (msg: JsonRpcMessage) => void): () => void;
|
|
78
|
+
//# sourceMappingURL=http-client.d.ts.map
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-client — a remote upstream over streamable HTTP or legacy SSE (Phase 2.2).
|
|
3
|
+
*
|
|
4
|
+
* Ported in shape from RRepeat's `mcp-http-client.ts`, but message-level rather
|
|
5
|
+
* than call-level: this transport must carry *any* frame in either direction,
|
|
6
|
+
* responses included (see the deviation note in `upstream/client.ts`).
|
|
7
|
+
*
|
|
8
|
+
* TWO TRANSPORTS, because both are in the wild:
|
|
9
|
+
*
|
|
10
|
+
* - **streamable HTTP** (`--transport http`, current): every client→server
|
|
11
|
+
* message is a POST to one endpoint. The reply comes back as a JSON body or
|
|
12
|
+
* as an SSE stream carrying it. A session id handed out in the
|
|
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.
|
|
16
|
+
* - **legacy SSE** (`--transport sse`): a long-lived GET carries every reply
|
|
17
|
+
* and announces the POST endpoint with an `endpoint` event, so the handshake
|
|
18
|
+
* is inverted — you must be listening before you can send.
|
|
19
|
+
*
|
|
20
|
+
* AUTH (§9). The host's own OAuth authenticates the server it is *configured*
|
|
21
|
+
* with, which is now the proxy — so a remote upstream's credentials have to
|
|
22
|
+
* 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.
|
|
24
|
+
*/
|
|
25
|
+
import { readFrames } from "../jsonrpc/framing.js";
|
|
26
|
+
import { logDetail, logLine, errText } from "../util/log.js";
|
|
27
|
+
import { BaseUpstreamClient } from "./client.js";
|
|
28
|
+
import { Readable } from "node:stream";
|
|
29
|
+
export class HttpUpstreamClient extends BaseUpstreamClient {
|
|
30
|
+
ready;
|
|
31
|
+
url;
|
|
32
|
+
transport;
|
|
33
|
+
headers;
|
|
34
|
+
label;
|
|
35
|
+
connectTimeoutMs;
|
|
36
|
+
sessionId;
|
|
37
|
+
/** Legacy SSE only: where to POST, announced by the stream's `endpoint` event. */
|
|
38
|
+
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;
|
|
43
|
+
constructor(opts) {
|
|
44
|
+
super();
|
|
45
|
+
this.url = opts.url;
|
|
46
|
+
this.transport = opts.transport ?? "http";
|
|
47
|
+
this.headers = opts.headers ?? {};
|
|
48
|
+
this.label = opts.label ?? new URL(opts.url).host;
|
|
49
|
+
this.connectTimeoutMs = opts.connectTimeoutMs ?? 15_000;
|
|
50
|
+
this.ready = this.connect();
|
|
51
|
+
}
|
|
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.
|
|
57
|
+
}
|
|
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}`);
|
|
67
|
+
}
|
|
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?.();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
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")
|
|
99
|
+
return;
|
|
100
|
+
this.standingStreamOpened = true;
|
|
101
|
+
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 });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
void this.pumpSse(res.body, (_event, data) => this.dispatch(data)).catch(() => {
|
|
112
|
+
logDetail("upstream.standing_stream_ended", { server: this.label });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// No standing stream. Not fatal — see the doc comment above.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
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)
|
|
127
|
+
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
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Hand one inbound JSON-RPC payload to the relay (or to a `bir:` waiter). */
|
|
148
|
+
dispatch(raw) {
|
|
149
|
+
let msg;
|
|
150
|
+
try {
|
|
151
|
+
msg = JSON.parse(raw);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
logDetail("upstream.unparseable", { server: this.label, sample: raw.slice(0, 120) });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg))
|
|
158
|
+
return;
|
|
159
|
+
this.accept(msg);
|
|
160
|
+
}
|
|
161
|
+
target() {
|
|
162
|
+
return this.transport === "sse" ? (this.postUrl ?? this.url) : this.url;
|
|
163
|
+
}
|
|
164
|
+
sessionHeaders() {
|
|
165
|
+
return this.sessionId ? { "mcp-session-id": this.sessionId } : {};
|
|
166
|
+
}
|
|
167
|
+
send(msg) {
|
|
168
|
+
if (this.dead)
|
|
169
|
+
return;
|
|
170
|
+
logDetail("upstream.send", {
|
|
171
|
+
server: this.label,
|
|
172
|
+
method: msg.method,
|
|
173
|
+
});
|
|
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);
|
|
178
|
+
}
|
|
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,
|
|
192
|
+
});
|
|
193
|
+
const sid = res.headers.get("mcp-session-id");
|
|
194
|
+
if (sid)
|
|
195
|
+
this.sessionId = sid;
|
|
196
|
+
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", {
|
|
203
|
+
server: this.label,
|
|
204
|
+
status: res.status,
|
|
205
|
+
method: msg.method,
|
|
206
|
+
});
|
|
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
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
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);
|
|
222
|
+
}
|
|
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.
|
|
228
|
+
}
|
|
229
|
+
if (isInitialize)
|
|
230
|
+
void this.openStandingStream();
|
|
231
|
+
}
|
|
232
|
+
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
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
close() {
|
|
247
|
+
if (this.dead)
|
|
248
|
+
return;
|
|
249
|
+
this.closed({});
|
|
250
|
+
this.abort.abort();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Adapt a Node stream of newline-delimited JSON into the message callback shape,
|
|
255
|
+
* used by the tests' in-process fakes. Exported here rather than in a test file
|
|
256
|
+
* so the production framing is what the fakes exercise.
|
|
257
|
+
*/
|
|
258
|
+
export function readFramesFrom(stream, onMessage) {
|
|
259
|
+
return readFrames(stream, { onMessage });
|
|
260
|
+
}
|
|
261
|
+
//# sourceMappingURL=http-client.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lazy-client — defer creating the real upstream until the host first speaks.
|
|
3
|
+
*
|
|
4
|
+
* WHAT `--lazy` CAN AND CANNOT BUY (docs §13, open question 3). Phase 8 suggests
|
|
5
|
+
* deferring the upstream spawn "until the first `tools/list`". That is not
|
|
6
|
+
* reachable without breaking Phase 3: answering `initialize` before the upstream
|
|
7
|
+
* exists means *fabricating* capabilities and `serverInfo`, which is precisely
|
|
8
|
+
* the mistake that makes a proxy silently swallow prompts and resources.
|
|
9
|
+
*
|
|
10
|
+
* So the honest deferral is one message earlier: the upstream is created on the
|
|
11
|
+
* **first host message**, whatever it is. For a client that connects its MCP
|
|
12
|
+
* servers at session start (Claude Code does) that saves nothing, because
|
|
13
|
+
* `initialize` arrives immediately. For a client that spawns servers eagerly but
|
|
14
|
+
* connects them on demand, it saves the whole upstream process until it is used.
|
|
15
|
+
* The flag is opt-in for exactly that reason, and this comment is the measurement
|
|
16
|
+
* the open question asks for: the saving is a property of the host, not of us.
|
|
17
|
+
*/
|
|
18
|
+
import type { JsonRpcMessage } from "../jsonrpc/types.js";
|
|
19
|
+
import { BaseUpstreamClient, type UpstreamClient } from "./client.js";
|
|
20
|
+
export declare class LazyUpstreamClient extends BaseUpstreamClient {
|
|
21
|
+
readonly ready: Promise<void>;
|
|
22
|
+
private inner?;
|
|
23
|
+
private readonly create;
|
|
24
|
+
private resolveReady;
|
|
25
|
+
private rejectReady;
|
|
26
|
+
constructor(create: () => UpstreamClient);
|
|
27
|
+
private ensure;
|
|
28
|
+
send(msg: JsonRpcMessage): void;
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=lazy-client.d.ts.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lazy-client — defer creating the real upstream until the host first speaks.
|
|
3
|
+
*
|
|
4
|
+
* WHAT `--lazy` CAN AND CANNOT BUY (docs §13, open question 3). Phase 8 suggests
|
|
5
|
+
* deferring the upstream spawn "until the first `tools/list`". That is not
|
|
6
|
+
* reachable without breaking Phase 3: answering `initialize` before the upstream
|
|
7
|
+
* exists means *fabricating* capabilities and `serverInfo`, which is precisely
|
|
8
|
+
* the mistake that makes a proxy silently swallow prompts and resources.
|
|
9
|
+
*
|
|
10
|
+
* So the honest deferral is one message earlier: the upstream is created on the
|
|
11
|
+
* **first host message**, whatever it is. For a client that connects its MCP
|
|
12
|
+
* servers at session start (Claude Code does) that saves nothing, because
|
|
13
|
+
* `initialize` arrives immediately. For a client that spawns servers eagerly but
|
|
14
|
+
* connects them on demand, it saves the whole upstream process until it is used.
|
|
15
|
+
* The flag is opt-in for exactly that reason, and this comment is the measurement
|
|
16
|
+
* the open question asks for: the saving is a property of the host, not of us.
|
|
17
|
+
*/
|
|
18
|
+
import { BaseUpstreamClient } from "./client.js";
|
|
19
|
+
export class LazyUpstreamClient extends BaseUpstreamClient {
|
|
20
|
+
ready;
|
|
21
|
+
inner;
|
|
22
|
+
create;
|
|
23
|
+
resolveReady;
|
|
24
|
+
rejectReady;
|
|
25
|
+
constructor(create) {
|
|
26
|
+
super();
|
|
27
|
+
this.create = create;
|
|
28
|
+
this.ready = new Promise((resolve, reject) => {
|
|
29
|
+
this.resolveReady = resolve;
|
|
30
|
+
this.rejectReady = reject;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
ensure() {
|
|
34
|
+
if (this.inner)
|
|
35
|
+
return this.inner;
|
|
36
|
+
const inner = this.create();
|
|
37
|
+
this.inner = inner;
|
|
38
|
+
inner.onMessage((msg) => this.accept(msg));
|
|
39
|
+
inner.onClose((info) => this.closed(info));
|
|
40
|
+
inner.ready.then(() => this.resolveReady(), (err) => this.rejectReady(err instanceof Error ? err : new Error(String(err))));
|
|
41
|
+
return inner;
|
|
42
|
+
}
|
|
43
|
+
send(msg) {
|
|
44
|
+
if (this.dead)
|
|
45
|
+
return;
|
|
46
|
+
this.ensure().send(msg);
|
|
47
|
+
}
|
|
48
|
+
close() {
|
|
49
|
+
this.inner?.close();
|
|
50
|
+
this.closed({});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=lazy-client.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio-client — an upstream MCP server spawned as a child process (Phase 2.1).
|
|
3
|
+
*
|
|
4
|
+
* TWO THINGS THAT BREAK STDIO PROXIES, both handled here:
|
|
5
|
+
*
|
|
6
|
+
* 1. **The upstream's stderr must never reach our stdout.** Our stdout *is* the
|
|
7
|
+
* host's JSON-RPC stream; one stray byte desynchronises it and the failure
|
|
8
|
+
* surfaces as an unrelated MCP error. Upstream stderr is forwarded to *our*
|
|
9
|
+
* stderr, line-prefixed with the server name so a multi-proxy session stays
|
|
10
|
+
* readable.
|
|
11
|
+
* 2. **Windows `.cmd` resolution.** `npx` on Windows is `npx.cmd`, and since
|
|
12
|
+
* Node 20.12 a `.cmd`/`.bat` cannot be spawned without a shell. Rather than
|
|
13
|
+
* hand the whole command line to `shell: true` (which re-parses arguments and
|
|
14
|
+
* mangles JSON), {@link resolveCommand} resolves the real file on PATH and,
|
|
15
|
+
* for a batch file, invokes `cmd.exe /d /s /c` with `windowsVerbatimArguments`
|
|
16
|
+
* and its own quoting.
|
|
17
|
+
*/
|
|
18
|
+
import type { JsonRpcMessage } from "../jsonrpc/types.js";
|
|
19
|
+
import { BaseUpstreamClient } from "./client.js";
|
|
20
|
+
export interface StdioUpstreamOptions {
|
|
21
|
+
command: string;
|
|
22
|
+
args?: string[];
|
|
23
|
+
env?: Record<string, string | undefined>;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
/** Prefix for forwarded stderr lines. Defaults to the command itself. */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Restart once with this backoff if the upstream dies unexpectedly. 0 disables. */
|
|
28
|
+
restartDelayMs?: number;
|
|
29
|
+
}
|
|
30
|
+
/** What `spawn` should actually be given for `command` on this platform. */
|
|
31
|
+
export interface ResolvedCommand {
|
|
32
|
+
command: string;
|
|
33
|
+
args: string[];
|
|
34
|
+
windowsVerbatimArguments?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Find `command` on PATH, trying PATHEXT extensions. Returns undefined if absent. */
|
|
37
|
+
export declare function whichSync(command: string, env?: NodeJS.ProcessEnv): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Turn `(command, args)` into something `spawn` can run with `shell: false`.
|
|
40
|
+
* On POSIX this is the identity. On Windows a batch file is wrapped in `cmd.exe`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveCommand(command: string, args: string[], env?: NodeJS.ProcessEnv): ResolvedCommand;
|
|
43
|
+
export declare class StdioUpstreamClient extends BaseUpstreamClient {
|
|
44
|
+
readonly ready: Promise<void>;
|
|
45
|
+
private child?;
|
|
46
|
+
private detach?;
|
|
47
|
+
private readonly label;
|
|
48
|
+
private readonly opts;
|
|
49
|
+
private restartsLeft;
|
|
50
|
+
private closing;
|
|
51
|
+
constructor(opts: StdioUpstreamOptions);
|
|
52
|
+
private start;
|
|
53
|
+
private onChildGone;
|
|
54
|
+
send(msg: JsonRpcMessage): void;
|
|
55
|
+
close(): void;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=stdio-client.d.ts.map
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio-client — an upstream MCP server spawned as a child process (Phase 2.1).
|
|
3
|
+
*
|
|
4
|
+
* TWO THINGS THAT BREAK STDIO PROXIES, both handled here:
|
|
5
|
+
*
|
|
6
|
+
* 1. **The upstream's stderr must never reach our stdout.** Our stdout *is* the
|
|
7
|
+
* host's JSON-RPC stream; one stray byte desynchronises it and the failure
|
|
8
|
+
* surfaces as an unrelated MCP error. Upstream stderr is forwarded to *our*
|
|
9
|
+
* stderr, line-prefixed with the server name so a multi-proxy session stays
|
|
10
|
+
* readable.
|
|
11
|
+
* 2. **Windows `.cmd` resolution.** `npx` on Windows is `npx.cmd`, and since
|
|
12
|
+
* Node 20.12 a `.cmd`/`.bat` cannot be spawned without a shell. Rather than
|
|
13
|
+
* hand the whole command line to `shell: true` (which re-parses arguments and
|
|
14
|
+
* mangles JSON), {@link resolveCommand} resolves the real file on PATH and,
|
|
15
|
+
* for a batch file, invokes `cmd.exe /d /s /c` with `windowsVerbatimArguments`
|
|
16
|
+
* and its own quoting.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
21
|
+
import { readFrames, writeFrame } from "../jsonrpc/framing.js";
|
|
22
|
+
import { logDetail, logLine, errText } from "../util/log.js";
|
|
23
|
+
import { BaseUpstreamClient } from "./client.js";
|
|
24
|
+
const BATCH_EXT = /\.(cmd|bat)$/i;
|
|
25
|
+
/** Quote one argument for `cmd.exe /c`, which re-parses what it is handed. */
|
|
26
|
+
function quoteForCmd(arg) {
|
|
27
|
+
return '"' + arg.replace(/"/g, '""') + '"';
|
|
28
|
+
}
|
|
29
|
+
/** Find `command` on PATH, trying PATHEXT extensions. Returns undefined if absent. */
|
|
30
|
+
export function whichSync(command, env = process.env) {
|
|
31
|
+
if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
|
|
32
|
+
return existsSync(command) ? command : undefined;
|
|
33
|
+
}
|
|
34
|
+
const exts = process.platform === "win32"
|
|
35
|
+
? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
36
|
+
: [""];
|
|
37
|
+
const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
38
|
+
for (const dir of dirs) {
|
|
39
|
+
for (const ext of exts) {
|
|
40
|
+
const candidate = join(dir, command + ext);
|
|
41
|
+
if (existsSync(candidate))
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Turn `(command, args)` into something `spawn` can run with `shell: false`.
|
|
49
|
+
* On POSIX this is the identity. On Windows a batch file is wrapped in `cmd.exe`.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveCommand(command, args, env = process.env) {
|
|
52
|
+
if (process.platform !== "win32")
|
|
53
|
+
return { command, args };
|
|
54
|
+
const resolved = whichSync(command, env) ?? command;
|
|
55
|
+
if (!BATCH_EXT.test(resolved))
|
|
56
|
+
return { command: resolved, args };
|
|
57
|
+
const comspec = env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
|
|
58
|
+
const line = [resolved, ...args].map(quoteForCmd).join(" ");
|
|
59
|
+
return {
|
|
60
|
+
command: comspec,
|
|
61
|
+
// `/d` skips AutoRun, `/s` makes cmd strip exactly the outer pair of quotes
|
|
62
|
+
// around the whole command, which is what lets our own quoting survive.
|
|
63
|
+
args: ["/d", "/s", "/c", '"' + line + '"'],
|
|
64
|
+
windowsVerbatimArguments: true,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export class StdioUpstreamClient extends BaseUpstreamClient {
|
|
68
|
+
ready;
|
|
69
|
+
child;
|
|
70
|
+
detach;
|
|
71
|
+
label;
|
|
72
|
+
opts;
|
|
73
|
+
restartsLeft;
|
|
74
|
+
closing = false;
|
|
75
|
+
constructor(opts) {
|
|
76
|
+
super();
|
|
77
|
+
this.opts = opts;
|
|
78
|
+
this.label = opts.label ?? opts.command;
|
|
79
|
+
this.restartsLeft = opts.restartDelayMs && opts.restartDelayMs > 0 ? 1 : 0;
|
|
80
|
+
this.ready = this.start();
|
|
81
|
+
}
|
|
82
|
+
start() {
|
|
83
|
+
const { command, args = [], env, cwd } = this.opts;
|
|
84
|
+
const spawnEnv = { ...process.env, ...env };
|
|
85
|
+
const resolved = resolveCommand(command, args, spawnEnv);
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let child;
|
|
88
|
+
try {
|
|
89
|
+
child = spawn(resolved.command, resolved.args, {
|
|
90
|
+
cwd,
|
|
91
|
+
env: spawnEnv,
|
|
92
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
93
|
+
shell: false,
|
|
94
|
+
windowsHide: true,
|
|
95
|
+
windowsVerbatimArguments: resolved.windowsVerbatimArguments,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.child = child;
|
|
103
|
+
child.once("spawn", () => {
|
|
104
|
+
logLine("upstream.spawned", {
|
|
105
|
+
server: this.label,
|
|
106
|
+
pid: child.pid,
|
|
107
|
+
command: resolved.command,
|
|
108
|
+
});
|
|
109
|
+
resolve();
|
|
110
|
+
});
|
|
111
|
+
child.once("error", (err) => {
|
|
112
|
+
// A spawn failure (ENOENT) and a mid-life error arrive the same way.
|
|
113
|
+
reject(err);
|
|
114
|
+
this.onChildGone({ error: err });
|
|
115
|
+
});
|
|
116
|
+
child.once("exit", (code, signal) => this.onChildGone({ code, signal }));
|
|
117
|
+
this.detach = readFrames(child.stdout, {
|
|
118
|
+
onMessage: (msg) => this.accept(msg),
|
|
119
|
+
onParseError: (d) => logLine("upstream.unparseable", {
|
|
120
|
+
server: this.label,
|
|
121
|
+
reason: d.reason,
|
|
122
|
+
bytes: d.bytes,
|
|
123
|
+
}),
|
|
124
|
+
});
|
|
125
|
+
// Upstream stderr → our stderr, never our stdout. Line-prefixed.
|
|
126
|
+
let errBuf = "";
|
|
127
|
+
child.stderr.setEncoding("utf8");
|
|
128
|
+
child.stderr.on("data", (chunk) => {
|
|
129
|
+
errBuf += chunk;
|
|
130
|
+
let nl;
|
|
131
|
+
while ((nl = errBuf.indexOf("\n")) !== -1) {
|
|
132
|
+
const line = errBuf.slice(0, nl).replace(/\r$/, "");
|
|
133
|
+
errBuf = errBuf.slice(nl + 1);
|
|
134
|
+
if (line)
|
|
135
|
+
process.stderr.write("[" + this.label + "] " + line + "\n");
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
onChildGone(info) {
|
|
141
|
+
this.detach?.();
|
|
142
|
+
this.detach = undefined;
|
|
143
|
+
if (this.closing) {
|
|
144
|
+
this.closed(info);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
logLine("upstream.exited", {
|
|
148
|
+
server: this.label,
|
|
149
|
+
code: info.code,
|
|
150
|
+
signal: info.signal,
|
|
151
|
+
error: info.error ? errText(info.error) : undefined,
|
|
152
|
+
});
|
|
153
|
+
if (this.restartsLeft > 0) {
|
|
154
|
+
this.restartsLeft -= 1;
|
|
155
|
+
const delay = this.opts.restartDelayMs ?? 500;
|
|
156
|
+
logLine("upstream.restarting", { server: this.label, delayMs: delay });
|
|
157
|
+
const timer = setTimeout(() => {
|
|
158
|
+
this.start().catch((err) => {
|
|
159
|
+
logLine("upstream.restart_failed", { server: this.label, error: errText(err) });
|
|
160
|
+
this.closed({ error: err instanceof Error ? err : new Error(String(err)) });
|
|
161
|
+
});
|
|
162
|
+
}, delay);
|
|
163
|
+
timer.unref?.();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Dead for good. The relay answers in-flight requests and stays up: the host
|
|
167
|
+
// sees one broken server, not a broken session (§10).
|
|
168
|
+
this.closed(info);
|
|
169
|
+
}
|
|
170
|
+
send(msg) {
|
|
171
|
+
const child = this.child;
|
|
172
|
+
if (!child || this.dead || child.stdin.destroyed)
|
|
173
|
+
return;
|
|
174
|
+
logDetail("upstream.send", {
|
|
175
|
+
server: this.label,
|
|
176
|
+
method: msg.method,
|
|
177
|
+
});
|
|
178
|
+
writeFrame(child.stdin, msg, (err) => logLine("upstream.write_failed", { server: this.label, error: errText(err) }));
|
|
179
|
+
}
|
|
180
|
+
close() {
|
|
181
|
+
this.closing = true;
|
|
182
|
+
this.detach?.();
|
|
183
|
+
const child = this.child;
|
|
184
|
+
if (!child) {
|
|
185
|
+
this.closed({});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
child.stdin.end();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* already gone */
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
child.kill();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* already gone */
|
|
199
|
+
}
|
|
200
|
+
this.closed({});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=stdio-client.js.map
|