@wrongstack/mcp 1.0.10 → 1.0.12

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
@@ -5,6 +5,14 @@ import * as http from "node:http";
5
5
  import * as https from "node:https";
6
6
  import * as net from "node:net";
7
7
  import { isPrivateIPv4, isPrivateIPv6 } from "@wrongstack/core/utils";
8
+ var MCPOAuthHttpError = class extends Error {
9
+ constructor(message, status) {
10
+ super(message);
11
+ this.status = status;
12
+ }
13
+ status;
14
+ name = "MCPOAuthHttpError";
15
+ };
8
16
  function canonicalMcpResource(rawUrl) {
9
17
  let url;
10
18
  try {
@@ -299,7 +307,8 @@ async function exchangeMcpAuthorizationCode(options) {
299
307
  timeoutMs: options.timeoutMs,
300
308
  maxResponseBytes: options.maxResponseBytes,
301
309
  lookup: options.lookup,
302
- allowedLoopbackHostname: loopbackHostnameForResource(resource)
310
+ allowedLoopbackHostname: loopbackHostnameForResource(resource),
311
+ label: "token endpoint"
303
312
  });
304
313
  if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
305
314
  return parseTokenResponse(response, resource);
@@ -321,7 +330,8 @@ async function refreshMcpAccessToken(options) {
321
330
  timeoutMs: options.timeoutMs,
322
331
  maxResponseBytes: options.maxResponseBytes,
323
332
  lookup: options.lookup,
324
- allowedLoopbackHostname: loopbackHostnameForResource(resource)
333
+ allowedLoopbackHostname: loopbackHostnameForResource(resource),
334
+ label: "token endpoint"
325
335
  });
326
336
  if (response === void 0) throw new Error("MCP OAuth token endpoint returned no response");
327
337
  const parsed = parseTokenResponse(response, resource);
@@ -412,7 +422,8 @@ async function discoverFirst(candidates, fetchJson, signal, parse, label) {
412
422
  throw new Error(`MCP ${label} discovery failed (${failures.join("; ")})`);
413
423
  }
