@sigrun/ext-mcp 0.1.0 → 0.2.1

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/dist/client.js CHANGED
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Minimal hand-rolled MCP stdio client (ruling 3): JSON-RPC 2.0 over
3
- * newline-delimited stdio, no pi-mcp dependency.
2
+ * The MCP client: a transport-neutral JSON-RPC 2.0 engine plus two
3
+ * transports.
4
4
  *
5
- * Lifecycle: spawn → `initialize` handshake (protocolVersion "2025-06-18",
6
- * clientInfo, capabilities) → `notifications/initialized` → `tools/list`
7
- * (discovery) → `tools/call` (execution). Requests correlate by id; the
8
- * response frame's `id` must match a pending request (a notification is
9
- * never a reply). A spawn failure or handshake failure rejects `connect`,
5
+ * stdio (the original transport): newline-delimited JSON-RPC over a spawned
6
+ * child's pipes. Remote (streamable HTTP): every request POSTs to the
7
+ * configured URL; the reply is a JSON body OR an SSE stream. A server that
8
+ * 404/405s the POST (the pre-2025-03-26 "HTTP+SSE" transport — still common,
9
+ * e.g. many self-hosted servers) falls back to the legacy shape: a GET
10
+ * stream for server→client frames and a discovered message endpoint for
11
+ * client→server POSTs.
12
+ *
13
+ * Lifecycle (both transports): `initialize` handshake (protocolVersion
14
+ * "2025-06-18", clientInfo, capabilities) → `notifications/initialized` →
15
+ * `tools/list` (discovery) → `tools/call` (execution). Requests correlate
16
+ * by id; the response frame's `id` must match a pending request (a
17
+ * notification is never a reply). A connect failure rejects `connect`,
10
18
  * never affecting any other server (per-server isolation).
11
19
  *
12
20
  * Per-request timeout (codemode parity, spec slice 3): every request REJECTS
@@ -14,69 +22,34 @@
14
22
  * awaiting a hung server forever. A `notifications/progress` frame naming a
15
23
  * pending request's id (sent as the request's `progressToken`) RESETS that
16
24
  * request's deadline — a server that keeps reporting progress stays alive
17
- * as long as it works. A timeout rejects the call but never kills the child
18
- * (per-server isolation: the next request gets a fresh chance).
25
+ * as long as it works. A timeout rejects the call but never kills the
26
+ * transport (per-server isolation: the next request gets a fresh chance).
19
27
  */
20
28
  import { spawn } from "node:child_process";
21
- import { spawnEnvWithToken } from "./tokens.js";
29
+ import { serverSpawnEnv } from "./spawn-env.js";
22
30
  import { DEFAULT_TIMEOUT_SECONDS } from "./config.js";
23
31
  const MCP_PROTOCOL_VERSION = "2025-06-18";
24
32
  function isRecord(value) {
25
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
26
34
  }
