@wrongstack/mcp 0.320.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 +7 -0
- package/dist/index.js +244 -22
- package/dist/manage.d.ts +1 -0
- package/dist/manifest-cache.d.ts +3 -0
- package/dist/transport-base.d.ts +31 -0
- package/dist/transport-security.d.ts +48 -0
- package/dist/transport-sse.d.ts +1 -0
- package/dist/transport-streamable.d.ts +9 -0
- package/package.json +3 -2
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
|
|
520
|
-
const records = await
|
|
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
|
|
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
|
-
|
|
2813
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5041
|
-
|
|
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)
|
|
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 (
|
|
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)
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
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
|
|
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;
|
package/dist/manifest-cache.d.ts
CHANGED
|
@@ -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
|
package/dist/transport-base.d.ts
CHANGED
|
@@ -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
|
package/dist/transport-sse.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
29
|
+
"@wrongstack/core": "1.0.0",
|
|
30
|
+
"undici": "^8.10.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@types/node": "^26.2.0",
|