414
424
  async function requestPinnedJson(rawUrl, options) {
415
- const url = secureOAuthUrl(rawUrl, "discovery URL");
425
+ const label = options.label ?? "discovery";
426
+ const url = secureOAuthUrl(rawUrl, `${label} URL`);
416
427
  const target = await resolvePinnedAddress(url, options);
417
428
  const timeoutMs = options.timeoutMs ?? 1e4;
418
429
  const maxBytes = options.maxResponseBytes ?? 64 * 1024;
@@ -456,12 +467,12 @@ async function requestPinnedJson(rawUrl, options) {
456
467
  }
457
468
  if (status >= 300 && status < 400) {
458
469
  response.resume();
459
- finish(new Error("MCP OAuth discovery redirects are not allowed"));
470
+ finish(new Error(`MCP OAuth ${label} redirects are not allowed`));
460
471
  return;
461
472
  }
462
473
  if (status < 200 || status >= 300) {
463
474
  response.resume();
464
- finish(new Error(`MCP OAuth discovery HTTP ${status}`));
475
+ finish(new MCPOAuthHttpError(`MCP OAuth ${label} HTTP ${status}`, status));
465
476
  return;
466
477
  }
467
478
  const contentType = response.headers["content-type"] ?? "";
@@ -817,6 +828,7 @@ function boundedServerName(value) {
817
828
 
818
829
  // src/client.ts
819
830
  import { spawn as spawn2 } from "node:child_process";
831
+ import { StringDecoder } from "node:string_decoder";
820
832
  import { buildChildEnv as buildChildEnv2, buildWin32CmdShimInvocation, toErrorMessage } from "@wrongstack/core/utils";
821
833
 
822
834
  // src/client-process.ts
@@ -1127,6 +1139,34 @@ function parseGetPromptResult(value) {
1127
1139
  }
1128
1140
 
1129
1141
  // src/tool-schema.ts
1142
+ var MAX_TOOL_PAGES = 100;
1143
+ var MAX_TOOLS = 1e4;
1144
+ async function listAllTools(requestPage) {
1145
+ const tools = [];
1146
+ const seenCursors = /* @__PURE__ */ new Set();
1147
+ let cursor;
1148
+ for (let page = 0; page < MAX_TOOL_PAGES; page++) {
1149
+ let response;
1150
+ try {
1151
+ response = await requestPage(cursor ? { cursor } : {});
1152
+ } catch (err) {
1153
+ if (page === 0) throw err;
1154
+ break;
1155
+ }
1156
+ if (response.error) {
1157
+ if (page === 0) return null;
1158
+ break;
1159
+ }
1160
+ const result = response.result;
1161
+ tools.push(...normalizeMCPTools(result?.tools));
1162
+ const next = result?.nextCursor;
1163
+ if (typeof next !== "string" || next.length === 0) break;
1164
+ if (seenCursors.has(next) || tools.length >= MAX_TOOLS) break;
1165
+ seenCursors.add(next);
1166
+ cursor = next;
1167
+ }
1168
+ return tools.slice(0, MAX_TOOLS);
1169
+ }
1130
1170
  function normalizeMCPTools(value) {
1131
1171
  if (!Array.isArray(value)) return [];
1132
1172
  const tools = [];
@@ -1164,6 +1204,20 @@ var SSE_READER_MAX_DATA_LINES = 1024;
1164
1204
  var SSEReader = class {
1165
1205
  buffer = "";
1166
1206
  dataLines = [];
1207
+ eventName = "";
1208
+ endpointListeners = [];
1209
+ /**
1210
+ * Legacy HTTP+SSE transport: the server's first event is
1211
+ * `event: endpoint` whose data is the (relative) URL to POST requests to.
1212
+ * Its payload is a URL, not JSON, so it is dispatched separately.
1213
+ */
1214
+ onEndpoint(cb) {
1215
+ this.endpointListeners.push(cb);
1216
+ return () => {
1217
+ const idx = this.endpointListeners.indexOf(cb);
1218
+ if (idx >= 0) this.endpointListeners.splice(idx, 1);
1219
+ };
1220
+ }
1167
1221
  listeners = [];
1168
1222
  onMessage(cb) {
1169
1223
  this.listeners.push(cb);
@@ -1216,6 +1270,7 @@ var SSEReader = class {
1216
1270
  let value = colonIdx === -1 ? "" : line.slice(colonIdx + 1);
1217
1271
  if (value.startsWith(" ")) value = value.slice(1);
1218
1272
  if (field === "event") {
1273
+ this.eventName = value;
1219
1274
  } else if (field === "data") {
1220
1275
  if (this.dataLines.length >= SSE_READER_MAX_DATA_LINES) {
1221
1276
  throw new ToolError({
@@ -1233,12 +1288,23 @@ var SSEReader = class {
1233
1288
  }
1234
1289
  }
1235
1290
  flush() {
1291
+ const eventName = this.eventName;
1292
+ this.eventName = "";
1236
1293
  if (this.dataLines.length === 0) {
1237
1294
  return;
1238
1295
  }
1239
1296
  const data = this.dataLines.join("\n").trim();
1240
1297
  this.dataLines = [];
1241
1298
  if (!data) return;
1299
+ if (eventName === "endpoint") {
1300
+ for (const cb of this.endpointListeners) {
1301
+ try {
1302
+ cb(data);
1303
+ } catch {
1304
+ }
1305
+ }
1306
+ return;
1307
+ }
1242
1308
  try {
1243
1309
  const parsed = JSON.parse(data);
1244
1310
  this.dispatch(parsed);
@@ -1256,7 +1322,9 @@ var SSEReader = class {
1256
1322
  reset() {
1257
1323
  this.buffer = "";
1258
1324
  this.dataLines = [];
1325
+ this.eventName = "";
1259
1326
  this.listeners = [];
1327
+ this.endpointListeners = [];
1260
1328
  }
1261
1329
  };
1262
1330
 
@@ -1688,6 +1756,11 @@ var BaseHTTPTransport = class {
1688
1756
 
1689
1757
  // src/transport-jsonrpc.ts
1690
1758
  import { ToolError as ToolError2 } from "@wrongstack/core/types";
1759
+ function encodeJsonRpcMessage(id, method, params) {
1760
+ return JSON.stringify(
1761
+ method.startsWith("notifications/") ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", id, method, params }
1762
+ );
1763
+ }
1691
1764
  function isJsonRpcResult(v) {
1692
1765
  if (typeof v !== "object" || v === null) return false;
1693
1766
  const r = v;
@@ -1813,11 +1886,20 @@ async function readBodyCapped(res, maxBytes = MAX_MCP_HTTP_BODY_BYTES) {
1813
1886
  }
1814
1887
 
1815
1888
  // src/transport-sse.ts
1889
+ var ENDPOINT_WAIT_MS = 1e3;
1890
+ var SESSION_FATAL_HTTP_STATUSES = /* @__PURE__ */ new Set([401, 403, 404, 410]);
1816
1891
  var SSETransport = class extends BaseHTTPTransport {
1817
1892
  _nextId = 1;
1818
1893
  readerDone = false;
1894
+ closed = false;
1819
1895
  readLoopAbort;
1820
1896
  reader;
1897
+ /** POST target announced by the server's `endpoint` event. */
1898
+ endpointUrl;
1899
+ /** Wakes connect() once the endpoint is known (or will not arrive). */
1900
+ streamSignal;
1901
+ /** Requests whose response is expected over the event stream, keyed by id. */
1902
+ streamPending = /* @__PURE__ */ new Map();
1821
1903
  constructor(opts) {
1822
1904
  super(opts, "SSETransport");
1823
1905
  }
@@ -1829,18 +1911,13 @@ var SSETransport = class extends BaseHTTPTransport {
1829
1911
  /** Refresh tool list when server sends notifications/tools/list_changed. */
1830
1912
  async handleToolsListChanged() {
1831
1913
  try {
1832
- const res = await this.httpPost("tools/list", {});
1833
- if (!res.error) {
1834
- this.tools.splice(
1835
- 0,
1836
- this.tools.length,
1837
- ...normalizeMCPTools(res.result?.tools)
1838
- );
1839
- for (const cb of this.toolsChangedListeners) {
1840
- try {
1841
- cb([...this.tools]);
1842
- } catch {
1843
- }
1914
+ const tools = await listAllTools((params) => this.httpPost("tools/list", params));
1915
+ if (!tools) return;
1916
+ this.tools.splice(0, this.tools.length, ...tools);
1917
+ for (const cb of this.toolsChangedListeners) {
1918
+ try {
1919
+ cb([...this.tools]);
1920
+ } catch {
1844
1921
  }
1845
1922
  }
1846
1923
  } catch {
@@ -1848,6 +1925,8 @@ var SSETransport = class extends BaseHTTPTransport {
1848
1925
  }
1849
1926
  async connect() {
1850
1927
  this.readerDone = false;
1928
+ this.closed = false;
1929
+ this.endpointUrl = void 0;
1851
1930
  this.state = "connecting";
1852
1931
  this.serverMetadata = void 0;
1853
1932
  this.abortController = new AbortController();
@@ -1856,7 +1935,7 @@ var SSETransport = class extends BaseHTTPTransport {
1856
1935
  try {
1857
1936
  const sseUrl = this.buildSSEUrl();
1858
1937
  const fetchOpts = {
1859
- headers: this.headers,
1938
+ headers: { Accept: "text/event-stream", ...this.headers },
1860
1939
  signal
1861
1940
  };
1862
1941
  this.applyTlsAgent(fetchOpts);
@@ -1880,7 +1959,15 @@ var SSETransport = class extends BaseHTTPTransport {
1880
1959
  const textDecoder = new TextDecoder();
1881
1960
  const sseReader = new SSEReader();
1882
1961
  this.readLoopAbort = new AbortController();
1962
+ const streamReady = new Promise((resolve) => {
1963
+ this.streamSignal = resolve;
1964
+ });
1965
+ sseReader.onEndpoint((endpoint) => {
1966
+ this.acceptEndpoint(endpoint);
1967
+ this.streamSignal?.();
1968
+ });
1883
1969
  sseReader.onMessage((msg) => {
1970
+ this.streamSignal?.();
1884
1971
  if (msg.method && !msg.id) {
1885
1972
  if (msg.method === "notifications/tools/list_changed") {
1886
1973
  void this.handleToolsListChanged();
@@ -1889,6 +1976,14 @@ var SSETransport = class extends BaseHTTPTransport {
1889
1976
  } else if (msg.method === "notifications/prompts/list_changed") {
1890
1977
  this.notifyPromptsChanged();
1891
1978
  }
1979
+ return;
1980
+ }
1981
+ if (!msg.method && isJsonRpcResult(msg)) {
1982
+ const pending = this.streamPending.get(msg.id);
1983
+ if (pending) {
1984
+ this.streamPending.delete(msg.id);
1985
+ pending.resolve(msg);
1986
+ }
1892
1987
  }
1893
1988
  });
1894
1989
  const reader = response.body.getReader();
@@ -1896,10 +1991,13 @@ var SSETransport = class extends BaseHTTPTransport {
1896
1991
  cancel: () => reader.cancel(),
1897
1992
  releaseLock: () => reader.releaseLock()
1898
1993
  };
1899
- this.readSSEBody(reader, textDecoder, sseReader);
1994
+ void this.readSSEBody(reader, textDecoder, sseReader);
1995
+ await this.waitForStream(streamReady, signal);
1900
1996
  const initRes = await this.httpPost("initialize", {
1901
1997
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
1902
- capabilities: { tools: {} },
1998
+ // Client capabilities (roots/sampling/elicitation) none are offered.
1999
+ // `tools` is a SERVER capability and never belonged here.
2000
+ capabilities: {},
1903
2001
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
1904
2002
  });
1905
2003
  if (initRes.error) {
@@ -1916,13 +2014,8 @@ var SSETransport = class extends BaseHTTPTransport {
1916
2014
  await this.httpPost("notifications/initialized", {});
1917
2015
  } catch {
1918
2016
  }
1919
- const toolsRes = await this.httpPost("tools/list", {});
1920
- if (toolsRes.error) {
1921
- this.tools.splice(0, this.tools.length);
1922
- } else {
1923
- const result = toolsRes.result;
1924
- this.tools.splice(0, this.tools.length, ...normalizeMCPTools(result?.tools));
1925
- }
2017
+ const tools = await listAllTools((params) => this.httpPost("tools/list", params));
2018
+ this.tools.splice(0, this.tools.length, ...tools ?? []);
1926
2019
  this.state = "connected";
1927
2020
  clearTimeout(startupTimer);
1928
2021
  } catch (err) {
@@ -1930,6 +2023,43 @@ var SSETransport = class extends BaseHTTPTransport {
1930
2023
  this.state = "failed";
1931
2024
  this.abortController.abort();
1932
2025
  throw err;
2026
+ } finally {
2027
+ this.streamSignal = void 0;
2028
+ }
2029
+ }
2030
+ /** Resolve once the stream announced its endpoint, emitted anything, ended, or the cap passed. */
2031
+ async waitForStream(ready, signal) {
2032
+ let timer;
2033
+ let onAbort;
2034
+ try {
2035
+ await Promise.race([
2036
+ ready,
2037
+ new Promise((resolve) => {
2038
+ timer = setTimeout(resolve, Math.min(ENDPOINT_WAIT_MS, this.timeout));
2039
+ timer.unref?.();
2040
+ }),
2041
+ new Promise((resolve) => {
2042
+ onAbort = resolve;
2043
+ signal.addEventListener("abort", onAbort, { once: true });
2044
+ })
2045
+ ]);
2046
+ } finally {
2047
+ clearTimeout(timer);
2048
+ if (onAbort) signal.removeEventListener("abort", onAbort);
2049
+ }
2050
+ }
2051
+ /**
2052
+ * Adopt the server's POST endpoint. It must stay on the configured origin:
2053
+ * a cross-origin endpoint would move every request — Authorization header
2054
+ * included — to a host that never passed configuration review.
2055
+ */
2056
+ acceptEndpoint(endpoint) {
2057
+ try {
2058
+ const next = new URL(endpoint, this.url);
2059
+ if (next.origin !== new URL(this.url).origin) return;
2060
+ validateTransportUrl(next.toString());
2061
+ this.endpointUrl = next.toString();
2062
+ } catch {
1933
2063
  }
1934
2064
  }
1935
2065
  async readSSEBody(reader, decoder, sseReader) {
@@ -1942,12 +2072,21 @@ var SSETransport = class extends BaseHTTPTransport {
1942
2072
  }
1943
2073
  } catch {
1944
2074
  } finally {
2075
+ this.streamSignal?.();
2076
+ this.rejectStreamPending("SSE stream closed");
1945
2077
  if (!this.readerDone && this.state !== "disconnected" && this.state !== "failed") {
1946
2078
  this.state = "disconnected";
1947
2079
  this.notifyDisconnect();
1948
2080
  }
1949
2081
  }
1950
2082
  }
2083
+ rejectStreamPending(reason) {
2084
+ if (this.streamPending.size === 0) return;
2085
+ const err = new Error(`MCP "${this.name}": ${reason}`);
2086
+ const pending = [...this.streamPending.values()];
2087
+ this.streamPending.clear();
2088
+ for (const entry of pending) entry.reject(err);
2089
+ }
1951
2090
  buildSSEUrl() {
1952
2091
  try {
1953
2092
  const url = new URL(this.url);
@@ -1957,12 +2096,37 @@ var SSETransport = class extends BaseHTTPTransport {
1957
2096
  return this.url;
1958
2097
  }
1959
2098
  }
1960
- async httpPost(method, params, opts) {
2099
+ /** Register interest in a stream-delivered response before the POST goes out. */
2100
+ awaitStreamResponse(id, signal) {
2101
+ const promise = new Promise((resolve, reject) => {
2102
+ const onAbort = () => reject(signal.reason instanceof Error ? signal.reason : new Error("MCP request aborted"));
2103
+ this.streamPending.set(id, {
2104
+ resolve: (result) => {
2105
+ signal.removeEventListener("abort", onAbort);
2106
+ resolve(result);
2107
+ },
2108
+ reject: (err) => {
2109
+ signal.removeEventListener("abort", onAbort);
2110
+ reject(err);
2111
+ }
2112
+ });
2113
+ if (signal.aborted) onAbort();
2114
+ else signal.addEventListener("abort", onAbort, { once: true });
2115
+ });
2116
+ promise.catch(() => void 0);
2117
+ return promise;
2118
+ }
2119
+ httpPost(method, params, opts) {
2120
+ return this.postJsonRpc(method, params, this.requestTimeout, opts);
2121
+ }
2122
+ async postJsonRpc(method, params, timeoutMs, opts) {
1961
2123
  const id = this.genId();
1962
- const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
2124
+ const isNotification = method.startsWith("notifications/");
2125
+ const body = encodeJsonRpcMessage(id, method, params);
1963
2126
  const external = opts?.signal;
1964
2127
  const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
1965
- const timeoutSignal = createTimeoutSignal(parent, this.requestTimeout);
2128
+ const timeoutSignal = createTimeoutSignal(parent, timeoutMs);
2129
+ const streamed = isNotification ? void 0 : this.awaitStreamResponse(id, timeoutSignal.signal);
1966
2130
  const fetchOpts = {
1967
2131
  method: "POST",
1968
2132
  headers: {
@@ -1974,8 +2138,19 @@ var SSETransport = class extends BaseHTTPTransport {
1974
2138
  };
1975
2139
  this.applyTlsAgent(fetchOpts);
1976
2140
  try {
1977
- const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
2141
+ let res;
2142
+ try {
2143
+ res = await this.fetchWithAuthorization(
2144
+ this.endpointUrl ?? this.url,
2145
+ fetchOpts,
2146
+ timeoutSignal.signal
2147
+ );
2148
+ } catch (err) {
2149
+ if (!timeoutSignal.signal.aborted) this.markDisconnected();
2150
+ throw err;
2151
+ }
1978
2152
  if (!res.ok) {
2153
+ if (SESSION_FATAL_HTTP_STATUSES.has(res.status)) this.markDisconnected();
1979
2154
  let snippet;
1980
2155
  try {
1981
2156
  snippet = await readBodyCapped(res, MCP_CONSTANTS.REQUEST_LOG_CAP);
@@ -1990,25 +2165,28 @@ var SSETransport = class extends BaseHTTPTransport {
1990
2165
  context: { transport: "sse", url: this.url, status: res.status }
1991
2166
  });
1992
2167
  }
1993
- if (method.startsWith("notifications/")) {
2168
+ if (isNotification || !streamed) {
1994
2169
  await readBodyCapped(res).catch(() => void 0);
1995
2170
  return { jsonrpc: "2.0", id };
1996
2171
  }
2172
+ let text;
2173
+ try {
2174
+ text = await readBodyCapped(res);
2175
+ } catch (err) {
2176
+ throw invalidResponse(method, this.url, err);
2177
+ }
2178
+ if (res.status === 202 || text.trim() === "") {
2179
+ return await streamed;
2180
+ }
1997
2181
  let data;
1998
2182
  try {
1999
- data = JSON.parse(await readBodyCapped(res));
2183
+ data = JSON.parse(text);
2000
2184
  } catch (err) {
2001
- throw new ToolError4({
2002
- message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
2003
- code: "TOOL_EXECUTION_FAILED",
2004
- toolName: method,
2005
- context: { transport: "sse", url: this.url, phase: "parse-json" },
2006
- cause: err
2007
- });
2185
+ throw invalidResponse(method, this.url, err);
2008
2186
  }
2009
2187
  return assertMatchingJsonRpcResult(data, id, method);
2010
2188
  } catch (err) {
2011
- if (external?.aborted && !method.startsWith("notifications/")) {
2189
+ if (external?.aborted && !isNotification) {
2012
2190
  void this.httpPost("notifications/cancelled", {
2013
2191
  requestId: id,
2014
2192
  reason: "client aborted"
@@ -2016,9 +2194,13 @@ var SSETransport = class extends BaseHTTPTransport {
2016
2194
  });
2017
2195
  throw makeAbortError(method);
2018
2196
  }
2019
- this.markDisconnected();
2020
2197
  throw err;
2021
2198
  } finally {
2199
+ const pending = this.streamPending.get(id);
2200
+ if (pending) {
2201
+ this.streamPending.delete(id);
2202
+ pending.reject(new Error("MCP request settled"));
2203
+ }
2022
2204
  timeoutSignal.dispose();
2023
2205
  }
2024
2206
  }
@@ -2043,73 +2225,16 @@ var SSETransport = class extends BaseHTTPTransport {
2043
2225
  }
2044
2226
  /** Generic JSON-RPC request — used by MCPClient.request() for SSE transports. */
2045
2227
  async request(method, params, timeoutMs, opts) {
2046
- const id = this.genId();
2047
- const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
2048
- const external = opts?.signal;
2049
- const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
2050
- const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
2051
- const fetchOpts = {
2052
- method: "POST",
2053
- headers: {
2054
- "Content-Type": "application/json",
2055
- ...this.headers
2056
- },
2057
- body,
2058
- signal: timeoutSignal.signal
2059
- };
2060
- this.applyTlsAgent(fetchOpts);
2061
- try {
2062
- const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
2063
- if (!res.ok) {
2064
- throw new ToolError4({
2065
- message: `HTTP ${res.status}: ${res.statusText}`,
2066
- code: "TOOL_EXECUTION_FAILED",
2067
- toolName: method,
2068
- context: {
2069
- transport: "sse",
2070
- url: this.url,
2071
- status: res.status,
2072
- statusText: res.statusText
2073
- }
2074
- });
2075
- }
2076
- if (method.startsWith("notifications/")) {
2077
- await readBodyCapped(res).catch(() => void 0);
2078
- return { jsonrpc: "2.0", id };
2079
- }
2080
- let data;
2081
- try {
2082
- data = JSON.parse(await readBodyCapped(res));
2083
- } catch (err) {
2084
- throw new ToolError4({
2085
- message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
2086
- code: "TOOL_EXECUTION_FAILED",
2087
- toolName: method,
2088
- context: { transport: "sse", url: this.url, phase: "parse-json" },
2089
- cause: err
2090
- });
2091
- }
2092
- const result = assertMatchingJsonRpcResult(data, id, method);
2093
- return { jsonrpc: "2.0", id, result: result.result, error: result.error };
2094
- } catch (err) {
2095
- if (external?.aborted && !method.startsWith("notifications/")) {
2096
- void this.httpPost("notifications/cancelled", {
2097
- requestId: id,
2098
- reason: "client aborted"
2099
- }).catch(() => {
2100
- });
2101
- throw makeAbortError(method);
2102
- }
2103
- this.markDisconnected();
2104
- throw err;
2105
- } finally {
2106
- timeoutSignal.dispose();
2107
- }
2228
+ const result = await this.postJsonRpc(method, params, timeoutMs ?? this.requestTimeout, opts);
2229
+ return { jsonrpc: "2.0", id: result.id, result: result.result, error: result.error };
2108
2230
  }
2109
2231
  async close() {
2110
2232
  this.releasePinnedDispatcher();
2111
- if (this.state === "disconnected") return;
2233
+ const alreadyClosed = this.closed;
2234
+ this.closed = true;
2112
2235
  this.readerDone = true;
2236
+ this.state = "disconnected";
2237
+ if (alreadyClosed) return;
2113
2238
  this.readLoopAbort?.abort();
2114
2239
  try {
2115
2240
  this.reader?.cancel();
@@ -2120,8 +2245,8 @@ var SSETransport = class extends BaseHTTPTransport {
2120
2245
  } catch {
2121
2246
  }
2122
2247
  this.abortController?.abort();
2248
+ this.rejectStreamPending("transport closed");
2123
2249
  this.disconnectHandlers.splice(0, this.disconnectHandlers.length);
2124
- this.state = "disconnected";
2125
2250
  }
2126
2251
  markDisconnected() {
2127
2252
  if (this.state === "connected") {
@@ -2130,10 +2255,20 @@ var SSETransport = class extends BaseHTTPTransport {
2130
2255
  }
2131
2256
  }
2132
2257
  };
2258
+ function invalidResponse(method, url, err) {
2259
+ return new ToolError4({
2260
+ message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
2261
+ code: "TOOL_EXECUTION_FAILED",
2262
+ toolName: method,
2263
+ context: { transport: "sse", url, phase: "parse-json" },
2264
+ cause: err
2265
+ });
2266
+ }
2133
2267
 
2134
2268
  // src/transport-streamable.ts
2135
2269
  var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTransport {
2136
2270
  _nextId = 1;
2271
+ closed = false;
2137
2272
  sessionId;
2138
2273
  constructor(opts) {
2139
2274
  super(opts, "StreamableHTTP");
@@ -2164,11 +2299,8 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2164
2299
  }
2165
2300
  async refreshTools() {
2166
2301
  try {
2167
- const response = await this.postRaw("tools/list", {});
2168
- if (response.error) return;
2169
- const tools = normalizeMCPTools(
2170
- response.result?.tools
2171
- );
2302
+ const tools = await listAllTools((params) => this.postRaw("tools/list", params));
2303
+ if (!tools) return;
2172
2304
  this.tools.splice(0, this.tools.length, ...tools);
2173
2305
  for (const listener of this.toolsChangedListeners) {
2174
2306
  try {
@@ -2180,6 +2312,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2180
2312
  }
2181
2313
  }
2182
2314
  async connect() {
2315
+ this.closed = false;
2183
2316
  this.state = "connecting";
2184
2317
  this.serverMetadata = void 0;
2185
2318
  this.abortController = new AbortController();
@@ -2200,7 +2333,8 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2200
2333
  method: "initialize",
2201
2334
  params: {
2202
2335
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2203
- capabilities: { tools: {} },
2336
+ // Client capabilities none offered (`tools` is a server capability).
2337
+ capabilities: {},
2204
2338
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
2205
2339
  }
2206
2340
  }),
@@ -2230,13 +2364,8 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2230
2364
  this.protocolVersion = this.serverMetadata.protocolVersion;
2231
2365
  this.sessionId = initRes.headers.get("mcp-session-id") ?? void 0;
2232
2366
  await this.postRaw("notifications/initialized", {});
2233
- const toolsRes = await this.postRaw("tools/list", {});
2234
- if (toolsRes.error) {
2235
- this.tools.splice(0, this.tools.length);
2236
- } else {
2237
- const result = toolsRes.result;
2238
- this.tools.splice(0, this.tools.length, ...normalizeMCPTools(result?.tools));
2239
- }
2367
+ const tools = await listAllTools((params) => this.postRaw("tools/list", params));
2368
+ this.tools.splice(0, this.tools.length, ...tools ?? []);
2240
2369
  this.state = "connected";
2241
2370
  clearTimeout(startupTimer);
2242
2371
  } catch (err) {
@@ -2248,7 +2377,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2248
2377
  }
2249
2378
  async postRaw(method, params, opts) {
2250
2379
  const id = this.genId();
2251
- const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
2380
+ const body = encodeJsonRpcMessage(id, method, params);
2252
2381
  const external = opts?.signal;
2253
2382
  const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
2254
2383
  const timeoutSignal = createTimeoutSignal(parent, this.requestTimeout);
@@ -2298,7 +2427,7 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2298
2427
  /** Generic JSON-RPC request — used by MCPClient.request() for SSE/streamable-http transports. */
2299
2428
  async request(method, params, timeoutMs, opts) {
2300
2429
  const id = this.genId();
2301
- const body = JSON.stringify({ jsonrpc: "2.0", id, method, params });
2430
+ const body = encodeJsonRpcMessage(id, method, params);
2302
2431
  const external = opts?.signal;
2303
2432
  const parent = external && this.abortController ? AbortSignal.any([this.abortController.signal, external]) : external ?? this.abortController?.signal;
2304
2433
  const timeoutSignal = createTimeoutSignal(parent, timeoutMs ?? this.requestTimeout);
@@ -2328,11 +2457,12 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2328
2457
  }
2329
2458
  const parsed = this.consumeResponseText(await readBodyCapped(res), id);
2330
2459
  if (parsed) {
2460
+ const matched = assertMatchingJsonRpcResult(parsed, id, method);
2331
2461
  return {
2332
2462
  jsonrpc: "2.0",
2333
2463
  id,
2334
- result: parsed.result,
2335
- error: parsed.error
2464
+ result: matched.result,
2465
+ error: matched.error
2336
2466
  };
2337
2467
  }
2338
2468
  throw new Error("Could not parse response as JSON-RPC");
@@ -2366,8 +2496,10 @@ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTra
2366
2496
  }
2367
2497
  async close() {
2368
2498
  this.releasePinnedDispatcher();
2369
- if (this.state === "disconnected") return;
2499
+ const alreadyClosed = this.closed;
2500
+ this.closed = true;
2370
2501
  this.state = "disconnected";
2502
+ if (alreadyClosed) return;
2371
2503
  this.abortController?.abort();
2372
2504
  this.disconnectHandlers.splice(0, this.disconnectHandlers.length);
2373
2505
  }
@@ -2413,6 +2545,14 @@ var MCPClient = class _MCPClient {
2413
2545
  pending = /* @__PURE__ */ new Map();
2414
2546
  rxBuffer = "";
2415
2547
  rxBufferBytes = 0;
2548
+ /**
2549
+ * Incremental UTF-8 decoder for the stdio rx path. A pipe read boundary can
2550
+ * land inside a multi-byte sequence; decoding each chunk with
2551
+ * `chunk.toString()` would replace it with U+FFFD and silently corrupt the
2552
+ * JSON-RPC payload. StringDecoder withholds the partial sequence until the
2553
+ * chunk that completes it (same remedy as `readFileHead` in core utils).
2554
+ */
2555
+ rxDecoder = new StringDecoder("utf8");
2416
2556
  _tools = [];
2417
2557
  /** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
2418
2558
  _serverMetadata;
@@ -2507,6 +2647,7 @@ var MCPClient = class _MCPClient {
2507
2647
  }
2508
2648
  this.rxBuffer = "";
2509
2649
  this.rxBufferBytes = 0;
2650
+ this.rxDecoder = new StringDecoder("utf8");
2510
2651
  const extraEnv = { ...this.opts.env };
2511
2652
  if (this.opts.passthroughEnv) {
2512
2653
  for (const name of this.opts.passthroughEnv) {
@@ -2525,15 +2666,23 @@ var MCPClient = class _MCPClient {
2525
2666
  return spawn2(shim.command, shim.args, {
2526
2667
  env: spawnEnv,
2527
2668
  stdio,
2669
+ ...this.opts.cwd ? { cwd: this.opts.cwd } : {},
2528
2670
  windowsVerbatimArguments: shim.windowsVerbatimArguments,
2529
2671
  // Without this every MCP server spawned from a console-less host
2530
2672
  // (WebUI server, scheduled runs) opens a visible console window.
2531
2673
  windowsHide: true
2532
2674
  });
2533
- })() : spawn2(this.opts.command, rawArgs, { env: spawnEnv, stdio, windowsHide: true });
2675
+ })() : spawn2(this.opts.command, rawArgs, {
2676
+ env: spawnEnv,
2677
+ stdio,
2678
+ windowsHide: true,
2679
+ ...this.opts.cwd ? { cwd: this.opts.cwd } : {}
2680
+ });
2534
2681
  this.child = child;
2535
- child.stdout?.on("data", (chunk) => this.onData(chunk.toString()));
2682
+ child.stdout?.on("data", (chunk) => this.onData(this.rxDecoder.write(chunk)));
2536
2683
  child.stdout?.on("end", () => {
2684
+ const tail = this.rxDecoder.end();
2685
+ if (tail) this.onData(tail);
2537
2686
  if (this.rxBuffer.trim()) {
2538
2687
  const line = this.rxBuffer.trim();
2539
2688
  this.rxBuffer = "";
@@ -2565,7 +2714,9 @@ var MCPClient = class _MCPClient {
2565
2714
  "initialize",
2566
2715
  {
2567
2716
  protocolVersion: MCP_CONSTANTS.PROTOCOL_VERSION,
2568
- capabilities: { tools: {} },
2717
+ // Client capabilities (roots/sampling/elicitation) none offered.
2718
+ // `tools` is a SERVER capability and never belonged here.
2719
+ capabilities: {},
2569
2720
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
2570
2721
  },
2571
2722
  this.opts.startupTimeoutMs ?? 1e4
@@ -2587,13 +2738,7 @@ var MCPClient = class _MCPClient {
2587
2738
  '[MCP] notify("notifications/initialized") failed for "' + this.opts.name + '": ' + toErrorMessage(err)
2588
2739
  );
2589
2740
  }
2590
- const toolsRes = await this.request("tools/list", {});
2591
- if (toolsRes.error) {
2592
- this._tools = [];
2593
- } else {
2594
- const result = toolsRes.result;
2595
- this._tools = normalizeMCPTools(result?.tools);
2596
- }
2741
+ this._tools = await listAllTools((params) => this.request("tools/list", params)) ?? [];
2597
2742
  this._toolsCache = this._tools;
2598
2743
  this.state = "connected";
2599
2744
  }
@@ -2843,8 +2988,7 @@ var MCPClient = class _MCPClient {
2843
2988
  this.child = void 0;
2844
2989
  }
2845
2990
  this.failPending(`MCP "${this.opts.name}" closed`);
2846
- this.sseTransport?.close();
2847
- this.httpTransport?.close();
2991
+ await Promise.allSettled([this.sseTransport?.close(), this.httpTransport?.close()]);
2848
2992
  this.state = "disconnected";
2849
2993
  }
2850
2994
  request(method, params, timeoutMs = this.opts.requestTimeoutMs ?? 6e4, opts) {
@@ -3085,11 +3229,13 @@ var MCPClient = class _MCPClient {
3085
3229
  }
3086
3230
  }
3087
3231
  handleServerRequest(request3) {
3088
- const message = request3.method === "sampling/createMessage" ? "Client sampling is disabled by policy" : `Method not found: ${request3.method}`;
3089
- const response = {
3232
+ const response = request3.method === "ping" ? { jsonrpc: "2.0", id: request3.id, result: {} } : {
3090
3233
  jsonrpc: "2.0",
3091
3234
  id: request3.id,
3092
- error: { code: -32601, message }
3235
+ error: {
3236
+ code: -32601,
3237
+ message: request3.method === "sampling/createMessage" ? "Client sampling is disabled by policy" : `Method not found: ${request3.method}`
3238
+ }
3093
3239
  };
3094
3240
  try {
3095
3241
  this.child?.stdin?.write(`${JSON.stringify(response)}
@@ -3105,10 +3251,8 @@ var MCPClient = class _MCPClient {
3105
3251
  */
3106
3252
  async handleToolsListChanged() {
3107
3253
  try {
3108
- const toolsRes = await this.request("tools/list", {});
3109
- const tools = normalizeMCPTools(
3110
- toolsRes.result?.tools
3111
- );
3254
+ const tools = await listAllTools((params) => this.request("tools/list", params));
3255
+ if (!tools) return;
3112
3256
  this._tools = tools;
3113
3257
  this._toolsCache = tools;
3114
3258
  for (const listener of this.toolsChangedListeners) {
@@ -3446,7 +3590,7 @@ async function updateMcp(input, deps) {
3446
3590
  servers[input.name] = cfg;
3447
3591
  await persist(deps.configPath, full, servers);
3448
3592
  if (cfg.enabled !== false) {
3449
- return startServer(input.name, cfg, deps, `Server "${input.name}" updated`, { restart: true });
3593
+ return startServer(input.name, cfg, deps, `Server "${input.name}" updated`);
3450
3594
  }
3451
3595
  await safeStop(input.name, deps);
3452
3596
  trackDisabled(deps.registry, cfg);
@@ -3483,7 +3627,7 @@ async function enableMcp(name, deps) {
3483
3627
  cfg.enabled = true;
3484
3628
  servers[name] = cfg;
3485
3629
  await persist(deps.configPath, full, servers);
3486
- return startServer(name, cfg, deps, `Server "${name}" enabled`, { restart: true });
3630
+ return startServer(name, cfg, deps, `Server "${name}" enabled`);
3487
3631
  }
3488
3632
  async function disableMcp(name, deps) {
3489
3633
  if (!name) return { ok: false, message: "Server name is required" };
@@ -3526,12 +3670,19 @@ async function restartMcp(name, deps) {
3526
3670
  }
3527
3671
  const cfg = servers[name];
3528
3672
  if (!cfg) return { ok: false, message: `Server "${name}" is not in config.` };
3529
- return startServer(name, { ...cfg, name }, deps, `Server "${name}" started`, { restart: true });
3673
+ return startServer(name, { ...cfg, name }, deps, `Server "${name}" started`);
3530
3674
  }
3531
3675
  async function discoverMcp(name, deps) {
3532
3676
  if (!name) return { ok: false, message: "Server name is required" };
3533
3677
  const result = await restartMcp(name, deps);
3534
3678
  if (!result.ok) return result;
3679
+ if (liveState(name, deps.registry).state === "dormant") {
3680
+ try {
3681
+ await deps.registry.ensureConnected(name);
3682
+ } catch (err) {
3683
+ return { ok: false, message: `Failed to discover "${name}": ${errMessage(err)}` };
3684
+ }
3685
+ }
3535
3686
  const { state, tools } = liveState(name, deps.registry);
3536
3687
  return {
3537
3688
  ok: true,
@@ -3540,15 +3691,13 @@ async function discoverMcp(name, deps) {
3540
3691
  tools
3541
3692
  };
3542
3693
  }
3543
- async function startServer(name, cfg, deps, okMessage, opts) {
3694
+ async function startServer(name, cfg, deps, okMessage) {
3544
3695
  try {
3545
3696
  const alreadyRegistered = deps.registry.list().some((s) => s.name === name);
3546
- if (alreadyRegistered && opts?.restart) {
3547
- await deps.registry.restart(name);
3548
- } else if (alreadyRegistered) {
3549
- await deps.registry.restart(name);
3697
+ if (alreadyRegistered) {
3698
+ await deps.registry.restart(name, { ...cfg, name, enabled: true });
3550
3699
  } else {
3551
- await deps.registry.start({ ...cfg, enabled: true });
3700
+ await deps.registry.start({ ...cfg, name, enabled: true });
3552
3701
  }
3553
3702
  const { state, tools } = liveState(name, deps.registry);
3554
3703
  return {
@@ -3905,14 +4054,37 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observ
3905
4054
  }
3906
4055
  };
3907
4056
  }
4057
+ function renderContentBlock(item) {
4058
+ const type = item["type"];
4059
+ if (type === "text") return typeof item["text"] === "string" ? item["text"] : "";
4060
+ if (type === "image" || type === "audio") {
4061
+ const mime = typeof item["mimeType"] === "string" ? item["mimeType"] : "unknown type";
4062
+ const data = typeof item["data"] === "string" ? item["data"] : "";
4063
+ const kb = Math.max(1, Math.round(data.length * 3 / 4 / 1024));
4064
+ return `[${type} content: ${mime}, ~${kb} KB \u2014 binary payload not inlined]`;
4065
+ }
4066
+ if (type === "resource" && item["resource"] && typeof item["resource"] === "object") {
4067
+ const resource = item["resource"];
4068
+ const uri = typeof resource["uri"] === "string" ? resource["uri"] : "unknown";
4069
+ if (typeof resource["text"] === "string") return `[resource ${uri}]
4070
+ ${resource["text"]}`;
4071
+ if (typeof resource["blob"] === "string") {
4072
+ const mime = typeof resource["mimeType"] === "string" ? `${resource["mimeType"]}, ` : "";
4073
+ return `[resource ${uri}: ${mime}binary payload not inlined]`;
4074
+ }
4075
+ return JSON.stringify(item);
4076
+ }
4077
+ if (type === "resource_link" && typeof item["uri"] === "string") {
4078
+ return `[resource link: ${item["uri"]}]`;
4079
+ }
4080
+ return JSON.stringify(item);
4081
+ }
3908
4082
  function stringify(c) {
3909
4083
  if (typeof c === "string") return c;
3910
4084
  if (Array.isArray(c)) {
3911
4085
  return c.map((item) => {
3912
4086
  if (item && typeof item === "object") {
3913
- const t = item.type;
3914
- if (t === "text") return item.text ?? "";
3915
- return JSON.stringify(item);
4087
+ return renderContentBlock(item);
3916
4088
  }
3917
4089
  return String(item);
3918
4090
  }).join("\n");
@@ -3928,9 +4100,14 @@ function stringify(c) {
3928
4100
 
3929
4101
  // src/registry-connect-loop.ts
3930
4102
  function applySlotTools(ctx, slot, tools, client) {
3931
- if (slot.lazy && slot.registeredLazy && !ctx.lazyMode) return;
4103
+ slot.discoveredTools = tools;
3932
4104
  const allowed = slot.cfg.allowedTools;
3933
4105
  const filtered = tools.filter((t) => !allowed || allowed.includes(t.name));
4106
+ const signature = JSON.stringify(
4107
+ filtered.map((t) => [t.name, t.description ?? null, t.inputSchema ?? null])
4108
+ );
4109
+ const lazyWrappersCurrent = slot.lazy && slot.toolSignature === signature && slot.lazyTools.length === filtered.length && (slot.registeredLazy || ctx.lazyMode);
4110
+ if (lazyWrappersCurrent) return;
3934
4111
  const clientArg = slot.lazy ? () => ctx.ensureConnected(slot.cfg.name) : expectDefined(client);
3935
4112
  const wrapped = filtered.map(
3936
4113
  (t) => wrapMCPTool(slot.cfg.name, t, clientArg, slot.cfg.permission ?? "confirm", {
@@ -3956,9 +4133,16 @@ function applySlotTools(ctx, slot, tools, client) {
3956
4133
  })
3957
4134
  );
3958
4135
  slot.lazyTools = wrapped;
3959
- if (ctx.lazyMode) {
3960
- return;
4136
+ slot.toolSignature = signature;
4137
+ const wasActive = slot.toolNames.length > 0;
4138
+ if (ctx.lazyMode && !wasActive) return;
4139
+ for (const name of slot.toolNames) {
4140
+ try {
4141
+ ctx.toolRegistry.unregister(name);
4142
+ } catch {
4143
+ }
3961
4144
  }
4145
+ slot.toolNames = [];
3962
4146
  for (const tool of wrapped) {
3963
4147
  try {
3964
4148
  ctx.toolRegistry.register(tool, `mcp:${slot.cfg.name}`);
@@ -3967,7 +4151,7 @@ function applySlotTools(ctx, slot, tools, client) {
3967
4151
  ctx.log.warn(`MCP tool "${tool.name}" not registered`, err);
3968
4152
  }
3969
4153
  }
3970
- if (slot.lazy && wrapped.length > 0) slot.registeredLazy = true;
4154
+ if (slot.lazy && !ctx.lazyMode && wrapped.length > 0) slot.registeredLazy = true;
3971
4155
  }
3972
4156
  async function discoverSlotCapabilities(ctx, slot, client) {
3973
4157
  const startedAt = Date.now();
@@ -4019,15 +4203,17 @@ async function discoverSlotCapabilities(ctx, slot, client) {
4019
4203
  async function persistSlotCapabilityManifest(cacheDir, slot) {
4020
4204
  if (!slot.lazy || !cacheDir) return;
4021
4205
  const previous = slot.manifestWrite ?? Promise.resolve();
4022
- const pending = previous.then(
4023
- () => writeCapabilityManifest(cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), {
4024
- tools: slot.client?.listTools() ?? [],
4206
+ const pending = previous.then(() => {
4207
+ const tools = slot.discoveredTools ?? slot.client?.listTools();
4208
+ if (!tools) return;
4209
+ return writeCapabilityManifest(cacheDir, slot.cfg.name, manifestConfigHash(slot.cfg), {
4210
+ tools,
4025
4211
  serverMetadata: slot.serverMetadata,
4026
4212
  resources: slot.resources,
4027
4213
  resourceTemplates: slot.resourceTemplates,
4028
4214
  prompts: slot.prompts
4029
- })
4030
- );
4215
+ });
4216
+ });
4031
4217
  slot.manifestWrite = pending;
4032
4218
  await pending;
4033
4219
  if (slot.manifestWrite === pending) slot.manifestWrite = void 0;
@@ -4036,7 +4222,7 @@ async function attemptConnectSlot(ctx, slot) {
4036
4222
  const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
4037
4223
  let attempt = 0;
4038
4224
  while (attempt < MAX_ATTEMPTS) {
4039
- if (ctx.servers.has(slot.cfg.name) && ctx.servers.get(slot.cfg.name) !== slot) {
4225
+ if (ctx.servers.get(slot.cfg.name) !== slot) {
4040
4226
  return;
4041
4227
  }
4042
4228
  attempt++;
@@ -4056,6 +4242,7 @@ async function attemptConnectSlot(ctx, slot) {
4056
4242
  headers: slot.cfg.headers,
4057
4243
  startupTimeoutMs: slot.cfg.startupTimeoutMs,
4058
4244
  requestTimeoutMs: slot.cfg.requestTimeoutMs,
4245
+ cwd: ctx.cwd,
4059
4246
  allowPrivateNetworks: slot.cfg.allowPrivateNetworks,
4060
4247
  passthroughEnv: slot.cfg.passthroughEnv,
4061
4248
  authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg)
@@ -4095,6 +4282,7 @@ async function attemptConnectSlot(ctx, slot) {
4095
4282
  slot.reconnectCycles = 0;
4096
4283
  const mc = client;
4097
4284
  const discovered = mc.listTools();
4285
+ slot.discoveredTools = discovered;
4098
4286
  await discoverSlotCapabilities(ctx, slot, mc);
4099
4287
  await persistSlotCapabilityManifest(ctx.cacheDir, slot);
4100
4288
  applySlotTools(ctx, slot, discovered, mc);
@@ -4150,7 +4338,7 @@ async function attemptConnectSlot(ctx, slot) {
4150
4338
  }
4151
4339
  const delay = 500 * 2 ** attempt;
4152
4340
  await new Promise((r) => setTimeout(r, delay));
4153
- if (slot.state === "disconnected" || ctx.servers.has(slot.cfg.name) && ctx.servers.get(slot.cfg.name) !== slot) {
4341
+ if (slot.state === "disconnected" || ctx.servers.get(slot.cfg.name) !== slot) {
4154
4342
  return;
4155
4343
  }
4156
4344
  }
@@ -4167,6 +4355,7 @@ function resetDisconnectedSlotTools(slot, toolRegistry) {
4167
4355
  }
4168
4356
  slot.toolNames = [];
4169
4357
  slot.lazyTools = [];
4358
+ slot.toolSignature = void 0;
4170
4359
  slot.serverMetadata = void 0;
4171
4360
  slot.resources = void 0;
4172
4361
  slot.resourceTemplates = void 0;
@@ -4387,6 +4576,7 @@ var MCPRegistry = class _MCPRegistry {
4387
4576
  log;
4388
4577
  lazyMode;
4389
4578
  cacheDir;
4579
+ cwd;
4390
4580
  idleTimeoutMs;
4391
4581
  authorizationProviderFactory;
4392
4582
  authorizationManager;
@@ -4399,6 +4589,7 @@ var MCPRegistry = class _MCPRegistry {
4399
4589
  this.log = opts.log;
4400
4590
  this.lazyMode = opts.lazyMode ?? false;
4401
4591
  this.cacheDir = opts.cacheDir;
4592
+ this.cwd = opts.cwd;
4402
4593
  this.idleTimeoutMs = opts.idleTimeoutMs ?? MCP_CONSTANTS.IDLE.DEFAULT_TIMEOUT_MS;
4403
4594
  this.authorizationProviderFactory = opts.authorizationProviderFactory;
4404
4595
  this.authorizationManager = opts.authorizationManager;
@@ -4558,7 +4749,6 @@ var MCPRegistry = class _MCPRegistry {
4558
4749
  }
4559
4750
  }
4560
4751
  this.log.info(`MCP server "${name}" activated (${slot.toolNames.length} tools)`);
4561
- this.events.emit("mcp.server.connected", { name, toolCount: slot.toolNames.length });
4562
4752
  }
4563
4753
  /**
4564
4754
  * Unregister all tools for a given server from the tool registry.
@@ -4578,9 +4768,27 @@ var MCPRegistry = class _MCPRegistry {
4578
4768
  }
4579
4769
  slot.toolNames = [];
4580
4770
  this.log.info(`MCP server "${name}" deactivated (${count} tools removed)`);
4581
- this.events.emit("mcp.server.disconnected", { name, reason: "deactivate" });
4582
4771
  return count;
4583
4772
  }
4773
+ /**
4774
+ * The tools a server offers — bare names, descriptions and input schemas —
4775
+ * without activating, registering or waking it. In token-saving mode the
4776
+ * model reaches MCP only through `mcp_use`, which needs the bare tool name
4777
+ * and its input shape; before this there was no way to learn either short
4778
+ * of guessing and reading the error. Honors `allowedTools`. Returns
4779
+ * `undefined` for an unknown server, `[]` when nothing was discovered yet.
4780
+ */
4781
+ describeTools(name) {
4782
+ const slot = this.servers.get(name);
4783
+ if (!slot) return void 0;
4784
+ const allowed = slot.cfg.allowedTools;
4785
+ const tools = slot.discoveredTools ?? slot.client?.listTools() ?? [];
4786
+ return tools.filter((tool) => !allowed || allowed.includes(tool.name)).map((tool) => ({
4787
+ name: tool.name,
4788
+ ...tool.description !== void 0 ? { description: tool.description } : {},
4789
+ inputSchema: structuredClone(tool.inputSchema)
4790
+ }));
4791
+ }
4584
4792
  /**
4585
4793
  * Check whether a server's tools are currently registered.
4586
4794
  */
@@ -4617,12 +4825,52 @@ var MCPRegistry = class _MCPRegistry {
4617
4825
  this.recordOperation(slot, "stop", "manual");
4618
4826
  this.events.emit("mcp.server.disconnected", { name, reason: "stop" });
4619
4827
  }
4620
- async restart(name) {
4828
+ /**
4829
+ * Stop and start a registered server. Pass `nextCfg` to apply an edited
4830
+ * configuration: without it the slot reconnects with the config it was
4831
+ * started with, so an update/enable routed through restart() silently kept
4832
+ * the old command, url, env, permission and lazy flag until the next boot.
4833
+ */
4834
+ /**
4835
+ * Put a running server to sleep while keeping its configuration enabled.
4836
+ *
4837
+ * A lazy server goes `dormant`: the process stops but its tools stay
4838
+ * registered and the next call wakes it. Surfaces previously used stop() for
4839
+ * this, which unregistered a lazy server's tools — "sleep" silently became
4840
+ * "unreachable until restarted". Eager servers have no dormant state, so
4841
+ * for them sleep is a stop.
4842
+ */
4843
+ async sleep(name) {
4844
+ const slot = this.requireSlot(name);
4845
+ if (!slot.lazy) {
4846
+ await this.stop(name);
4847
+ return;
4848
+ }
4849
+ if (slot.state === "dormant") return;
4850
+ if (slot.operations.inFlightCalls > 0) {
4851
+ throw new Error(`MCP server "${name}" has tool calls in flight \u2014 try again when they finish`);
4852
+ }
4853
+ await sleepIdleSlot(this.idleContext(), slot);
4854
+ }
4855
+ async restart(name, nextCfg) {
4621
4856
  const slot = this.servers.get(name);
4622
4857
  if (!slot) throw new Error(`MCP server "${name}" not registered`);
4858
+ if (nextCfg && nextCfg.name !== name) {
4859
+ throw new Error(`MCP restart config names "${nextCfg.name}", expected "${name}"`);
4860
+ }
4861
+ if (nextCfg?.enabled === false) {
4862
+ await this.stop(name);
4863
+ this.markDisabled(nextCfg);
4864
+ return;
4865
+ }
4623
4866
  slot.operations.restartCount++;
4624
4867
  this.recordOperation(slot, "restart", "manual");
4625
4868
  await this.stop(name);
4869
+ if (nextCfg) {
4870
+ slot.cfg = nextCfg;
4871
+ slot.lazy = !!nextCfg.lazy && !!this.cacheDir;
4872
+ slot.discoveredTools = void 0;
4873
+ }
4626
4874
  slot.attempts = 0;
4627
4875
  slot.reconnectCycles = 0;
4628
4876
  if (slot.lazy) {
@@ -4744,6 +4992,7 @@ var MCPRegistry = class _MCPRegistry {
4744
4992
  log: this.log,
4745
4993
  lazyMode: this.lazyMode,
4746
4994
  cacheDir: this.cacheDir,
4995
+ cwd: this.cwd,
4747
4996
  authorizationProviderFactory: this.authorizationProviderFactory,
4748
4997
  operationListeners: this.operationListeners,
4749
4998
  ensureConnected: (name) => this.ensureConnected(name),
@@ -4855,21 +5104,9 @@ var MCPRegistry = class _MCPRegistry {
4855
5104
  onToolsChanged = (name, _tools) => {
4856
5105
  const slot = this.servers.get(name);
4857
5106
  if (!slot?.client) return;
4858
- for (const t of slot.toolNames) {
4859
- try {
4860
- this.toolRegistry.unregister(t);
4861
- } catch {
4862
- }
4863
- }
4864
- slot.toolNames = [];
4865
- slot.registeredLazy = false;
4866
5107
  const discovered = slot.client.listTools();
4867
5108
  this.applyTools(slot, discovered, slot.client);
4868
5109
  void this.persistCapabilityManifest(slot);
4869
- this.events.emit("mcp.server.connected", {
4870
- name: slot.cfg.name,
4871
- toolCount: slot.toolNames.length
4872
- });
4873
5110
  this.log.info(
4874
5111
  `MCP server "${slot.cfg.name}" tools refreshed (${this.toolNamesForSlot(slot).length} active)`
4875
5112
  );
@@ -4899,7 +5136,7 @@ var MCPRegistry = class _MCPRegistry {
4899
5136
  }
4900
5137
  onChildExit = (name, code, _signal) => {
4901
5138
  const slot = this.servers.get(name);
4902
- if (!slot) return;
5139
+ if (slot?.state !== "connected") return;
4903
5140
  if (slot.lazy) {
4904
5141
  this.recordFailure(slot, "transport", "process-exit-lazy");
4905
5142
  markLazySlotDormant(slot, this.events, `exit:${code ?? "unknown"}`, {
@@ -4918,7 +5155,7 @@ var MCPRegistry = class _MCPRegistry {
4918
5155
  /** Handles SSE / streamable-http disconnect — same recovery as stdio child exit. */
4919
5156
  onTransportDisconnect = (name) => {
4920
5157
  const slot = this.servers.get(name);
4921
- if (!slot) return;
5158
+ if (slot?.state !== "connected") return;
4922
5159
  if (slot.lazy) {
4923
5160
  this.recordFailure(slot, "transport", "http-disconnect-lazy");
4924
5161
  markLazySlotDormant(slot, this.events, "http-disconnect", {
@@ -4954,7 +5191,7 @@ var MCPRegistry = class _MCPRegistry {
4954
5191
  slot.reconnectCycles++;
4955
5192
  slot.operations.reconnectCount++;
4956
5193
  this.recordOperation(slot, "reconnect", "automatic");
4957
- await this.attemptConnect(slot);
5194
+ await this.singleFlightConnect(slot);
4958
5195
  }
4959
5196
  recordSuccess(slot, resetFailures = true) {
4960
5197
  recordRegistrySuccess(slot, resetFailures);
@@ -5639,6 +5876,8 @@ var MCPRefreshingAuthorizationProvider = class {
5639
5876
  }
5640
5877
  options;
5641
5878
  refreshPromise;
5879
+ /** Refresh token the authorization server rejected; cleared by a new authorization. */
5880
+ rejectedRefreshToken;
5642
5881
  resource;
5643
5882
  refreshSkewMs;
5644
5883
  async getAccessToken(context) {
@@ -5676,16 +5915,28 @@ var MCPRefreshingAuthorizationProvider = class {
5676
5915
  }
5677
5916
  async refreshInner(state) {
5678
5917
  const refreshToken = state.tokenSet.refreshToken;
5679
- if (!refreshToken) {
5680
- this.emit("reauth_required", state);
5918
+ if (!refreshToken || refreshToken === this.rejectedRefreshToken) {
5919
+ if (!refreshToken) this.emit("reauth_required", state);
5681
5920
  return void 0;
5682
5921
  }
5683
- const tokenSet = await refreshMcpAccessToken({
5684
- authorizationServer: state.authorizationServer,
5685
- clientId: state.clientId,
5686
- resource: state.resource,
5687
- refreshToken
5688
- });
5922
+ let tokenSet;
5923
+ try {
5924
+ tokenSet = await refreshMcpAccessToken({
5925
+ authorizationServer: state.authorizationServer,
5926
+ clientId: state.clientId,
5927
+ resource: state.resource,
5928
+ refreshToken
5929
+ });
5930
+ } catch (err) {
5931
+ const current = await this.options.store.load(this.options.serverName, this.resource).catch(() => void 0);
5932
+ if (current && current.tokenSet.refreshToken !== refreshToken) return current;
5933
+ if (err instanceof MCPOAuthHttpError && err.status >= 400 && err.status < 500) {
5934
+ this.rejectedRefreshToken = refreshToken;
5935
+ this.emit("reauth_required", state);
5936
+ return void 0;
5937
+ }
5938
+ throw err;
5939
+ }
5689
5940
  const next = normalizeStoredAuthorization({
5690
5941
  ...state,
5691
5942
  tokenSet,