27
- /** One connected MCP server: a live child process speaking JSON-RPC. */
28
- export class McpClient {
29
- #process;
30
- #stdin;
31
- #stdout;
35
+ /**
36
+ * The JSON-RPC engine: id correlation, per-request deadlines, progress
37
+ * resets, and the handshake/list/call methods. Transport-agnostic — frames
38
+ * in, frames out, death unwinds pending.
39
+ */
40
+ class McpEngine {
32
41
  #name;
42
+ #transport;
33
43
  #nextId = 1;
34
- #buffer = "";
35
44
  #pending = new Map();
36
- #exitError;
37
- /** True when the child exited through an intentional close(). */
38
- #exitClean = false;
39
- #exitWaiters = [];
45
+ #dead;
40
46
  #closed = false;
41
- /** @internal constructed by {@link connectMcpServer}. */
42
- constructor(name, child) {
47
+ constructor(name, transport) {
43
48
  this.#name = name;
44
- this.#process = child;
45
- if (child.stdin === null || child.stdout === null) {
46
- throw new Error(`mcp server "${name}" spawned without stdio`);
47
- }
48
- this.#stdin = child.stdin;
49
- this.#stdout = child.stdout;
50
- this.#stdout.setEncoding("utf8");
51
- this.#stdout.on("data", (chunk) => this.#onData(chunk));
52
- this.#stdout.on("error", () => this.#failExits()); // closed pipe: ignore
53
- // A broken stdin pipe (server died) must not crash the host with an
54
- // unhandled stream error; surface it as the exit error instead.
55
- this.#stdin.on("error", () => this.#failExits());
56
- this.#process.on("error", (error) => this.#setExitError(error));
57
- this.#process.on("exit", () => this.#setExitError(undefined));
49
+ this.#transport = transport;
58
50
  }
59
- #onData(chunk) {
60
- this.#buffer += chunk;
61
- for (;;) {
62
- const newline = this.#buffer.indexOf("\n");
63
- if (newline < 0)
64
- return;
65
- const line = this.#buffer.slice(0, newline).trim();
66
- this.#buffer = this.#buffer.slice(newline + 1);
67
- if (line === "")
68
- continue;
69
- this.#onLine(line);
70
- }
71
- }
72
- #onLine(line) {
73
- let frame;
74
- try {
75
- frame = JSON.parse(line);
76
- }
77
- catch {
78
- return; // a non-JSON line (server chatter) is not a protocol reply
79
- }
51
+ /** A transport delivered one parsed frame (reply or notification). */
52
+ handleFrame(frame) {
80
53
  if (frame.id === undefined || frame.id === null) {
81
54
  // A frame with no id is a server-initiated NOTIFICATION (a reply
82
55
  // always carries the request's id). A `notifications/progress`
@@ -107,43 +80,18 @@ export class McpClient {
107
80
  pending.resolve(frame.result);
108
81
  }
109
82
  }
110
- #setExitError(error) {
111
- if (this.#exitError !== undefined)
83
+ /** The transport died (once): unwind everything pending with the error. */
84
+ handleDeath(error) {
85
+ if (this.#dead !== undefined)
112
86
  return;
113
- // An INTENTIONAL close() is a clean shutdown: resolve waiters with no
114
- // error (a close is a shutdown, never a failure).
115
- if (this.#closed)
116
- this.#exitClean = true;
117
- const waiters = this.#exitWaiters;
118
- this.#exitWaiters = [];
119
- if (!this.#exitClean) {
120
- this.#exitError =
121
- error ??
122
- new Error(`mcp server "${this.#name}" exited early (code ${this.#process.exitCode ?? "?"})`);
123
- }
124
- for (const waiter of waiters)
125
- waiter(this.#exitError);
126
- this.#rejectPending();
127
- }
128
- #failExits() {
129
- // stdout closed: surface as an exit error if the process has not yet
130
- // reported one, so a hung `tools/call` unwinds.
131
- this.#setExitError(undefined);
132
- }
133
- #rejectPending() {
87
+ this.#dead = error;
134
88
  const pending = [...this.#pending.values()];
135
89
  this.#pending.clear();
136
90
  for (const entry of pending) {
137
91
  entry.clearTimeout();
138
- entry.reject(this.#exitError ?? new Error("mcp server closed"));
92
+ entry.reject(error);
139
93
  }
140
94
  }
141
- /** Write one frame as a single newline-terminated line. */
142
- #write(frame) {
143
- if (this.#closed)
144
- throw new Error(`mcp server "${this.#name}" is closed`);
145
- this.#stdin.write(JSON.stringify(frame) + "\n");
146
- }
147
95
  /**
148
96
  * Send a request and await its reply (id-correlated) under a per-request
149
97
  * deadline. `timeoutSeconds` defaults to 60; the request's id doubles as
@@ -151,6 +99,8 @@ export class McpClient {
151
99
  * convention), so progress notifications can name it.
152
100
  */
153
101
  #request(method, params, timeoutSeconds) {
102
+ if (this.#dead !== undefined)
103
+ return Promise.reject(this.#dead);
154
104
  const id = this.#nextId++;
155
105
  const seconds = timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
156
106
  return new Promise((resolve, reject) => {
@@ -176,30 +126,20 @@ export class McpClient {
176
126
  };
177
127
  const deadline = () => fail(new Error(`mcp server "${this.#name}" ${method} timed out after ${seconds}s`));
178
128
  this.#pending.set(id, entry);
179
- try {
180
- // MCP convention: the progressToken rides in params._meta (a
181
- // spec-conforming server reads/echoes it from there). params is
182
- // merged (never mutated) so an object request keeps its fields;
183
- // a null-params request (tools/list) gets params just for _meta.
184
- const requestParams = isRecord(params)
185
- ? { ...params, _meta: { progressToken: id } }
186
- : { _meta: { progressToken: id } };
187
- this.#write({
188
- jsonrpc: "2.0",
189
- id,
190
- method,
191
- params: requestParams,
192
- });
193
- }
194
- catch (error) {
195
- fail(error instanceof Error ? error : new Error(String(error)));
129
+ // MCP convention: the progressToken rides in params._meta (a
130
+ // spec-conforming server reads/echoes it from there). params is
131
+ // merged (never mutated) so an object request keeps its fields;
132
+ // a null-params request (tools/list) gets params just for _meta.
133
+ const requestParams = isRecord(params)
134
+ ? { ...params, _meta: { progressToken: id } }
135
+ : { _meta: { progressToken: id } };
136
+ this.#transport
137
+ .send({ jsonrpc: "2.0", id, method, params: requestParams })
138
+ .catch((cause) => {
139
+ fail(cause instanceof Error ? cause : new Error(String(cause)));
196
140
  return;
197
- }
141
+ });
198
142
  timer = setTimeout(deadline, seconds * 1000);
199
- if (this.#exitError !== undefined) {
200
- // The server already died: unwind this request too.
201
- fail(this.#exitError);
202
- }
203
143
  });
204
144
  }
205
145
  /**
@@ -210,12 +150,15 @@ export class McpClient {
210
150
  const result = (await this.#request("initialize", {
211
151
  protocolVersion: MCP_PROTOCOL_VERSION,
212
152
  capabilities: {},
213
- clientInfo: { name: "sigrun-ext-mcp", version: "0.1.0" },
153
+ clientInfo: { name: "sigrun-ext-mcp", version: "0.2.0" },
214
154
  }, undefined));
215
155
  if (typeof result?.protocolVersion !== "string") {
216
156
  throw new Error(`mcp server "${this.#name}" handshake reply lacks protocolVersion`);
217
157
  }
218
- this.#write({ jsonrpc: "2.0", method: "notifications/initialized" });
158
+ await this.#transport.send({
159
+ jsonrpc: "2.0",
160
+ method: "notifications/initialized",
161
+ });
219
162
  }
220
163
  /** Discover the server's tools (`tools/list`). */
221
164
  async listTools() {
@@ -232,48 +175,608 @@ export class McpClient {
232
175
  }
233
176
  return result;
234
177
  }
235
- /** Wait for the child to exit (used by tests / shutdown checks). */
236
- async exited() {
237
- if (this.#exitError !== undefined)
238
- return this.#exitError;
239
- if (this.#process.exitCode !== null || this.#process.signalCode !== null) {
240
- return undefined; // exited cleanly; already unwound pending
178
+ close() {
179
+ if (this.#closed)
180
+ return;
181
+ this.#closed = true;
182
+ this.#transport.close();
183
+ this.handleDeath(new Error("mcp client closed"));
184
+ }
185
+ }
186
+ /** One connected stdio MCP server: a live child process speaking JSON-RPC. */
187
+ class StdioTransport {
188
+ #engine;
189
+ #process;
190
+ #stdin;
191
+ #stdout;
192
+ #buffer = "";
193
+ #closed = false;
194
+ constructor(name, command, args, env) {
195
+ // The engine is constructed with this transport; handleFrame/handleDeath
196
+ // route the child's output back into it.
197
+ this.#process = spawn(command, [...args], {
198
+ stdio: ["pipe", "pipe", "pipe"],
199
+ env,
200
+ });
201
+ this.#engine = new McpEngine(name, this);
202
+ if (this.#process.stdin === null || this.#process.stdout === null) {
203
+ this.#process.kill("SIGKILL");
204
+ throw new Error(`mcp server "${name}" spawned without stdio`);
205
+ }
206
+ this.#stdin = this.#process.stdin;
207
+ this.#stdout = this.#process.stdout;
208
+ this.#stdout.setEncoding("utf8");
209
+ this.#stdout.on("data", (chunk) => this.#onData(chunk));
210
+ // A closed pipe is transport death (never an unhandled stream error).
211
+ this.#stdout.on("error", () => this.#die());
212
+ this.#stdin.on("error", () => this.#die());
213
+ this.#process.on("error", (error) => this.#die(error));
214
+ this.#process.on("exit", () => this.#die());
215
+ }
216
+ get engine() {
217
+ return this.#engine;
218
+ }
219
+ #onData(chunk) {
220
+ this.#buffer += chunk;
221
+ for (;;) {
222
+ const newline = this.#buffer.indexOf("\n");
223
+ if (newline < 0)
224
+ return;
225
+ const line = this.#buffer.slice(0, newline).trim();
226
+ this.#buffer = this.#buffer.slice(newline + 1);
227
+ if (line === "")
228
+ continue;
229
+ let frame;
230
+ try {
231
+ frame = JSON.parse(line);
232
+ }
233
+ catch {
234
+ continue; // a non-JSON line (server chatter) is not a protocol reply
235
+ }
236
+ this.#engine.handleFrame(frame);
237
+ }
238
+ }
239
+ #die(error) {
240
+ if (this.#closed)
241
+ return;
242
+ this.#closed = true;
243
+ this.#engine.handleDeath(error ??
244
+ new Error(`mcp server exited early (code ${this.#process.exitCode ?? "?"})`));
245
+ }
246
+ send(frame) {
247
+ if (this.#closed) {
248
+ return Promise.reject(new Error("mcp stdio transport is closed"));
241
249
  }
242
- return new Promise((resolve) => {
243
- this.#exitWaiters.push(resolve);
250
+ return new Promise((resolve, reject) => {
251
+ this.#stdin.write(JSON.stringify(frame) + "\n", (error) => {
252
+ if (error === undefined || error === null)
253
+ resolve();
254
+ else
255
+ reject(error instanceof Error ? error : new Error(String(error)));
256
+ });
244
257
  });
245
258
  }
246
- /** Kill the child and tear down (idempotent). */
247
259
  close() {
248
260
  if (this.#closed)
249
261
  return;
250
262
  this.#closed = true;
251
263
  this.#process.kill("SIGTERM");
252
- this.#rejectPending();
264
+ this.#engine.handleDeath(new Error("mcp client closed"));
265
+ }
266
+ }
267
+ /** MCP framing over a core-owned service's Web-standard streams. The host
268
+ * owns the jail and process; this transport owns only its stream handle. */
269
+ class HandleTransport {
270
+ #engine;
271
+ #handle;
272
+ #writer;
273
+ #closed = false;
274
+ #released = false;
275
+ #read;
276
+ constructor(name, handle) {
277
+ this.#handle = handle;
278
+ this.#engine = new McpEngine(name, this);
279
+ this.#writer = handle.stdin.getWriter();
280
+ this.#read = this.#readStdout(name);
281
+ void handle.exited.then(({ code, signal }) => {
282
+ this.#die(new Error(`mcp service exited (code ${code ?? "?"}, signal ${signal ?? "?"})`));
283
+ });
284
+ }
285
+ get engine() {
286
+ return this.#engine;
287
+ }
288
+ async #readStdout(name) {
289
+ const reader = this.#handle.stdout.getReader();
290
+ const decoder = new TextDecoder();
291
+ let buffer = "";
292
+ try {
293
+ for (;;) {
294
+ const { done, value } = await reader.read();
295
+ if (done)
296
+ break;
297
+ buffer += decoder.decode(value, { stream: true });
298
+ for (;;) {
299
+ const newline = buffer.indexOf("\n");
300
+ if (newline < 0)
301
+ break;
302
+ const line = buffer.slice(0, newline).trim();
303
+ buffer = buffer.slice(newline + 1);
304
+ if (line === "")
305
+ continue;
306
+ try {
307
+ this.#engine.handleFrame(JSON.parse(line));
308
+ }
309
+ catch {
310
+ // A non-JSON line is server chatter, not a protocol frame.
311
+ }
312
+ }
313
+ }
314
+ if (!this.#closed) {
315
+ this.#die(new Error(`mcp service "${name}" stdout ended`));
316
+ }
317
+ }
318
+ catch (cause) {
319
+ if (!this.#closed) {
320
+ this.#die(cause instanceof Error
321
+ ? cause
322
+ : new Error(`mcp service "${name}" stream failed`));
323
+ }
324
+ }
325
+ finally {
326
+ reader.releaseLock();
327
+ }
328
+ }
329
+ #die(error) {
330
+ if (this.#closed)
331
+ return;
332
+ this.#closed = true;
333
+ this.#engine.handleDeath(error);
334
+ this.#release();
335
+ }
336
+ #release() {
337
+ if (this.#released)
338
+ return;
339
+ this.#released = true;
340
+ this.#handle.release();
341
+ }
342
+ send(frame) {
343
+ if (this.#closed) {
344
+ return Promise.reject(new Error("mcp service transport is closed"));
345
+ }
346
+ return this.#writer.write(new TextEncoder().encode(JSON.stringify(frame) + "\n"));
347
+ }
348
+ close() {
349
+ if (this.#closed)
350
+ return;
351
+ this.#closed = true;
352
+ void this.#writer.close().catch((cause) => {
353
+ console.warn(`mcp service stdin close failed: ${cause instanceof Error ? cause.message : String(cause)}`);
354
+ });
355
+ this.#engine.handleDeath(new Error("mcp client closed"));
356
+ this.#release();
253
357
  }
254
358
  }
359
+ // ---------------------------------------------------------------------------
360
+ // Remote transport: streamable HTTP with a legacy-SSE fallback.
361
+ // ---------------------------------------------------------------------------
255
362
  /**
256
- * Spawn and connect one configured MCP server. Throws on spawn failure or a
257
- * failed handshake — per-server isolation is the CALLER's policy (the
258
- * extension connects all servers with `.catch` per server).
363
+ * Hand-rolled SSE parsing (no EventSource dependency — this is Node, and
364
+ * the stream is a fetch body, not a DOM event target): buffer bytes, split
365
+ * complete events on a blank line, read `data:` lines, JSON-parse each
366
+ * data payload as one JSON-RPC message per event. Non-`message` event
367
+ * names are read for transport control (the legacy `endpoint` event) and
368
+ * otherwise ignored.
259
369
  */
260
- export async function connectMcpServer(name, config) {
261
- const child = spawn(config.command, [...config.args], {
262
- stdio: ["pipe", "pipe", "pipe"],
263
- // Token injection (ruling 3): a server with an oauth block AND a stored
264
- // token gets it as MCP_ACCESS_TOKEN, merged over the server's own env.
265
- env: { ...process.env, ...spawnEnvWithToken(name, config) },
266
- });
267
- const client = new McpClient(name, child);
370
+ class SseParser {
371
+ #buffer = "";
372
+ #pendingData = [];
373
+ #pendingEvent = "";
374
+ /** Feed bytes; emits complete parsed frames through `onFrame` and
375
+ * legacy `endpoint` events through `onEndpoint`. */
376
+ feed(chunk, onFrame, onEndpoint) {
377
+ this.#buffer += chunk;
378
+ for (;;) {
379
+ // The SSE spec allows \r\n, \n, or \r line endings; live servers
380
+ // (e.g. behind certain proxies) emit CRLF, so a plain \n\n split
381
+ // never finds the event boundary. Normalize the whole buffer's line
382
+ // endings FIRST, then split events on blank lines.
383
+ this.#buffer = this.#buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
384
+ const boundary = this.#buffer.indexOf("\n\n");
385
+ if (boundary < 0)
386
+ return;
387
+ const raw = this.#buffer.slice(0, boundary);
388
+ this.#buffer = this.#buffer.slice(boundary + 2);
389
+ const dataLines = [];
390
+ let eventName = "";
391
+ for (const line of raw.split("\n")) {
392
+ if (line.startsWith("data:")) {
393
+ // One leading space after the colon is per-spec optional.
394
+ const value = line.slice(5);
395
+ dataLines.push(value.startsWith(" ") ? value.slice(1) : value);
396
+ }
397
+ else if (line.startsWith("event:")) {
398
+ const value = line.slice(6);
399
+ eventName = (value.startsWith(" ") ? value.slice(1) : value).trim();
400
+ }
401
+ }
402
+ if (dataLines.length === 0)
403
+ continue;
404
+ const payload = dataLines.join("\n");
405
+ if (eventName === "endpoint") {
406
+ onEndpoint?.(payload.trim());
407
+ continue;
408
+ }
409
+ try {
410
+ onFrame(JSON.parse(payload));
411
+ }
412
+ catch {
413
+ // A non-JSON data payload is not a protocol frame; skip it.
414
+ }
415
+ }
416
+ }
417
+ }
418
+ /**
419
+ * The remote transport. Starts in STREAMABLE mode: every frame POSTs to the
420
+ * configured URL; replies arrive as the POST response body (JSON or SSE).
421
+ * If the INITIAL POST answers 404/405 (a legacy HTTP+SSE server), the
422
+ * transport switches to LEGACY mode: a long-lived GET SSE stream delivers
423
+ * every server→client frame, and client→server frames POST to the message
424
+ * endpoint the server announces via an `endpoint` event.
425
+ */
426
+ class HttpTransport {
427
+ #engine;
428
+ #name;
429
+ #url;
430
+ #headers;
431
+ /** Legacy mode's message endpoint (absolute); streamable mode's POST target stays #url. */
432
+ #messageUrl;
433
+ /** Streamable HTTP session id (echoed on every request once seen). */
434
+ #sessionId;
435
+ /** Legacy GET stream bookkeeping. */
436
+ #getController;
437
+ #mode = "streamable";
438
+ #closed = false;
439
+ constructor(name, config) {
440
+ this.#name = name;
441
+ this.#url = config.url;
442
+ this.#headers = { ...(config.headers ?? {}) };
443
+ this.#engine = new McpEngine(name, this);
444
+ }
445
+ get engine() {
446
+ return this.#engine;
447
+ }
448
+ #requestHeaders(extra) {
449
+ const headers = {
450
+ "content-type": "application/json",
451
+ accept: "application/json, text/event-stream",
452
+ ...this.#headers,
453
+ ...extra,
454
+ };
455
+ if (this.#sessionId !== undefined) {
456
+ headers["mcp-session-id"] = this.#sessionId;
457
+ }
458
+ return headers;
459
+ }
460
+ send(frame) {
461
+ if (this.#closed) {
462
+ return Promise.reject(new Error(`mcp server "${this.#name}" is closed`));
463
+ }
464
+ // A reply-bearing REQUEST: the reply may be the POST response body or
465
+ // (legacy) arrive on the GET stream — this send resolves when the POST
466
+ // is accepted; the ENGINE's pending entry settles when the frame lands.
467
+ // A NOTIFICATION never expects a body reply (202/204 are fine).
468
+ return this.#post(frame);
469
+ }
470
+ async #post(frame) {
471
+ const url = this.#messageUrl ?? this.#url;
472
+ const body = JSON.stringify(frame);
473
+ const controller = new AbortController();
474
+ const abortOnClose = () => controller.abort();
475
+ this.#closeHooks.add(abortOnClose);
476
+ try {
477
+ const response = await fetch(url, {
478
+ method: "POST",
479
+ headers: this.#requestHeaders({}),
480
+ body,
481
+ signal: controller.signal,
482
+ });
483
+ const sessionHeader = response.headers.get("mcp-session-id");
484
+ if (sessionHeader !== null && sessionHeader !== "") {
485
+ this.#sessionId = sessionHeader;
486
+ }
487
+ if (response.status === 404 || response.status === 405) {
488
+ if (this.#mode === "streamable" && frame.method === "initialize") {
489
+ // The legacy negotiation point: only on the FIRST request.
490
+ // Later 404/405s are ordinary errors (below).
491
+ await this.#startLegacy();
492
+ // The initialize POST must now go to the legacy message
493
+ // endpoint; the endpoint event has set #messageUrl.
494
+ if (this.#messageUrl === undefined) {
495
+ throw new Error(`mcp server "${this.#name}" legacy SSE stream announced no endpoint`);
496
+ }
497
+ return this.#post(frame);
498
+ }
499
+ throw new Error(`mcp server "${this.#name}" POST ${url} failed: ${response.status} ${response.statusText}`);
500
+ }
501
+ if (!response.ok) {
502
+ const snippet = (await response.text()).slice(0, 200);
503
+ throw new Error(`mcp server "${this.#name}" POST ${url} failed: ${response.status} ${response.statusText} ${snippet}`.trim());
504
+ }
505
+ // A notification needs no body processing; a request's reply may
506
+ // be here (streamable) or on the GET stream (legacy — where the
507
+ // body is typically empty 202). 202/204 carry no reply body per
508
+ // spec — and a legacy server may 202 WITHOUT ever ending the
509
+ // response stream, so awaiting the body would hang: treat them as
510
+ // accepted-and-done (the reply, if any, rides the GET stream).
511
+ const contentType = response.headers.get("content-type") ?? "";
512
+ if (response.status === 202 || response.status === 204) {
513
+ // Nothing to read; cancel any body the server left open.
514
+ response.body?.cancel().catch(() => {
515
+ // A cancel failure (already-closed stream) needs no handling.
516
+ });
517
+ }
518
+ else if (contentType.includes("text/event-stream")) {
519
+ // Streamable SSE: the reply to THIS request arrives as an event
520
+ // on the response stream — but the stream legitimately STAYS OPEN
521
+ // after it (the server may push notifications). Read until the
522
+ // id-matching reply frame lands, then cancel the stream; a
523
+ // notification POST (no id) has no reply to wait for, so it just
524
+ // cancels. Reading past the reply would hang on servers that
525
+ // hold the stream open (e.g. searmcp).
526
+ //
527
+ // LEGACY DETECTION (generalized): some legacy servers (crawl4ai's
528
+ // shape) answer the initial POST itself with 200 + SSE + an
529
+ // `endpoint` event — no 404/405 ever fires. The endpoint event is
530
+ // unambiguous (streamable HTTP never sends one), so on the FIRST
531
+ // request an endpoint event means legacy: adopt this stream as
532
+ // the server→client channel and re-POST to the message endpoint.
533
+ if (this.#mode === "streamable" && frame.method === "initialize") {
534
+ const legacy = await this.#maybeAdoptLegacyFromPost(response);
535
+ if (legacy) {
536
+ if (this.#messageUrl === undefined) {
537
+ throw new Error(`mcp server "${this.#name}" legacy SSE stream announced no endpoint`);
538
+ }
539
+ return this.#post(frame);
540
+ }
541
+ // Not legacy: fall through to the reply-wait drain.
542
+ }
543
+ const waitForId = typeof frame.id === "number" ? frame.id : undefined;
544
+ await this.#drainSseBody(response, (parsed) => {
545
+ this.#engine.handleFrame(parsed);
546
+ }, waitForId);
547
+ }
548
+ else if (frame.id !== undefined && frame.id !== null) {
549
+ const text = await response.text();
550
+ if (text !== "") {
551
+ try {
552
+ this.#engine.handleFrame(JSON.parse(text));
553
+ }
554
+ catch {
555
+ // A non-JSON body for a request is a protocol break on
556
+ // this reply; the engine's deadline will surface it.
557
+ }
558
+ }
559
+ }
560
+ else {
561
+ // Drain (and discard) notification bodies.
562
+ await response.text().catch(() => "");
563
+ }
564
+ }
565
+ finally {
566
+ this.#closeHooks.delete(abortOnClose);
567
+ }
568
+ }
569
+ /** On the INITIAL POST's SSE response: read events until either the
570
+ * id-matching reply arrives (streamable — report not-legacy) or an
571
+ * `endpoint` event appears (legacy — adopt the stream as the
572
+ * server→client channel, set #messageUrl, report legacy). */
573
+ async #maybeAdoptLegacyFromPost(response) {
574
+ const parser = new SseParser();
575
+ const reader = response.body?.getReader();
576
+ if (reader === undefined)
577
+ return false;
578
+ const decoder = new TextDecoder();
579
+ const self = this;
580
+ let sawReply = false;
581
+ const deliver = (parsed) => {
582
+ if (parsed.id !== undefined && parsed.id !== null)
583
+ sawReply = true;
584
+ this.#engine.handleFrame(parsed);
585
+ };
586
+ for (;;) {
587
+ const { done, value } = await reader.read();
588
+ if (done)
589
+ return false; // stream ended with no endpoint event
590
+ let sawEndpoint = false;
591
+ parser.feed(decoder.decode(value, { stream: true }), deliver, (endpoint) => {
592
+ sawEndpoint = true;
593
+ self.#messageUrl = new URL(endpoint, self.#url).toString();
594
+ });
595
+ if (sawEndpoint) {
596
+ // Adopt this stream as the legacy server→client channel: legacy
597
+ // mode from here; a background reader keeps feeding frames.
598
+ this.#mode = "legacy";
599
+ const controller = new AbortController();
600
+ this.#getController = controller;
601
+ const abortOnClose = () => controller.abort();
602
+ this.#closeHooks.add(abortOnClose);
603
+ const engine = this.#engine;
604
+ void (async () => {
605
+ try {
606
+ for (;;) {
607
+ const next = await reader.read();
608
+ if (next.done)
609
+ break;
610
+ parser.feed(decoder.decode(next.value, { stream: true }), (frame2) => engine.handleFrame(frame2));
611
+ }
612
+ engine.handleDeath(new Error(`mcp server "${self.#name}" legacy SSE stream ended`));
613
+ }
614
+ catch (cause) {
615
+ if (self.#closed)
616
+ return;
617
+ engine.handleDeath(cause instanceof Error
618
+ ? cause
619
+ : new Error(`mcp server "${self.#name}" SSE stream failed`));
620
+ }
621
+ })();
622
+ return true;
623
+ }
624
+ if (sawReply)
625
+ return false; // a reply already arrived: streamable
626
+ }
627
+ }
628
+ /** Read a fetch response whose body is an SSE stream. When `waitForId`
629
+ * is set, stop (cancel the stream) once a reply frame with that id has
630
+ * been delivered — the streamable transport keeps streams open past the
631
+ * reply; without a wait the stream reads to completion. */
632
+ async #drainSseBody(response, onFrame, waitForId) {
633
+ const parser = new SseParser();
634
+ const reader = response.body?.getReader();
635
+ if (reader === undefined)
636
+ return;
637
+ const decoder = new TextDecoder();
638
+ let seen = false;
639
+ const deliver = (frame) => {
640
+ onFrame(frame);
641
+ if (waitForId !== undefined && frame.id === waitForId) {
642
+ seen = true;
643
+ }
644
+ };
645
+ for (;;) {
646
+ const { done, value } = await reader.read();
647
+ if (done)
648
+ break;
649
+ parser.feed(decoder.decode(value, { stream: true }), deliver);
650
+ if (seen) {
651
+ await reader.cancel().catch(() => {
652
+ // Already-closed streams cancel noisily; harmless.
653
+ });
654
+ return;
655
+ }
656
+ }
657
+ parser.feed(decoder.decode(), deliver);
658
+ }
659
+ /** Switch to the legacy transport: open the GET SSE stream and wait for
660
+ * the server's `endpoint` event naming the message POST URL. */
661
+ async #startLegacy() {
662
+ this.#mode = "legacy";
663
+ const controller = new AbortController();
664
+ this.#getController = controller;
665
+ const abortOnClose = () => controller.abort();
666
+ this.#closeHooks.add(abortOnClose);
667
+ const response = await fetch(this.#url, {
668
+ method: "GET",
669
+ headers: {
670
+ accept: "text/event-stream",
671
+ ...this.#headers,
672
+ },
673
+ signal: controller.signal,
674
+ });
675
+ if (!response.ok || !(response.headers.get("content-type") ?? "").includes("text/event-stream")) {
676
+ controller.abort();
677
+ throw new Error(`mcp server "${this.#name}" legacy SSE stream failed: ${response.status} ${response.statusText}`);
678
+ }
679
+ const sessionHeader = response.headers.get("mcp-session-id");
680
+ if (sessionHeader !== null && sessionHeader !== "") {
681
+ this.#sessionId = sessionHeader;
682
+ }
683
+ // Read the stream; the endpoint event flips #messageUrl, frames feed
684
+ // the engine, stream end is transport death.
685
+ const reader = response.body?.getReader();
686
+ if (reader === undefined) {
687
+ throw new Error(`mcp server "${this.#name}" legacy SSE stream has no body`);
688
+ }
689
+ const parser = new SseParser();
690
+ const decoder = new TextDecoder();
691
+ const engine = this.#engine;
692
+ const self = this;
693
+ // The endpoint usually arrives first; race a bounded wait so a
694
+ // silent server cannot hang the connect forever (the engine's own
695
+ // initialize deadline still applies on top).
696
+ const endpointSeen = new Promise((resolveEndpoint) => {
697
+ void (async () => {
698
+ try {
699
+ for (;;) {
700
+ const { done, value } = await reader.read();
701
+ if (done)
702
+ break;
703
+ parser.feed(decoder.decode(value, { stream: true }), (frame) => engine.handleFrame(frame), (endpoint) => {
704
+ self.#messageUrl = new URL(endpoint, self.#url).toString();
705
+ resolveEndpoint();
706
+ });
707
+ }
708
+ const tail = decoder.decode();
709
+ if (tail !== "") {
710
+ parser.feed(tail, (frame) => engine.handleFrame(frame), (endpoint) => {
711
+ self.#messageUrl = new URL(endpoint, self.#url).toString();
712
+ resolveEndpoint();
713
+ });
714
+ }
715
+ engine.handleDeath(new Error(`mcp server "${self.#name}" legacy SSE stream ended`));
716
+ }
717
+ catch (cause) {
718
+ if (self.#closed)
719
+ return; // our own abort: not a failure
720
+ engine.handleDeath(cause instanceof Error
721
+ ? cause
722
+ : new Error(`mcp server "${self.#name}" SSE stream failed`));
723
+ }
724
+ })();
725
+ });
726
+ // A 15s cap on waiting for the endpoint event — plenty for any
727
+ // compliant server, and short enough to fail loud on a broken one.
728
+ await Promise.race([
729
+ endpointSeen,
730
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`mcp server "${this.#name}" legacy SSE stream announced no endpoint within 15s`)), 15_000)),
731
+ ]);
732
+ }
733
+ #closeHooks = new Set();
734
+ close() {
735
+ if (this.#closed)
736
+ return;
737
+ this.#closed = true;
738
+ for (const hook of this.#closeHooks)
739
+ hook();
740
+ this.#closeHooks.clear();
741
+ this.#getController?.abort();
742
+ this.#engine.handleDeath(new Error("mcp client closed"));
743
+ }
744
+ }
745
+ /**
746
+ * Spawn/connect one configured MCP server (stdio or remote by config
747
+ * shape). Throws on spawn/connect failure or a failed handshake —
748
+ * per-server isolation is the CALLER's policy (the extension connects all
749
+ * servers with `.catch` per server).
750
+ */
751
+ export async function connectMcpServer(name, config, handle) {
752
+ let engine;
753
+ if (handle !== undefined) {
754
+ if (!("command" in config)) {
755
+ handle.release();
756
+ throw new Error(`mcp server "${name}" remote transport cannot use a service handle`);
757
+ }
758
+ const transport = new HandleTransport(name, handle);
759
+ engine = transport.engine;
760
+ }
761
+ else if ("url" in config) {
762
+ const transport = new HttpTransport(name, config);
763
+ engine = transport.engine;
764
+ }
765
+ else {
766
+ // Never sigrun's own environment: an allowlist, the server's config
767
+ // env, then its stored token as MCP_ACCESS_TOKEN (ruling 3).
768
+ const env = serverSpawnEnv(name, config);
769
+ const transport = new StdioTransport(name, config.command, config.args, env);
770
+ engine = transport.engine;
771
+ }
268
772
  try {
269
- await client.handshake();
773
+ await engine.handshake();
270
774
  }
271
775
  catch (cause) {
272
- client.close();
273
- child.kill("SIGKILL");
776
+ engine.close();
274
777
  throw cause instanceof Error
275
778
  ? cause
276
779
  : new Error(`mcp server "${name}" handshake failed`);
277
780
  }
278
- return client;
781
+ return engine;
279
782
  }