@yofriadi/pi-mcp 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.
@@ -0,0 +1,823 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { Readable } from "node:stream";
3
+ import type { McpResolvedConfig, McpServerConfig } from "../config/mcp-config";
4
+
5
+ const JSON_RPC_VERSION = "2.0";
6
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
7
+
8
+ type JsonRpcId = number;
9
+
10
+ interface JsonRpcResponse {
11
+ jsonrpc: string;
12
+ id: JsonRpcId;
13
+ result?: unknown;
14
+ error?: {
15
+ code: number;
16
+ message: string;
17
+ data?: unknown;
18
+ };
19
+ }
20
+
21
+ interface McpSpawnOptions {
22
+ cwd?: string;
23
+ env?: NodeJS.ProcessEnv;
24
+ }
25
+
26
+ interface McpSubprocess {
27
+ pid: number | undefined;
28
+ stdin: {
29
+ write(data: string | Uint8Array): unknown;
30
+ end(): unknown;
31
+ };
32
+ stdout: ReadableStream<Uint8Array>;
33
+ stderr: ReadableStream<Uint8Array> | null;
34
+ exited: Promise<number | null>;
35
+ kill?(signal?: string | number): unknown;
36
+ }
37
+
38
+ export type McpSpawn = (command: string[], options: McpSpawnOptions) => McpSubprocess;
39
+
40
+ export interface McpRuntimeServerStatus {
41
+ name: string;
42
+ transport: "stdio" | "http";
43
+ state: "ready" | "error" | "inactive";
44
+ reason: string;
45
+ pid?: number;
46
+ command?: string[];
47
+ url?: string;
48
+ }
49
+
50
+ export interface McpRuntimeStatus {
51
+ state: "inactive" | "starting" | "ready" | "error";
52
+ reason: string;
53
+ configuredServers: number;
54
+ activeServers: number;
55
+ servers: McpRuntimeServerStatus[];
56
+ diagnostics: string[];
57
+ }
58
+
59
+ export interface McpRequestOptions {
60
+ timeoutMs?: number;
61
+ signal?: AbortSignal;
62
+ }
63
+
64
+ export interface McpRuntime {
65
+ start(config: McpResolvedConfig): Promise<void>;
66
+ stop(): Promise<void>;
67
+ request(serverName: string, method: string, params?: unknown, options?: McpRequestOptions): Promise<unknown>;
68
+ listTools(serverName: string, options?: McpRequestOptions): Promise<unknown>;
69
+ callTool(serverName: string, toolName: string, args?: unknown, options?: McpRequestOptions): Promise<unknown>;
70
+ getStatus(): McpRuntimeStatus;
71
+ }
72
+
73
+ interface McpRuntimeOptions {
74
+ spawn?: McpSpawn;
75
+ fetchImpl?: typeof fetch;
76
+ env?: NodeJS.ProcessEnv;
77
+ }
78
+
79
+ interface PendingRequest {
80
+ resolve: (value: unknown) => void;
81
+ reject: (error: Error) => void;
82
+ timeoutId: ReturnType<typeof setTimeout>;
83
+ }
84
+
85
+ interface McpClient {
86
+ start(): Promise<void>;
87
+ stop(): Promise<void>;
88
+ request(method: string, params: unknown, options?: McpRequestOptions): Promise<unknown>;
89
+ getStatus(): McpRuntimeServerStatus;
90
+ }
91
+
92
+ class McpStdioClient implements McpClient {
93
+ private process: McpSubprocess | undefined;
94
+ private lineBuffer = "";
95
+ private nextId = 1;
96
+ private readonly pending = new Map<number, PendingRequest>();
97
+ private status: McpRuntimeServerStatus;
98
+ private readonly textDecoder = new TextDecoder();
99
+
100
+ constructor(
101
+ private readonly server: McpServerConfig,
102
+ private readonly spawn: McpSpawn,
103
+ private readonly env: NodeJS.ProcessEnv,
104
+ ) {
105
+ this.status = {
106
+ name: server.name,
107
+ transport: "stdio",
108
+ state: "inactive",
109
+ reason: "not started",
110
+ command: server.command ? [server.command, ...server.args] : undefined,
111
+ };
112
+ }
113
+
114
+ async start(): Promise<void> {
115
+ if (!this.server.command) {
116
+ throw new Error(`Server ${this.server.name} is missing command`);
117
+ }
118
+
119
+ const mergedEnv: NodeJS.ProcessEnv = {
120
+ ...this.env,
121
+ ...this.server.env,
122
+ };
123
+
124
+ const command = [this.server.command, ...this.server.args];
125
+ this.process = this.spawn(command, {
126
+ env: mergedEnv,
127
+ });
128
+ this.status = {
129
+ ...this.status,
130
+ state: "ready",
131
+ reason: "process started",
132
+ pid: this.process.pid,
133
+ command,
134
+ };
135
+
136
+ void this.consumeStream(this.process.stdout, false);
137
+ if (this.process.stderr) {
138
+ void this.consumeStream(this.process.stderr, true);
139
+ }
140
+ void this.watchExit(this.process.exited);
141
+
142
+ await this.initializeHandshake();
143
+ }
144
+
145
+ async stop(): Promise<void> {
146
+ const proc = this.process;
147
+ if (!proc) {
148
+ this.status = {
149
+ ...this.status,
150
+ state: "inactive",
151
+ reason: "already stopped",
152
+ };
153
+ return;
154
+ }
155
+
156
+ this.process = undefined;
157
+
158
+ try {
159
+ await this.request("shutdown", null, {
160
+ timeoutMs: 3_000,
161
+ });
162
+ } catch {
163
+ // Best effort shutdown.
164
+ }
165
+
166
+ try {
167
+ this.sendNotification("exit", null);
168
+ } catch {
169
+ // Ignore notify failures while shutting down.
170
+ }
171
+
172
+ try {
173
+ proc.stdin.end();
174
+ } catch {
175
+ // Ignore end errors.
176
+ }
177
+
178
+ if (proc.kill) {
179
+ try {
180
+ proc.kill("SIGTERM");
181
+ } catch {
182
+ // Ignore kill errors.
183
+ }
184
+ }
185
+
186
+ for (const pending of this.pending.values()) {
187
+ clearTimeout(pending.timeoutId);
188
+ pending.reject(new Error(`Server ${this.server.name} stopped before responding`));
189
+ }
190
+ this.pending.clear();
191
+
192
+ this.status = {
193
+ ...this.status,
194
+ state: "inactive",
195
+ reason: "stopped",
196
+ pid: undefined,
197
+ };
198
+ }
199
+
200
+ async request(method: string, params: unknown, options: McpRequestOptions = {}): Promise<unknown> {
201
+ const proc = this.process;
202
+ if (!proc) {
203
+ throw new Error(`Server ${this.server.name} is not running`);
204
+ }
205
+
206
+ const id = this.nextId++;
207
+ const timeoutMs = options.timeoutMs ?? this.server.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
208
+
209
+ const timeoutPromise = new Promise<never>((_, reject) => {
210
+ const timeoutId = setTimeout(() => {
211
+ this.pending.delete(id);
212
+ reject(new Error(`MCP request timed out for ${this.server.name}: ${method}`));
213
+ }, timeoutMs);
214
+
215
+ this.pending.set(id, {
216
+ resolve: () => {},
217
+ reject: (error) => reject(error),
218
+ timeoutId,
219
+ });
220
+ });
221
+
222
+ const responsePromise = new Promise<unknown>((resolve, reject) => {
223
+ const pending = this.pending.get(id);
224
+ if (!pending) {
225
+ reject(new Error(`Internal MCP runtime error: pending request ${id} missing`));
226
+ return;
227
+ }
228
+ pending.resolve = (value) => {
229
+ clearTimeout(pending.timeoutId);
230
+ this.pending.delete(id);
231
+ resolve(value);
232
+ };
233
+ pending.reject = (error) => {
234
+ clearTimeout(pending.timeoutId);
235
+ this.pending.delete(id);
236
+ reject(error);
237
+ };
238
+ });
239
+
240
+ this.sendRequest(id, method, params);
241
+ const requestPromise = Promise.race([responsePromise, timeoutPromise]);
242
+ if (options.signal) {
243
+ return withAbort(requestPromise, options.signal, `MCP request aborted: ${method}`);
244
+ }
245
+ return requestPromise;
246
+ }
247
+
248
+ getStatus(): McpRuntimeServerStatus {
249
+ return { ...this.status };
250
+ }
251
+
252
+ private async initializeHandshake(): Promise<void> {
253
+ await this.request(
254
+ "initialize",
255
+ {
256
+ protocolVersion: "2024-11-05",
257
+ capabilities: {},
258
+ clientInfo: {
259
+ name: "pi-extension-mcp-scaffold",
260
+ version: "0.1.0",
261
+ },
262
+ },
263
+ { timeoutMs: this.server.timeoutMs },
264
+ );
265
+ this.sendNotification("notifications/initialized", {});
266
+ }
267
+
268
+ private sendRequest(id: number, method: string, params: unknown): void {
269
+ this.writeJsonRpc({
270
+ jsonrpc: JSON_RPC_VERSION,
271
+ id,
272
+ method,
273
+ params,
274
+ });
275
+ }
276
+
277
+ private sendNotification(method: string, params: unknown): void {
278
+ this.writeJsonRpc({
279
+ jsonrpc: JSON_RPC_VERSION,
280
+ method,
281
+ params,
282
+ });
283
+ }
284
+
285
+ private writeJsonRpc(payload: Record<string, unknown>): void {
286
+ const proc = this.process;
287
+ if (!proc) {
288
+ throw new Error(`Server ${this.server.name} is not running`);
289
+ }
290
+
291
+ const serialized = JSON.stringify(payload);
292
+ proc.stdin.write(`${serialized}\n`);
293
+ }
294
+
295
+ private async consumeStream(stream: ReadableStream<Uint8Array>, isStdErr: boolean): Promise<void> {
296
+ const reader = stream.getReader();
297
+ try {
298
+ while (true) {
299
+ const { done, value } = await reader.read();
300
+ if (done) {
301
+ break;
302
+ }
303
+ if (!value) {
304
+ continue;
305
+ }
306
+ if (isStdErr) {
307
+ continue;
308
+ }
309
+ this.lineBuffer += this.textDecoder.decode(value, { stream: true });
310
+ this.drainLines();
311
+ }
312
+ } finally {
313
+ reader.releaseLock();
314
+ }
315
+ }
316
+
317
+ private drainLines(): void {
318
+ while (true) {
319
+ const newlineIndex = this.lineBuffer.indexOf("\n");
320
+ if (newlineIndex === -1) {
321
+ return;
322
+ }
323
+ const payload = this.lineBuffer.slice(0, newlineIndex).trim();
324
+ this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
325
+ if (!payload) {
326
+ continue;
327
+ }
328
+ if (payload.startsWith("Content-Length:")) {
329
+ continue;
330
+ }
331
+
332
+ let parsed: unknown;
333
+ try {
334
+ parsed = JSON.parse(payload);
335
+ } catch {
336
+ continue;
337
+ }
338
+ this.handleMessage(parsed);
339
+ }
340
+ }
341
+
342
+ private handleMessage(payload: unknown): void {
343
+ if (!isObject(payload) || typeof payload.id !== "number") {
344
+ return;
345
+ }
346
+ const response = payload as JsonRpcResponse;
347
+ const pending = this.pending.get(response.id);
348
+ if (!pending) {
349
+ return;
350
+ }
351
+
352
+ if (response.error) {
353
+ pending.reject(new Error(`MCP ${this.server.name} error ${response.error.code}: ${response.error.message}`));
354
+ return;
355
+ }
356
+
357
+ pending.resolve(response.result);
358
+ }
359
+
360
+ private async watchExit(exited: Promise<number | null>): Promise<void> {
361
+ const exitCode = await exited;
362
+ if (this.process) {
363
+ this.process = undefined;
364
+ }
365
+ for (const pending of this.pending.values()) {
366
+ clearTimeout(pending.timeoutId);
367
+ pending.reject(new Error(`MCP server ${this.server.name} exited with code ${exitCode ?? "unknown"}`));
368
+ }
369
+ this.pending.clear();
370
+ if (this.status.state === "inactive") {
371
+ return;
372
+ }
373
+ this.status = {
374
+ ...this.status,
375
+ state: "error",
376
+ reason: `process exited with code ${exitCode ?? "unknown"}`,
377
+ pid: undefined,
378
+ };
379
+ }
380
+ }
381
+
382
+ class McpHttpClient implements McpClient {
383
+ private status: McpRuntimeServerStatus;
384
+ private nextId = 1;
385
+
386
+ constructor(
387
+ private readonly server: McpServerConfig,
388
+ private readonly fetchImpl: typeof fetch,
389
+ ) {
390
+ this.status = {
391
+ name: server.name,
392
+ transport: "http",
393
+ state: "inactive",
394
+ reason: "not started",
395
+ url: server.url,
396
+ };
397
+ }
398
+
399
+ async start(): Promise<void> {
400
+ if (!this.server.url) {
401
+ throw new Error(`Server ${this.server.name} is missing URL`);
402
+ }
403
+
404
+ this.status = {
405
+ ...this.status,
406
+ state: "ready",
407
+ reason: "http endpoint configured",
408
+ };
409
+
410
+ try {
411
+ await this.request(
412
+ "initialize",
413
+ {
414
+ protocolVersion: "2024-11-05",
415
+ capabilities: {},
416
+ clientInfo: {
417
+ name: "pi-extension-mcp-scaffold",
418
+ version: "0.1.0",
419
+ },
420
+ },
421
+ { timeoutMs: this.server.timeoutMs },
422
+ );
423
+ } catch (error) {
424
+ this.status = {
425
+ ...this.status,
426
+ state: "error",
427
+ reason: `initialize failed: ${formatError(error)}`,
428
+ };
429
+ throw error;
430
+ }
431
+ }
432
+
433
+ async stop(): Promise<void> {
434
+ this.status = {
435
+ ...this.status,
436
+ state: "inactive",
437
+ reason: "stopped",
438
+ };
439
+ }
440
+
441
+ async request(method: string, params: unknown, options: McpRequestOptions = {}): Promise<unknown> {
442
+ if (!this.server.url) {
443
+ throw new Error(`Server ${this.server.name} is missing URL`);
444
+ }
445
+
446
+ const id = this.nextId++;
447
+ const timeoutMs = options.timeoutMs ?? this.server.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
448
+ const controller = new AbortController();
449
+ const timeout = setTimeout(() => {
450
+ controller.abort(new Error(`MCP HTTP request timed out for ${this.server.name}: ${method}`));
451
+ }, timeoutMs);
452
+
453
+ const cleanAbort = bindAbortSignal(options.signal, controller);
454
+
455
+ try {
456
+ const response = await this.fetchImpl(this.server.url, {
457
+ method: "POST",
458
+ headers: {
459
+ "content-type": "application/json",
460
+ accept: "application/json, text/event-stream",
461
+ ...this.server.headers,
462
+ },
463
+ body: JSON.stringify({
464
+ jsonrpc: JSON_RPC_VERSION,
465
+ id,
466
+ method,
467
+ params,
468
+ }),
469
+ signal: controller.signal,
470
+ });
471
+
472
+ if (!response.ok) {
473
+ const body = await response.text().catch(() => "");
474
+ throw new Error(`HTTP ${response.status} ${response.statusText}${body ? `: ${body.slice(0, 300)}` : ""}`);
475
+ }
476
+
477
+ const contentType = response.headers.get("content-type") ?? "";
478
+ const json = contentType.includes("text/event-stream")
479
+ ? parseSseJsonRpcResponse(await response.text(), id)
480
+ : ((await response.json()) as JsonRpcResponse);
481
+
482
+ if (!json) {
483
+ throw new Error("Failed to parse JSON response from MCP HTTP server");
484
+ }
485
+
486
+ if (json?.error) {
487
+ throw new Error(`MCP ${this.server.name} error ${json.error.code}: ${json.error.message}`);
488
+ }
489
+ return json?.result;
490
+ } finally {
491
+ clearTimeout(timeout);
492
+ cleanAbort();
493
+ }
494
+ }
495
+
496
+ getStatus(): McpRuntimeServerStatus {
497
+ return { ...this.status };
498
+ }
499
+ }
500
+
501
+ export function createMcpRuntime(options: McpRuntimeOptions = {}): McpRuntime {
502
+ const spawn = options.spawn ?? createDefaultSpawn();
503
+ const fetchImpl = options.fetchImpl ?? fetch;
504
+ const env = options.env ?? process.env;
505
+ const clients = new Map<string, McpClient>();
506
+ let status: McpRuntimeStatus = {
507
+ state: "inactive",
508
+ reason: "not started",
509
+ configuredServers: 0,
510
+ activeServers: 0,
511
+ servers: [],
512
+ diagnostics: [],
513
+ };
514
+
515
+ function buildStatusSnapshot(): McpRuntimeStatus {
516
+ const serverStatuses = status.servers.map((server) => {
517
+ const liveStatus = clients.get(server.name)?.getStatus();
518
+ return liveStatus ?? cloneServerStatus(server);
519
+ });
520
+ const activeServers = serverStatuses.filter((entry) => entry.state === "ready").length;
521
+
522
+ let state = status.state;
523
+ let reason = status.reason;
524
+ if (state !== "inactive" && state !== "starting") {
525
+ if (activeServers > 0) {
526
+ state = "ready";
527
+ reason = `connected to ${activeServers} MCP server(s)`;
528
+ } else if (status.configuredServers === 0) {
529
+ state = "inactive";
530
+ reason = "no MCP servers configured";
531
+ } else {
532
+ state = "error";
533
+ const firstError = serverStatuses.find((entry) => entry.state === "error");
534
+ reason = firstError?.reason ?? "all MCP servers failed to start";
535
+ }
536
+ }
537
+
538
+ return {
539
+ ...status,
540
+ state,
541
+ reason,
542
+ activeServers,
543
+ servers: serverStatuses.map(cloneServerStatus),
544
+ diagnostics: [...status.diagnostics],
545
+ };
546
+ }
547
+
548
+ return {
549
+ async start(config: McpResolvedConfig): Promise<void> {
550
+ await this.stop();
551
+
552
+ status = {
553
+ ...status,
554
+ state: "starting",
555
+ reason: "starting servers",
556
+ configuredServers: config.servers.length,
557
+ activeServers: 0,
558
+ servers: [],
559
+ diagnostics: config.diagnostics.map((diag) => `${diag.level}:${diag.code}:${diag.message}`),
560
+ };
561
+
562
+ for (const server of config.servers) {
563
+ if (server.disabled) {
564
+ continue;
565
+ }
566
+
567
+ const client: McpClient =
568
+ server.transport === "http"
569
+ ? new McpHttpClient(server, fetchImpl)
570
+ : new McpStdioClient(server, spawn, env);
571
+ clients.set(server.name, client);
572
+ try {
573
+ await client.start();
574
+ } catch (error) {
575
+ status.servers.push({
576
+ name: server.name,
577
+ transport: server.transport,
578
+ state: "error",
579
+ reason: formatError(error),
580
+ command: server.command ? [server.command, ...server.args] : undefined,
581
+ url: server.url,
582
+ });
583
+ continue;
584
+ }
585
+ status.servers.push(client.getStatus());
586
+ }
587
+
588
+ status.activeServers = status.servers.filter((entry) => entry.state === "ready").length;
589
+ if (status.activeServers > 0) {
590
+ status.state = "ready";
591
+ status.reason = `connected to ${status.activeServers} MCP server(s)`;
592
+ } else if (status.configuredServers === 0) {
593
+ status.state = "inactive";
594
+ status.reason = "no MCP servers configured";
595
+ } else {
596
+ status.state = "error";
597
+ status.reason = "all MCP servers failed to start";
598
+ }
599
+ },
600
+
601
+ async stop(): Promise<void> {
602
+ const stopResults = await Promise.allSettled([...clients.values()].map((client) => client.stop()));
603
+ clients.clear();
604
+ const stopErrors = stopResults.filter((result) => result.status === "rejected");
605
+ status = {
606
+ ...status,
607
+ state: "inactive",
608
+ reason: stopErrors.length > 0 ? `stopped with ${stopErrors.length} error(s)` : "stopped",
609
+ activeServers: 0,
610
+ servers: [],
611
+ };
612
+ },
613
+
614
+ async request(
615
+ serverName: string,
616
+ method: string,
617
+ params: unknown = {},
618
+ options: McpRequestOptions = {},
619
+ ): Promise<unknown> {
620
+ const client = clients.get(serverName);
621
+ if (!client) {
622
+ throw new Error(`Unknown MCP server: ${serverName}`);
623
+ }
624
+ return client.request(method, params, options);
625
+ },
626
+
627
+ async listTools(serverName: string, options?: McpRequestOptions): Promise<unknown> {
628
+ return this.request(serverName, "tools/list", {}, options);
629
+ },
630
+
631
+ async callTool(
632
+ serverName: string,
633
+ toolName: string,
634
+ args: unknown = {},
635
+ options?: McpRequestOptions,
636
+ ): Promise<unknown> {
637
+ return this.request(
638
+ serverName,
639
+ "tools/call",
640
+ {
641
+ name: toolName,
642
+ arguments: args,
643
+ },
644
+ options,
645
+ );
646
+ },
647
+
648
+ getStatus(): McpRuntimeStatus {
649
+ return buildStatusSnapshot();
650
+ },
651
+ };
652
+ }
653
+
654
+ function createDefaultSpawn(): McpSpawn {
655
+ const bun = getBunRuntime();
656
+ if (bun) {
657
+ return (command: string[], options: McpSpawnOptions): McpSubprocess => {
658
+ const processHandle = bun.spawn({
659
+ cmd: command,
660
+ cwd: options.cwd,
661
+ env: options.env,
662
+ stdin: "pipe",
663
+ stdout: "pipe",
664
+ stderr: "pipe",
665
+ });
666
+
667
+ return {
668
+ pid: processHandle.pid,
669
+ stdin: processHandle.stdin,
670
+ stdout: processHandle.stdout,
671
+ stderr: processHandle.stderr,
672
+ exited: processHandle.exited,
673
+ kill: (signal?: string | number): unknown => processHandle.kill(signal as any),
674
+ };
675
+ };
676
+ }
677
+
678
+ return (command: string[], options: McpSpawnOptions): McpSubprocess => {
679
+ const child = nodeSpawn(command[0], command.slice(1), {
680
+ cwd: options.cwd,
681
+ env: options.env,
682
+ stdio: ["pipe", "pipe", "pipe"],
683
+ });
684
+
685
+ if (!child.stdin || !child.stdout) {
686
+ throw new Error(`Failed to spawn MCP server process for command: ${command.join(" ")}`);
687
+ }
688
+
689
+ return {
690
+ pid: child.pid ?? undefined,
691
+ stdin: child.stdin,
692
+ stdout: Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
693
+ stderr: child.stderr ? (Readable.toWeb(child.stderr) as ReadableStream<Uint8Array>) : null,
694
+ exited: new Promise<number | null>((resolve) => {
695
+ child.once("exit", (code) => resolve(code));
696
+ }),
697
+ kill: (signal?: string | number): unknown => child.kill(signal as any),
698
+ };
699
+ };
700
+ }
701
+
702
+ function getBunRuntime():
703
+ | {
704
+ spawn(options: {
705
+ cmd: string[];
706
+ cwd?: string;
707
+ env?: NodeJS.ProcessEnv;
708
+ stdin: "pipe";
709
+ stdout: "pipe";
710
+ stderr: "pipe";
711
+ }): {
712
+ pid: number | undefined;
713
+ stdin: {
714
+ write(data: string | Uint8Array): unknown;
715
+ end(): unknown;
716
+ };
717
+ stdout: ReadableStream<Uint8Array>;
718
+ stderr: ReadableStream<Uint8Array>;
719
+ exited: Promise<number | null>;
720
+ kill(signal?: string | number): unknown;
721
+ };
722
+ }
723
+ | undefined {
724
+ const candidate = (globalThis as { Bun?: unknown }).Bun;
725
+ if (!candidate || typeof candidate !== "object") {
726
+ return undefined;
727
+ }
728
+ const bunLike = candidate as {
729
+ spawn?: unknown;
730
+ };
731
+ if (typeof bunLike.spawn !== "function") {
732
+ return undefined;
733
+ }
734
+ return bunLike as {
735
+ spawn(options: {
736
+ cmd: string[];
737
+ cwd?: string;
738
+ env?: NodeJS.ProcessEnv;
739
+ stdin: "pipe";
740
+ stdout: "pipe";
741
+ stderr: "pipe";
742
+ }): {
743
+ pid: number | undefined;
744
+ stdin: {
745
+ write(data: string | Uint8Array): unknown;
746
+ end(): unknown;
747
+ };
748
+ stdout: ReadableStream<Uint8Array>;
749
+ stderr: ReadableStream<Uint8Array>;
750
+ exited: Promise<number | null>;
751
+ kill(signal?: string | number): unknown;
752
+ };
753
+ };
754
+ }
755
+
756
+ function isObject(value: unknown): value is Record<string, unknown> {
757
+ return !!value && typeof value === "object";
758
+ }
759
+
760
+ function formatError(error: unknown): string {
761
+ if (error instanceof Error) {
762
+ return error.message;
763
+ }
764
+ return String(error);
765
+ }
766
+
767
+ function bindAbortSignal(signal: AbortSignal | undefined, controller: AbortController): () => void {
768
+ if (!signal) {
769
+ return () => {};
770
+ }
771
+ const onAbort = () => {
772
+ controller.abort(signal.reason ?? new Error("MCP request aborted"));
773
+ };
774
+ signal.addEventListener("abort", onAbort, { once: true });
775
+ return () => signal.removeEventListener("abort", onAbort);
776
+ }
777
+
778
+ function withAbort<T>(promise: Promise<T>, signal: AbortSignal, message: string): Promise<T> {
779
+ if (signal.aborted) {
780
+ return Promise.reject(new Error(message));
781
+ }
782
+
783
+ return new Promise<T>((resolve, reject) => {
784
+ const onAbort = () => reject(new Error(message));
785
+ signal.addEventListener("abort", onAbort, { once: true });
786
+ promise
787
+ .then(resolve)
788
+ .catch(reject)
789
+ .finally(() => {
790
+ signal.removeEventListener("abort", onAbort);
791
+ });
792
+ });
793
+ }
794
+
795
+ function parseSseJsonRpcResponse(payload: string, requestId: number): JsonRpcResponse | undefined {
796
+ const lines = payload.split("\n");
797
+ for (const rawLine of lines) {
798
+ const line = rawLine.trim();
799
+ if (!line.startsWith("data:")) {
800
+ continue;
801
+ }
802
+ const jsonPayload = line.slice(5).trim();
803
+ if (!jsonPayload || jsonPayload === "[DONE]") {
804
+ continue;
805
+ }
806
+ try {
807
+ const parsed = JSON.parse(jsonPayload) as JsonRpcResponse;
808
+ if (parsed?.id === requestId) {
809
+ return parsed;
810
+ }
811
+ } catch {
812
+ // Ignore malformed SSE chunks and continue searching.
813
+ }
814
+ }
815
+ return undefined;
816
+ }
817
+
818
+ function cloneServerStatus(status: McpRuntimeServerStatus): McpRuntimeServerStatus {
819
+ return {
820
+ ...status,
821
+ command: status.command ? [...status.command] : undefined,
822
+ };
823
+ }