@wrongstack/mcp 0.320.1 → 1.0.1

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?;
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";
@@ -1355,6 +1457,10 @@ var BaseHTTPTransport = class {
1355
1457
  authorizationResource;
1356
1458
  /** Per-request TLS agent — created once from HttpTransportOptions.tls */
1357
1459
  tlsAgent;
1460
+ tlsOptions;
1461
+ allowPrivateNetworks;
1462
+ lookup;
1463
+ pinnedAgent;
1358
1464
  tools = [];
1359
1465
  serverMetadata;
1360
1466
  abortController;
@@ -1390,6 +1496,9 @@ var BaseHTTPTransport = class {
1390
1496
  rejectUnauthorized: opts.tls.rejectUnauthorized
1391
1497
  });
1392
1498
  }
1499
+ this.tlsOptions = opts.tls;
1500
+ this.allowPrivateNetworks = opts.allowPrivateNetworks === true || ALLOW_MCP_PRIVATE_NETWORKS;
1501
+ this.lookup = opts.lookup;
1393
1502
  }
1394
1503
  getState() {
1395
1504
  return this.state;
@@ -1415,7 +1524,9 @@ var BaseHTTPTransport = class {
1415
1524
  let currentUrl = typeof input === "string" ? input : String(input);
1416
1525
  let hopHeaders = headers;
1417
1526
  for (let hop = 0; hop < MAX_TRANSPORT_REDIRECTS; hop++) {
1418
- 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);
1419
1530
  if (res.status !== 301 && res.status !== 302 && res.status !== 303 && res.status !== 307 && res.status !== 308) {
1420
1531
  return res;
1421
1532
  }
@@ -1510,12 +1621,48 @@ var BaseHTTPTransport = class {
1510
1621
  }
1511
1622
  }
1512
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
+ }
1513
1657
  /**
1514
1658
  * Apply the pinned TLS agent (if configured) to a `RequestInit` object.
1515
1659
  * Uses `HttpDispatcher` from `@wrongstack/core`'s dispatcher-types shim,
1516
1660
  * which declares `https.Agent` compatible with `RequestInit.dispatcher`.
1517
1661
  * Verified safe: https.Agent implements the `dispatch(req, opts)` method
1518
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.
1519
1666
  */
1520
1667
  applyTlsAgent(fetchOpts) {
1521
1668
  if (this.tlsAgent) {
@@ -1851,6 +1998,7 @@ var SSETransport = class extends BaseHTTPTransport {
1851
1998
  });
1852
1999
  throw makeAbortError(method);
1853
2000
  }
2001
+ this.markDisconnected();
1854
2002
  throw err;
1855
2003
  } finally {
1856
2004
  timeoutSignal.dispose();
@@ -1934,12 +2082,14 @@ var SSETransport = class extends BaseHTTPTransport {
1934
2082
  });
1935
2083
  throw makeAbortError(method);
1936
2084
  }
2085
+ this.markDisconnected();
1937
2086
  throw err;
1938
2087
  } finally {
1939
2088
  timeoutSignal.dispose();
1940
2089
  }
1941
2090
  }
1942
2091
  async close() {
2092
+ this.releasePinnedDispatcher();
1943
2093
  if (this.state === "disconnected") return;
1944
2094
  this.readerDone = true;
1945
2095
  this.readLoopAbort?.abort();
@@ -1955,10 +2105,16 @@ var SSETransport = class extends BaseHTTPTransport {
1955
2105
  this.disconnectHandlers.splice(0, this.disconnectHandlers.length);
1956
2106
  this.state = "disconnected";
1957
2107
  }
2108
+ markDisconnected() {
2109
+ if (this.state === "connected") {
2110
+ this.state = "disconnected";
2111
+ this.notifyDisconnect();
2112
+ }
2113
+ }
1958
2114
  };
1959
2115
 
1960
2116
  // src/transport-streamable.ts
