@wrongstack/mcp 0.1.2 → 0.1.3

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/index.js CHANGED
@@ -1,5 +1,436 @@
1
1
  import { spawn } from 'child_process';
2
2
 
3
+ // src/client.ts
4
+
5
+ // src/transport.ts
6
+ var SSEReader = class {
7
+ buffer = "";
8
+ listeners = [];
9
+ onMessage(cb) {
10
+ this.listeners.push(cb);
11
+ return () => {
12
+ const idx = this.listeners.indexOf(cb);
13
+ if (idx >= 0) this.listeners.splice(idx, 1);
14
+ };
15
+ }
16
+ feed(chunk) {
17
+ this.buffer += chunk;
18
+ let idx = this.buffer.indexOf("\n");
19
+ while (idx !== -1) {
20
+ const line = this.buffer.slice(0, idx);
21
+ this.buffer = this.buffer.slice(idx + 1);
22
+ idx = this.buffer.indexOf("\n");
23
+ if (line.startsWith("event:")) ; else if (line.startsWith("data:")) {
24
+ const data = line.slice(5).trim();
25
+ if (data) {
26
+ try {
27
+ const parsed = JSON.parse(data);
28
+ this.dispatch(parsed);
29
+ } catch {
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ dispatch(msg) {
36
+ for (const cb of this.listeners) {
37
+ try {
38
+ cb(msg);
39
+ } catch {
40
+ }
41
+ }
42
+ }
43
+ reset() {
44
+ this.buffer = "";
45
+ this.listeners = [];
46
+ }
47
+ };
48
+ function isJsonRpcResult(v) {
49
+ return typeof v === "object" && v !== null && "jsonrpc" in v;
50
+ }
51
+ var SSETransport = class {
52
+ state = "idle";
53
+ url;
54
+ headers;
55
+ timeout;
56
+ nextId = 1;
57
+ pending = /* @__PURE__ */ new Map();
58
+ tools = [];
59
+ abortController;
60
+ reader;
61
+ readerDone = false;
62
+ disconnectHandlers = [];
63
+ readLoopAbort;
64
+ toolsChangedListeners = /* @__PURE__ */ new Set();
65
+ constructor(opts) {
66
+ this.url = opts.url;
67
+ this.headers = { ...opts.headers };
68
+ this.timeout = opts.startupTimeoutMs ?? 1e4;
69
+ }
70
+ getState() {
71
+ return this.state;
72
+ }
73
+ listTools() {
74
+ return [...this.tools];
75
+ }
76
+ onDisconnect(cb) {
77
+ this.disconnectHandlers.push(cb);
78
+ return () => {
79
+ const idx = this.disconnectHandlers.indexOf(cb);
80
+ if (idx >= 0) this.disconnectHandlers.splice(idx, 1);
81
+ };
82
+ }
83
+ onToolsChanged(cb) {
84
+ this.toolsChangedListeners.add(cb);
85
+ return () => {
86
+ this.toolsChangedListeners.delete(cb);
87
+ };
88
+ }
89
+ /** Refresh tool list when server sends notifications/tools/list_changed. */
90
+ async handleToolsListChanged() {
91
+ try {
92
+ const res = await this.httpPost("tools/list", {});
93
+ if (!res.error) {
94
+ this.tools = res.result?.tools ?? [];
95
+ for (const cb of this.toolsChangedListeners) {
96
+ try {
97
+ cb([...this.tools]);
98
+ } catch {
99
+ }
100
+ }
101
+ }
102
+ } catch {
103
+ }
104
+ }
105
+ async connect() {
106
+ this.state = "connecting";
107
+ this.abortController = new AbortController();
108
+ const signal = this.abortController.signal;
109
+ const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
110
+ try {
111
+ const sseUrl = this.buildSSEUrl();
112
+ const response = await fetch(sseUrl, {
113
+ headers: this.headers,
114
+ signal
115
+ });
116
+ if (!response.ok) {
117
+ throw new Error(`SSE connect HTTP ${response.status}: ${response.statusText}`);
118
+ }
119
+ if (!response.body) {
120
+ throw new Error("SSE response has no body");
121
+ }
122
+ const textDecoder = new TextDecoder();
123
+ const sseReader = new SSEReader();
124
+ this.readLoopAbort = new AbortController();
125
+ sseReader.onMessage((msg) => {
126
+ if (msg.id !== void 0) {
127
+ const resolve = this.pending.get(msg.id);
128
+ if (resolve) {
129
+ this.pending.delete(msg.id);
130
+ resolve({ jsonrpc: "2.0", id: msg.id, result: msg.params });
131
+ }
132
+ }
133
+ if (msg.method && !msg.id) {
134
+ if (msg.method === "notifications/tools/list_changed") {
135
+ void this.handleToolsListChanged();
136
+ }
137
+ }
138
+ });
139
+ const reader = response.body.getReader();
140
+ this.reader = {
141
+ cancel: () => reader.cancel(),
142
+ releaseLock: () => reader.releaseLock()
143
+ };
144
+ this.readSSEBody(reader, textDecoder, sseReader);
145
+ const initRes = await this.httpPost("initialize", {
146
+ protocolVersion: "2024-11-05",
147
+ capabilities: { tools: {} },
148
+ clientInfo: { name: "wrongstack", version: "0.1.1" }
149
+ });
150
+ if (initRes.error) {
151
+ throw new Error(`initialize failed: ${initRes.error.message}`);
152
+ }
153
+ try {
154
+ await this.httpPost("notifications/initialized", {});
155
+ } catch {
156
+ }
157
+ const toolsRes = await this.httpPost("tools/list", {});
158
+ if (toolsRes.error) {
159
+ this.tools = [];
160
+ } else {
161
+ const result = toolsRes.result;
162
+ this.tools = result?.tools ?? [];
163
+ }
164
+ this.state = "connected";
165
+ clearTimeout(startupTimer);
166
+ } catch (err) {
167
+ clearTimeout(startupTimer);
168
+ this.state = "failed";
169
+ this.abortController.abort();
170
+ throw err;
171
+ }
172
+ }
173
+ async readSSEBody(reader, decoder, sseReader) {
174
+ try {
175
+ while (!this.readerDone) {
176
+ const { done, value } = await reader.read();
177
+ if (done) break;
178
+ const chunk = decoder.decode(value, { stream: true });
179
+ sseReader.feed(chunk);
180
+ }
181
+ } catch {
182
+ if (this.state !== "disconnected" && this.state !== "failed") {
183
+ this.state = "disconnected";
184
+ for (const cb of this.disconnectHandlers) {
185
+ try {
186
+ cb();
187
+ } catch {
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ buildSSEUrl() {
194
+ try {
195
+ const url = new URL(this.url);
196
+ url.searchParams.set("session", String(Date.now()));
197
+ return url.toString();
198
+ } catch {
199
+ return this.url;
200
+ }
201
+ }
202
+ async httpPost(method, params) {
203
+ const id = this.nextId++;
204
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
205
+ const res = await fetch(this.url, {
206
+ method: "POST",
207
+ headers: {
208
+ "Content-Type": "application/json",
209
+ ...this.headers
210
+ },
211
+ body,
212
+ signal: this.abortController?.signal
213
+ });
214
+ if (!res.ok) {
215
+ throw new Error(`HTTP ${res.status}: ${await res.text()}`);
216
+ }
217
+ const data = await res.json();
218
+ if (!isJsonRpcResult(data)) {
219
+ throw new Error("Invalid JSON-RPC response");
220
+ }
221
+ return data;
222
+ }
223
+ async callTool(name, input) {
224
+ if (this.state !== "connected") {
225
+ throw new Error(`SSE transport not connected (state=${this.state})`);
226
+ }
227
+ const res = await this.httpPost("tools/call", { name, arguments: input });
228
+ if (res.error) {
229
+ return { content: res.error.message, isError: true };
230
+ }
231
+ const result = res.result;
232
+ return {
233
+ content: result?.content ?? "",
234
+ isError: Boolean(result?.isError)
235
+ };
236
+ }
237
+ async close() {
238
+ if (this.state === "disconnected") return;
239
+ this.readerDone = true;
240
+ this.readLoopAbort?.abort();
241
+ try {
242
+ this.reader?.cancel();
243
+ } catch {
244
+ }
245
+ try {
246
+ this.reader?.releaseLock();
247
+ } catch {
248
+ }
249
+ this.abortController?.abort();
250
+ this.disconnectHandlers = [];
251
+ this.state = "disconnected";
252
+ }
253
+ };
254
+ var StreamableHTTPTransport = class {
255
+ state = "idle";
256
+ url;
257
+ headers;
258
+ timeout;
259
+ nextId = 1;
260
+ tools = [];
261
+ abortController;
262
+ sessionId;
263
+ disconnectHandlers = [];
264
+ toolsChangedListeners = /* @__PURE__ */ new Set();
265
+ constructor(opts) {
266
+ this.url = opts.url;
267
+ this.headers = { ...opts.headers };
268
+ this.timeout = opts.startupTimeoutMs ?? 1e4;
269
+ }
270
+ getState() {
271
+ return this.state;
272
+ }
273
+ listTools() {
274
+ return [...this.tools];
275
+ }
276
+ onDisconnect(cb) {
277
+ this.disconnectHandlers.push(cb);
278
+ return () => {
279
+ const idx = this.disconnectHandlers.indexOf(cb);
280
+ if (idx >= 0) this.disconnectHandlers.splice(idx, 1);
281
+ };
282
+ }
283
+ onToolsChanged(cb) {
284
+ this.toolsChangedListeners.add(cb);
285
+ return () => {
286
+ this.toolsChangedListeners.delete(cb);
287
+ };
288
+ }
289
+ async handleToolsListChanged() {
290
+ try {
291
+ const res = await this.postRaw("tools/list", {});
292
+ if (!res.error) {
293
+ this.tools = res.result?.tools ?? [];
294
+ for (const cb of this.toolsChangedListeners) {
295
+ try {
296
+ cb([...this.tools]);
297
+ } catch {
298
+ }
299
+ }
300
+ }
301
+ } catch {
302
+ }
303
+ }
304
+ async connect() {
305
+ this.state = "connecting";
306
+ this.abortController = new AbortController();
307
+ const signal = this.abortController.signal;
308
+ const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
309
+ try {
310
+ const initRes = await fetch(this.url, {
311
+ method: "POST",
312
+ headers: {
313
+ "Content-Type": "application/json",
314
+ "Accept": "application/json, text/event-stream",
315
+ ...this.headers
316
+ },
317
+ body: JSON.stringify({
318
+ jsonrpc: "2.0",
319
+ id: this.nextId++,
320
+ method: "initialize",
321
+ params: {
322
+ protocolVersion: "2024-11-05",
323
+ capabilities: { tools: {} },
324
+ clientInfo: { name: "wrongstack", version: "0.1.1" }
325
+ }
326
+ }),
327
+ signal
328
+ });
329
+ if (!initRes.ok) {
330
+ throw new Error(`initialize HTTP ${initRes.status}: ${initRes.statusText}`);
331
+ }
332
+ const contentType = initRes.headers.get("content-type") ?? "";
333
+ let data;
334
+ if (contentType.includes("application/json")) {
335
+ const parsed = await initRes.json();
336
+ if (isJsonRpcResult(parsed)) data = parsed;
337
+ } else {
338
+ const text = await initRes.text();
339
+ const lines = text.split("\n").filter((l) => l.trim());
340
+ for (const line of lines) {
341
+ try {
342
+ const parsed = JSON.parse(line);
343
+ if (isJsonRpcResult(parsed)) {
344
+ data = parsed;
345
+ break;
346
+ }
347
+ } catch {
348
+ continue;
349
+ }
350
+ }
351
+ }
352
+ if (!data) {
353
+ throw new Error("Could not parse initialize response");
354
+ }
355
+ if (data.error) {
356
+ throw new Error(`initialize failed: ${data.error.message}`);
357
+ }
358
+ this.sessionId = initRes.headers.get("x-mcp-session") ?? void 0;
359
+ await this.postRaw("notifications/initialized", {});
360
+ const toolsRes = await this.postRaw("tools/list", {});
361
+ if (toolsRes.error) {
362
+ this.tools = [];
363
+ } else {
364
+ const result = toolsRes.result;
365
+ this.tools = result?.tools ?? [];
366
+ }
367
+ this.state = "connected";
368
+ clearTimeout(startupTimer);
369
+ } catch (err) {
370
+ clearTimeout(startupTimer);
371
+ this.state = "failed";
372
+ this.abortController.abort();
373
+ throw err;
374
+ }
375
+ }
376
+ async postRaw(method, params) {
377
+ const id = this.nextId++;
378
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
379
+ const url = this.sessionId ? `${this.url}${this.url.includes("?") ? "&" : "?"}session=${this.sessionId}` : this.url;
380
+ const res = await fetch(url, {
381
+ method: "POST",
382
+ headers: {
383
+ "Content-Type": "application/json",
384
+ "Accept": "application/json, text/event-stream",
385
+ ...this.sessionId ? { "x-mcp-session": this.sessionId } : {},
386
+ ...this.headers
387
+ },
388
+ body,
389
+ signal: this.abortController?.signal
390
+ });
391
+ if (!res.ok) {
392
+ throw new Error(`HTTP ${res.status}: ${res.statusText}`);
393
+ }
394
+ const text = await res.text();
395
+ const lines = text.split("\n").filter((l) => l.trim());
396
+ for (const line of lines) {
397
+ try {
398
+ const parsed = JSON.parse(line);
399
+ if (isJsonRpcResult(parsed)) return parsed;
400
+ } catch {
401
+ continue;
402
+ }
403
+ }
404
+ throw new Error("Could not parse response as JSON-RPC");
405
+ }
406
+ async callTool(name, input) {
407
+ if (this.state !== "connected") {
408
+ throw new Error(`streamable-http transport not connected (state=${this.state})`);
409
+ }
410
+ const res = await this.postRaw("tools/call", { name, arguments: input });
411
+ if (res.error) {
412
+ return { content: res.error.message, isError: true };
413
+ }
414
+ const result = res.result;
415
+ return {
416
+ content: result?.content ?? "",
417
+ isError: Boolean(result?.isError)
418
+ };
419
+ }
420
+ async close() {
421
+ if (this.state === "disconnected") return;
422
+ this.state = "disconnected";
423
+ this.abortController?.abort();
424
+ for (const cb of this.disconnectHandlers) {
425
+ try {
426
+ cb();
427
+ } catch {
428
+ }
429
+ }
430
+ this.disconnectHandlers = [];
431
+ }
432
+ };
433
+
3
434
  // src/client.ts
4
435
  var MCPClient = class {
5
436
  constructor(opts) {
@@ -11,31 +442,64 @@ var MCPClient = class {
11
442
  nextId = 1;
12
443
  pending = /* @__PURE__ */ new Map();
13
444
  rxBuffer = "";
14
- tools = [];
15
- /**
16
- * Guards against multiple concurrent drain-waits. When `stdin.write()`
17
- * returns false the first waiter sets this flag; any subsequent callers
18
- * skip the drain wait and emit a warning instead of racing.
19
- */
445
+ _tools = [];
446
+ /** Cached tool list — survives reconnects so the registry can re-register without re-discovering. */
447
+ _toolsCache;
20
448
  _drainPending = false;
21
- /** Set when a notify() call failed for reasons the caller should know about. */
22
449
  _lastNotifySkipped = false;
450
+ // HTTP transports
451
+ sseTransport;
452
+ httpTransport;
453
+ /** Notified when the stdio child process exits so the registry can attempt reconnect. */
454
+ exitListeners = /* @__PURE__ */ new Set();
455
+ /** Notified when the server announces a tools/list_changed notification. */
456
+ toolsChangedListeners = /* @__PURE__ */ new Set();
457
+ /** Notified when an HTTP transport (SSE or streamable-http) disconnects. */
458
+ disconnectListeners = /* @__PURE__ */ new Set();
23
459
  getState() {
24
460
  return this.state;
25
461
  }
26
462
  listTools() {
27
- return [...this.tools];
463
+ return this._tools.length > 0 ? [...this._tools] : this._toolsCache ? [...this._toolsCache] : [];
28
464
  }
29
465
  /** Returns true if a prior notify() call was skipped due to backpressure. */
30
466
  hadNotifySkipped() {
31
467
  return this._lastNotifySkipped;
32
468
  }
469
+ /**
470
+ * Register a listener for child-process exit events.
471
+ * The registry uses this to trigger reconnection.
472
+ */
473
+ addExitListener(listener) {
474
+ this.exitListeners.add(listener);
475
+ }
476
+ removeExitListener(listener) {
477
+ this.exitListeners.delete(listener);
478
+ }
479
+ /**
480
+ * Register a listener for transport disconnect events (SSE / streamable-http).
481
+ * Used by the registry to trigger reconnection for HTTP-based servers.
482
+ */
483
+ addDisconnectListener(listener) {
484
+ this.disconnectListeners.add(listener);
485
+ }
486
+ removeDisconnectListener(listener) {
487
+ this.disconnectListeners.delete(listener);
488
+ }
33
489
  async connect() {
34
490
  this.state = "connecting";
35
- if (this.opts.transport !== "stdio") {
491
+ if (this.opts.transport === "stdio") {
492
+ await this.connectStdio();
493
+ } else if (this.opts.transport === "sse") {
494
+ await this.connectSSE();
495
+ } else if (this.opts.transport === "streamable-http") {
496
+ await this.connectStreamableHTTP();
497
+ } else {
36
498
  this.state = "failed";
37
- throw new Error(`MCP transport "${this.opts.transport}" not supported in this build`);
499
+ throw new Error(`Unknown transport "${this.opts.transport}"`);
38
500
  }
501
+ }
502
+ async connectStdio() {
39
503
  if (!this.opts.command) {
40
504
  this.state = "failed";
41
505
  throw new Error('MCP stdio transport requires "command"');
@@ -48,8 +512,14 @@ var MCPClient = class {
48
512
  child.stdout?.on("data", (chunk) => this.onData(chunk.toString()));
49
513
  child.stderr?.on("data", () => {
50
514
  });
51
- child.on("exit", () => {
515
+ child.on("exit", (code, signal) => {
52
516
  this.state = "disconnected";
517
+ for (const listener of this.exitListeners) {
518
+ try {
519
+ listener(this.opts.name, code, signal);
520
+ } catch {
521
+ }
522
+ }
53
523
  });
54
524
  child.on("error", () => {
55
525
  this.state = "failed";
@@ -78,17 +548,104 @@ var MCPClient = class {
78
548
  }
79
549
  const toolsRes = await this.request("tools/list", {});
80
550
  if (toolsRes.error) {
81
- this.tools = [];
551
+ this._tools = [];
82
552
  } else {
83
553
  const result = toolsRes.result;
84
- this.tools = result?.tools ?? [];
554
+ this._tools = result?.tools ?? [];
555
+ }
556
+ this._toolsCache = this._tools;
557
+ this.state = "connected";
558
+ }
559
+ async connectSSE() {
560
+ if (!this.opts.url) {
561
+ this.state = "failed";
562
+ throw new Error('MCP SSE transport requires "url"');
563
+ }
564
+ const httpOpts = {
565
+ name: this.opts.name,
566
+ url: this.opts.url,
567
+ headers: this.opts.headers,
568
+ startupTimeoutMs: this.opts.startupTimeoutMs
569
+ };
570
+ this.sseTransport = new SSETransport(httpOpts);
571
+ this.sseTransport.onDisconnect(() => {
572
+ this.state = "disconnected";
573
+ for (const cb of this.disconnectListeners) {
574
+ try {
575
+ cb();
576
+ } catch {
577
+ }
578
+ }
579
+ });
580
+ this.sseTransport.onToolsChanged((tools) => {
581
+ this._tools = tools;
582
+ for (const cb of this.toolsChangedListeners) {
583
+ try {
584
+ cb(this.opts.name, tools);
585
+ } catch {
586
+ }
587
+ }
588
+ });
589
+ try {
590
+ await this.sseTransport.connect();
591
+ } catch (err) {
592
+ this.state = "failed";
593
+ throw err;
594
+ }
595
+ this._tools = this.sseTransport.listTools();
596
+ this._toolsCache = this._tools;
597
+ this.state = "connected";
598
+ }
599
+ async connectStreamableHTTP() {
600
+ if (!this.opts.url) {
601
+ this.state = "failed";
602
+ throw new Error('MCP streamable-http transport requires "url"');
603
+ }
604
+ const httpOpts = {
605
+ name: this.opts.name,
606
+ url: this.opts.url,
607
+ headers: this.opts.headers,
608
+ startupTimeoutMs: this.opts.startupTimeoutMs
609
+ };
610
+ this.httpTransport = new StreamableHTTPTransport(httpOpts);
611
+ this.httpTransport.onDisconnect(() => {
612
+ this.state = "disconnected";
613
+ for (const cb of this.disconnectListeners) {
614
+ try {
615
+ cb();
616
+ } catch {
617
+ }
618
+ }
619
+ });
620
+ this.httpTransport.onToolsChanged((tools) => {
621
+ this._tools = tools;
622
+ for (const cb of this.toolsChangedListeners) {
623
+ try {
624
+ cb(this.opts.name, tools);
625
+ } catch {
626
+ }
627
+ }
628
+ });
629
+ try {
630
+ await this.httpTransport.connect();
631
+ } catch (err) {
632
+ this.state = "failed";
633
+ throw err;
85
634
  }
635
+ this._tools = this.httpTransport.listTools();
636
+ this._toolsCache = this._tools;
86
637
  this.state = "connected";
87
638
  }
88
639
  async callTool(name, input) {
89
640
  if (this.state !== "connected") {
90
641
  throw new Error(`MCP client "${this.opts.name}" not connected (state=${this.state})`);
91
642
  }
643
+ if (this.sseTransport) {
644
+ return this.sseTransport.callTool(name, input);
645
+ }
646
+ if (this.httpTransport) {
647
+ return this.httpTransport.callTool(name, input);
648
+ }
92
649
  const res = await this.request("tools/call", { name, arguments: input });
93
650
  if (res.error) {
94
651
  return { content: res.error.message, isError: true };
@@ -101,11 +658,19 @@ var MCPClient = class {
101
658
  }
102
659
  async close() {
103
660
  if (this.child) {
661
+ const child = this.child;
662
+ const exitPromise = child.exitCode === null && child.signalCode === null ? new Promise((resolve) => child.once("exit", () => resolve())) : Promise.resolve();
104
663
  try {
105
- this.child.kill();
664
+ child.kill();
106
665
  } catch {
107
666
  }
667
+ await Promise.race([
668
+ exitPromise,
669
+ new Promise((resolve) => setTimeout(resolve, 1e3))
670
+ ]);
108
671
  }
672
+ this.sseTransport?.close();
673
+ this.httpTransport?.close();
109
674
  this.state = "disconnected";
110
675
  }
111
676
  request(method, params) {
@@ -177,12 +742,58 @@ var MCPClient = class {
177
742
  const resolve = this.pending.get(msg.id);
178
743
  this.pending.delete(msg.id);
179
744
  resolve?.(msg);
745
+ return;
746
+ }
747
+ if (typeof msg.method === "string" && msg.method === "notifications/tools/list_changed") {
748
+ void this.handleToolsListChanged();
749
+ }
750
+ }
751
+ /**
752
+ * L2-C: refresh the cached tool list when the server announces a
753
+ * `tools/list_changed`. Listeners (the registry) re-wrap and
754
+ * re-register. Failures are swallowed — a stale cache is preferable
755
+ * to a hard crash on a transient notification glitch.
756
+ */
757
+ async handleToolsListChanged() {
758
+ try {
759
+ const toolsRes = await this.request("tools/list", {});
760
+ const tools = (toolsRes.result?.tools ?? []).filter(
761
+ (t) => !!t && typeof t.name === "string"
762
+ );
763
+ this._tools = tools;
764
+ this._toolsCache = tools;
765
+ for (const listener of this.toolsChangedListeners) {
766
+ try {
767
+ listener(this.opts.name, [...tools]);
768
+ } catch {
769
+ }
770
+ }
771
+ } catch {
180
772
  }
181
773
  }
774
+ addToolsChangedListener(listener) {
775
+ this.toolsChangedListeners.add(listener);
776
+ }
777
+ removeToolsChangedListener(listener) {
778
+ this.toolsChangedListeners.delete(listener);
779
+ }
182
780
  };
183
781
 
184
782
  // src/wrap-tool.ts
185
783
  var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
784
+ function isMutatingTool(mcpTool) {
785
+ if (MUTATING_RE.test(mcpTool.name)) return true;
786
+ const schema = mcpTool.inputSchema;
787
+ if (schema && typeof schema === "object") {
788
+ const props = schema.properties;
789
+ if (props) {
790
+ for (const key of Object.keys(props)) {
791
+ if (MUTATING_RE.test(key)) return true;
792
+ }
793
+ }
794
+ }
795
+ return false;
796
+ }
186
797
  function wrapMCPTool(serverName, mcpTool, client, permission = "confirm") {
187
798
  const qualifiedName = `mcp__${serverName}__${mcpTool.name}`;
188
799
  return {
@@ -190,7 +801,7 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm") {
190
801
  description: mcpTool.description ?? `${qualifiedName} (MCP tool)`,
191
802
  usageHint: `Tool provided by MCP server "${serverName}". ${mcpTool.description ?? ""}`,
192
803
  permission,
193
- mutating: MUTATING_RE.test(mcpTool.name),
804
+ mutating: isMutatingTool(mcpTool),
194
805
  inputSchema: mcpTool.inputSchema ?? { type: "object", properties: {} },
195
806
  async execute(input, ctx, opts) {
196
807
  const res = await client.callTool(mcpTool.name, input);
@@ -223,7 +834,7 @@ function stringify(c) {
223
834
  }
224
835
 
225
836
  // src/registry.ts
226
- var MCPRegistry = class {
837
+ var MCPRegistry = class _MCPRegistry {
227
838
  servers = /* @__PURE__ */ new Map();
228
839
  toolRegistry;
229
840
  events;
@@ -239,7 +850,9 @@ var MCPRegistry = class {
239
850
  cfg,
240
851
  state: "idle",
241
852
  toolNames: [],
242
- attempts: 0
853
+ attempts: 0,
854
+ reconnectPending: false,
855
+ reconnectCycles: 0
243
856
  };
244
857
  this.servers.set(cfg.name, slot);
245
858
  await this.attemptConnect(slot);
@@ -247,7 +860,13 @@ var MCPRegistry = class {
247
860
  async stop(name) {
248
861
  const slot = this.servers.get(name);
249
862
  if (!slot) return;
250
- if (slot.client) await slot.client.close();
863
+ slot.reconnectPending = false;
864
+ if (slot.client) {
865
+ slot.client.removeExitListener(this.onChildExit);
866
+ slot.client.removeDisconnectListener(() => this.onTransportDisconnect(slot.cfg.name));
867
+ slot.client.removeToolsChangedListener(this.onToolsChanged);
868
+ await slot.client.close();
869
+ }
251
870
  for (const t of slot.toolNames) this.toolRegistry.unregister(t);
252
871
  slot.toolNames = [];
253
872
  slot.state = "disconnected";
@@ -258,6 +877,7 @@ var MCPRegistry = class {
258
877
  if (!slot) throw new Error(`MCP server "${name}" not registered`);
259
878
  await this.stop(name);
260
879
  slot.attempts = 0;
880
+ slot.reconnectCycles = 0;
261
881
  await this.attemptConnect(slot);
262
882
  }
263
883
  list() {
@@ -272,6 +892,117 @@ var MCPRegistry = class {
272
892
  await this.stop(name);
273
893
  }
274
894
  }
895
+ /**
896
+ * Health check — returns 'ok' for connected servers, the current state otherwise.
897
+ * For HTTP-based transports this could also ping the server.
898
+ */
899
+ health() {
900
+ return Array.from(this.servers.values()).map((s) => ({
901
+ name: s.cfg.name,
902
+ alive: s.state === "connected"
903
+ }));
904
+ }
905
+ /**
906
+ * L2-C: handle `notifications/tools/list_changed` from the server.
907
+ * Unregister the previous wrapper set, then re-register the fresh
908
+ * tool list. The client has already refreshed its cache before
909
+ * dispatching — we just need to re-wrap and re-register.
910
+ */
911
+ onToolsChanged = (name, _tools) => {
912
+ const slot = this.servers.get(name);
913
+ if (!slot || !slot.client) return;
914
+ for (const t of slot.toolNames) {
915
+ try {
916
+ this.toolRegistry.unregister(t);
917
+ } catch {
918
+ }
919
+ }
920
+ slot.toolNames = [];
921
+ const allowed = slot.cfg.allowedTools;
922
+ const wrapped = slot.client.listTools().filter((t) => !allowed || allowed.includes(t.name)).map((t) => wrapMCPTool(slot.cfg.name, t, slot.client, slot.cfg.permission ?? "confirm"));
923
+ for (const tool of wrapped) {
924
+ try {
925
+ this.toolRegistry.register(tool, `mcp:${slot.cfg.name}`);
926
+ slot.toolNames.push(tool.name);
927
+ } catch (err) {
928
+ this.log.warn(`MCP tool "${tool.name}" not re-registered after list_changed`, err);
929
+ }
930
+ }
931
+ this.events.emit("mcp.server.connected", {
932
+ name: slot.cfg.name,
933
+ toolCount: slot.toolNames.length
934
+ });
935
+ this.log.info(
936
+ `MCP server "${slot.cfg.name}" tools refreshed (${slot.toolNames.length} active)`
937
+ );
938
+ };
939
+ onChildExit = (name, code, _signal) => {
940
+ const slot = this.servers.get(name);
941
+ if (!slot) return;
942
+ for (const t of slot.toolNames) {
943
+ try {
944
+ this.toolRegistry.unregister(t);
945
+ } catch {
946
+ }
947
+ }
948
+ slot.toolNames = [];
949
+ slot.state = "disconnected";
950
+ this.events.emit("mcp.server.disconnected", { name, reason: `exit:${code ?? "unknown"}` });
951
+ this.scheduleReconnect(slot);
952
+ };
953
+ /** Handles SSE / streamable-http disconnect — same recovery as stdio child exit. */
954
+ onTransportDisconnect = (name) => {
955
+ const slot = this.servers.get(name);
956
+ if (!slot) return;
957
+ for (const t of slot.toolNames) {
958
+ try {
959
+ this.toolRegistry.unregister(t);
960
+ } catch {
961
+ }
962
+ }
963
+ slot.toolNames = [];
964
+ slot.state = "disconnected";
965
+ this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect" });
966
+ this.scheduleReconnect(slot);
967
+ };
968
+ /**
969
+ * L2-B: maximum number of reconnect cycles before staying `failed`.
970
+ * One cycle = one full `attemptConnect` (which itself may try up to 3
971
+ * times). Caps total reconnect storm at ~5 cycles, then the slot
972
+ * needs an explicit `restart()` to re-engage.
973
+ */
974
+ static MAX_RECONNECT_CYCLES = 5;
975
+ /** Base delay between cycles, in ms. Real delay adds jitter. */
976
+ static BASE_RECONNECT_DELAY_MS = 1e3;
977
+ /** Hard ceiling on the inter-cycle delay so the user doesn't wait minutes. */
978
+ static MAX_RECONNECT_DELAY_MS = 3e4;
979
+ scheduleReconnect(slot) {
980
+ if (slot.reconnectPending) return;
981
+ if (slot.reconnectCycles >= _MCPRegistry.MAX_RECONNECT_CYCLES) {
982
+ slot.state = "failed";
983
+ this.log.error(
984
+ `MCP server "${slot.cfg.name}" giving up after ${slot.reconnectCycles} reconnect cycles. Use \`/mcp restart ${slot.cfg.name}\` to retry.`
985
+ );
986
+ this.events.emit("mcp.server.disconnected", {
987
+ name: slot.cfg.name,
988
+ reason: `reconnect-exhausted:${slot.reconnectCycles}`
989
+ });
990
+ return;
991
+ }
992
+ slot.reconnectPending = true;
993
+ const base = Math.min(
994
+ _MCPRegistry.BASE_RECONNECT_DELAY_MS * 2 ** slot.reconnectCycles,
995
+ _MCPRegistry.MAX_RECONNECT_DELAY_MS
996
+ );
997
+ const jitter = base * 0.2 * (Math.random() * 2 - 1);
998
+ const delay = Math.max(100, Math.round(base + jitter));
999
+ setTimeout(() => this.attemptReconnect(slot), delay);
1000
+ }
1001
+ async attemptReconnect(slot) {
1002
+ slot.reconnectPending = false;
1003
+ slot.reconnectCycles++;
1004
+ await this.attemptConnect(slot);
1005
+ }
275
1006
  async attemptConnect(slot) {
276
1007
  const MAX_ATTEMPTS = 3;
277
1008
  let attempt = 0;
@@ -279,8 +1010,9 @@ var MCPRegistry = class {
279
1010
  attempt++;
280
1011
  slot.state = attempt === 1 ? "connecting" : "reconnecting";
281
1012
  slot.attempts = attempt;
1013
+ let client;
282
1014
  try {
283
- const client = new MCPClient({
1015
+ client = new MCPClient({
284
1016
  name: slot.cfg.name,
285
1017
  transport: slot.cfg.transport,
286
1018
  command: slot.cfg.command,
@@ -290,12 +1022,22 @@ var MCPRegistry = class {
290
1022
  headers: slot.cfg.headers,
291
1023
  startupTimeoutMs: slot.cfg.startupTimeoutMs
292
1024
  });
1025
+ if (slot.cfg.transport === "stdio") {
1026
+ client.addExitListener(this.onChildExit);
1027
+ } else {
1028
+ client.addDisconnectListener(() => this.onTransportDisconnect(slot.cfg.name));
1029
+ }
1030
+ client.addToolsChangedListener(this.onToolsChanged);
293
1031
  await client.connect();
294
1032
  slot.client = client;
295
1033
  const isReconnect = attempt > 1;
296
1034
  slot.state = "connected";
1035
+ slot.reconnectCycles = 0;
297
1036
  const allowed = slot.cfg.allowedTools;
298
- const wrapped = client.listTools().filter((t) => !allowed || allowed.includes(t.name)).map((t) => wrapMCPTool(slot.cfg.name, t, client, slot.cfg.permission ?? "confirm"));
1037
+ const mc = client;
1038
+ const candidateTools = mc.listTools();
1039
+ const toWrap = candidateTools.length > 0 ? candidateTools : mc.listTools();
1040
+ const wrapped = toWrap.filter((t) => !allowed || allowed.includes(t.name)).map((t) => wrapMCPTool(slot.cfg.name, t, mc, slot.cfg.permission ?? "confirm"));
299
1041
  for (const tool of wrapped) {
300
1042
  try {
301
1043
  this.toolRegistry.register(tool, `mcp:${slot.cfg.name}`);
@@ -311,9 +1053,17 @@ var MCPRegistry = class {
311
1053
  return;
312
1054
  } catch (err) {
313
1055
  this.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
1056
+ if (client) {
1057
+ client.removeExitListener(this.onChildExit);
1058
+ client.removeDisconnectListener(() => this.onTransportDisconnect(slot.cfg.name));
1059
+ client.removeToolsChangedListener(this.onToolsChanged);
1060
+ await client.close().catch(() => {
1061
+ });
1062
+ }
314
1063
  if (attempt >= MAX_ATTEMPTS) {
315
1064
  this.log.error(`MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS} attempts`, err);
316
1065
  slot.state = "failed";
1066
+ slot.client = void 0;
317
1067
  this.events.emit("mcp.server.disconnected", {
318
1068
  name: slot.cfg.name,
319
1069
  reason: err instanceof Error ? err.message : "unknown"
@@ -327,6 +1077,6 @@ var MCPRegistry = class {
327
1077
  }
328
1078
  };
329
1079
 
330
- export { MCPClient, MCPRegistry, wrapMCPTool };
1080
+ export { MCPClient, MCPRegistry, SSEReader, SSETransport, StreamableHTTPTransport, wrapMCPTool };
331
1081
  //# sourceMappingURL=index.js.map
332
1082
  //# sourceMappingURL=index.js.map