@wrongstack/mcp 1.0.29 → 1.0.32

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
@@ -6,7 +6,7 @@ import * as dns from "node:dns/promises";
6
6
  import * as http from "node:http";
7
7
  import * as https from "node:https";
8
8
  import * as net from "node:net";
9
- import { isPrivateIPv4, isPrivateIPv6 } from "@wrongstack/core/utils";
9
+ import { isPrivateIPv4, isPrivateIPv6, toErrorMessage } from "@wrongstack/core/utils";
10
10
  var MCPOAuthHttpError = class extends Error {
11
11
  constructor(message, status) {
12
12
  super(message);
@@ -216,10 +216,10 @@ function requiredString(value, field) {
216
216
  return value;
217
217
  }
218
218
  function optionalString(value, field) {
219
- return value === void 0 ? void 0 : requiredString(value, field);
219
+ return value === void 0 || value === null || value === "" ? void 0 : requiredString(value, field);
220
220
  }
221
221
  function optionalBoolean(value, field) {
222
- if (value === void 0) return void 0;
222
+ if (value === void 0 || value === null) return void 0;
223
223
  if (typeof value !== "boolean") {
224
224
  throw new Error(`MCP authorization field "${field}" must be a boolean`);
225
225
  }
@@ -232,7 +232,7 @@ function boundedStringArray(value, field, maxItems) {
232
232
  return [...new Set(value.map((entry) => requiredString(entry, field)))];
233
233
  }
234
234
  function optionalStringArray(value, field, maxItems) {
235
- return value === void 0 ? [] : boundedStringArray(value, field, maxItems);
235
+ return value === void 0 || value === null ? [] : boundedStringArray(value, field, maxItems);
236
236
  }
237
237
  function secureOAuthUrl(value, label2) {
238
238
  let url;
@@ -263,7 +263,7 @@ async function discoverFirst(candidates, fetchJson, signal, parse, label2) {
263
263
  return { url: candidate, value: parse(value) };
264
264
  } catch (error) {
265
265
  signal?.throwIfAborted();
266
- failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`);
266
+ failures.push(`${candidate}: ${toErrorMessage(error)}`);
267
267
  }
268
268
  }
269
269
  throw new Error(`MCP ${label2} discovery failed (${failures.join("; ")})`);
@@ -634,7 +634,7 @@ function parseTokenResponse(value, resource) {
634
634
  }
635
635
  const expiresIn = response["expires_in"];
636
636
  let expiresAt;
637
- if (expiresIn !== void 0) {
637
+ if (expiresIn !== void 0 && expiresIn !== null) {
638
638
  if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 31536e3) {
639
639
  throw new Error("MCP OAuth expires_in must be between 1 second and 1 year");
640
640
  }
@@ -1154,9 +1154,7 @@ function boundedServerName(value) {
1154
1154
  }
1155
1155
 
1156
1156
  // src/client.ts
1157
- import { spawn as spawn2 } from "node:child_process";
1158
- import { StringDecoder } from "node:string_decoder";
1159
- import { buildChildEnv as buildChildEnv2, buildWin32CmdShimInvocation, toErrorMessage } from "@wrongstack/core/utils";
1157
+ import { StringDecoder as StringDecoder2 } from "node:string_decoder";
1160
1158
 
1161
1159
  // src/client-protocol-helpers.ts
1162
1160
  var MAX_PROTOCOL_INPUT_CHARS = 8192;
@@ -1479,7 +1477,7 @@ function resolveHttpBearerHeaders(options, env = process.env) {
1479
1477
  if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(envName)) {
1480
1478
  throw new Error(`MCP bearerTokenEnv "${envName}" is not a valid environment variable name`);
1481
1479
  }
1482
- const token = env[envName];
1480
+ const token = Object.hasOwn(env, envName) ? env[envName] : void 0;
1483
1481
  if (!token) throw new Error(`MCP "${options.name}" requires environment variable ${envName}`);
1484
1482
  if (token.length > 16384 || /[\r\n]/.test(token)) {
1485
1483
  throw new Error(`MCP "${options.name}" bearer token is oversized or contains newlines`);
@@ -1528,7 +1526,9 @@ var SSE_READER_MAX_BUFFER = 256 * 1024;
1528
1526
  var SSE_READER_MAX_DATA_LINES = 1024;
1529
1527
  var SSEReader = class {
1530
1528
  buffer = "";
1529
+ skipLeadingLF = false;
1531
1530
  dataLines = [];
1531
+ dataLength = 0;
1532
1532
  eventName = "";
1533
1533
  endpointListeners = [];
1534
1534
  /**
@@ -1553,15 +1553,21 @@ var SSEReader = class {
1553
1553
  }
1554
1554
  feed(chunk) {
1555
1555
  const carried = this.buffer.length;
1556
+ if (this.skipLeadingLF && chunk.length > 0) {
1557
+ this.skipLeadingLF = false;
1558
+ if (chunk.startsWith("\n")) chunk = chunk.slice(1);
1559
+ }
1556
1560
  this.buffer += chunk;
1557
1561
  let start = 0;
1558
- let idx = this.buffer.indexOf("\n", start);
1559
- while (idx !== -1) {
1560
- let end = idx;
1561
- if (end > start && this.buffer.charCodeAt(end - 1) === 13) end--;
1562
- this.processLine(this.buffer.slice(start, end));
1562
+ for (let idx = 0; idx < this.buffer.length; idx++) {
1563
+ const code = this.buffer.charCodeAt(idx);
1564
+ if (code !== 10 && code !== 13) continue;
1565
+ this.processLine(this.buffer.slice(start, idx));
1566
+ if (code === 13) {
1567
+ if (this.buffer.charCodeAt(idx + 1) === 10) idx++;
1568
+ else if (idx + 1 === this.buffer.length) this.skipLeadingLF = true;
1569
+ }
1563
1570
  start = idx + 1;
1564
- idx = this.buffer.indexOf("\n", start);
1565
1571
  }
1566
1572
  if (start > 0) this.buffer = this.buffer.slice(start);
1567
1573
  if (this.buffer.length <= SSE_READER_MAX_BUFFER) return;
@@ -1585,6 +1591,13 @@ var SSEReader = class {
1585
1591
  });
1586
1592
  }
1587
1593
  processLine(line) {
1594
+ if (line.length > SSE_READER_MAX_BUFFER) {
1595
+ throw new ToolError({
1596
+ message: `SSE: completed line exceeds ${SSE_READER_MAX_BUFFER} characters`,
1597
+ code: "TOOL_EXECUTION_FAILED",
1598
+ toolName: "mcp_transport_sse_reader"
1599
+ });
1600
+ }
1588
1601
  if (line === "") {
1589
1602
  this.flush();
1590
1603
  return;
@@ -1597,6 +1610,14 @@ var SSEReader = class {
1597
1610
  if (field === "event") {
1598
1611
  this.eventName = value;
1599
1612
  } else if (field === "data") {
1613
+ const nextLength = this.dataLength + value.length + (this.dataLines.length > 0 ? 1 : 0);
1614
+ if (nextLength > SSE_READER_MAX_BUFFER) {
1615
+ throw new ToolError({
1616
+ message: `SSE: event data exceeds ${SSE_READER_MAX_BUFFER} characters`,
1617
+ code: "TOOL_EXECUTION_FAILED",
1618
+ toolName: "mcp_transport_sse_reader"
1619
+ });
1620
+ }
1600
1621
  if (this.dataLines.length >= SSE_READER_MAX_DATA_LINES) {
1601
1622
  throw new ToolError({
1602
1623
  message: `SSE: exceeded ${SSE_READER_MAX_DATA_LINES} data lines per event \u2014 upstream is not sending blank-line delimiters`,
@@ -1609,12 +1630,14 @@ var SSEReader = class {
1609
1630
  }
1610
1631
  });
1611
1632
  }
1633
+ this.dataLength = nextLength;
1612
1634
  this.dataLines.push(value);
1613
1635
  }
1614
1636
  }
1615
1637
  flush() {
1616
1638
  const eventName = this.eventName;
1617
1639
  this.eventName = "";
1640
+ this.dataLength = 0;
1618
1641
  if (this.dataLines.length === 0) {
1619
1642
  return;
1620
1643
  }
@@ -1646,8 +1669,10 @@ var SSEReader = class {
1646
1669
  }
1647
1670
  reset() {
1648
1671
  this.buffer = "";
1672
+ this.skipLeadingLF = false;
1649
1673
  this.dataLines = [];
1650
1674
  this.eventName = "";
1675
+ this.dataLength = 0;
1651
1676
  this.listeners = [];
1652
1677
  this.endpointListeners = [];
1653
1678
  }
@@ -1896,9 +1921,14 @@ function checkValue(field, raw) {
1896
1921
  function validateElicitationContent(fields, content) {
1897
1922
  const out = {};
1898
1923
  for (const field of fields) {
1899
- const raw = content[field.name];
1924
+ const raw = Object.hasOwn(content, field.name) ? content[field.name] : void 0;
1900
1925
  if (Array.isArray(raw) && raw.length === 0 && field.kind === "multi-enum" && field.minItems === 0) {
1901
- out[field.name] = [];
1926
+ Object.defineProperty(out, field.name, {
1927
+ value: [],
1928
+ enumerable: true,
1929
+ writable: true,
1930
+ configurable: true
1931
+ });
1902
1932
  continue;
1903
1933
  }
1904
1934
  if (raw === void 0 || raw === null || Array.isArray(raw) && raw.length === 0) {
@@ -1907,7 +1937,12 @@ function validateElicitationContent(fields, content) {
1907
1937
  }
1908
1938
  const checked = checkValue(field, raw);
1909
1939
  if (!checked.ok) return checked;
1910
- out[field.name] = checked.value;
1940
+ Object.defineProperty(out, field.name, {
1941
+ value: checked.value,
1942
+ enumerable: true,
1943
+ writable: true,
1944
+ configurable: true
1945
+ });
1911
1946
  }
1912
1947
  return { ok: true, value: out };
1913
1948
  }
@@ -1965,7 +2000,7 @@ var ServerRequestResponder = class {
1965
2000
  return { jsonrpc: "2.0", id, result: { action: "cancel" } };
1966
2001
  } finally {
1967
2002
  clearTimeout(deadline);
1968
- this.pending.delete(id);
2003
+ if (this.pending.get(id) === controller) this.pending.delete(id);
1969
2004
  }
1970
2005
  }
1971
2006
  /**
@@ -2011,7 +2046,17 @@ async function readBodyCapped(res, maxBytes = MAX_MCP_HTTP_BODY_BYTES) {
2011
2046
  const safeCap = typeof maxBytes === "number" && Number.isFinite(maxBytes) && maxBytes >= 0 ? maxBytes : MAX_MCP_HTTP_BODY_BYTES;
2012
2047
  const body = res.body;
2013
2048
  if (!body || typeof body.getReader !== "function") {
2014
- return await res.text();
2049
+ const text = await res.text();
2050
+ const received = Buffer.byteLength(text, "utf8");
2051
+ if (received > safeCap) {
2052
+ throw new ToolError2({
2053
+ message: `MCP response body exceeded ${safeCap} bytes \u2014 refusing to buffer`,
2054
+ code: "TOOL_EXECUTION_FAILED",
2055
+ toolName: "mcp_transport",
2056
+ context: { maxBytes: safeCap, received }
2057
+ });
2058
+ }
2059
+ return text;
2015
2060
  }
2016
2061
  const reader = body.getReader();
2017
2062
  const chunks = [];
@@ -2568,7 +2613,7 @@ function extractJsonRpcEnvelopes(text) {
2568
2613
  } catch {
2569
2614
  }
2570
2615
  };
2571
- for (const raw of text.split("\n")) {
2616
+ for (const raw of text.split(/\r\n|\r|\n/)) {
2572
2617
  const line = raw.replace(/\r$/, "");
2573
2618
  if (line === "") {
2574
2619
  flush();
@@ -2773,6 +2818,8 @@ var SSETransport = class extends BaseHTTPTransport {
2773
2818
  _nextId = 1;
2774
2819
  readerDone = false;
2775
2820
  closed = false;
2821
+ toolCatalogVersion = 0;
2822
+ toolCatalogRevision = 0;
2776
2823
  readLoopAbort;
2777
2824
  reader;
2778
2825
  /** POST target announced by the server's `endpoint` event. */
@@ -2791,10 +2838,12 @@ var SSETransport = class extends BaseHTTPTransport {
2791
2838
  }
2792
2839
  /** Refresh tool list when server sends notifications/tools/list_changed. */
2793
2840
  async handleToolsListChanged() {
2841
+ const version = ++this.toolCatalogVersion;
2794
2842
  try {
2795
2843
  const tools = await listAllTools((params) => this.httpPost("tools/list", params));
2796
- if (!tools) return;
2844
+ if (!tools || version !== this.toolCatalogVersion) return;
2797
2845
  this.tools.splice(0, this.tools.length, ...tools);
2846
+ this.toolCatalogRevision++;
2798
2847
  for (const cb of this.toolsChangedListeners) {
2799
2848
  try {
2800
2849
  cb([...this.tools]);
@@ -2805,14 +2854,21 @@ var SSETransport = class extends BaseHTTPTransport {
2805
2854
  }
2806
2855
  }
2807
2856
  async connect() {
2857
+ this.toolCatalogVersion++;
2808
2858
  this.readerDone = false;
2809
2859
  this.closed = false;
2810
2860
  this.endpointUrl = void 0;
2811
2861
  this.state = "connecting";
2812
2862
  this.serverMetadata = void 0;
2813
- this.abortController = new AbortController();
2814
- const signal = this.abortController.signal;
2815
- const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
2863
+ const controller = new AbortController();
2864
+ this.abortController = controller;
2865
+ const signal = controller.signal;
2866
+ const assertCurrent = () => {
2867
+ if (this.abortController !== controller || signal.aborted || this.closed) {
2868
+ throw makeAbortError("connect");
2869
+ }
2870
+ };
2871
+ const startupTimer = setTimeout(() => controller.abort(), this.timeout);
2816
2872
  try {
2817
2873
  const sseUrl = this.buildSSEUrl();
2818
2874
  const fetchOpts = {
@@ -2821,6 +2877,12 @@ var SSETransport = class extends BaseHTTPTransport {
2821
2877
  };
2822
2878
  this.applyTlsAgent(fetchOpts);
2823
2879
  const response = await this.fetchWithAuthorization(sseUrl, fetchOpts, signal);
2880
+ try {
2881
+ assertCurrent();
2882
+ } catch (error) {
2883
+ await response.body?.cancel().catch(() => void 0);
2884
+ throw error;
2885
+ }
2824
2886
  if (!response.ok) {
2825
2887
  throw new ToolError4({
2826
2888
  message: `SSE connect HTTP ${response.status}: ${response.statusText}`,
@@ -2839,15 +2901,18 @@ var SSETransport = class extends BaseHTTPTransport {
2839
2901
  }
2840
2902
  const textDecoder = new TextDecoder();
2841
2903
  const sseReader = new SSEReader();
2842
- this.readLoopAbort = new AbortController();
2904
+ const readLoopAbort = new AbortController();
2905
+ this.readLoopAbort = readLoopAbort;
2843
2906
  const streamReady = new Promise((resolve) => {
2844
2907
  this.streamSignal = resolve;
2845
2908
  });
2846
2909
  sseReader.onEndpoint((endpoint) => {
2910
+ if (this.abortController !== controller || signal.aborted || this.closed) return;
2847
2911
  this.acceptEndpoint(endpoint);
2848
2912
  this.streamSignal?.();
2849
2913
  });
2850
2914
  sseReader.onMessage((msg) => {
2915
+ if (this.abortController !== controller || signal.aborted || this.closed) return;
2851
2916
  this.streamSignal?.();
2852
2917
  const id = msg.id;
2853
2918
  if (msg.method && (typeof id === "number" || typeof id === "string")) {
@@ -2886,8 +2951,9 @@ var SSETransport = class extends BaseHTTPTransport {
2886
2951
  cancel: () => reader.cancel(),
2887
2952
  releaseLock: () => reader.releaseLock()
2888
2953
  };
2889
- void this.readSSEBody(reader, textDecoder, sseReader);
2954
+ void this.readSSEBody(reader, textDecoder, sseReader, controller, readLoopAbort.signal);
2890
2955
  await this.waitForStream(streamReady, signal);
2956
+ assertCurrent();
2891
2957
  const initRes = await this.httpPost("initialize", {
2892
2958
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2893
2959
  // Client capabilities: elicitation when the host can ask its user.
@@ -2895,6 +2961,7 @@ var SSETransport = class extends BaseHTTPTransport {
2895
2961
  capabilities: this.serverRequests.capabilities(),
2896
2962
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
2897
2963
  });
2964
+ assertCurrent();
2898
2965
  if (initRes.error) {
2899
2966
  throw new ToolError4({
2900
2967
  message: `initialize failed: ${initRes.error.message}`,
@@ -2909,17 +2976,24 @@ var SSETransport = class extends BaseHTTPTransport {
2909
2976
  await this.httpPost("notifications/initialized", {});
2910
2977
  } catch {
2911
2978
  }
2979
+ assertCurrent();
2980
+ const revision = this.toolCatalogRevision;
2912
2981
  const tools = await listAllTools((params) => this.httpPost("tools/list", params));
2913
- this.tools.splice(0, this.tools.length, ...tools ?? []);
2982
+ assertCurrent();
2983
+ if (revision === this.toolCatalogRevision) {
2984
+ this.tools.splice(0, this.tools.length, ...tools ?? []);
2985
+ }
2914
2986
  this.state = "connected";
2915
2987
  clearTimeout(startupTimer);
2916
2988
  } catch (err) {
2917
2989
  clearTimeout(startupTimer);
2918
- this.state = "failed";
2919
- this.abortController.abort();
2990
+ if (this.abortController === controller && !this.closed) {
2991
+ this.state = "failed";
2992
+ }
2993
+ controller.abort();
2920
2994
  throw err;
2921
2995
  } finally {
2922
- this.streamSignal = void 0;
2996
+ if (this.abortController === controller) this.streamSignal = void 0;
2923
2997
  }
2924
2998
  }
2925
2999
  /** Resolve once the stream announced its endpoint, emitted anything, ended, or the cap passed. */
@@ -2957,9 +3031,9 @@ var SSETransport = class extends BaseHTTPTransport {
2957
3031
  } catch {
2958
3032
  }
2959
3033
  }
2960
- async readSSEBody(reader, decoder, sseReader) {
3034
+ async readSSEBody(reader, decoder, sseReader, controller, signal) {
2961
3035
  try {
2962
- while (!this.readerDone) {
3036
+ while (!this.readerDone && !signal?.aborted && (!controller || this.abortController === controller) && !this.closed) {
2963
3037
  const { done, value } = await reader.read();
2964
3038
  if (done) break;
2965
3039
  const chunk = decoder.decode(value, { stream: true });
@@ -2967,11 +3041,13 @@ var SSETransport = class extends BaseHTTPTransport {
2967
3041
  }
2968
3042
  } catch {
2969
3043
  } finally {
2970
- this.streamSignal?.();
2971
- this.rejectStreamPending("SSE stream closed");
2972
- if (!this.readerDone && this.state !== "disconnected" && this.state !== "failed") {
2973
- this.state = "disconnected";
2974
- this.notifyDisconnect();
3044
+ if (!controller || this.abortController === controller) {
3045
+ this.streamSignal?.();
3046
+ this.rejectStreamPending("SSE stream closed");
3047
+ if (!this.readerDone && !signal?.aborted && !this.closed && this.state !== "disconnected" && this.state !== "failed") {
3048
+ this.state = "disconnected";
3049
+ this.notifyDisconnect();
3050
+ }
2975
3051
  }
2976
3052
  }
2977
3053
  }
@@ -3117,6 +3193,7 @@ var SSETransport = class extends BaseHTTPTransport {
3117
3193
  return { jsonrpc: "2.0", id: result.id, result: result.result, error: result.error };
3118
3194
  }
3119
3195
  async close() {
3196
+ this.toolCatalogVersion++;
3120
3197
  this.releasePinnedDispatcher();
3121
3198
  const alreadyClosed = this.closed;
3122
3199
  this.closed = true;
@@ -3157,6 +3234,8 @@ function invalidResponse(method, url, err) {
3157
3234
  var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTransport {
3158
3235
  _nextId = 1;
3159
3236
  closed = false;
3237
+ toolCatalogVersion = 0;
3238
+ toolCatalogRevision = 0;
3160
3239
  sessionId;
3161
3240
  constructor(opts) {
3162
3241
  super(opts, "StreamableHTTP");
@@ -3252,10 +3331,12 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
3252
3331
  }
3253
3332
  }
3254
3333
  async refreshTools() {
3334
+ const version = ++this.toolCatalogVersion;
3255
3335
  try {
3256
3336
  const tools = await listAllTools((params) => this.postRaw("tools/list", params));
3257
- if (!tools) return;
3337
+ if (!tools || version !== this.toolCatalogVersion) return;
3258
3338
  this.tools.splice(0, this.tools.length, ...tools);
3339
+ this.toolCatalogRevision++;
3259
3340
  for (const listener of this.toolsChangedListeners) {
3260
3341
  try {
3261
3342
  listener([...tools]);
@@ -3266,12 +3347,19 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
3266
3347
  }
3267
3348
  }
3268
3349
  async connect() {
3350
+ this.toolCatalogVersion++;
3269
3351
  this.closed = false;
3270
3352
  this.state = "connecting";
3271
3353
  this.serverMetadata = void 0;
3272
- this.abortController = new AbortController();
3273
- const signal = this.abortController.signal;
3274
- const startupTimer = setTimeout(() => this.abortController?.abort(), this.timeout);
3354
+ const controller = new AbortController();
3355
+ this.abortController = controller;
3356
+ const signal = controller.signal;
3357
+ const assertCurrent = () => {
3358
+ if (this.abortController !== controller || signal.aborted || this.closed) {
3359
+ throw makeAbortError("connect");
3360
+ }
3361
+ };
3362
+ const startupTimer = setTimeout(() => controller.abort(), this.timeout);
3275
3363
  try {
3276
3364
  const initializeId = this.genId();
3277
3365
  const initFetchOpts = {
@@ -3297,6 +3385,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
3297
3385
  };
3298
3386
  this.applyTlsAgent(initFetchOpts);
3299
3387
  const initRes = await this.fetchWithAuthorization(this.url, initFetchOpts, signal);
3388
+ assertCurrent();
3300
3389
  if (!initRes.ok) {
3301
3390
  throw new Error(`initialize HTTP ${initRes.status}: ${initRes.statusText}`);
3302
3391
  }
@@ -3308,6 +3397,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
3308
3397
  } else {
3309
3398
  data = extractJsonRpcResults(await readBodyCapped(initRes))[0];
3310
3399
  }
3400
+ assertCurrent();
3311
3401
  if (!data) {
3312
3402
  throw new Error("Could not parse initialize response");
3313
3403
  }
@@ -3319,14 +3409,21 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
3319
3409
  this.protocolVersion = this.serverMetadata.protocolVersion;
3320
3410
  this.sessionId = initRes.headers.get("mcp-session-id") ?? void 0;
3321
3411
  await this.postRaw("notifications/initialized", {});
3412
+ assertCurrent();
3413
+ const revision = this.toolCatalogRevision;
3322
3414
  const tools = await listAllTools((params) => this.postRaw("tools/list", params));
3323
- this.tools.splice(0, this.tools.length, ...tools ?? []);
3415
+ assertCurrent();
3416
+ if (revision === this.toolCatalogRevision) {
3417
+ this.tools.splice(0, this.tools.length, ...tools ?? []);
3418
+ }
3324
3419
  this.state = "connected";
3325
3420
  clearTimeout(startupTimer);
3326
3421
  } catch (err) {
3327
3422
  clearTimeout(startupTimer);
3328
- this.state = "failed";
3329
- this.abortController.abort();
3423
+ if (this.abortController === controller && !this.closed) {
3424
+ this.state = "failed";
3425
+ }
3426
+ controller.abort();
3330
3427
  throw err;
3331
3428
  }
3332
3429
  }
@@ -3443,6 +3540,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
3443
3540
  return toToolCallResult(res);
3444
3541
  }
3445
3542
  async close() {
3543
+ this.toolCatalogVersion++;
3446
3544
  this.releasePinnedDispatcher();
3447
3545
  const alreadyClosed = this.closed;
3448
3546
  this.closed = true;
@@ -3467,9 +3565,17 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
3467
3565
  }
3468
3566
  };
3469
3567
  function completeEventsEnd(text) {
3470
- const lf = text.lastIndexOf("\n\n");
3471
- const crlf = text.lastIndexOf("\r\n\r\n");
3472
- return Math.max(lf === -1 ? 0 : lf + 2, crlf === -1 ? 0 : crlf + 4);
3568
+ let start = 0;
3569
+ let end = 0;
3570
+ for (let idx = 0; idx < text.length; idx++) {
3571
+ const code = text.charCodeAt(idx);
3572
+ if (code !== 10 && code !== 13) continue;
3573
+ const empty = idx === start;
3574
+ if (code === 13 && text.charCodeAt(idx + 1) === 10) idx++;
3575
+ start = idx + 1;
3576
+ if (empty) end = start;
3577
+ }
3578
+ return end;
3473
3579
  }
3474
3580
 
3475
3581
  // src/client-http-connection.ts
@@ -3580,8 +3686,248 @@ async function connectStreamableHTTP(host) {
3580
3686
  host.state = "connected";
3581
3687
  }
3582
3688
 
3689
+ // src/client-stdio.ts
3690
+ import { spawn as spawn2 } from "node:child_process";
3691
+ import { StringDecoder } from "node:string_decoder";
3692
+ import { buildChildEnv as buildChildEnv2, buildWin32CmdShimInvocation, toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
3693
+ async function connectStdio(host) {
3694
+ if (!host.opts.command) {
3695
+ host.state = "failed";
3696
+ throw new Error('MCP stdio transport requires "command"');
3697
+ }
3698
+ host.rxBuffer = "";
3699
+ host.rxBufferBytes = 0;
3700
+ host.rxDecoder = new StringDecoder("utf8");
3701
+ const extraEnv = { ...host.opts.env };
3702
+ if (host.opts.passthroughEnv) {
3703
+ for (const name of host.opts.passthroughEnv) {
3704
+ const val = process.env[name];
3705
+ if (val !== void 0) {
3706
+ extraEnv[name] = val;
3707
+ }
3708
+ }
3709
+ }
3710
+ const isWin = process.platform === "win32";
3711
+ const rawArgs = host.opts.args ?? [];
3712
+ const spawnEnv = buildChildEnv2({ extra: extraEnv });
3713
+ const stdio = ["pipe", "pipe", "pipe"];
3714
+ const child = isWin ? (() => {
3715
+ const shim = buildWin32CmdShimInvocation(host.opts.command, rawArgs);
3716
+ return spawn2(shim.command, shim.args, {
3717
+ env: spawnEnv,
3718
+ stdio,
3719
+ ...host.opts.cwd ? { cwd: host.opts.cwd } : {},
3720
+ windowsVerbatimArguments: shim.windowsVerbatimArguments,
3721
+ // Without this every MCP server spawned from a console-less host
3722
+ // (WebUI server, scheduled runs) opens a visible console window.
3723
+ windowsHide: true
3724
+ });
3725
+ })() : spawn2(host.opts.command, rawArgs, {
3726
+ env: spawnEnv,
3727
+ stdio,
3728
+ windowsHide: true,
3729
+ ...host.opts.cwd ? { cwd: host.opts.cwd } : {}
3730
+ });
3731
+ host.child = child;
3732
+ child.stdout?.on("data", (chunk) => host.onData(host.rxDecoder.write(chunk)));
3733
+ child.stdout?.on("end", () => {
3734
+ const tail = host.rxDecoder.end();
3735
+ if (tail) host.onData(tail);
3736
+ if (host.rxBuffer.trim()) {
3737
+ const line = host.rxBuffer.trim();
3738
+ host.rxBuffer = "";
3739
+ host.onLine(line);
3740
+ }
3741
+ });
3742
+ child.stderr?.on("data", () => {
3743
+ });
3744
+ child.stdin?.on("error", (err) => {
3745
+ host.failPending(`MCP "${host.opts.name}" stdin error: ${toErrorMessage2(err)}`);
3746
+ });
3747
+ child.on("exit", (code, signal) => {
3748
+ host.state = "disconnected";
3749
+ host.failPending(
3750
+ `MCP "${host.opts.name}" child exited (code=${code ?? "null"} signal=${signal ?? "null"})`
3751
+ );
3752
+ for (const listener of host.exitListeners) {
3753
+ try {
3754
+ listener(host.opts.name, code, signal);
3755
+ } catch {
3756
+ }
3757
+ }
3758
+ });
3759
+ child.on("error", (err) => {
3760
+ host.state = "failed";
3761
+ host.failPending(`MCP "${host.opts.name}" child error: ${toErrorMessage2(err)}`);
3762
+ });
3763
+ const initialize = await host.request(
3764
+ "initialize",
3765
+ {
3766
+ protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
3767
+ // Client capabilities: elicitation when the host can ask its user.
3768
+ // `tools` is a SERVER capability and never belonged here.
3769
+ capabilities: host.serverRequests.capabilities(),
3770
+ clientInfo: MCP_CONSTANTS.CLIENT_INFO
3771
+ },
3772
+ typeof host.opts.startupTimeoutMs === "number" && Number.isFinite(host.opts.startupTimeoutMs) && host.opts.startupTimeoutMs > 0 ? host.opts.startupTimeoutMs : 1e4
3773
+ );
3774
+ if (initialize.error) {
3775
+ host.state = "failed";
3776
+ throw new Error(`MCP initialize failed: ${initialize.error.message}`);
3777
+ }
3778
+ try {
3779
+ host._serverMetadata = parseServerMetadata(initialize.result);
3780
+ } catch (err) {
3781
+ host.state = "failed";
3782
+ throw new Error(`MCP initialize returned malformed server metadata: ${toErrorMessage2(err)}`);
3783
+ }
3784
+ try {
3785
+ await host.notify("notifications/initialized", {});
3786
+ } catch (err) {
3787
+ console.warn(
3788
+ '[MCP] notify("notifications/initialized") failed for "' + host.opts.name + '": ' + toErrorMessage2(err)
3789
+ );
3790
+ }
3791
+ const revision = host.toolCatalogRevision;
3792
+ const tools = await listAllTools((params) => host.request("tools/list", params)) ?? [];
3793
+ if (revision === host.toolCatalogRevision) host._tools = tools;
3794
+ host._toolsCache = host._tools;
3795
+ host.state = "connected";
3796
+ }
3797
+ async function notifyStdio(host, method, params) {
3798
+ if (host._drainPending) {
3799
+ host._lastNotifySkipped = true;
3800
+ console.warn(
3801
+ JSON.stringify({
3802
+ level: "warn",
3803
+ event: "mcp.notify_skipped_backpressure",
3804
+ server: host.opts.name,
3805
+ method,
3806
+ message: "stdin buffer backpressure (already waiting for drain)",
3807
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3808
+ })
3809
+ );
3810
+ return;
3811
+ }
3812
+ const stdin = host.child?.stdin;
3813
+ if (!stdin || stdin.destroyed === true || stdin.writable === false) {
3814
+ return;
3815
+ }
3816
+ const req = { jsonrpc: "2.0", method, params };
3817
+ const encoded = JSON.stringify(req) + "\n";
3818
+ try {
3819
+ const ok = stdin.write(encoded);
3820
+ if (!ok) {
3821
+ host._drainPending = true;
3822
+ await new Promise((resolve, reject) => {
3823
+ const timeout = setTimeout(() => {
3824
+ stdin.removeListener?.("drain", onDrain);
3825
+ stdin.removeListener?.("error", onError);
3826
+ host._drainPending = false;
3827
+ reject(new Error(`MCP notify("${method}") drain timeout`));
3828
+ }, 500);
3829
+ const onDrain = () => {
3830
+ clearTimeout(timeout);
3831
+ stdin.removeListener?.("drain", onDrain);
3832
+ stdin.removeListener?.("error", onError);
3833
+ host._drainPending = false;
3834
+ resolve();
3835
+ };
3836
+ const onError = (err) => {
3837
+ clearTimeout(timeout);
3838
+ stdin.removeListener?.("drain", onDrain);
3839
+ stdin.removeListener?.("error", onError);
3840
+ host._drainPending = false;
3841
+ reject(err);
3842
+ };
3843
+ stdin.once?.("drain", onDrain);
3844
+ stdin.once?.("error", onError);
3845
+ });
3846
+ }
3847
+ } catch (err) {
3848
+ throw new Error(`[MCP] notify("${method}") failed: ${toErrorMessage2(err)}`);
3849
+ }
3850
+ }
3851
+
3852
+ // src/client-stdio-protocol.ts
3853
+ var MAX_RX_BUFFER_BYTES = 16 * 1024 * 1024;
3854
+ function receiveStdioData(host, s) {
3855
+ host.rxBufferBytes += Buffer.byteLength(s, "utf8");
3856
+ if (host.rxBufferBytes > MAX_RX_BUFFER_BYTES) {
3857
+ const truncated = host.rxBufferBytes;
3858
+ host.rxParts = [];
3859
+ host.rxBufferBytes = 0;
3860
+ host.failPending(
3861
+ `MCP "${host.opts.name}" rx buffer overflow (${truncated} bytes without a newline) \u2014 closing connection`
3862
+ );
3863
+ void host.close();
3864
+ return;
3865
+ }
3866
+ let start = 0;
3867
+ let idx = s.indexOf("\n");
3868
+ if (idx === -1) {
3869
+ host.rxParts.push(s);
3870
+ return;
3871
+ }
3872
+ while (idx !== -1) {
3873
+ const head = host.rxParts.length > 0 ? host.rxParts.join("") : "";
3874
+ host.rxParts = [];
3875
+ const line = (head + s.slice(start, idx)).trim();
3876
+ start = idx + 1;
3877
+ if (line) host.onLine(line);
3878
+ idx = s.indexOf("\n", start);
3879
+ }
3880
+ const tail = s.slice(start);
3881
+ host.rxBufferBytes = Buffer.byteLength(tail, "utf8");
3882
+ if (tail) host.rxParts.push(tail);
3883
+ }
3884
+ function receiveStdioLine(host, line) {
3885
+ let msg;
3886
+ try {
3887
+ msg = JSON.parse(line);
3888
+ } catch {
3889
+ return;
3890
+ }
3891
+ if (typeof msg !== "object" || msg === null) return;
3892
+ const envelope = msg;
3893
+ if (envelope["jsonrpc"] !== "2.0") return;
3894
+ if (typeof envelope["method"] === "string") {
3895
+ const id = envelope["id"];
3896
+ if (typeof id === "number" || typeof id === "string") {
3897
+ void host.handleServerRequest({
3898
+ jsonrpc: "2.0",
3899
+ id,
3900
+ method: envelope["method"],
3901
+ params: envelope["params"]
3902
+ });
3903
+ return;
3904
+ }
3905
+ if (Object.hasOwn(envelope, "id")) return;
3906
+ if (envelope["method"] === "notifications/cancelled") {
3907
+ host.serverRequests.cancel(envelope["params"]);
3908
+ } else if (envelope["method"] === "notifications/tools/list_changed") {
3909
+ void host.handleToolsListChanged();
3910
+ } else if (envelope["method"] === "notifications/resources/list_changed") {
3911
+ host.emitCapabilityChanged("resources");
3912
+ } else if (envelope["method"] === "notifications/prompts/list_changed") {
3913
+ host.emitCapabilityChanged("prompts");
3914
+ } else if (envelope["method"] === "notifications/resources/updated") {
3915
+ const uri = resourceUpdatedUri(envelope["params"]);
3916
+ if (uri) host.emitResourceUpdated(uri);
3917
+ }
3918
+ return;
3919
+ }
3920
+ if (!isJsonRpcResult(msg)) return;
3921
+ const response = msg;
3922
+ if (host.pending.has(response.id)) {
3923
+ const entry = host.pending.get(response.id);
3924
+ host.pending.delete(response.id);
3925
+ entry?.resolve(response);
3926
+ }
3927
+ }
3928
+
3583
3929
  // src/client.ts
3584
- var MCPClient = class _MCPClient {
3930
+ var MCPClient = class {
3585
3931
  constructor(opts) {
3586
3932
  this.opts = opts;
3587
3933
  this.serverRequests = new ServerRequestResponder(opts.elicitation);
@@ -3591,15 +3937,6 @@ var MCPClient = class _MCPClient {
3591
3937
  this.requestCapability.bind(this),
3592
3938
  this.requireResourceSubscriptions.bind(this)
3593
3939
  );
3594
- /**
3595
- * Maximum bytes the rx buffer may accumulate before the connection is
3596
- * forcefully closed. A well-behaved JSON-RPC server emits newline-delimited
3597
- * messages that are individually much smaller than this; a server that never
3598
- * sends a newline would grow the buffer without limit and OOM the process.
3599
- * 16 MiB is generous for any legitimate single message while bounding the
3600
- * worst-case memory to a predictable cap.
3601
- */
3602
- static MAX_RX_BUFFER_BYTES = 16 * 1024 * 1024;
3603
3940
  state = "idle";
3604
3941
  child;
3605
3942
  nextId = 1;
@@ -3630,7 +3967,7 @@ var MCPClient = class _MCPClient {
3630
3967
  * JSON-RPC payload. StringDecoder withholds the partial sequence until the
3631
3968
  * chunk that completes it (same remedy as `readFileHead` in core utils).
3632
3969
  */
3633
- rxDecoder = new StringDecoder("utf8");
3970
+ rxDecoder = new StringDecoder2("utf8");
3634
3971
  _tools = [];
3635
3972
  /** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
3636
3973
  _serverMetadata;
@@ -3640,6 +3977,8 @@ var MCPClient = class _MCPClient {
3640
3977
  _lastNotifySkipped = false;
3641
3978
  closePromise;
3642
3979
  connectPromise;
3980
+ toolCatalogVersion = 0;
3981
+ toolCatalogRevision = 0;
3643
3982
  // HTTP transports
3644
3983
  sseTransport;
3645
3984
  httpTransport;
@@ -3702,6 +4041,7 @@ var MCPClient = class _MCPClient {
3702
4041
  return this.connectPromise;
3703
4042
  }
3704
4043
  async connectInner() {
4044
+ this.toolCatalogVersion++;
3705
4045
  this.state = "connecting";
3706
4046
  this._serverMetadata = void 0;
3707
4047
  try {
@@ -3722,106 +4062,7 @@ var MCPClient = class _MCPClient {
3722
4062
  }
3723
4063
  }
3724
4064
  async connectStdio() {
3725
- if (!this.opts.command) {
3726
- this.state = "failed";
3727
- throw new Error('MCP stdio transport requires "command"');
3728
- }
3729
- this.rxBuffer = "";
3730
- this.rxBufferBytes = 0;
3731
- this.rxDecoder = new StringDecoder("utf8");
3732
- const extraEnv = { ...this.opts.env };
3733
- if (this.opts.passthroughEnv) {
3734
- for (const name of this.opts.passthroughEnv) {
3735
- const val = process.env[name];
3736
- if (val !== void 0) {
3737
- extraEnv[name] = val;
3738
- }
3739
- }
3740
- }
3741
- const isWin = process.platform === "win32";
3742
- const rawArgs = this.opts.args ?? [];
3743
- const spawnEnv = buildChildEnv2({ extra: extraEnv });
3744
- const stdio = ["pipe", "pipe", "pipe"];
3745
- const child = isWin ? (() => {
3746
- const shim = buildWin32CmdShimInvocation(this.opts.command, rawArgs);
3747
- return spawn2(shim.command, shim.args, {
3748
- env: spawnEnv,
3749
- stdio,
3750
- ...this.opts.cwd ? { cwd: this.opts.cwd } : {},
3751
- windowsVerbatimArguments: shim.windowsVerbatimArguments,
3752
- // Without this every MCP server spawned from a console-less host
3753
- // (WebUI server, scheduled runs) opens a visible console window.
3754
- windowsHide: true
3755
- });
3756
- })() : spawn2(this.opts.command, rawArgs, {
3757
- env: spawnEnv,
3758
- stdio,
3759
- windowsHide: true,
3760
- ...this.opts.cwd ? { cwd: this.opts.cwd } : {}
3761
- });
3762
- this.child = child;
3763
- child.stdout?.on("data", (chunk) => this.onData(this.rxDecoder.write(chunk)));
3764
- child.stdout?.on("end", () => {
3765
- const tail = this.rxDecoder.end();
3766
- if (tail) this.onData(tail);
3767
- if (this.rxBuffer.trim()) {
3768
- const line = this.rxBuffer.trim();
3769
- this.rxBuffer = "";
3770
- this.onLine(line);
3771
- }
3772
- });
3773
- child.stderr?.on("data", () => {
3774
- });
3775
- child.stdin?.on("error", (err) => {
3776
- this.failPending(`MCP "${this.opts.name}" stdin error: ${toErrorMessage(err)}`);
3777
- });
3778
- child.on("exit", (code, signal) => {
3779
- this.state = "disconnected";
3780
- this.failPending(
3781
- `MCP "${this.opts.name}" child exited (code=${code ?? "null"} signal=${signal ?? "null"})`
3782
- );
3783
- for (const listener of this.exitListeners) {
3784
- try {
3785
- listener(this.opts.name, code, signal);
3786
- } catch {
3787
- }
3788
- }
3789
- });
3790
- child.on("error", (err) => {
3791
- this.state = "failed";
3792
- this.failPending(`MCP "${this.opts.name}" child error: ${toErrorMessage(err)}`);
3793
- });
3794
- const initialize = await this.request(
3795
- "initialize",
3796
- {
3797
- protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
3798
- // Client capabilities: elicitation when the host can ask its user.
3799
- // `tools` is a SERVER capability and never belonged here.
3800
- capabilities: this.serverRequests.capabilities(),
3801
- clientInfo: MCP_CONSTANTS.CLIENT_INFO
3802
- },
3803
- typeof this.opts.startupTimeoutMs === "number" && Number.isFinite(this.opts.startupTimeoutMs) && this.opts.startupTimeoutMs > 0 ? this.opts.startupTimeoutMs : 1e4
3804
- );
3805
- if (initialize.error) {
3806
- this.state = "failed";
3807
- throw new Error(`MCP initialize failed: ${initialize.error.message}`);
3808
- }
3809
- try {
3810
- this._serverMetadata = parseServerMetadata(initialize.result);
3811
- } catch (err) {
3812
- this.state = "failed";
3813
- throw new Error(`MCP initialize returned malformed server metadata: ${toErrorMessage(err)}`);
3814
- }
3815
- try {
3816
- await this.notify("notifications/initialized", {});
3817
- } catch (err) {
3818
- console.warn(
3819
- '[MCP] notify("notifications/initialized") failed for "' + this.opts.name + '": ' + toErrorMessage(err)
3820
- );
3821
- }
3822
- this._tools = await listAllTools((params) => this.request("tools/list", params)) ?? [];
3823
- this._toolsCache = this._tools;
3824
- this.state = "connected";
4065
+ return connectStdio(this.clientStdioHost());
3825
4066
  }
3826
4067
  async connectSSE() {
3827
4068
  return connectSSE(this.clientHttpConnectionHost());
@@ -3878,6 +4119,7 @@ var MCPClient = class _MCPClient {
3878
4119
  return this.closePromise;
3879
4120
  }
3880
4121
  async closeInner() {
4122
+ this.toolCatalogVersion++;
3881
4123
  if (this.child) {
3882
4124
  const child = this.child;
3883
4125
  const exitPromise = new Promise((resolve) => {
@@ -4055,132 +4297,13 @@ var MCPClient = class _MCPClient {
4055
4297
  this.pending.clear();
4056
4298
  }
4057
4299
  async notify(method, params) {
4058
- if (this._drainPending) {
4059
- this._lastNotifySkipped = true;
4060
- console.warn(
4061
- JSON.stringify({
4062
- level: "warn",
4063
- event: "mcp.notify_skipped_backpressure",
4064
- server: this.opts.name,
4065
- method,
4066
- message: "stdin buffer backpressure (already waiting for drain)",
4067
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
4068
- })
4069
- );
4070
- return;
4071
- }
4072
- const stdin = this.child?.stdin;
4073
- if (!stdin || stdin.destroyed === true || stdin.writable === false) {
4074
- return;
4075
- }
4076
- const req = { jsonrpc: "2.0", method, params };
4077
- const encoded = JSON.stringify(req) + "\n";
4078
- try {
4079
- const ok = stdin.write(encoded);
4080
- if (!ok) {
4081
- this._drainPending = true;
4082
- await new Promise((resolve, reject) => {
4083
- const timeout = setTimeout(() => {
4084
- stdin.removeListener?.("drain", onDrain);
4085
- stdin.removeListener?.("error", onError);
4086
- this._drainPending = false;
4087
- reject(new Error(`MCP notify("${method}") drain timeout`));
4088
- }, 500);
4089
- const onDrain = () => {
4090
- clearTimeout(timeout);
4091
- stdin.removeListener?.("drain", onDrain);
4092
- stdin.removeListener?.("error", onError);
4093
- this._drainPending = false;
4094
- resolve();
4095
- };
4096
- const onError = (err) => {
4097
- clearTimeout(timeout);
4098
- stdin.removeListener?.("drain", onDrain);
4099
- stdin.removeListener?.("error", onError);
4100
- this._drainPending = false;
4101
- reject(err);
4102
- };
4103
- stdin.once?.("drain", onDrain);
4104
- stdin.once?.("error", onError);
4105
- });
4106
- }
4107
- } catch (err) {
4108
- throw new Error(`[MCP] notify("${method}") failed: ${toErrorMessage(err)}`);
4109
- }
4300
+ return notifyStdio(this.clientStdioHost(), method, params);
4110
4301
  }
4111
4302
  onData(s) {
4112
- this.rxBufferBytes += Buffer.byteLength(s, "utf8");
4113
- if (this.rxBufferBytes > _MCPClient.MAX_RX_BUFFER_BYTES) {
4114
- const truncated = this.rxBufferBytes;
4115
- this.rxParts = [];
4116
- this.rxBufferBytes = 0;
4117
- this.failPending(
4118
- `MCP "${this.opts.name}" rx buffer overflow (${truncated} bytes without a newline) \u2014 closing connection`
4119
- );
4120
- void this.close();
4121
- return;
4122
- }
4123
- let start = 0;
4124
- let idx = s.indexOf("\n");
4125
- if (idx === -1) {
4126
- this.rxParts.push(s);
4127
- return;
4128
- }
4129
- while (idx !== -1) {
4130
- const head = this.rxParts.length > 0 ? this.rxParts.join("") : "";
4131
- this.rxParts = [];
4132
- const line = (head + s.slice(start, idx)).trim();
4133
- start = idx + 1;
4134
- if (line) this.onLine(line);
4135
- idx = s.indexOf("\n", start);
4136
- }
4137
- const tail = s.slice(start);
4138
- this.rxBufferBytes = Buffer.byteLength(tail, "utf8");
4139
- if (tail) this.rxParts.push(tail);
4303
+ receiveStdioData(this.clientStdioProtocolHost(), s);
4140
4304
  }
4141
4305
  onLine(line) {
4142
- let msg;
4143
- try {
4144
- msg = JSON.parse(line);
4145
- } catch {
4146
- return;
4147
- }
4148
- if (typeof msg !== "object" || msg === null) return;
4149
- const envelope = msg;
4150
- if (envelope["jsonrpc"] !== "2.0") return;
4151
- if (typeof envelope["method"] === "string") {
4152
- const id = envelope["id"];
4153
- if (typeof id === "number" || typeof id === "string") {
4154
- void this.handleServerRequest({
4155
- jsonrpc: "2.0",
4156
- id,
4157
- method: envelope["method"],
4158
- params: envelope["params"]
4159
- });
4160
- return;
4161
- }
4162
- if (Object.hasOwn(envelope, "id")) return;
4163
- if (envelope["method"] === "notifications/cancelled") {
4164
- this.serverRequests.cancel(envelope["params"]);
4165
- } else if (envelope["method"] === "notifications/tools/list_changed") {
4166
- void this.handleToolsListChanged();
4167
- } else if (envelope["method"] === "notifications/resources/list_changed") {
4168
- this.emitCapabilityChanged("resources");
4169
- } else if (envelope["method"] === "notifications/prompts/list_changed") {
4170
- this.emitCapabilityChanged("prompts");
4171
- } else if (envelope["method"] === "notifications/resources/updated") {
4172
- const uri = resourceUpdatedUri(envelope["params"]);
4173
- if (uri) this.emitResourceUpdated(uri);
4174
- }
4175
- return;
4176
- }
4177
- if (!isJsonRpcResult(msg)) return;
4178
- const response = msg;
4179
- if (this.pending.has(response.id)) {
4180
- const entry = this.pending.get(response.id);
4181
- this.pending.delete(response.id);
4182
- entry?.resolve(response);
4183
- }
4306
+ receiveStdioLine(this.clientStdioProtocolHost(), line);
4184
4307
  }
4185
4308
  async handleServerRequest(request3) {
4186
4309
  const response = await this.serverRequests.answer(request3);
@@ -4197,11 +4320,13 @@ var MCPClient = class _MCPClient {
4197
4320
  * to a hard crash on a transient notification glitch.
4198
4321
  */
4199
4322
  async handleToolsListChanged() {
4323
+ const version = ++this.toolCatalogVersion;
4200
4324
  try {
4201
4325
  const tools = await listAllTools((params) => this.request("tools/list", params));
4202
- if (!tools) return;
4326
+ if (!tools || version !== this.toolCatalogVersion) return;
4203
4327
  this._tools = tools;
4204
4328
  this._toolsCache = tools;
4329
+ this.toolCatalogRevision++;
4205
4330
  for (const listener of this.toolsChangedListeners) {
4206
4331
  try {
4207
4332
  listener(this.opts.name, [...tools]);
@@ -4255,9 +4380,7 @@ var MCPClient = class _MCPClient {
4255
4380
  clientHttpConnectionHost() {
4256
4381
  const self = this;
4257
4382
  return {
4258
- get opts() {
4259
- return self.opts;
4260
- },
4383
+ opts: this.opts,
4261
4384
  get state() {
4262
4385
  return self.state;
4263
4386
  },
@@ -4301,6 +4424,109 @@ var MCPClient = class _MCPClient {
4301
4424
  serverRequests: this.serverRequests
4302
4425
  };
4303
4426
  }
4427
+ clientStdioHost() {
4428
+ const self = this;
4429
+ return {
4430
+ opts: this.opts,
4431
+ get state() {
4432
+ return self.state;
4433
+ },
4434
+ set state(value) {
4435
+ self.state = value;
4436
+ },
4437
+ get rxBuffer() {
4438
+ return self.rxBuffer;
4439
+ },
4440
+ set rxBuffer(value) {
4441
+ self.rxBuffer = value;
4442
+ },
4443
+ get rxBufferBytes() {
4444
+ return self.rxBufferBytes;
4445
+ },
4446
+ set rxBufferBytes(value) {
4447
+ self.rxBufferBytes = value;
4448
+ },
4449
+ get rxDecoder() {
4450
+ return self.rxDecoder;
4451
+ },
4452
+ set rxDecoder(value) {
4453
+ self.rxDecoder = value;
4454
+ },
4455
+ get child() {
4456
+ return self.child;
4457
+ },
4458
+ set child(value) {
4459
+ self.child = value;
4460
+ },
4461
+ onData: (...args) => this.onData(...args),
4462
+ onLine: (...args) => this.onLine(...args),
4463
+ failPending: (...args) => this.failPending(...args),
4464
+ exitListeners: this.exitListeners,
4465
+ request: (...args) => this.request(...args),
4466
+ serverRequests: this.serverRequests,
4467
+ get _serverMetadata() {
4468
+ return self._serverMetadata;
4469
+ },
4470
+ set _serverMetadata(value) {
4471
+ self._serverMetadata = value;
4472
+ },
4473
+ notify: (...args) => this.notify(...args),
4474
+ get toolCatalogRevision() {
4475
+ return self.toolCatalogRevision;
4476
+ },
4477
+ get _tools() {
4478
+ return self._tools;
4479
+ },
4480
+ set _tools(value) {
4481
+ self._tools = value;
4482
+ },
4483
+ get _toolsCache() {
4484
+ return self._toolsCache;
4485
+ },
4486
+ set _toolsCache(value) {
4487
+ self._toolsCache = value;
4488
+ },
4489
+ get _drainPending() {
4490
+ return self._drainPending;
4491
+ },
4492
+ set _drainPending(value) {
4493
+ self._drainPending = value;
4494
+ },
4495
+ get _lastNotifySkipped() {
4496
+ return self._lastNotifySkipped;
4497
+ },
4498
+ set _lastNotifySkipped(value) {
4499
+ self._lastNotifySkipped = value;
4500
+ }
4501
+ };
4502
+ }
4503
+ clientStdioProtocolHost() {
4504
+ const self = this;
4505
+ return {
4506
+ get rxBufferBytes() {
4507
+ return self.rxBufferBytes;
4508
+ },
4509
+ set rxBufferBytes(value) {
4510
+ self.rxBufferBytes = value;
4511
+ },
4512
+ get rxParts() {
4513
+ return self.rxParts;
4514
+ },
4515
+ set rxParts(value) {
4516
+ self.rxParts = value;
4517
+ },
4518
+ failPending: (...args) => this.failPending(...args),
4519
+ opts: this.opts,
4520
+ close: (...args) => this.close(...args),
4521
+ onLine: (...args) => this.onLine(...args),
4522
+ handleServerRequest: (...args) => this.handleServerRequest(...args),
4523
+ serverRequests: this.serverRequests,
4524
+ handleToolsListChanged: (...args) => this.handleToolsListChanged(...args),
4525
+ emitCapabilityChanged: (...args) => this.emitCapabilityChanged(...args),
4526
+ emitResourceUpdated: (...args) => this.emitResourceUpdated(...args),
4527
+ pending: this.pending
4528
+ };
4529
+ }
4304
4530
  };
4305
4531
 
4306
4532
  // src/config-env.ts
@@ -4321,7 +4547,7 @@ function mcpEnvPlaceholders(cfg) {
4321
4547
  function expandMcpEnvPlaceholders(cfg, env = process.env) {
4322
4548
  const missing = /* @__PURE__ */ new Set();
4323
4549
  const expand = (value) => value.replace(PLACEHOLDER, (_match, name, fallback) => {
4324
- const resolved = env[name];
4550
+ const resolved = Object.hasOwn(env, name) ? env[name] : void 0;
4325
4551
  if (resolved !== void 0 && resolved !== "") return resolved;
4326
4552
  if (fallback !== void 0) return fallback;
4327
4553
  missing.add(name);
@@ -4680,7 +4906,12 @@ async function elicitViaUserInput(request3, fallback, options = {}) {
4680
4906
  }
4681
4907
  const content = {};
4682
4908
  form.fields.forEach((field, i) => {
4683
- content[field.name] = answerValue(field, byId.get(questionId(i)));
4909
+ Object.defineProperty(content, field.name, {
4910
+ value: answerValue(field, byId.get(questionId(i))),
4911
+ enumerable: true,
4912
+ writable: true,
4913
+ configurable: true
4914
+ });
4684
4915
  });
4685
4916
  const checked = validateElicitationContent(form.fields, content);
4686
4917
  if (checked.ok) return { action: "accept", content: checked.value };
@@ -5031,6 +5262,12 @@ function forgetRegistryState(registry, name) {
5031
5262
  import { createHash as createHash2, randomBytes as randomBytes4 } from "node:crypto";
5032
5263
  import * as fs2 from "node:fs/promises";
5033
5264
  import * as path from "node:path";
5265
+ function isRecord2(value) {
5266
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5267
+ }
5268
+ function isCachedTool(value) {
5269
+ return isRecord2(value) && typeof value["name"] === "string" && value["name"].trim().length > 0 && (value["description"] === void 0 || typeof value["description"] === "string") && isRecord2(value["inputSchema"]) && (value["outputSchema"] === void 0 || isRecord2(value["outputSchema"]));
5270
+ }
5034
5271
  function manifestConfigHash(cfg) {
5035
5272
  const basis = JSON.stringify({
5036
5273
  transport: cfg.transport,
@@ -5066,7 +5303,8 @@ async function readCapabilityManifest(cacheDir, name, configHash) {
5066
5303
  try {
5067
5304
  const raw = await fs2.readFile(manifestFile(cacheDir, name), "utf8");
5068
5305
  const parsed = JSON.parse(raw);
5069
- if (parsed.configHash !== configHash || !Array.isArray(parsed.tools)) return null;
5306
+ if (parsed.configHash !== configHash || !Array.isArray(parsed.tools) || !parsed.tools.every(isCachedTool))
5307
+ return null;
5070
5308
  return {
5071
5309
  tools: parsed.tools,
5072
5310
  serverMetadata: parsed.serverMetadata === void 0 ? void 0 : parseServerMetadata(parsed.serverMetadata),
@@ -5283,6 +5521,13 @@ async function completeRegistryAuthorization(manager, cfg, name, callbackUrl, si
5283
5521
  // src/registry-catalog.ts
5284
5522
  var MAX_CATALOG_PAGES = 100;
5285
5523
  var MAX_CATALOG_ITEMS = 1e4;
5524
+ function advanceCatalogVersion(slot, kind) {
5525
+ slot.catalogVersions ??= {};
5526
+ const versions = slot.catalogVersions;
5527
+ const version = (versions[kind] ?? 0) + 1;
5528
+ versions[kind] = version;
5529
+ return version;
5530
+ }
5286
5531
  async function collectCatalogPages(load, select) {
5287
5532
  const items = [];
5288
5533
  const seenCursors = /* @__PURE__ */ new Set();
@@ -5315,10 +5560,105 @@ function registryCatalogSnapshot(slot) {
5315
5560
  };
5316
5561
  }
5317
5562
 
5563
+ // src/registry-catalog-operations.ts
5564
+ function getCatalog(name) {
5565
+ const slot = this.servers.get(name);
5566
+ if (slot) {
5567
+ return registryCatalogSnapshot(slot);
5568
+ }
5569
+ const disabled = this.disabledServers.get(name);
5570
+ if (disabled) {
5571
+ return {
5572
+ name: disabled.name,
5573
+ state: "idle"
5574
+ };
5575
+ }
5576
+ return void 0;
5577
+ }
5578
+ async function listResources(name, opts = {}) {
5579
+ const slot = this.requireSlot(name);
5580
+ if (!opts.refresh && slot.resources) return cloneCatalogRecords(slot.resources);
5581
+ const version = advanceCatalogVersion(slot, "resources");
5582
+ return this.withConnectedClient(name, async (client, assertCurrent) => {
5583
+ if (!client.getServerMetadata()?.capabilities.resources) return [];
5584
+ const resources = await collectCatalogPages(
5585
+ (cursor) => client.listResources(cursor ? { cursor } : {}),
5586
+ (page) => page.resources
5587
+ );
5588
+ assertCurrent();
5589
+ if (slot.catalogVersions?.resources === version) {
5590
+ slot.resources = resources;
5591
+ await this.persistCapabilityManifest(slot);
5592
+ }
5593
+ return cloneCatalogRecords(resources);
5594
+ });
5595
+ }
5596
+ async function listResourceTemplates(name, opts = {}) {
5597
+ const slot = this.requireSlot(name);
5598
+ if (!opts.refresh && slot.resourceTemplates) return cloneCatalogRecords(slot.resourceTemplates);
5599
+ const version = advanceCatalogVersion(slot, "resourceTemplates");
5600
+ return this.withConnectedClient(name, async (client, assertCurrent) => {
5601
+ if (!client.getServerMetadata()?.capabilities.resources) return [];
5602
+ const templates = await collectCatalogPages(
5603
+ (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
5604
+ (page) => page.resourceTemplates
5605
+ );
5606
+ assertCurrent();
5607
+ if (slot.catalogVersions?.resourceTemplates === version) {
5608
+ slot.resourceTemplates = templates;
5609
+ await this.persistCapabilityManifest(slot);
5610
+ }
5611
+ return cloneCatalogRecords(templates);
5612
+ });
5613
+ }
5614
+ async function readResource(name, uri) {
5615
+ return this.withConnectedClient(name, (client) => client.readResource(uri));
5616
+ }
5617
+ async function selectResourceForInsertion(name, uri, policy) {
5618
+ return prepareResourceInsertion(name, uri, await this.readResource(name, uri), policy);
5619
+ }
5620
+ async function subscribeResource(name, uri) {
5621
+ await this.withConnectedClient(name, (client) => client.subscribeResource(uri));
5622
+ }
5623
+ async function unsubscribeResource(name, uri) {
5624
+ await this.withConnectedClient(name, (client) => client.unsubscribeResource(uri));
5625
+ }
5626
+ async function listPrompts(name, opts = {}) {
5627
+ const slot = this.requireSlot(name);
5628
+ if (!opts.refresh && slot.prompts) return cloneCatalogRecords(slot.prompts);
5629
+ const version = advanceCatalogVersion(slot, "prompts");
5630
+ return this.withConnectedClient(name, async (client, assertCurrent) => {
5631
+ if (!client.getServerMetadata()?.capabilities.prompts) return [];
5632
+ const prompts = await collectCatalogPages(
5633
+ (cursor) => client.listPrompts(cursor ? { cursor } : {}),
5634
+ (page) => page.prompts
5635
+ );
5636
+ assertCurrent();
5637
+ if (slot.catalogVersions?.prompts === version) {
5638
+ slot.prompts = prompts;
5639
+ await this.persistCapabilityManifest(slot);
5640
+ }
5641
+ return cloneCatalogRecords(prompts);
5642
+ });
5643
+ }
5644
+ async function getPrompt(serverName, promptName, args) {
5645
+ return this.withConnectedClient(serverName, (client) => client.getPrompt(promptName, args));
5646
+ }
5647
+ async function selectPromptForInsertion(serverName, promptName, args, policy) {
5648
+ return preparePromptInsertion(
5649
+ serverName,
5650
+ promptName,
5651
+ args,
5652
+ await this.getPrompt(serverName, promptName, args),
5653
+ policy
5654
+ );
5655
+ }
5656
+
5318
5657
  // src/registry-connect-loop.ts
5319
5658
  import { expectDefined } from "@wrongstack/core/utils";
5320
5659
 
5321
5660
  // src/wrap-tool.ts
5661
+ import { createSandboxMcpGate } from "@wrongstack/core/sandbox";
5322
5662
  import { ToolCapabilities } from "@wrongstack/core/security";
5323
5663
  import { mcpQualifiedToolName } from "@wrongstack/core/utils";
5324
5664
 
@@ -5385,9 +5725,10 @@ function isMutatingTool(mcpTool) {
5385
5725
  }
5386
5726
  return false;
5387
5727
  }
5388
- function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observer) {
5728
+ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", sandboxTrust = false, observer) {
5729
+ const sandboxGate = createSandboxMcpGate({ trusted: sandboxTrust });
5389
5730
  const qualifiedName = mcpQualifiedToolName(serverName, mcpTool.name);
5390
- return {
5731
+ const tool = {
5391
5732
  name: qualifiedName,
5392
5733
  description: mcpTool.description ?? `${qualifiedName} (MCP tool)`,
5393
5734
  usageHint: `Tool provided by MCP server "${serverName}". ${mcpTool.description ?? ""}`,
@@ -5426,6 +5767,7 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observ
5426
5767
  }
5427
5768
  }
5428
5769
  };
5770
+ return sandboxGate(tool);
5429
5771
  }
5430
5772
  function describeUrlOutcomes(serverName, toolName, outcomes) {
5431
5773
  const lines = outcomes.map(({ elicitation, action }) => {
@@ -5490,36 +5832,48 @@ function applySlotTools(ctx, slot, tools, client) {
5490
5832
  const allowed = slot.cfg.allowedTools;
5491
5833
  const filtered = tools.filter((t) => !allowed || allowed.includes(t.name));
5492
5834
  const signature = JSON.stringify(
5493
- filtered.map((t) => [t.name, t.description ?? null, t.inputSchema ?? null])
5835
+ filtered.map((t) => [
5836
+ t.name,
5837
+ t.description ?? null,
5838
+ t.inputSchema ?? null,
5839
+ t.outputSchema ?? null
5840
+ ])
5494
5841
  );
5495
5842
  const lazyWrappersCurrent = slot.lazy && slot.toolSignature === signature && slot.lazyTools.length === filtered.length && (slot.registeredLazy || ctx.lazyMode);
5496
5843
  if (lazyWrappersCurrent) return;
5497
5844
  const clientArg = slot.lazy ? () => ctx.ensureConnected(slot.cfg.name) : expectDefined(client);
5498
5845
  const wrapped = filtered.map(
5499
- (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
5500
- onStart: (caller) => {
5501
- if (caller) slot.activeCallers = [...slot.activeCallers ?? [], caller];
5502
- slot.operations.inFlightCalls++;
5503
- slot.operations.peakInFlightCalls = Math.max(
5504
- slot.operations.peakInFlightCalls,
5505
- slot.operations.inFlightCalls
5506
- );
5507
- ctx.recordOperation(slot, "call", "started", void 0, void 0, false);
5508
- },
5509
- onFinish: ({ durationMs, ok }, caller) => {
5510
- const at = caller ? slot.activeCallers?.lastIndexOf(caller) ?? -1 : -1;
5511
- if (at >= 0) slot.activeCallers?.splice(at, 1);
5512
- slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
5513
- slot.lastUsed = Date.now();
5514
- pushBounded(slot.operations.callSamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
5515
- if (ok) {
5516
- ctx.recordSuccess(slot);
5517
- ctx.recordOperation(slot, "call", "ok", void 0, durationMs, false);
5518
- } else {
5519
- ctx.recordFailure(slot, "tool", "tool-call-failed", durationMs);
5846
+ (t) => wrapMCPTool(
5847
+ slot.cfg.name,
5848
+ t,
5849
+ clientArg,
5850
+ slot.cfg.permission ?? "confirm",
5851
+ slot.cfg.sandboxTrust === true,
5852
+ {
5853
+ onStart: (caller) => {
5854
+ if (caller) slot.activeCallers = [...slot.activeCallers ?? [], caller];
5855
+ slot.operations.inFlightCalls++;
5856
+ slot.operations.peakInFlightCalls = Math.max(
5857
+ slot.operations.peakInFlightCalls,
5858
+ slot.operations.inFlightCalls
5859
+ );
5860
+ ctx.recordOperation(slot, "call", "started", void 0, void 0, false);
5861
+ },
5862
+ onFinish: ({ durationMs, ok }, caller) => {
5863
+ const at = caller ? slot.activeCallers?.lastIndexOf(caller) ?? -1 : -1;
5864
+ if (at >= 0) slot.activeCallers?.splice(at, 1);
5865
+ slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
5866
+ slot.lastUsed = Date.now();
5867
+ pushBounded(slot.operations.callSamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
5868
+ if (ok) {
5869
+ ctx.recordSuccess(slot);
5870
+ ctx.recordOperation(slot, "call", "ok", void 0, durationMs, false);
5871
+ } else {
5872
+ ctx.recordFailure(slot, "tool", "tool-call-failed", durationMs);
5873
+ }
5520
5874
  }
5521
5875
  }
5522
- })
5876
+ )
5523
5877
  );
5524
5878
  slot.lazyTools = wrapped;
5525
5879
  slot.toolSignature = signature;
@@ -5544,51 +5898,62 @@ function applySlotTools(ctx, slot, tools, client) {
5544
5898
  }
5545
5899
  async function discoverSlotCapabilities(ctx, slot, client) {
5546
5900
  const startedAt = Date.now();
5547
- slot.serverMetadata = client.getServerMetadata();
5548
- const capabilities = slot.serverMetadata?.capabilities;
5901
+ const versions = { ...slot.catalogVersions };
5902
+ const serverMetadata = client.getServerMetadata();
5903
+ const capabilities = serverMetadata?.capabilities;
5904
+ let resources;
5905
+ let resourceTemplates;
5906
+ let prompts;
5549
5907
  if (capabilities?.resources) {
5550
5908
  try {
5551
- slot.resources = await collectCatalogPages(
5909
+ resources = await collectCatalogPages(
5552
5910
  (cursor) => client.listResources(cursor ? { cursor } : {}),
5553
5911
  (page) => page.resources
5554
5912
  );
5555
5913
  } catch (err) {
5556
- slot.resources = void 0;
5914
+ if (!ownsConnectedSlot(ctx, slot, client)) return;
5557
5915
  ctx.recordFailure(slot, "protocol", "resource-discovery-failed");
5558
5916
  ctx.log.warn(`MCP server "${slot.cfg.name}" resource discovery failed`, err);
5559
5917
  }
5918
+ if (!ownsConnectedSlot(ctx, slot, client)) return;
5560
5919
  try {
5561
- slot.resourceTemplates = await collectCatalogPages(
5920
+ resourceTemplates = await collectCatalogPages(
5562
5921
  (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
5563
5922
  (page) => page.resourceTemplates
5564
5923
  );
5565
5924
  } catch (err) {
5566
- slot.resourceTemplates = void 0;
5925
+ if (!ownsConnectedSlot(ctx, slot, client)) return;
5567
5926
  ctx.recordFailure(slot, "protocol", "resource-template-discovery-failed");
5568
5927
  ctx.log.warn(`MCP server "${slot.cfg.name}" resource template discovery failed`, err);
5569
5928
  }
5570
- } else {
5571
- slot.resources = void 0;
5572
- slot.resourceTemplates = void 0;
5573
5929
  }
5930
+ if (!ownsConnectedSlot(ctx, slot, client)) return;
5574
5931
  if (capabilities?.prompts) {
5575
5932
  try {
5576
- slot.prompts = await collectCatalogPages(
5933
+ prompts = await collectCatalogPages(
5577
5934
  (cursor) => client.listPrompts(cursor ? { cursor } : {}),
5578
5935
  (page) => page.prompts
5579
5936
  );
5580
5937
  } catch (err) {
5581
- slot.prompts = void 0;
5938
+ if (!ownsConnectedSlot(ctx, slot, client)) return;
5582
5939
  ctx.recordFailure(slot, "protocol", "prompt-discovery-failed");
5583
5940
  ctx.log.warn(`MCP server "${slot.cfg.name}" prompt discovery failed`, err);
5584
5941
  }
5585
- } else {
5586
- slot.prompts = void 0;
5587
5942
  }
5943
+ if (!ownsConnectedSlot(ctx, slot, client)) return;
5944
+ slot.serverMetadata = serverMetadata;
5945
+ if (slot.catalogVersions?.resources === versions.resources) slot.resources = resources;
5946
+ if (slot.catalogVersions?.resourceTemplates === versions.resourceTemplates) {
5947
+ slot.resourceTemplates = resourceTemplates;
5948
+ }
5949
+ if (slot.catalogVersions?.prompts === versions.prompts) slot.prompts = prompts;
5588
5950
  const durationMs = Date.now() - startedAt;
5589
5951
  pushBounded(slot.operations.discoverySamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
5590
5952
  ctx.recordOperation(slot, "discover", "complete", void 0, durationMs, false);
5591
5953
  }
5954
+ function ownsConnectedSlot(ctx, slot, client) {
5955
+ return ctx.servers.get(slot.cfg.name) === slot && slot.client === client && slot.state === "connected";
5956
+ }
5592
5957
  async function persistSlotCapabilityManifest(cacheDir, slot) {
5593
5958
  if (!slot.lazy || !cacheDir) return;
5594
5959
  const previous = slot.manifestWrite ?? Promise.resolve();
@@ -5609,9 +5974,11 @@ async function persistSlotCapabilityManifest(cacheDir, slot) {
5609
5974
  }
5610
5975
  async function attemptConnectSlot(ctx, slot) {
5611
5976
  const MAX_ATTEMPTS2 = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
5977
+ const generation = slot.startupGeneration;
5978
+ const isCurrent = () => ctx.servers.get(slot.cfg.name) === slot && slot.startupGeneration === generation;
5612
5979
  let attempt = 0;
5613
5980
  while (attempt < MAX_ATTEMPTS2) {
5614
- if (ctx.servers.get(slot.cfg.name) !== slot) {
5981
+ if (!isCurrent()) {
5615
5982
  return;
5616
5983
  }
5617
5984
  attempt++;
@@ -5645,7 +6012,7 @@ async function attemptConnectSlot(ctx, slot) {
5645
6012
  client.addToolsChangedListener(ctx.onToolsChanged);
5646
6013
  ctx.addCatalogListeners(client);
5647
6014
  await client.connect();
5648
- if (slot.state === "disconnected" || !ctx.servers.has(slot.cfg.name) || ctx.servers.get(slot.cfg.name) !== slot) {
6015
+ if (slot.state === "disconnected" || !isCurrent()) {
5649
6016
  client.removeExitListener(ctx.onChildExit);
5650
6017
  if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
5651
6018
  client.removeToolsChangedListener(ctx.onToolsChanged);
@@ -5673,8 +6040,10 @@ async function attemptConnectSlot(ctx, slot) {
5673
6040
  const discovered = mc.listTools();
5674
6041
  slot.discoveredTools = discovered;
5675
6042
  await discoverSlotCapabilities(ctx, slot, mc);
6043
+ if (!ownsConnectedSlot(ctx, slot, mc)) return;
5676
6044
  await persistSlotCapabilityManifest(ctx.cacheDir, slot);
5677
- applySlotTools(ctx, slot, discovered, mc);
6045
+ if (!ownsConnectedSlot(ctx, slot, mc)) return;
6046
+ applySlotTools(ctx, slot, mc.listTools(), mc);
5678
6047
  const durationMs = Date.now() - startedAt;
5679
6048
  pushBounded(
5680
6049
  slot.operations.connectionSamples,
@@ -5697,8 +6066,6 @@ async function attemptConnectSlot(ctx, slot) {
5697
6066
  });
5698
6067
  return;
5699
6068
  } catch (err) {
5700
- ctx.recordFailure(slot, "transport", "connect-attempt-failed", Date.now() - startedAt);
5701
- ctx.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
5702
6069
  if (client) {
5703
6070
  client.removeExitListener(ctx.onChildExit);
5704
6071
  if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
@@ -5707,6 +6074,9 @@ async function attemptConnectSlot(ctx, slot) {
5707
6074
  await client.close().catch(() => {
5708
6075
  });
5709
6076
  }
6077
+ if (!isCurrent() || slot.state === "disconnected") return;
6078
+ ctx.recordFailure(slot, "transport", "connect-attempt-failed", Date.now() - startedAt);
6079
+ ctx.log.warn(`MCP server "${slot.cfg.name}" connect attempt ${attempt} failed`, err);
5710
6080
  if (attempt >= MAX_ATTEMPTS2) {
5711
6081
  ctx.log.error(
5712
6082
  `MCP server "${slot.cfg.name}" connect exhausted after ${MAX_ATTEMPTS2} attempts`,
@@ -5728,7 +6098,7 @@ async function attemptConnectSlot(ctx, slot) {
5728
6098
  }
5729
6099
  const delay = 500 * 2 ** attempt;
5730
6100
  await new Promise((r) => setTimeout(r, delay));
5731
- if (slot.state === "disconnected" || ctx.servers.get(slot.cfg.name) !== slot) {
6101
+ if (slot.state === "disconnected" || !isCurrent()) {
5732
6102
  return;
5733
6103
  }
5734
6104
  }
@@ -5860,7 +6230,7 @@ async function sleepIdleSlot(ctx, slot) {
5860
6230
  ctx.log.warn(`MCP server "${slot.cfg.name}" error during idle sleep close`, err);
5861
6231
  }
5862
6232
  }
5863
- if (slot.client || slot.state !== "dormant") return;
6233
+ if (ctx.servers.get(slot.cfg.name) !== slot || slot.client || slot.state !== "dormant") return;
5864
6234
  slot.onDisconnect = void 0;
5865
6235
  slot.operations.sleepCount++;
5866
6236
  ctx.recordOperation(slot, "sleep", "idle-timeout");
@@ -6092,7 +6462,9 @@ var MCPRegistry = class _MCPRegistry {
6092
6462
  async startLazy(slot) {
6093
6463
  const cacheDir = expectDefined2(this.cacheDir);
6094
6464
  const hash = manifestConfigHash(slot.cfg);
6465
+ const generation = slot.startupGeneration;
6095
6466
  const cached = await readCapabilityManifest(cacheDir, slot.cfg.name, hash);
6467
+ if (this.servers.get(slot.cfg.name) !== slot || slot.startupGeneration !== generation) return;
6096
6468
  if (cached) {
6097
6469
  slot.serverMetadata = cached.serverMetadata;
6098
6470
  slot.resources = cached.resources;
@@ -6111,15 +6483,15 @@ var MCPRegistry = class _MCPRegistry {
6111
6483
  singleFlightConnect(slot) {
6112
6484
  if (slot.client && slot.state === "connected") return Promise.resolve(slot.client);
6113
6485
  if (slot.connecting) return slot.connecting;
6114
- const promise = (async () => {
6115
- try {
6116
- await this.attemptConnect(slot);
6117
- return slot.client;
6118
- } finally {
6119
- slot.connecting = void 0;
6120
- }
6121
- })();
6486
+ const generation = slot.startupGeneration;
6487
+ const promise = this.attemptConnect(slot).then(
6488
+ () => slot.startupGeneration === generation ? slot.client : void 0
6489
+ );
6122
6490
  slot.connecting = promise;
6491
+ const clearFlight = () => {
6492
+ if (slot.connecting === promise) slot.connecting = void 0;
6493
+ };
6494
+ void promise.then(clearFlight, clearFlight);
6123
6495
  return promise;
6124
6496
  }
6125
6497
  /**
@@ -6217,6 +6589,7 @@ var MCPRegistry = class _MCPRegistry {
6217
6589
  async stop(name) {
6218
6590
  const slot = this.servers.get(name);
6219
6591
  if (!slot) return;
6592
+ slot.startupGeneration = (slot.startupGeneration ?? 0) + 1;
6220
6593
  slot.reconnectPending = false;
6221
6594
  if (slot.reconnectTimer) {
6222
6595
  clearTimeout(slot.reconnectTimer);
@@ -6268,7 +6641,7 @@ var MCPRegistry = class _MCPRegistry {
6268
6641
  }
6269
6642
  if (slot.state === "dormant") return;
6270
6643
  if (slot.operations.inFlightCalls > 0) {
6271
- throw new Error(`MCP server "${name}" has tool calls in flight \u2014 try again when they finish`);
6644
+ throw new Error(`MCP server "${name}" has requests in flight \u2014 try again when they finish`);
6272
6645
  }
6273
6646
  await sleepIdleSlot(this.idleContext(), slot);
6274
6647
  }
@@ -6323,76 +6696,73 @@ var MCPRegistry = class _MCPRegistry {
6323
6696
  return buildRegistryOperationalHealth(this.servers.values(), this.disabledServers.values());
6324
6697
  }
6325
6698
  getCatalog(name) {
6326
- const slot = this.servers.get(name);
6327
- if (slot) {
6328
- return registryCatalogSnapshot(slot);
6329
- }
6330
- const disabled = this.disabledServers.get(name);
6331
- if (disabled) {
6332
- return {
6333
- name: disabled.name,
6334
- state: "idle"
6335
- };
6336
- }
6337
- return void 0;
6699
+ return getCatalog.call(this.registryCatalogOperationsHost(), name);
6338
6700
  }
6339
6701
  async listResources(name, opts = {}) {
6340
- const slot = this.requireSlot(name);
6341
- if (!opts.refresh && slot.resources) return cloneCatalogRecords(slot.resources);
6342
- const client = await this.ensureConnected(name);
6343
- if (!client.getServerMetadata()?.capabilities.resources) return [];
6344
- slot.resources = await collectCatalogPages(
6345
- (cursor) => client.listResources(cursor ? { cursor } : {}),
6346
- (page) => page.resources
6347
- );
6348
- await this.persistCapabilityManifest(slot);
6349
- return cloneCatalogRecords(slot.resources);
6702
+ return listResources.call(this.registryCatalogOperationsHost(), name, opts);
6350
6703
  }
6351
6704
  async listResourceTemplates(name, opts = {}) {
6352
- const slot = this.requireSlot(name);
6353
- if (!opts.refresh && slot.resourceTemplates) return cloneCatalogRecords(slot.resourceTemplates);
6354
- const client = await this.ensureConnected(name);
6355
- if (!client.getServerMetadata()?.capabilities.resources) return [];
6356
- slot.resourceTemplates = await collectCatalogPages(
6357
- (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
6358
- (page) => page.resourceTemplates
6359
- );
6360
- await this.persistCapabilityManifest(slot);
6361
- return cloneCatalogRecords(slot.resourceTemplates);
6705
+ return listResourceTemplates.call(this.registryCatalogOperationsHost(), name, opts);
6362
6706
  }
6363
6707
  async readResource(name, uri) {
6364
- return (await this.ensureConnected(name)).readResource(uri);
6708
+ return readResource.call(this.registryCatalogOperationsHost(), name, uri);
6365
6709
  }
6366
6710
  async selectResourceForInsertion(name, uri, policy) {
6367
- return prepareResourceInsertion(name, uri, await this.readResource(name, uri), policy);
6711
+ return selectResourceForInsertion.call(
6712
+ this.registryCatalogOperationsHost(),
6713
+ name,
6714
+ uri,
6715
+ policy
6716
+ );
6368
6717
  }
6369
6718
  async subscribeResource(name, uri) {
6370
- await (await this.ensureConnected(name)).subscribeResource(uri);
6719
+ return subscribeResource.call(this.registryCatalogOperationsHost(), name, uri);
6371
6720
  }
6372
6721
  async unsubscribeResource(name, uri) {
6373
- await (await this.ensureConnected(name)).unsubscribeResource(uri);
6722
+ return unsubscribeResource.call(this.registryCatalogOperationsHost(), name, uri);
6374
6723
  }
6375
6724
  async listPrompts(name, opts = {}) {
6376
- const slot = this.requireSlot(name);
6377
- if (!opts.refresh && slot.prompts) return cloneCatalogRecords(slot.prompts);
6378
- const client = await this.ensureConnected(name);
6379
- if (!client.getServerMetadata()?.capabilities.prompts) return [];
6380
- slot.prompts = await collectCatalogPages(
6381
- (cursor) => client.listPrompts(cursor ? { cursor } : {}),
6382
- (page) => page.prompts
6383
- );
6384
- await this.persistCapabilityManifest(slot);
6385
- return cloneCatalogRecords(slot.prompts);
6725
+ return listPrompts.call(this.registryCatalogOperationsHost(), name, opts);
6386
6726
  }
6387
6727
  async getPrompt(serverName, promptName, args) {
6388
- return (await this.ensureConnected(serverName)).getPrompt(promptName, args);
6728
+ return getPrompt.call(
6729
+ this.registryCatalogOperationsHost(),
6730
+ serverName,
6731
+ promptName,
6732
+ args
6733
+ );
6734
+ }
6735
+ /** Keep all remote requests awake and reject results from superseded clients. */
6736
+ async withConnectedClient(name, run) {
6737
+ const slot = this.requireSlot(name);
6738
+ const generation = slot.startupGeneration;
6739
+ slot.operations.inFlightCalls++;
6740
+ slot.operations.peakInFlightCalls = Math.max(
6741
+ slot.operations.peakInFlightCalls,
6742
+ slot.operations.inFlightCalls
6743
+ );
6744
+ try {
6745
+ const client = await this.ensureConnected(name);
6746
+ const assertCurrent = () => {
6747
+ if (this.servers.get(name) !== slot || slot.startupGeneration !== generation || slot.client !== client || slot.state !== "connected") {
6748
+ throw new Error(`MCP server "${name}" connection changed during request`);
6749
+ }
6750
+ };
6751
+ assertCurrent();
6752
+ const result = await run(client, assertCurrent);
6753
+ assertCurrent();
6754
+ return result;
6755
+ } finally {
6756
+ slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
6757
+ if (slot.startupGeneration === generation) slot.lastUsed = Date.now();
6758
+ }
6389
6759
  }
6390
6760
  async selectPromptForInsertion(serverName, promptName, args, policy) {
6391
- return preparePromptInsertion(
6761
+ return selectPromptForInsertion.call(
6762
+ this.registryCatalogOperationsHost(),
6392
6763
  serverName,
6393
6764
  promptName,
6394
6765
  args,
6395
- await this.getPrompt(serverName, promptName, args),
6396
6766
  policy
6397
6767
  );
6398
6768
  }
@@ -6535,6 +6905,8 @@ var MCPRegistry = class _MCPRegistry {
6535
6905
  onResourcesChanged = (name) => {
6536
6906
  const slot = this.servers.get(name);
6537
6907
  if (!slot) return;
6908
+ advanceCatalogVersion(slot, "resources");
6909
+ advanceCatalogVersion(slot, "resourceTemplates");
6538
6910
  slot.resources = void 0;
6539
6911
  slot.resourceTemplates = void 0;
6540
6912
  void this.persistCapabilityManifest(slot);
@@ -6547,6 +6919,7 @@ var MCPRegistry = class _MCPRegistry {
6547
6919
  onPromptsChanged = (name) => {
6548
6920
  const slot = this.servers.get(name);
6549
6921
  if (!slot) return;
6922
+ advanceCatalogVersion(slot, "prompts");
6550
6923
  slot.prompts = void 0;
6551
6924
  void this.persistCapabilityManifest(slot);
6552
6925
  this.log.info(`MCP server "${name}" prompt catalog invalidated`);
@@ -6640,12 +7013,20 @@ var MCPRegistry = class _MCPRegistry {
6640
7013
  async attemptConnect(slot) {
6641
7014
  return attemptConnectSlot(this.connectContext(), slot);
6642
7015
  }
7016
+ registryCatalogOperationsHost() {
7017
+ void this.servers;
7018
+ void this.disabledServers;
7019
+ void this.requireSlot;
7020
+ void this.withConnectedClient;
7021
+ void this.persistCapabilityManifest;
7022
+ void this.readResource;
7023
+ void this.getPrompt;
7024
+ return this;
7025
+ }
6643
7026
  };
6644
7027
 
6645
- // src/server.ts
6646
- import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
6647
- import { createServer as createServer2 } from "node:http";
6648
- import { expectDefined as expectDefined3, toErrorMessage as toErrorMessage2, validateAgainstSchema as validateAgainstSchema2 } from "@wrongstack/core/utils";
7028
+ // src/server-dispatch.ts
7029
+ import { expectDefined as expectDefined3, toErrorMessage as toErrorMessage3, validateAgainstSchema as validateAgainstSchema2 } from "@wrongstack/core/utils";
6649
7030
  var PARSE_ERROR = -32700;
6650
7031
  var INVALID_REQUEST = -32600;
6651
7032
  var METHOD_NOT_FOUND = -32601;
@@ -6711,7 +7092,7 @@ var MCPServer = class {
6711
7092
  }
6712
7093
  return JSON.stringify({ jsonrpc: "2.0", id: msg.id, result });
6713
7094
  } catch (err) {
6714
- const message = toErrorMessage2(err);
7095
+ const message = toErrorMessage3(err);
6715
7096
  this.logger?.warn?.(`MCP server: method "${msg.method}" threw: ${message}`);
6716
7097
  const code = err instanceof InvalidToolArgumentsError ? INVALID_PARAMS : INTERNAL_ERROR;
6717
7098
  return this.encodeError(requestId, code, message);
@@ -6932,6 +7313,14 @@ function stringifyItem(c) {
6932
7313
  return String(c);
6933
7314
  }
6934
7315
  }
7316
+
7317
+ // src/server-http.ts
7318
+ import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
7319
+ import { createServer as createServer2 } from "node:http";
7320
+ import { toErrorMessage as toErrorMessage5 } from "@wrongstack/core/utils";
7321
+
7322
+ // src/server-stdio.ts
7323
+ import { toErrorMessage as toErrorMessage4 } from "@wrongstack/core/utils";
6935
7324
  function serveStdio(server, opts = {}) {
6936
7325
  const stdin = opts.stdin ?? process.stdin;
6937
7326
  const stdout = opts.stdout ?? process.stdout;
@@ -6949,7 +7338,7 @@ function serveStdio(server, opts = {}) {
6949
7338
  `, () => resolve());
6950
7339
  })
6951
7340
  ).catch((err) => {
6952
- const msg = toErrorMessage2(err);
7341
+ const msg = toErrorMessage4(err);
6953
7342
  console.error(
6954
7343
  JSON.stringify({
6955
7344
  level: "error",
@@ -7001,7 +7390,7 @@ function serveStdio(server, opts = {}) {
7001
7390
  JSON.stringify({
7002
7391
  level: "error",
7003
7392
  event: "mcp_server.handle_message_failed",
7004
- message: toErrorMessage2(err),
7393
+ message: toErrorMessage4(err),
7005
7394
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7006
7395
  })
7007
7396
  );
@@ -7036,7 +7425,7 @@ function serveStdio(server, opts = {}) {
7036
7425
  JSON.stringify({
7037
7426
  level: "error",
7038
7427
  event: "mcp_server.handle_message_failed",
7039
- message: toErrorMessage2(err),
7428
+ message: toErrorMessage4(err),
7040
7429
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
7041
7430
  })
7042
7431
  );
@@ -7061,6 +7450,8 @@ function serveStdio(server, opts = {}) {
7061
7450
  };
7062
7451
  }
7063
7452
  var HTTP_BODY_CAP = 4 * 1024 * 1024;
7453
+
7454
+ // src/server-http.ts
7064
7455
  function isLoopbackHost(host) {
7065
7456
  return host === "127.0.0.1" || host === "::1" || host === "localhost";
7066
7457
  }
@@ -7097,7 +7488,7 @@ function serveHttp(server, opts = {}) {
7097
7488
  });
7098
7489
  }
7099
7490
  async function handleHttpRequest(server, req, res, token, log, boundHost) {
7100
- const send = (status, body2, type = "application/json") => {
7491
+ const send = (status, body, type = "application/json") => {
7101
7492
  res.writeHead(status, {
7102
7493
  "content-type": type,
7103
7494
  // This endpoint executes tools. Nothing about it should be embedded,
@@ -7108,7 +7499,7 @@ async function handleHttpRequest(server, req, res, token, log, boundHost) {
7108
7499
  // No CORS headers are ever emitted: a cross-origin reader must not be
7109
7500
  // able to see a response even if it manages to send the request.
7110
7501
  });
7111
- res.end(body2);
7502
+ res.end(body);
7112
7503
  };
7113
7504
  if (!originIsAcceptable(req, boundHost)) {
7114
7505
  return send(403, JSON.stringify({ error: "cross-origin forbidden" }));
@@ -7128,13 +7519,13 @@ async function handleHttpRequest(server, req, res, token, log, boundHost) {
7128
7519
  if (!isJsonContentType(req.headers["content-type"])) {
7129
7520
  return send(415, JSON.stringify({ error: "content-type must be application/json" }));
7130
7521
  }
7131
- let body = "";
7522
+ const chunks = [];
7132
7523
  let bodyBytes = 0;
7133
7524
  let aborted = false;
7134
7525
  req.on("data", (chunk) => {
7135
7526
  if (aborted) return;
7136
7527
  bodyBytes += chunk.byteLength;
7137
- body += chunk.toString("utf8");
7528
+ chunks.push(chunk);
7138
7529
  if (bodyBytes > HTTP_BODY_CAP) {
7139
7530
  aborted = true;
7140
7531
  send(413, JSON.stringify({ error: "payload too large" }));
@@ -7143,11 +7534,12 @@ async function handleHttpRequest(server, req, res, token, log, boundHost) {
7143
7534
  });
7144
7535
  req.on("end", () => {
7145
7536
  if (aborted) return;
7537
+ const body = Buffer.concat(chunks, bodyBytes).toString("utf8");
7146
7538
  void server.handleMessage(body).then((out) => {
7147
7539
  if (out === null) return send(202, "");
7148
7540
  return send(200, out);
7149
7541
  }).catch((err) => {
7150
- log?.warn?.(`MCP http handler error: ${toErrorMessage2(err)}`);
7542
+ log?.warn?.(`MCP http handler error: ${toErrorMessage5(err)}`);
7151
7543
  send(500, JSON.stringify({ error: "internal error" }));
7152
7544
  });
7153
7545
  });
@@ -7451,7 +7843,7 @@ function emptyFile() {
7451
7843
  return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
7452
7844
  }
7453
7845
  function validateStoreFile(value) {
7454
- if (!isRecord2(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
7846
+ if (!isRecord3(value) || value["version"] !== TOKEN_STORE_VERSION || !Array.isArray(value["entries"])) {
7455
7847
  throw new Error("MCP token store has an unsupported or malformed structure");
7456
7848
  }
7457
7849
  if (value["entries"].length > MAX_ENTRIES)
@@ -7463,7 +7855,7 @@ function validateStoreFile(value) {
7463
7855
  };
7464
7856
  }
7465
7857
  function validateEncryptedEntry(value) {
7466
- if (!isRecord2(value)) throw new Error("MCP token store entry must be an object");
7858
+ if (!isRecord3(value)) throw new Error("MCP token store entry must be an object");
7467
7859
  const resource = canonicalMcpResource(boundedString(value["resource"], "resource", 4096));
7468
7860
  const authorizationServer = validateMcpAuthorizationServerMetadata(value["authorizationServer"]);
7469
7861
  const scopes = stringArray(value["scopes"], "scopes", 128);
@@ -7508,7 +7900,7 @@ function normalizeStoredAuthorization(value) {
7508
7900
  updatedAt: boundedString(value.updatedAt, "updatedAt", 128)
7509
7901
  };
7510
7902
  }
7511
- function isRecord2(value) {
7903
+ function isRecord3(value) {
7512
7904
  return typeof value === "object" && value !== null && !Array.isArray(value);
7513
7905
  }
7514
7906
  function boundedString(value, field, maxLength) {