@wrongstack/mcp 0.319.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -23,6 +23,12 @@ export interface MCPClientOptions {
23
23
  * without storing them in config.json or being scrubbed by the secret filter.
24
24
  */
25
25
  passthroughEnv?: string[] | undefined;
26
+ /**
27
+ * Resolution-bound private-network policy for HTTP transports. Default:
28
+ * private/LAN targets are blocked at dial time (DNS-rebinding safe); the
29
+ * flag opts this server in. See MCPServerConfig.allowPrivateNetworks.
30
+ */
31
+ allowPrivateNetworks?: boolean | undefined;
26
32
  }
27
33
  import type { ConnectionState, JsonRpcResponse, MCPTool, ToolCallResult } from './contracts.js';
28
34
  export type { ConnectionState, JsonRpcResponse, MCPTool, ToolCallResult };
@@ -56,6 +62,7 @@ export declare class MCPClient {
56
62
  */
57
63
  private readonly pending;
58
64
  private rxBuffer;
65
+ private rxBufferBytes;
59
66
  private _tools;
60
67
  /** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
61
68
  private _serverMetadata?;
@@ -63,6 +70,7 @@ export declare class MCPClient {
63
70
  private _toolsCache?;
64
71
  private _drainPending;
65
72
  private _lastNotifySkipped;
73
+ private closePromise?;
66
74
  private sseTransport?;
67
75
  private httpTransport?;
68
76
  /** Notified when the stdio child process exits so the registry can attempt reconnect. */
@@ -106,6 +114,7 @@ export declare class MCPClient {
106
114
  listPrompts(opts?: MCPPageOptions): Promise<MCPListPromptsResult>;
107
115
  getPrompt(name: string, args?: Record<string, string> | undefined, opts?: MCPRequestOptions): Promise<MCPGetPromptResult>;
108
116
  close(): Promise<void>;
117
+ private closeInner;
109
118
  private request;
110
119
  private requestCapability;
111
120
  private requireResourceSubscriptions;
package/dist/index.js CHANGED
@@ -516,8 +516,8 @@ async function resolvePinnedAddress(url, options) {
516
516
  );
517
517
  return { address: hostname, family: literalFamily };
518
518
  }
519
- const lookup2 = options.lookup ?? ((host) => dns.lookup(host, { all: true }));
520
- const records = await lookup2(hostname);
519
+ const lookup3 = options.lookup ?? ((host) => dns.lookup(host, { all: true }));
520
+ const records = await lookup3(hostname);
521
521
  if (records.length === 0)
522
522
  throw new Error(`MCP OAuth discovery DNS returned no addresses for ${hostname}`);
523
523
  for (const record3 of records) {
@@ -1256,10 +1256,13 @@ var SSEReader = class {
1256
1256
  // src/transport-base.ts
1257
1257
  import * as https2 from "node:https";
1258
1258
  import { ConfigError as ConfigError2 } from "@wrongstack/core/types";
1259
+ import { Agent as UndiciAgent, fetch as undiciFetch } from "undici";
1259
1260
 
1260
1261
  // src/transport-security.ts
1262
+ import * as dns2 from "node:dns/promises";
1261
1263
  import * as net2 from "node:net";
1262
1264
  import { ConfigError } from "@wrongstack/core/types";
1265
+ import { isPrivateIPv4 as isPrivateIPv42, isPrivateIPv6 as isPrivateIPv62 } from "@wrongstack/core/utils";
1263
1266
  function isTlsUnsafeAllowed() {
1264
1267
  return process.env["WRONGSTACK_UNSAFE_MCP_TLS"] === "1";
1265
1268
  }
@@ -1315,9 +1318,108 @@ function validateTransportUrl(rawUrl) {
1315
1318
  }
1316
1319
  }
1317
1320
  }
1321
+ var ALLOW_MCP_PRIVATE_NETWORKS = process.env["WRONGSTACK_MCP_ALLOW_PRIVATE"] === "1";
1322
+ if (ALLOW_MCP_PRIVATE_NETWORKS && !process.env["CI"]) {
1323
+ console.warn(
1324
+ "[WrongStack] WARNING: WRONGSTACK_MCP_ALLOW_PRIVATE=1 is active \u2014\n MCP HTTP transports may dial private/LAN addresses (10.x, 192.168.x,\n 172.16-31.x, ULA) when a server config sets allowPrivateNetworks. Link-local/IMDS targets stay blocked."
1325
+ );
1326
+ }
1327
+ function classifyTransportAddress(address, family) {
1328
+ if (family === 4) {
1329
+ const v4 = address.toLowerCase();
1330
+ if (v4.startsWith("169.254.")) return "blocked";
1331
+ if (v4.startsWith("127.")) return "loopback";
1332
+ return isPrivateIPv42(address) ? "private" : "public";
1333
+ }
1334
+ if (family === 6) {
1335
+ const v6 = address.toLowerCase();
1336
+ if (v6 === "::1") return "loopback";
1337
+ if (/^fe[89ab]/.test(v6) || v6 === "fd00:ec2::254") return "blocked";
1338
+ if (v6.startsWith("::ffff:")) {
1339
+ const tail = v6.slice("::ffff:".length);
1340
+ if (tail.includes(".")) return classifyTransportAddress(tail, 4);
1341
+ const hextets = tail.split(":");
1342
+ if (hextets.length === 2) {
1343
+ const high = Number.parseInt(hextets[0], 16);
1344
+ const low = Number.parseInt(hextets[1], 16);
1345
+ if (Number.isFinite(high) && Number.isFinite(low)) {
1346
+ return classifyTransportAddress(
1347
+ `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`,
1348
+ 4
1349
+ );
1350
+ }
1351
+ }
1352
+ return "blocked";
1353
+ }
1354
+ return isPrivateIPv62(v6) ? "private" : "public";
1355
+ }
1356
+ return "blocked";
1357
+ }
1358
+ function assertTransportAddressAllowed(address, family, hostname, allowPrivateNetworks) {
1359
+ const classification = classifyTransportAddress(address, family);
1360
+ if (classification === "blocked") {
1361
+ throw new ConfigError({
1362
+ message: `MCP transport: resolved address "${address}" for "${hostname}" is link-local/IMDS \u2014 never a valid MCP target`,
1363
+ code: "CONFIG_INVALID",
1364
+ context: { hostname, address }
1365
+ });
1366
+ }
1367
+ if (classification === "private" && !allowPrivateNetworks) {
1368
+ throw new ConfigError({
1369
+ message: `MCP transport: "${hostname}" resolved to private address ${address}. Private/LAN targets are blocked by default; if this server really runs on your private network, set allowPrivateNetworks: true on its config (or WRONGSTACK_MCP_ALLOW_PRIVATE=1 globally).`,
1370
+ code: "CONFIG_INVALID",
1371
+ context: { hostname, address }
1372
+ });
1373
+ }
1374
+ }
1375
+ function transportPinnedLookup(options) {
1376
+ const lookup3 = options.lookup ?? (async (host) => dns2.lookup(host, { all: true }));
1377
+ return (hostname, connectOptions, callback) => {
1378
+ lookup3(hostname).then((records) => {
1379
+ if (records.length === 0) {
1380
+ callback(
1381
+ Object.assign(new Error(`MCP transport: no addresses for "${hostname}"`), {
1382
+ code: "ENOTFOUND"
1383
+ })
1384
+ );
1385
+ return;
1386
+ }
1387
+ for (const record3 of records) {
1388
+ assertTransportAddressAllowed(
1389
+ record3.address,
1390
+ record3.family,
1391
+ hostname,
1392
+ options.allowPrivateNetworks
1393
+ );
1394
+ }
1395
+ const wanted = connectOptions?.family;
1396
+ const filtered = wanted === 4 || wanted === 6 ? records.filter((record3) => record3.family === wanted) : records;
1397
+ const list = filtered.length > 0 ? filtered : records;
1398
+ if (connectOptions?.all) {
1399
+ callback(
1400
+ null,
1401
+ list.map((record3) => ({ address: record3.address, family: record3.family }))
1402
+ );
1403
+ return;
1404
+ }
1405
+ const first = list[0];
1406
+ callback(null, first.address, first.family);
1407
+ }).catch((error) => callback(error));
1408
+ };
1409
+ }
1318
1410
 
1319
1411
  // src/transport-base.ts
1320
1412
  var MAX_TRANSPORT_REDIRECTS = 5;
1413
+ var nativeGlobalFetch = globalThis.fetch;
1414
+ var pinnedAgents = /* @__PURE__ */ new Set();
1415
+ var pinnedAgentsCleanupRegistered = false;
1416
+ if (!pinnedAgentsCleanupRegistered) {
1417
+ pinnedAgentsCleanupRegistered = true;
1418
+ process.on("beforeExit", () => {
1419
+ for (const agent of pinnedAgents) agent.destroy();
1420
+ pinnedAgents.clear();
1421
+ });
1422
+ }
1321
1423
  function makeAbortError(method) {
1322
1424
  const err = new Error(`MCP request "${method}" aborted by client`);
1323
1425
  err.name = "AbortError";
@@ -1335,6 +1437,7 @@ function createTimeoutSignal(parent, timeoutMs) {
1335
1437
  () => ctrl.abort(new Error(`MCP HTTP request timed out after ${timeoutMs}ms`)),
1336
1438
  timeoutMs
1337
1439
  );
1440
+ timer.unref?.();
1338
1441
  return {
1339
1442
  signal: ctrl.signal,
1340
1443
  dispose: () => {
@@ -1354,6 +1457,10 @@ var BaseHTTPTransport = class {
1354
1457
  authorizationResource;
1355
1458
  /** Per-request TLS agent — created once from HttpTransportOptions.tls */
1356
1459
  tlsAgent;
1460
+ tlsOptions;
1461
+ allowPrivateNetworks;
1462
+ lookup;
1463
+ pinnedAgent;
1357
1464
  tools = [];
1358
1465
  serverMetadata;
1359
1466
  abortController;
@@ -1389,6 +1496,9 @@ var BaseHTTPTransport = class {
1389
1496
  rejectUnauthorized: opts.tls.rejectUnauthorized
1390
1497
  });
1391
1498
  }
1499
+ this.tlsOptions = opts.tls;
1500
+ this.allowPrivateNetworks = opts.allowPrivateNetworks === true || ALLOW_MCP_PRIVATE_NETWORKS;
1501
+ this.lookup = opts.lookup;
1392
1502
  }
1393
1503
  getState() {
1394
1504
  return this.state;
@@ -1414,7 +1524,9 @@ var BaseHTTPTransport = class {
1414
1524
  let currentUrl = typeof input === "string" ? input : String(input);
1415
1525
  let hopHeaders = headers;
1416
1526
  for (let hop = 0; hop < MAX_TRANSPORT_REDIRECTS; hop++) {
1417
- const res = await fetch(currentUrl, { ...init, headers: hopHeaders, redirect: "manual" });
1527
+ const fetchOpts = { ...init, headers: hopHeaders, redirect: "manual" };
1528
+ this.applyPinnedDispatcher(fetchOpts);
1529
+ const res = await this.dispatcherFetch()(currentUrl, fetchOpts);
1418
1530
  if (res.status !== 301 && res.status !== 302 && res.status !== 303 && res.status !== 307 && res.status !== 308) {
1419
1531
  return res;
1420
1532
  }
@@ -1509,12 +1621,48 @@ var BaseHTTPTransport = class {
1509
1621
  }
1510
1622
  }
1511
1623
  }
1624
+ dispatcherFetch() {
1625
+ return globalThis.fetch === nativeGlobalFetch ? undiciFetch : globalThis.fetch;
1626
+ }
1627
+ pinnedDispatcher() {
1628
+ if (!this.pinnedAgent) {
1629
+ const tls = this.tlsOptions;
1630
+ this.pinnedAgent = new UndiciAgent({
1631
+ allowH2: false,
1632
+ connect: {
1633
+ ...tls ? { ca: tls.ca, rejectUnauthorized: tls.rejectUnauthorized } : {},
1634
+ lookup: transportPinnedLookup({
1635
+ allowPrivateNetworks: this.allowPrivateNetworks,
1636
+ lookup: this.lookup
1637
+ })
1638
+ }
1639
+ });
1640
+ pinnedAgents.add(this.pinnedAgent);
1641
+ }
1642
+ return this.pinnedAgent;
1643
+ }
1644
+ applyPinnedDispatcher(fetchOpts) {
1645
+ fetchOpts.dispatcher = this.pinnedDispatcher();
1646
+ }
1647
+ /**
1648
+ * Destroy this transport's pinned Agent and its connection pool. Idempotent.
1649
+ * Subclasses call it from close(); the process-exit sweep is the backstop.
1650
+ */
1651
+ releasePinnedDispatcher() {
1652
+ if (!this.pinnedAgent) return;
1653
+ pinnedAgents.delete(this.pinnedAgent);
1654
+ this.pinnedAgent.destroy();
1655
+ this.pinnedAgent = void 0;
1656
+ }
1512
1657
  /**
1513
1658
  * Apply the pinned TLS agent (if configured) to a `RequestInit` object.
1514
1659
  * Uses `HttpDispatcher` from `@wrongstack/core`'s dispatcher-types shim,
1515
1660
  * which declares `https.Agent` compatible with `RequestInit.dispatcher`.
1516
1661
  * Verified safe: https.Agent implements the `dispatch(req, opts)` method
1517
1662
  * that fetch requires at runtime.
1663
+ *
1664
+ * Superseded at fetch time by `applyPinnedDispatcher`, whose Agent embeds
1665
+ * these same TLS options plus the resolution-bound lookup.
1518
1666
  */
1519
1667
  applyTlsAgent(fetchOpts) {
1520
1668
  if (this.tlsAgent) {
@@ -1775,7 +1923,8 @@ var SSETransport = class extends BaseHTTPTransport {
1775
1923
  sseReader.feed(chunk);
1776
1924
  }
1777
1925
  } catch {
1778
- if (this.state !== "disconnected" && this.state !== "failed") {
1926
+ } finally {
1927
+ if (!this.readerDone && this.state !== "disconnected" && this.state !== "failed") {
1779
1928
  this.state = "disconnected";
1780
1929
  this.notifyDisconnect();
1781
1930
  }
@@ -1809,9 +1958,13 @@ var SSETransport = class extends BaseHTTPTransport {
1809
1958
  try {
1810
1959
  const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
1811
1960
  if (!res.ok) {
1812
- const body2 = await res.text();
1813
- const cap = MCP_CONSTANTS.REQUEST_LOG_CAP;
1814
- const snippet = body2.length > cap ? `${body2.slice(0, cap)}\u2026 [${body2.length} bytes total]` : body2;
1961
+ let snippet;
1962
+ try {
1963
+ snippet = await readBodyCapped(res, MCP_CONSTANTS.REQUEST_LOG_CAP);
1964
+ } catch (err) {
1965
+ const received = err instanceof ToolError4 && typeof err.context?.["received"] === "number" ? err.context["received"] : void 0;
1966
+ snippet = typeof received === "number" ? `\u2026 [${received}+ bytes total]` : "\u2026 [error body unreadable]";
1967
+ }
1815
1968
  throw new ToolError4({
1816
1969
  message: `HTTP ${res.status}: ${snippet}`,
1817
1970
  code: "TOOL_EXECUTION_FAILED",
@@ -1819,6 +1972,10 @@ var SSETransport = class extends BaseHTTPTransport {
1819
1972
  context: { transport: "sse", url: this.url, status: res.status }
1820
1973
  });
1821
1974
  }
1975
+ if (method.startsWith("notifications/")) {
1976
+ await readBodyCapped(res).catch(() => void 0);
1977
+ return { jsonrpc: "2.0", id };
1978
+ }
1822
1979
  let data;
1823
1980
  try {
1824
1981
  data = JSON.parse(await readBodyCapped(res));
@@ -1841,6 +1998,7 @@ var SSETransport = class extends BaseHTTPTransport {
1841
1998
  });
1842
1999
  throw makeAbortError(method);
1843
2000
  }
2001
+ this.markDisconnected();
1844
2002
  throw err;
1845
2003
  } finally {
1846
2004
  timeoutSignal.dispose();
@@ -1897,6 +2055,10 @@ var SSETransport = class extends BaseHTTPTransport {
1897
2055
  }
1898
2056
  });
1899
2057
  }
2058
+ if (method.startsWith("notifications/")) {
2059
+ await readBodyCapped(res).catch(() => void 0);
2060
+ return { jsonrpc: "2.0", id };
2061
+ }
1900
2062
  let data;
1901
2063
  try {
1902
2064
  data = JSON.parse(await readBodyCapped(res));
@@ -1920,12 +2082,14 @@ var SSETransport = class extends BaseHTTPTransport {
1920
2082
  });
1921
2083
  throw makeAbortError(method);
1922
2084
  }
2085
+ this.markDisconnected();
1923
2086
  throw err;
1924
2087
  } finally {
1925
2088
  timeoutSignal.dispose();
1926
2089
  }
1927
2090
  }
1928
2091
  async close() {
2092
+ this.releasePinnedDispatcher();
1929
2093
  if (this.state === "disconnected") return;
1930
2094
  this.readerDone = true;
1931
2095
  this.readLoopAbort?.abort();
@@ -1941,10 +2105,16 @@ var SSETransport = class extends BaseHTTPTransport {
1941
2105
  this.disconnectHandlers.splice(0, this.disconnectHandlers.length);
1942
2106
  this.state = "disconnected";
1943
2107
  }
2108
+ markDisconnected() {
2109
+ if (this.state === "connected") {
2110
+ this.state = "disconnected";
2111
+ this.notifyDisconnect();
2112
+ }
2113
+ }
1944
2114
  };
1945
2115
 
1946
2116
  // src/transport-streamable.ts
1947
- var StreamableHTTPTransport = class extends BaseHTTPTransport {
2117
+ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTransport {
1948
2118
  _nextId = 1;
1949
2119
  sessionId;
1950
2120
  constructor(opts) {
@@ -2023,10 +2193,10 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
2023
2193
  const contentType = initRes.headers.get("content-type") ?? "";
2024
2194
  let data;
2025
2195
  if (contentType.includes("application/json")) {
2026
- const parsed = await initRes.json();
2196
+ const parsed = JSON.parse(await readBodyCapped(initRes));
2027
2197
  if (isJsonRpcResult(parsed)) data = parsed;
2028
2198
  } else {
2029
- data = extractJsonRpcResults(await initRes.text())[0];
2199
+ data = extractJsonRpcResults(await readBodyCapped(initRes))[0];
2030
2200
  }
2031
2201
  if (!data) {
2032
2202
  throw new Error("Could not parse initialize response");
@@ -2076,10 +2246,13 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
2076
2246
  try {
2077
2247
  const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
2078
2248
  if (!res.ok) {
2249
+ if (_StreamableHTTPTransport.SESSION_FATAL_HTTP_STATUSES.has(res.status)) {
2250
+ this.markDisconnected();
2251
+ }
2079
2252
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
2080
2253
  }
2081
2254
  if (method.startsWith("notifications/")) {
2082
- await res.text().catch(() => void 0);
2255
+ await readBodyCapped(res).catch(() => void 0);
2083
2256
  return { jsonrpc: "2.0", id };
2084
2257
  }
2085
2258
  const match = this.consumeResponseText(await readBodyCapped(res), id);
@@ -2123,10 +2296,13 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
2123
2296
  try {
2124
2297
  const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
2125
2298
  if (!res.ok) {
2299
+ if (_StreamableHTTPTransport.SESSION_FATAL_HTTP_STATUSES.has(res.status)) {
2300
+ this.markDisconnected();
2301
+ }
2126
2302
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
2127
2303
  }
2128
2304
  if (method.startsWith("notifications/")) {
2129
- await res.text().catch(() => void 0);
2305
+ await readBodyCapped(res).catch(() => void 0);
2130
2306
  return { jsonrpc: "2.0", id };
2131
2307
  }
2132
2308
  const parsed = this.consumeResponseText(await readBodyCapped(res), id);
@@ -2168,11 +2344,26 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
2168
2344
  };
2169
2345
  }
2170
2346
  async close() {
2347
+ this.releasePinnedDispatcher();
2171
2348
  if (this.state === "disconnected") return;
2172
2349
  this.state = "disconnected";
2173
2350
  this.abortController?.abort();
2174
2351
  this.disconnectHandlers.splice(0, this.disconnectHandlers.length);
2175
2352
  }
2353
+ /**
2354
+ * HTTP statuses that mean the streamable-http session itself is gone
2355
+ * (auth rejected, session id unknown or expired). Only these tear the
2356
+ * connection down on the request path; transient faults (5xx, network
2357
+ * resets) surface as call errors and keep the session alive so the next
2358
+ * request can succeed — the contract pinned by http-fault-soak.test.ts.
2359
+ */
2360
+ static SESSION_FATAL_HTTP_STATUSES = /* @__PURE__ */ new Set([401, 403, 404, 410]);
2361
+ markDisconnected() {
2362
+ if (this.state === "connected") {
2363
+ this.state = "disconnected";
2364
+ this.notifyDisconnect();
2365
+ }
2366
+ }
2176
2367
  };
2177
2368
 
2178
2369
  // src/client.ts
@@ -2200,6 +2391,7 @@ var MCPClient = class _MCPClient {
2200
2391
  */
2201
2392
  pending = /* @__PURE__ */ new Map();
2202
2393
  rxBuffer = "";
2394
+ rxBufferBytes = 0;
2203
2395
  _tools = [];
2204
2396
  /** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
2205
2397
  _serverMetadata;
@@ -2207,6 +2399,7 @@ var MCPClient = class _MCPClient {
2207
2399
  _toolsCache;
2208
2400
  _drainPending = false;
2209
2401
  _lastNotifySkipped = false;
2402
+ closePromise;
2210
2403
  // HTTP transports
2211
2404
  sseTransport;
2212
2405
  httpTransport;
@@ -2260,15 +2453,21 @@ var MCPClient = class _MCPClient {
2260
2453
  async connect() {
2261
2454
  this.state = "connecting";
2262
2455
  this._serverMetadata = void 0;
2263
- if (this.opts.transport === "stdio") {
2264
- await this.connectStdio();
2265
- } else if (this.opts.transport === "sse") {
2266
- await this.connectSSE();
2267
- } else if (this.opts.transport === "streamable-http") {
2268
- await this.connectStreamableHTTP();
2269
- } else {
2456
+ try {
2457
+ if (this.opts.transport === "stdio") {
2458
+ await this.connectStdio();
2459
+ } else if (this.opts.transport === "sse") {
2460
+ await this.connectSSE();
2461
+ } else if (this.opts.transport === "streamable-http") {
2462
+ await this.connectStreamableHTTP();
2463
+ } else {
2464
+ throw new Error(`Unknown transport "${this.opts.transport}"`);
2465
+ }
2466
+ } catch (err) {
2467
+ await this.close().catch(() => {
2468
+ });
2270
2469
  this.state = "failed";
2271
- throw new Error(`Unknown transport "${this.opts.transport}"`);
2470
+ throw err;
2272
2471
  }
2273
2472
  }
2274
2473
  async connectStdio() {
@@ -2277,6 +2476,7 @@ var MCPClient = class _MCPClient {
2277
2476
  throw new Error('MCP stdio transport requires "command"');
2278
2477
  }
2279
2478
  this.rxBuffer = "";
2479
+ this.rxBufferBytes = 0;
2280
2480
  const extraEnv = { ...this.opts.env };
2281
2481
  if (this.opts.passthroughEnv) {
2282
2482
  for (const name of this.opts.passthroughEnv) {
@@ -2303,6 +2503,13 @@ var MCPClient = class _MCPClient {
2303
2503
  })() : spawn2(this.opts.command, rawArgs, { env: spawnEnv, stdio, windowsHide: true });
2304
2504
  this.child = child;
2305
2505
  child.stdout?.on("data", (chunk) => this.onData(chunk.toString()));
2506
+ child.stdout?.on("end", () => {
2507
+ if (this.rxBuffer.trim()) {
2508
+ const line = this.rxBuffer.trim();
2509
+ this.rxBuffer = "";
2510
+ this.onLine(line);
2511
+ }
2512
+ });
2306
2513
  child.stderr?.on("data", () => {
2307
2514
  });
2308
2515
  child.stdin?.on("error", (err) => {
@@ -2371,7 +2578,8 @@ var MCPClient = class _MCPClient {
2371
2578
  headers: this.opts.headers,
2372
2579
  startupTimeoutMs: this.opts.startupTimeoutMs,
2373
2580
  requestTimeoutMs: this.opts.requestTimeoutMs,
2374
- authorizationProvider: this.opts.authorizationProvider
2581
+ authorizationProvider: this.opts.authorizationProvider,
2582
+ allowPrivateNetworks: this.opts.allowPrivateNetworks
2375
2583
  };
2376
2584
  this.sseTransport = new SSETransport(httpOpts);
2377
2585
  this.sseTransport.onDisconnect(() => {
@@ -2421,7 +2629,8 @@ var MCPClient = class _MCPClient {
2421
2629
  headers: this.opts.headers,
2422
2630
  startupTimeoutMs: this.opts.startupTimeoutMs,
2423
2631
  requestTimeoutMs: this.opts.requestTimeoutMs,
2424
- authorizationProvider: this.opts.authorizationProvider
2632
+ authorizationProvider: this.opts.authorizationProvider,
2633
+ allowPrivateNetworks: this.opts.allowPrivateNetworks
2425
2634
  };
2426
2635
  this.httpTransport = new StreamableHTTPTransport(httpOpts);
2427
2636
  this.httpTransport.onDisconnect(() => {
@@ -2554,6 +2763,13 @@ var MCPClient = class _MCPClient {
2554
2763
  );
2555
2764
  }
2556
2765
  async close() {
2766
+ if (this.closePromise) return this.closePromise;
2767
+ this.closePromise = this.closeInner().finally(() => {
2768
+ this.closePromise = void 0;
2769
+ });
2770
+ return this.closePromise;
2771
+ }
2772
+ async closeInner() {
2557
2773
  if (this.child) {
2558
2774
  const child = this.child;
2559
2775
  const exitPromise = new Promise((resolve) => {
@@ -2570,16 +2786,26 @@ var MCPClient = class _MCPClient {
2570
2786
  }
2571
2787
  const GRACEFUL_MS = 800;
2572
2788
  const FORCE_TIMEOUT_MS = 1200;
2789
+ let gracefulTimer;
2573
2790
  const gracefulRace = await Promise.race([
2574
2791
  exitPromise.then(() => "exited"),
2575
- new Promise((resolve) => setTimeout(() => resolve("timeout"), GRACEFUL_MS))
2792
+ new Promise((resolve) => {
2793
+ gracefulTimer = setTimeout(() => resolve("timeout"), GRACEFUL_MS);
2794
+ gracefulTimer.unref?.();
2795
+ })
2576
2796
  ]);
2797
+ if (gracefulTimer) clearTimeout(gracefulTimer);
2577
2798
  if (gracefulRace === "timeout") {
2578
2799
  forceKillTree(child);
2800
+ let forceTimer;
2579
2801
  await Promise.race([
2580
2802
  exitPromise,
2581
- new Promise((resolve) => setTimeout(resolve, FORCE_TIMEOUT_MS))
2803
+ new Promise((resolve) => {
2804
+ forceTimer = setTimeout(resolve, FORCE_TIMEOUT_MS);
2805
+ forceTimer.unref?.();
2806
+ })
2582
2807
  ]);
2808
+ if (forceTimer) clearTimeout(forceTimer);
2583
2809
  }
2584
2810
  child.stdout?.removeAllListeners();
2585
2811
  child.stderr?.removeAllListeners();
@@ -2709,49 +2935,53 @@ var MCPClient = class _MCPClient {
2709
2935
  this.pending.clear();
2710
2936
  }
2711
2937
  async notify(method, params) {
2938
+ if (this._drainPending) {
2939
+ this._lastNotifySkipped = true;
2940
+ console.warn(
2941
+ JSON.stringify({
2942
+ level: "warn",
2943
+ event: "mcp.notify_skipped_backpressure",
2944
+ server: this.opts.name,
2945
+ method,
2946
+ message: "stdin buffer backpressure (already waiting for drain)",
2947
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2948
+ })
2949
+ );
2950
+ return;
2951
+ }
2952
+ const stdin = this.child?.stdin;
2953
+ if (!stdin || stdin.destroyed === true || stdin.writable === false) {
2954
+ return;
2955
+ }
2712
2956
  const req = { jsonrpc: "2.0", method, params };
2713
2957
  const encoded = JSON.stringify(req) + "\n";
2714
2958
  try {
2715
- const ok = this.child?.stdin?.write(encoded);
2959
+ const ok = stdin.write(encoded);
2716
2960
  if (!ok) {
2717
- if (this._drainPending) {
2718
- this._lastNotifySkipped = true;
2719
- console.warn(
2720
- JSON.stringify({
2721
- level: "warn",
2722
- event: "mcp.notify_skipped_backpressure",
2723
- server: this.opts.name,
2724
- method,
2725
- message: "stdin buffer backpressure (already waiting for drain)",
2726
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2727
- })
2728
- );
2729
- return;
2730
- }
2731
2961
  this._drainPending = true;
2732
2962
  await new Promise((resolve, reject) => {
2733
2963
  const timeout = setTimeout(() => {
2734
- this.child?.stdin?.removeListener?.("drain", onDrain);
2735
- this.child?.stdin?.removeListener?.("error", onError);
2964
+ stdin.removeListener?.("drain", onDrain);
2965
+ stdin.removeListener?.("error", onError);
2736
2966
  this._drainPending = false;
2737
2967
  reject(new Error(`MCP notify("${method}") drain timeout`));
2738
2968
  }, 500);
2739
2969
  const onDrain = () => {
2740
2970
  clearTimeout(timeout);
2741
- this.child?.stdin?.removeListener?.("drain", onDrain);
2742
- this.child?.stdin?.removeListener?.("error", onError);
2971
+ stdin.removeListener?.("drain", onDrain);
2972
+ stdin.removeListener?.("error", onError);
2743
2973
  this._drainPending = false;
2744
2974
  resolve();
2745
2975
  };
2746
2976
  const onError = (err) => {
2747
2977
  clearTimeout(timeout);
2748
- this.child?.stdin?.removeListener?.("drain", onDrain);
2749
- this.child?.stdin?.removeListener?.("error", onError);
2978
+ stdin.removeListener?.("drain", onDrain);
2979
+ stdin.removeListener?.("error", onError);
2750
2980
  this._drainPending = false;
2751
2981
  reject(err);
2752
2982
  };
2753
- this.child?.stdin?.once("drain", onDrain);
2754
- this.child?.stdin?.once("error", onError);
2983
+ stdin.once?.("drain", onDrain);
2984
+ stdin.once?.("error", onError);
2755
2985
  });
2756
2986
  }
2757
2987
  } catch (err) {
@@ -2760,21 +2990,28 @@ var MCPClient = class _MCPClient {
2760
2990
  }
2761
2991
  onData(s) {
2762
2992
  this.rxBuffer += s;
2763
- if (this.rxBuffer.length > _MCPClient.MAX_RX_BUFFER_BYTES) {
2764
- const truncated = this.rxBuffer.length;
2993
+ this.rxBufferBytes += Buffer.byteLength(s, "utf8");
2994
+ if (this.rxBufferBytes > _MCPClient.MAX_RX_BUFFER_BYTES) {
2995
+ const truncated = this.rxBufferBytes;
2765
2996
  this.rxBuffer = "";
2997
+ this.rxBufferBytes = 0;
2766
2998
  this.failPending(
2767
2999
  `MCP "${this.opts.name}" rx buffer overflow (${truncated} bytes without a newline) \u2014 closing connection`
2768
3000
  );
2769
3001
  void this.close();
2770
3002
  return;
2771
3003
  }
3004
+ let start = 0;
2772
3005
  let idx = this.rxBuffer.indexOf("\n");
2773
3006
  while (idx !== -1) {
2774
- const line = this.rxBuffer.slice(0, idx).trim();
2775
- this.rxBuffer = this.rxBuffer.slice(idx + 1);
3007
+ const line = this.rxBuffer.slice(start, idx).trim();
3008
+ start = idx + 1;
2776
3009
  if (line) this.onLine(line);
2777
- idx = this.rxBuffer.indexOf("\n");
3010
+ idx = this.rxBuffer.indexOf("\n", start);
3011
+ }
3012
+ if (start > 0) {
3013
+ this.rxBufferBytes -= Buffer.byteLength(this.rxBuffer.slice(0, start), "utf8");
3014
+ this.rxBuffer = this.rxBuffer.slice(start);
2778
3015
  }
2779
3016
  }
2780
3017
  onLine(line) {
@@ -3091,6 +3328,8 @@ function buildConfig(input, base) {
3091
3328
  if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
3092
3329
  const permission = input.permission ?? base?.permission;
3093
3330
  if (permission !== void 0) cfg.permission = permission;
3331
+ const allowPrivateNetworks = input.allowPrivateNetworks ?? base?.allowPrivateNetworks;
3332
+ if (allowPrivateNetworks !== void 0) cfg.allowPrivateNetworks = allowPrivateNetworks;
3094
3333
  const enabled = input.enabled ?? base?.enabled;
3095
3334
  if (enabled !== void 0) cfg.enabled = enabled;
3096
3335
  const lazy = input.lazy ?? base?.lazy;
@@ -3321,13 +3560,26 @@ function manifestConfigHash(cfg) {
3321
3560
  transport: cfg.transport,
3322
3561
  command: cfg.command ?? null,
3323
3562
  args: cfg.args ?? null,
3324
- url: cfg.url ?? null
3563
+ url: cfg.url ?? null,
3564
+ env: sortedEntries(cfg.env),
3565
+ headers: sortedEntries(cfg.headers),
3566
+ passthroughEnv: passthroughEntries(cfg.passthroughEnv)
3325
3567
  });
3326
3568
  return createHash2("sha256").update(basis).digest("hex").slice(0, 16);
3327
3569
  }
3570
+ function sortedEntries(values) {
3571
+ return values ? Object.entries(values).sort(([left], [right]) => left.localeCompare(right)) : null;
3572
+ }
3573
+ function passthroughEntries(names) {
3574
+ return names ? [...new Set(names)].sort().map((name) => [name, process.env[name] ?? null]) : null;
3575
+ }
3328
3576
  function manifestFile(cacheDir, name) {
3329
3577
  const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
3330
- return path.join(cacheDir, "mcp-tools", `${safe}.json`);
3578
+ if (safe === name && name === name.toLowerCase()) {
3579
+ return path.join(cacheDir, "mcp-tools", `${safe}.json`);
3580
+ }
3581
+ const identity = createHash2("sha256").update(name).digest("hex");
3582
+ return path.join(cacheDir, "mcp-tools", `${safe}-${identity}.json`);
3331
3583
  }
3332
3584
  async function readManifest(cacheDir, name, configHash) {
3333
3585
  const manifest = await readCapabilityManifest(cacheDir, name, configHash);
@@ -3611,7 +3863,8 @@ function wrapMCPTool(serverName, mcpTool, client, permission = "confirm", observ
3611
3863
  const signal = opts?.signal ?? ctx?.signal;
3612
3864
  const res = await live.callTool(mcpTool.name, input, signal ? { signal } : void 0);
3613
3865
  if (res.isError) {
3614
- throw new Error(stringify(res.content));
3866
+ const errText = stringify(res.content);
3867
+ throw new Error(errText || `MCP tool "${qualifiedName}" failed`);
3615
3868
  }
3616
3869
  ok = true;
3617
3870
  return stringify(res.content);
@@ -3660,6 +3913,7 @@ function applySlotTools(ctx, slot, tools, client) {
3660
3913
  },
3661
3914
  onFinish: ({ durationMs, ok }) => {
3662
3915
  slot.operations.inFlightCalls = Math.max(0, slot.operations.inFlightCalls - 1);
3916
+ slot.lastUsed = Date.now();
3663
3917
  pushBounded(slot.operations.callSamples, durationMs, MCP_OPERATION_LIMITS.LATENCY_SAMPLES);
3664
3918
  if (ok) {
3665
3919
  ctx.recordSuccess(slot);
@@ -3771,6 +4025,7 @@ async function attemptConnectSlot(ctx, slot) {
3771
4025
  headers: slot.cfg.headers,
3772
4026
  startupTimeoutMs: slot.cfg.startupTimeoutMs,
3773
4027
  requestTimeoutMs: slot.cfg.requestTimeoutMs,
4028
+ allowPrivateNetworks: slot.cfg.allowPrivateNetworks,
3774
4029
  passthroughEnv: slot.cfg.passthroughEnv,
3775
4030
  authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg)
3776
4031
  });
@@ -3783,7 +4038,7 @@ async function attemptConnectSlot(ctx, slot) {
3783
4038
  client.addToolsChangedListener(ctx.onToolsChanged);
3784
4039
  ctx.addCatalogListeners(client);
3785
4040
  await client.connect();
3786
- if (slot.state === "disconnected" || ctx.servers.has(slot.cfg.name) && ctx.servers.get(slot.cfg.name) !== slot) {
4041
+ if (slot.state === "disconnected" || !ctx.servers.has(slot.cfg.name) || ctx.servers.get(slot.cfg.name) !== slot) {
3787
4042
  client.removeExitListener(ctx.onChildExit);
3788
4043
  if (boundDisconnect) client.removeDisconnectListener(boundDisconnect);
3789
4044
  client.removeToolsChangedListener(ctx.onToolsChanged);
@@ -3886,8 +4141,28 @@ function resetDisconnectedSlotTools(slot, toolRegistry) {
3886
4141
  slot.resourceTemplates = void 0;
3887
4142
  slot.prompts = void 0;
3888
4143
  }
3889
- function markLazySlotDormant(slot, events, reason) {
3890
- slot.client = void 0;
4144
+ function markLazySlotDormant(slot, events, reason, options) {
4145
+ slot.reconnectPending = false;
4146
+ if (slot.reconnectTimer) {
4147
+ clearTimeout(slot.reconnectTimer);
4148
+ slot.reconnectTimer = void 0;
4149
+ }
4150
+ if (slot.client) {
4151
+ if (options?.onChildExit) {
4152
+ slot.client.removeExitListener?.(options.onChildExit);
4153
+ }
4154
+ if (slot.onDisconnect) {
4155
+ slot.client.removeDisconnectListener?.(slot.onDisconnect);
4156
+ }
4157
+ if (options?.onToolsChanged) {
4158
+ slot.client.removeToolsChangedListener?.(options.onToolsChanged);
4159
+ }
4160
+ options?.removeCatalogListeners?.(slot.client);
4161
+ slot.client.close?.().catch(() => {
4162
+ });
4163
+ slot.client = void 0;
4164
+ }
4165
+ slot.onDisconnect = void 0;
3891
4166
  slot.state = "dormant";
3892
4167
  events.emit("mcp.server.disconnected", {
3893
4168
  name: slot.cfg.name,
@@ -3950,17 +4225,23 @@ function buildRegistryOperationalHealth(servers, disabledServers) {
3950
4225
 
3951
4226
  // src/registry-idle.ts
3952
4227
  async function sleepIdleSlot(ctx, slot) {
4228
+ if (slot.operations.inFlightCalls > 0) return;
3953
4229
  slot.reconnectPending = false;
3954
4230
  if (slot.reconnectTimer) {
3955
4231
  clearTimeout(slot.reconnectTimer);
3956
4232
  slot.reconnectTimer = void 0;
3957
4233
  }
3958
4234
  if (slot.client) {
3959
- slot.client.removeExitListener(ctx.onChildExit);
3960
- if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
3961
- slot.client.removeToolsChangedListener(ctx.onToolsChanged);
3962
- ctx.removeCatalogListeners(slot.client);
3963
- await slot.client.close();
4235
+ const client = slot.client;
4236
+ client.removeExitListener?.(ctx.onChildExit);
4237
+ if (slot.onDisconnect) client.removeDisconnectListener?.(slot.onDisconnect);
4238
+ client.removeToolsChangedListener?.(ctx.onToolsChanged);
4239
+ ctx.removeCatalogListeners(client);
4240
+ try {
4241
+ await client.close?.();
4242
+ } catch (err) {
4243
+ ctx.log.warn(`MCP server "${slot.cfg.name}" error during idle sleep close`, err);
4244
+ }
3964
4245
  slot.client = void 0;
3965
4246
  }
3966
4247
  slot.onDisconnect = void 0;
@@ -3974,7 +4255,7 @@ async function sweepIdleSlots(ctx) {
3974
4255
  if (ctx.idleTimeoutMs <= 0) return false;
3975
4256
  const now = Date.now();
3976
4257
  for (const slot of ctx.servers.values()) {
3977
- if (slot.lazy && slot.state === "connected" && slot.client && now - slot.lastUsed > ctx.idleTimeoutMs) {
4258
+ if (slot.lazy && slot.state === "connected" && slot.client && slot.operations.inFlightCalls === 0 && now - slot.lastUsed > ctx.idleTimeoutMs) {
3978
4259
  await sleepIdleSlot(ctx, slot);
3979
4260
  }
3980
4261
  }
@@ -4062,6 +4343,7 @@ function scheduleRegistryReconnect({
4062
4343
  slot.reconnectTimer = void 0;
4063
4344
  void attemptReconnect(slot);
4064
4345
  }, delay);
4346
+ slot.reconnectTimer.unref?.();
4065
4347
  }
4066
4348
 
4067
4349
  // src/registry.ts
@@ -4209,7 +4491,7 @@ var MCPRegistry = class _MCPRegistry {
4209
4491
  if (!slot) throw new Error(`MCP server "${name}" not registered`);
4210
4492
  slot.lastUsed = Date.now();
4211
4493
  if (slot.client && slot.state === "connected") return slot.client;
4212
- const waking = slot.state === "dormant";
4494
+ const waking = slot.state === "dormant" && !slot.connecting;
4213
4495
  if (waking) {
4214
4496
  slot.operations.wakeCount++;
4215
4497
  this.recordOperation(slot, "wake", "lazy-demand");
@@ -4285,22 +4567,21 @@ var MCPRegistry = class _MCPRegistry {
4285
4567
  }
4286
4568
  slot.state = "disconnected";
4287
4569
  if (slot.client) {
4288
- slot.client.removeExitListener(this.onChildExit);
4289
- if (slot.onDisconnect) slot.client.removeDisconnectListener(slot.onDisconnect);
4290
- slot.client.removeToolsChangedListener(this.onToolsChanged);
4291
- this.removeCatalogListeners(slot.client);
4292
- await slot.client.close();
4570
+ const client = slot.client;
4571
+ client.removeExitListener?.(this.onChildExit);
4572
+ if (slot.onDisconnect) client.removeDisconnectListener?.(slot.onDisconnect);
4573
+ client.removeToolsChangedListener?.(this.onToolsChanged);
4574
+ this.removeCatalogListeners(client);
4575
+ try {
4576
+ await client.close?.();
4577
+ } catch (err) {
4578
+ this.log.warn(`MCP server "${name}" error during stop close`, err);
4579
+ }
4293
4580
  slot.client = void 0;
4294
4581
  }
4295
4582
  slot.onDisconnect = void 0;
4296
4583
  slot.connecting = void 0;
4297
- for (const t of slot.toolNames) this.toolRegistry.unregister(t);
4298
- slot.toolNames = [];
4299
- slot.lazyTools = [];
4300
- slot.serverMetadata = void 0;
4301
- slot.resources = void 0;
4302
- slot.resourceTemplates = void 0;
4303
- slot.prompts = void 0;
4584
+ resetDisconnectedSlotTools(slot, this.toolRegistry);
4304
4585
  slot.registeredLazy = false;
4305
4586
  this.recordOperation(slot, "stop", "manual");
4306
4587
  this.events.emit("mcp.server.disconnected", { name, reason: "stop" });
@@ -4344,8 +4625,17 @@ var MCPRegistry = class _MCPRegistry {
4344
4625
  }
4345
4626
  getCatalog(name) {
4346
4627
  const slot = this.servers.get(name);
4347
- if (!slot) return void 0;
4348
- return registryCatalogSnapshot(slot);
4628
+ if (slot) {
4629
+ return registryCatalogSnapshot(slot);
4630
+ }
4631
+ const disabled = this.disabledServers.get(name);
4632
+ if (disabled) {
4633
+ return {
4634
+ name: disabled.name,
4635
+ state: "idle"
4636
+ };
4637
+ }
4638
+ return void 0;
4349
4639
  }
4350
4640
  async listResources(name, opts = {}) {
4351
4641
  const slot = this.requireSlot(name);
@@ -4502,9 +4792,16 @@ var MCPRegistry = class _MCPRegistry {
4502
4792
  clearInterval(this.idleTimer);
4503
4793
  this.idleTimer = void 0;
4504
4794
  }
4505
- for (const name of Array.from(this.servers.keys())) {
4506
- await this.stop(name);
4507
- }
4795
+ const names = Array.from(this.servers.keys());
4796
+ await Promise.all(
4797
+ names.map(async (name) => {
4798
+ try {
4799
+ await this.stop(name);
4800
+ } catch (err) {
4801
+ this.log.warn(`MCP server "${name}" failed to stop during stopAll`, err);
4802
+ }
4803
+ })
4804
+ );
4508
4805
  this.disabledServers.clear();
4509
4806
  }
4510
4807
  /**
@@ -4566,15 +4863,19 @@ var MCPRegistry = class _MCPRegistry {
4566
4863
  client.addPromptsChangedListener(this.onPromptsChanged);
4567
4864
  }
4568
4865
  removeCatalogListeners(client) {
4569
- client.removeResourcesChangedListener(this.onResourcesChanged);
4570
- client.removePromptsChangedListener(this.onPromptsChanged);
4866
+ client.removeResourcesChangedListener?.(this.onResourcesChanged);
4867
+ client.removePromptsChangedListener?.(this.onPromptsChanged);
4571
4868
  }
4572
4869
  onChildExit = (name, code, _signal) => {
4573
4870
  const slot = this.servers.get(name);
4574
4871
  if (!slot) return;
4575
4872
  if (slot.lazy) {
4576
4873
  this.recordFailure(slot, "transport", "process-exit-lazy");
4577
- markLazySlotDormant(slot, this.events, `exit:${code ?? "unknown"}`);
4874
+ markLazySlotDormant(slot, this.events, `exit:${code ?? "unknown"}`, {
4875
+ onChildExit: this.onChildExit,
4876
+ onToolsChanged: this.onToolsChanged,
4877
+ removeCatalogListeners: (c) => this.removeCatalogListeners(c)
4878
+ });
4578
4879
  return;
4579
4880
  }
4580
4881
  resetDisconnectedSlotTools(slot, this.toolRegistry);
@@ -4589,7 +4890,11 @@ var MCPRegistry = class _MCPRegistry {
4589
4890
  if (!slot) return;
4590
4891
  if (slot.lazy) {
4591
4892
  this.recordFailure(slot, "transport", "http-disconnect-lazy");
4592
- markLazySlotDormant(slot, this.events, "http-disconnect");
4893
+ markLazySlotDormant(slot, this.events, "http-disconnect", {
4894
+ onChildExit: this.onChildExit,
4895
+ onToolsChanged: this.onToolsChanged,
4896
+ removeCatalogListeners: (c) => this.removeCatalogListeners(c)
4897
+ });
4593
4898
  return;
4594
4899
  }
4595
4900
  resetDisconnectedSlotTools(slot, this.toolRegistry);
@@ -4908,6 +5213,7 @@ function serveStdio(server, opts = {}) {
4908
5213
  const stdin = opts.stdin ?? process.stdin;
4909
5214
  const stdout = opts.stdout ?? process.stdout;
4910
5215
  let buffer = "";
5216
+ let bufferBytes = 0;
4911
5217
  let closed = false;
4912
5218
  let bufferTooLarge = false;
4913
5219
  let writeChain = Promise.resolve();
@@ -4932,10 +5238,13 @@ function serveStdio(server, opts = {}) {
4932
5238
  };
4933
5239
  const onData = (chunk) => {
4934
5240
  if (bufferTooLarge) return;
4935
- buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
4936
- if (buffer.length > HTTP_BODY_CAP) {
5241
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
5242
+ buffer += text;
5243
+ bufferBytes += Buffer.byteLength(text, "utf8");
5244
+ if (bufferBytes > HTTP_BODY_CAP) {
4937
5245
  bufferTooLarge = true;
4938
5246
  buffer = "";
5247
+ bufferBytes = 0;
4939
5248
  console.error(
4940
5249
  JSON.stringify({
4941
5250
  level: "error",
@@ -4952,11 +5261,14 @@ function serveStdio(server, opts = {}) {
4952
5261
  onEnd();
4953
5262
  return;
4954
5263
  }
4955
- let idx = buffer.indexOf("\n");
5264
+ let start = 0;
5265
+ let idx = buffer.indexOf("\n", start);
4956
5266
  while (idx !== -1) {
4957
- const line = buffer.slice(0, idx);
4958
- buffer = buffer.slice(idx + 1);
4959
- idx = buffer.indexOf("\n");
5267
+ let end = idx;
5268
+ if (end > start && buffer.charCodeAt(end - 1) === 13) end--;
5269
+ const line = buffer.slice(start, end);
5270
+ start = idx + 1;
5271
+ idx = buffer.indexOf("\n", start);
4960
5272
  if (!line.trim()) continue;
4961
5273
  const handler = server.handleMessage(line).then((res) => {
4962
5274
  if (res !== null) writeLine(res);
@@ -4974,6 +5286,10 @@ function serveStdio(server, opts = {}) {
4974
5286
  });
4975
5287
  inFlightHandlers.add(handler);
4976
5288
  }
5289
+ if (start > 0) {
5290
+ bufferBytes -= Buffer.byteLength(buffer.slice(0, start), "utf8");
5291
+ buffer = buffer.slice(start);
5292
+ }
4977
5293
  };
4978
5294
  let resolveDone;
4979
5295
  const done = new Promise((resolve) => {
@@ -4985,6 +5301,26 @@ function serveStdio(server, opts = {}) {
4985
5301
  if (closed) return;
4986
5302
  closed = true;
4987
5303
  stdin.off("data", onData);
5304
+ if (!bufferTooLarge && buffer.trim()) {
5305
+ const line = buffer.trim();
5306
+ buffer = "";
5307
+ bufferBytes = 0;
5308
+ const handler = server.handleMessage(line).then((res) => {
5309
+ if (res !== null) writeLine(res);
5310
+ }).catch((err) => {
5311
+ console.error(
5312
+ JSON.stringify({
5313
+ level: "error",
5314
+ event: "mcp_server.handle_message_failed",
5315
+ message: toErrorMessage2(err),
5316
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
5317
+ })
5318
+ );
5319
+ }).finally(() => {
5320
+ inFlightHandlers.delete(handler);
5321
+ });
5322
+ inFlightHandlers.add(handler);
5323
+ }
4988
5324
  resolveDone();
4989
5325
  };
4990
5326
  stdin.on("data", onData);
@@ -5069,18 +5405,26 @@ async function handleHttpRequest(server, req, res, token, log, boundHost) {
5069
5405
  return send(415, JSON.stringify({ error: "content-type must be application/json" }));
5070
5406
  }
5071
5407
  let body = "";
5408
+ let bodyBytes = 0;
5409
+ let aborted = false;
5072
5410
  req.on("data", (chunk) => {
5411
+ if (aborted) return;
5412
+ bodyBytes += chunk.byteLength;
5073
5413
  body += chunk.toString("utf8");
5074
- if (body.length > HTTP_BODY_CAP) {
5414
+ if (bodyBytes > HTTP_BODY_CAP) {
5415
+ aborted = true;
5075
5416
  send(413, JSON.stringify({ error: "payload too large" }));
5076
5417
  req.destroy();
5077
5418
  }
5078
5419
  });
5079
5420
  req.on("end", () => {
5421
+ if (aborted) return;
5080
5422
  void server.handleMessage(body).then((out) => {
5423
+ if (aborted) return;
5081
5424
  if (out === null) return send(202, "");
5082
5425
  return send(200, out);
5083
5426
  }).catch((err) => {
5427
+ if (aborted) return;
5084
5428
  log?.warn?.(`MCP http handler error: ${toErrorMessage2(err)}`);
5085
5429
  send(500, JSON.stringify({ error: "internal error" }));
5086
5430
  });
@@ -5288,13 +5632,14 @@ var MCPRefreshingAuthorizationProvider = class {
5288
5632
  return await this.refresh(state, context.signal) !== void 0;
5289
5633
  }
5290
5634
  refresh(state, signal) {
5291
- if (this.refreshPromise) return this.refreshPromise;
5292
- this.refreshPromise = this.refreshInner(state, signal).finally(() => {
5293
- this.refreshPromise = void 0;
5294
- });
5295
- return this.refreshPromise;
5635
+ if (!this.refreshPromise) {
5636
+ this.refreshPromise = this.refreshInner(state).finally(() => {
5637
+ this.refreshPromise = void 0;
5638
+ });
5639
+ }
5640
+ return awaitWithAbort(this.refreshPromise, signal);
5296
5641
  }
5297
- async refreshInner(state, signal) {
5642
+ async refreshInner(state) {
5298
5643
  const refreshToken = state.tokenSet.refreshToken;
5299
5644
  if (!refreshToken) {
5300
5645
  this.emit("reauth_required", state);
@@ -5304,8 +5649,7 @@ var MCPRefreshingAuthorizationProvider = class {
5304
5649
  authorizationServer: state.authorizationServer,
5305
5650
  clientId: state.clientId,
5306
5651
  resource: state.resource,
5307
- refreshToken,
5308
- signal
5652
+ refreshToken
5309
5653
  });
5310
5654
  const next = normalizeStoredAuthorization({
5311
5655
  ...state,
@@ -5351,6 +5695,18 @@ function createVaultBackedMcpAuthorizationProviderFactory(options) {
5351
5695
  return provider;
5352
5696
  };
5353
5697
  }
5698
+ function awaitWithAbort(promise, signal) {
5699
+ if (!signal) return promise;
5700
+ if (signal.aborted) return Promise.reject(abortReason(signal));
5701
+ return new Promise((resolve, reject) => {
5702
+ const onAbort = () => reject(abortReason(signal));
5703
+ signal.addEventListener("abort", onAbort, { once: true });
5704
+ void promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
5705
+ });
5706
+ }
5707
+ function abortReason(signal) {
5708
+ return signal.reason instanceof Error ? signal.reason : new Error("MCP token refresh aborted");
5709
+ }
5354
5710
  function emptyFile() {
5355
5711
  return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
5356
5712
  }
package/dist/manage.d.ts CHANGED
@@ -6,6 +6,7 @@ type TransportInput = 'stdio' | 'sse' | 'streamable-http' | 'http';
6
6
  export interface McpServerInput {
7
7
  name: string;
8
8
  transport?: TransportInput | string | undefined;
9
+ allowPrivateNetworks?: boolean | undefined;
9
10
  description?: string | undefined;
10
11
  enabled?: boolean | undefined;
11
12
  command?: string | undefined;
@@ -13,6 +13,9 @@ export declare function manifestConfigHash(cfg: {
13
13
  command?: string | undefined;
14
14
  args?: string[] | undefined;
15
15
  url?: string | undefined;
16
+ env?: Record<string, string> | undefined;
17
+ headers?: Record<string, string> | undefined;
18
+ passthroughEnv?: string[] | undefined;
16
19
  }): string;
17
20
  /**
18
21
  * Read a server's cached tools. Returns null when there is no cache or when the
@@ -2,5 +2,12 @@ import type { EventBus } from '@wrongstack/core/kernel';
2
2
  import type { ToolRegistry } from '@wrongstack/core/registry';
3
3
  import type { ServerSlot } from './registry-slots.js';
4
4
  export declare function resetDisconnectedSlotTools(slot: ServerSlot, toolRegistry: ToolRegistry): void;
5
- export declare function markLazySlotDormant(slot: ServerSlot, events: EventBus, reason: string): void;
5
+ export interface MarkLazySlotDormantOptions {
6
+ onChildExit?: (name: string, code: number | null, signal: string | null) => void;
7
+ onToolsChanged?: (name: string, tools: {
8
+ name: string;
9
+ }[]) => void;
10
+ removeCatalogListeners?: (client: import('./client.js').MCPClient) => void;
11
+ }
12
+ export declare function markLazySlotDormant(slot: ServerSlot, events: EventBus, reason: string, options?: MarkLazySlotDormantOptions): void;
6
13
  //# sourceMappingURL=registry-disconnect.d.ts.map
@@ -2,6 +2,7 @@ import * as https from 'node:https';
2
2
  import { type MCPAuthorizationProvider } from './authorization.js';
3
3
  import type { ConnectionState, MCPTool } from './contracts.js';
4
4
  import type { MCPServerMetadata } from './protocol.js';
5
+ import { type TransportDnsLookup } from './transport-security.js';
5
6
  export interface HttpTransportOptions {
6
7
  name: string;
7
8
  url: string;
@@ -27,6 +28,21 @@ export interface HttpTransportOptions {
27
28
  ca?: string | undefined;
28
29
  rejectUnauthorized?: boolean | undefined;
29
30
  };
31
+ /**
32
+ * Resolution-bound private-network policy. Default: the transport resolves
33
+ * the configured hostname itself and refuses dial-time addresses outside
34
+ * the public internet — link-local/IMDS always, other private/LAN ranges
35
+ * unless this flag is set. The configured hostname is still used for the
36
+ * Host header and TLS SNI, so certificate validation is unaffected, and
37
+ * plaintext http:// remains loopback-only (validateTransportUrl).
38
+ */
39
+ allowPrivateNetworks?: boolean | undefined;
40
+ /**
41
+ * DNS seam for tests and hosts with custom resolvers. Production callers
42
+ * omit it: dns.lookup(hostname, { all: true }) runs and every returned
43
+ * record is policy-checked before the dial.
44
+ */
45
+ lookup?: TransportDnsLookup | undefined;
30
46
  }
31
47
  /**
32
48
  * Abort error whose `name` is `'AbortError'` so the core executor's
@@ -52,6 +68,10 @@ export declare abstract class BaseHTTPTransport {
52
68
  protected readonly authorizationResource: string;
53
69
  /** Per-request TLS agent — created once from HttpTransportOptions.tls */
54
70
  protected readonly tlsAgent?: https.Agent | undefined;
71
+ private readonly tlsOptions;
72
+ private readonly allowPrivateNetworks;
73
+ private readonly lookup;
74
+ private pinnedAgent;
55
75
  protected readonly tools: MCPTool[];
56
76
  protected serverMetadata?: MCPServerMetadata | undefined;
57
77
  protected abortController?: AbortController | undefined;
@@ -76,12 +96,23 @@ export declare abstract class BaseHTTPTransport {
76
96
  protected notifyDisconnect(): void;
77
97
  protected notifyResourcesChanged(): void;
78
98
  protected notifyPromptsChanged(): void;
99
+ private dispatcherFetch;
100
+ private pinnedDispatcher;
101
+ private applyPinnedDispatcher;
102
+ /**
103
+ * Destroy this transport's pinned Agent and its connection pool. Idempotent.
104
+ * Subclasses call it from close(); the process-exit sweep is the backstop.
105
+ */
106
+ protected releasePinnedDispatcher(): void;
79
107
  /**
80
108
  * Apply the pinned TLS agent (if configured) to a `RequestInit` object.
81
109
  * Uses `HttpDispatcher` from `@wrongstack/core`'s dispatcher-types shim,
82
110
  * which declares `https.Agent` compatible with `RequestInit.dispatcher`.
83
111
  * Verified safe: https.Agent implements the `dispatch(req, opts)` method
84
112
  * that fetch requires at runtime.
113
+ *
114
+ * Superseded at fetch time by `applyPinnedDispatcher`, whose Agent embeds
115
+ * these same TLS options plus the resolution-bound lookup.
85
116
  */
86
117
  protected applyTlsAgent(fetchOpts: RequestInit): void;
87
118
  /** Generate the next JSON-RPC request id. Subclasses provide the counter. */
@@ -10,4 +10,52 @@ export declare function isTlsUnsafeAllowed(): boolean;
10
10
  * the most obvious attack vectors.
11
11
  */
12
12
  export declare function validateTransportUrl(rawUrl: string): void;
13
+ /** Global escape hatch, mirroring the fetch tool's WRONGSTACK_FETCH_ALLOW_PRIVATE. */
14
+ export declare const ALLOW_MCP_PRIVATE_NETWORKS: boolean;
15
+ /** One resolved DNS record for an MCP transport hostname. */
16
+ export interface TransportDnsRecord {
17
+ address: string;
18
+ family: number;
19
+ }
20
+ /** DNS seam: production uses dns.lookup(host, { all: true }); tests inject. */
21
+ export type TransportDnsLookup = (hostname: string) => Promise<readonly TransportDnsRecord[]>;
22
+ /** Policy verdict for one resolved address. */
23
+ export type TransportAddressClass = 'loopback' | 'private' | 'blocked' | 'public';
24
+ /**
25
+ * Classify one resolved address under MCP transport policy:
26
+ * - `blocked` — link-local / IMDS (169.254/16, fe80::/10, fd00:ec2::254);
27
+ * never a valid MCP target, regardless of opt-in.
28
+ * - `loopback` — 127/8, ::1; the documented local topology, always allowed.
29
+ * - `private` — other private/reserved ranges (10/8, 172.16/12, 192.168/16,
30
+ * CGNAT, ULA, ...); allowed only with allowPrivateNetworks.
31
+ * - `public` — no dial-time restriction beyond the string-level checks.
32
+ */
33
+ export declare function classifyTransportAddress(address: string, family: number): TransportAddressClass;
34
+ /**
35
+ * Refuse `address` unless the transport policy allows dialing it:
36
+ * link-local/IMDS always throws; other private ranges throw unless opted in
37
+ * via allowPrivateNetworks. `hostname` is the configured name the address was
38
+ * resolved from, kept for error context.
39
+ */
40
+ export declare function assertTransportAddressAllowed(address: string, family: number, hostname: string, allowPrivateNetworks: boolean): void;
41
+ /** Node-style DNS callback for undici Agent connect options. */
42
+ type NodeLookupCallback = (err: NodeJS.ErrnoException | null, address?: string | readonly TransportDnsRecord[], family?: number) => void;
43
+ export interface PinnedLookupOptions {
44
+ allowPrivateNetworks: boolean;
45
+ /** Test/host seam. Production omits it: dns.lookup(all: true). */
46
+ lookup?: TransportDnsLookup | undefined;
47
+ }
48
+ /**
49
+ * The lookup installed into the pinned undici Agent's connect options. It is
50
+ * the single DNS resolution the TCP dial performs: every returned record is
51
+ * classified and refused BEFORE the socket connects, so there is no rebinding
52
+ * window between validation and connect. Mirrors tools/_fetch-guard.ts
53
+ * guardedLookup with MCP policy (loopback allowed, link-local never, opt-in
54
+ * for other private ranges).
55
+ */
56
+ export declare function transportPinnedLookup(options: PinnedLookupOptions): (hostname: string, connectOptions: {
57
+ family?: number | undefined;
58
+ all?: boolean | undefined;
59
+ }, callback: NodeLookupCallback) => void;
60
+ export {};
13
61
  //# sourceMappingURL=transport-security.d.ts.map
@@ -27,5 +27,6 @@ export declare class SSETransport extends BaseHTTPTransport {
27
27
  signal?: AbortSignal | undefined;
28
28
  }): Promise<JsonRpcResponse>;
29
29
  close(): Promise<void>;
30
+ private markDisconnected;
30
31
  }
31
32
  //# sourceMappingURL=transport-sse.d.ts.map
@@ -23,5 +23,14 @@ export declare class StreamableHTTPTransport extends BaseHTTPTransport {
23
23
  signal?: AbortSignal | undefined;
24
24
  }): Promise<ToolCallResult>;
25
25
  close(): Promise<void>;
26
+ /**
27
+ * HTTP statuses that mean the streamable-http session itself is gone
28
+ * (auth rejected, session id unknown or expired). Only these tear the
29
+ * connection down on the request path; transient faults (5xx, network
30
+ * resets) surface as call errors and keep the session alive so the next
31
+ * request can succeed — the contract pinned by http-fault-soak.test.ts.
32
+ */
33
+ private static readonly SESSION_FATAL_HTTP_STATUSES;
34
+ private markDisconnected;
26
35
  }
27
36
  //# sourceMappingURL=transport-streamable.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/mcp",
3
- "version": "0.319.1",
3
+ "version": "1.0.0",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack Model Context Protocol client and registry: stdio, SSE, and streamable HTTP transports.",
6
6
  "repository": {
@@ -26,7 +26,8 @@
26
26
  "!dist/**/*.map"
27
27
  ],
28
28
  "dependencies": {
29
- "@wrongstack/core": "0.319.1"
29
+ "@wrongstack/core": "1.0.0",
30
+ "undici": "^8.10.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^26.2.0",