1961
- var StreamableHTTPTransport = class extends BaseHTTPTransport {
2117
+ var StreamableHTTPTransport = class _StreamableHTTPTransport extends BaseHTTPTransport {
1962
2118
  _nextId = 1;
1963
2119
  sessionId;
1964
2120
  constructor(opts) {
@@ -2090,6 +2246,9 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
2090
2246
  try {
2091
2247
  const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
2092
2248
  if (!res.ok) {
2249
+ if (_StreamableHTTPTransport.SESSION_FATAL_HTTP_STATUSES.has(res.status)) {
2250
+ this.markDisconnected();
2251
+ }
2093
2252
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
2094
2253
  }
2095
2254
  if (method.startsWith("notifications/")) {
@@ -2137,6 +2296,9 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
2137
2296
  try {
2138
2297
  const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
2139
2298
  if (!res.ok) {
2299
+ if (_StreamableHTTPTransport.SESSION_FATAL_HTTP_STATUSES.has(res.status)) {
2300
+ this.markDisconnected();
2301
+ }
2140
2302
  throw new Error(`HTTP ${res.status}: ${res.statusText}`);
2141
2303
  }
2142
2304
  if (method.startsWith("notifications/")) {
@@ -2182,11 +2344,26 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
2182
2344
  };
2183
2345
  }
2184
2346
  async close() {
2347
+ this.releasePinnedDispatcher();
2185
2348
  if (this.state === "disconnected") return;
2186
2349
  this.state = "disconnected";
2187
2350
  this.abortController?.abort();
2188
2351
  this.disconnectHandlers.splice(0, this.disconnectHandlers.length);
2189
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
+ }
2190
2367
  };
2191
2368
 
2192
2369
  // src/client.ts
@@ -2214,6 +2391,7 @@ var MCPClient = class _MCPClient {
2214
2391
  */
2215
2392
  pending = /* @__PURE__ */ new Map();
2216
2393
  rxBuffer = "";
2394
+ rxBufferBytes = 0;
2217
2395
  _tools = [];
2218
2396
  /** Server-declared handshake metadata. Populated for stdio in the first protocol slice. */
2219
2397
  _serverMetadata;
@@ -2298,6 +2476,7 @@ var MCPClient = class _MCPClient {
2298
2476
  throw new Error('MCP stdio transport requires "command"');
2299
2477
  }
2300
2478
  this.rxBuffer = "";
2479
+ this.rxBufferBytes = 0;
2301
2480
  const extraEnv = { ...this.opts.env };
2302
2481
  if (this.opts.passthroughEnv) {
2303
2482
  for (const name of this.opts.passthroughEnv) {
@@ -2399,7 +2578,8 @@ var MCPClient = class _MCPClient {
2399
2578
  headers: this.opts.headers,
2400
2579
  startupTimeoutMs: this.opts.startupTimeoutMs,
2401
2580
  requestTimeoutMs: this.opts.requestTimeoutMs,
2402
- authorizationProvider: this.opts.authorizationProvider
2581
+ authorizationProvider: this.opts.authorizationProvider,
2582
+ allowPrivateNetworks: this.opts.allowPrivateNetworks
2403
2583
  };
2404
2584
  this.sseTransport = new SSETransport(httpOpts);
2405
2585
  this.sseTransport.onDisconnect(() => {
@@ -2449,7 +2629,8 @@ var MCPClient = class _MCPClient {
2449
2629
  headers: this.opts.headers,
2450
2630
  startupTimeoutMs: this.opts.startupTimeoutMs,
2451
2631
  requestTimeoutMs: this.opts.requestTimeoutMs,
2452
- authorizationProvider: this.opts.authorizationProvider
2632
+ authorizationProvider: this.opts.authorizationProvider,
2633
+ allowPrivateNetworks: this.opts.allowPrivateNetworks
2453
2634
  };
2454
2635
  this.httpTransport = new StreamableHTTPTransport(httpOpts);
2455
2636
  this.httpTransport.onDisconnect(() => {
@@ -2809,9 +2990,11 @@ var MCPClient = class _MCPClient {
2809
2990
  }
2810
2991
  onData(s) {
2811
2992
  this.rxBuffer += s;
2812
- if (this.rxBuffer.length > _MCPClient.MAX_RX_BUFFER_BYTES) {
2813
- 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;
2814
2996
  this.rxBuffer = "";
2997
+ this.rxBufferBytes = 0;
2815
2998
  this.failPending(
2816
2999
  `MCP "${this.opts.name}" rx buffer overflow (${truncated} bytes without a newline) \u2014 closing connection`
2817
3000
  );
@@ -2827,6 +3010,7 @@ var MCPClient = class _MCPClient {
2827
3010
  idx = this.rxBuffer.indexOf("\n", start);
2828
3011
  }
2829
3012
  if (start > 0) {
3013
+ this.rxBufferBytes -= Buffer.byteLength(this.rxBuffer.slice(0, start), "utf8");
2830
3014
  this.rxBuffer = this.rxBuffer.slice(start);
2831
3015
  }
2832
3016
  }
@@ -3144,6 +3328,8 @@ function buildConfig(input, base) {
3144
3328
  if (allowedTools !== void 0) cfg.allowedTools = allowedTools;
3145
3329
  const permission = input.permission ?? base?.permission;
3146
3330
  if (permission !== void 0) cfg.permission = permission;
3331
+ const allowPrivateNetworks = input.allowPrivateNetworks ?? base?.allowPrivateNetworks;
3332
+ if (allowPrivateNetworks !== void 0) cfg.allowPrivateNetworks = allowPrivateNetworks;
3147
3333
  const enabled = input.enabled ?? base?.enabled;
3148
3334
  if (enabled !== void 0) cfg.enabled = enabled;
3149
3335
  const lazy = input.lazy ?? base?.lazy;
@@ -3374,13 +3560,26 @@ function manifestConfigHash(cfg) {
3374
3560
  transport: cfg.transport,
3375
3561
  command: cfg.command ?? null,
3376
3562
  args: cfg.args ?? null,
3377
- url: cfg.url ?? null
3563
+ url: cfg.url ?? null,
3564
+ env: sortedEntries(cfg.env),
3565
+ headers: sortedEntries(cfg.headers),
3566
+ passthroughEnv: passthroughEntries(cfg.passthroughEnv)
3378
3567
  });
3379
3568
  return createHash2("sha256").update(basis).digest("hex").slice(0, 16);
3380
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
+ }
3381
3576
  function manifestFile(cacheDir, name) {
3382
3577
  const safe = name.replace(/[^a-zA-Z0-9._-]/g, "_");
3383
- 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`);
3384
3583
  }
3385
3584
  async function readManifest(cacheDir, name, configHash) {
3386
3585
  const manifest = await readCapabilityManifest(cacheDir, name, configHash);
@@ -3826,6 +4025,7 @@ async function attemptConnectSlot(ctx, slot) {
3826
4025
  headers: slot.cfg.headers,
3827
4026
  startupTimeoutMs: slot.cfg.startupTimeoutMs,
3828
4027
  requestTimeoutMs: slot.cfg.requestTimeoutMs,
4028
+ allowPrivateNetworks: slot.cfg.allowPrivateNetworks,
3829
4029
  passthroughEnv: slot.cfg.passthroughEnv,
3830
4030
  authorizationProvider: ctx.authorizationProviderFactory?.(slot.cfg)
3831
4031
  });
@@ -5013,6 +5213,7 @@ function serveStdio(server, opts = {}) {
5013
5213
  const stdin = opts.stdin ?? process.stdin;
5014
5214
  const stdout = opts.stdout ?? process.stdout;
5015
5215
  let buffer = "";
5216
+ let bufferBytes = 0;
5016
5217
  let closed = false;
5017
5218
  let bufferTooLarge = false;
5018
5219
  let writeChain = Promise.resolve();
@@ -5037,10 +5238,13 @@ function serveStdio(server, opts = {}) {
5037
5238
  };
5038
5239
  const onData = (chunk) => {
5039
5240
  if (bufferTooLarge) return;
5040
- buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
5041
- 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) {
5042
5245
  bufferTooLarge = true;
5043
5246
  buffer = "";
5247
+ bufferBytes = 0;
5044
5248
  console.error(
5045
5249
  JSON.stringify({
5046
5250
  level: "error",
@@ -5082,7 +5286,10 @@ function serveStdio(server, opts = {}) {
5082
5286
  });
5083
5287
  inFlightHandlers.add(handler);
5084
5288
  }
5085
- if (start > 0) buffer = buffer.slice(start);
5289
+ if (start > 0) {
5290
+ bufferBytes -= Buffer.byteLength(buffer.slice(0, start), "utf8");
5291
+ buffer = buffer.slice(start);
5292
+ }
5086
5293
  };
5087
5294
  let resolveDone;
5088
5295
  const done = new Promise((resolve) => {
@@ -5097,6 +5304,7 @@ function serveStdio(server, opts = {}) {
5097
5304
  if (!bufferTooLarge && buffer.trim()) {
5098
5305
  const line = buffer.trim();
5099
5306
  buffer = "";
5307
+ bufferBytes = 0;
5100
5308
  const handler = server.handleMessage(line).then((res) => {
5101
5309
  if (res !== null) writeLine(res);
5102
5310
  }).catch((err) => {
@@ -5197,11 +5405,13 @@ async function handleHttpRequest(server, req, res, token, log, boundHost) {
5197
5405
  return send(415, JSON.stringify({ error: "content-type must be application/json" }));
5198
5406
  }
5199
5407
  let body = "";
5408
+ let bodyBytes = 0;
5200
5409
  let aborted = false;
5201
5410
  req.on("data", (chunk) => {
5202
5411
  if (aborted) return;
5412
+ bodyBytes += chunk.byteLength;
5203
5413
  body += chunk.toString("utf8");
5204
- if (body.length > HTTP_BODY_CAP) {
5414
+ if (bodyBytes > HTTP_BODY_CAP) {
5205
5415
  aborted = true;
5206
5416
  send(413, JSON.stringify({ error: "payload too large" }));
5207
5417
  req.destroy();
@@ -5422,13 +5632,14 @@ var MCPRefreshingAuthorizationProvider = class {
5422
5632
  return await this.refresh(state, context.signal) !== void 0;
5423
5633
  }
5424
5634
  refresh(state, signal) {
5425
- if (this.refreshPromise) return this.refreshPromise;
5426
- this.refreshPromise = this.refreshInner(state, signal).finally(() => {
5427
- this.refreshPromise = void 0;
5428
- });
5429
- 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);
5430
5641
  }
5431
- async refreshInner(state, signal) {
5642
+ async refreshInner(state) {
5432
5643
  const refreshToken = state.tokenSet.refreshToken;
5433
5644
  if (!refreshToken) {
5434
5645
  this.emit("reauth_required", state);
@@ -5438,8 +5649,7 @@ var MCPRefreshingAuthorizationProvider = class {
5438
5649
  authorizationServer: state.authorizationServer,
5439
5650
  clientId: state.clientId,
5440
5651
  resource: state.resource,
5441
- refreshToken,
5442
- signal
5652
+ refreshToken
5443
5653
  });
5444
5654
  const next = normalizeStoredAuthorization({
5445
5655
  ...state,
@@ -5485,6 +5695,18 @@ function createVaultBackedMcpAuthorizationProviderFactory(options) {
5485
5695
  return provider;
5486
5696
  };
5487
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
+ }
5488
5710
  function emptyFile() {
5489
5711
  return { version: TOKEN_STORE_VERSION, updatedAt: (/* @__PURE__ */ new Date(0)).toISOString(), entries: [] };
5490
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,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.320.1",
3
+ "version": "1.0.1",
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.320.1"
29
+ "@wrongstack/core": "1.0.1",
30
+ "undici": "^8.10.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^26.2.0",