@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 +9 -0
- package/dist/index.js +457 -101
- package/dist/manage.d.ts +1 -0
- package/dist/manifest-cache.d.ts +3 -0
- package/dist/registry-disconnect.d.ts +8 -1
- 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?;
|
|
@@ -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
|
|
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";
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
|
2196
|
+
const parsed = JSON.parse(await readBodyCapped(initRes));
|
|
2027
2197
|
if (isJsonRpcResult(parsed)) data = parsed;
|
|
2028
2198
|
} else {
|
|
2029
|
-
data = extractJsonRpcResults(await initRes
|
|
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
|
|
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
|
|
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
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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
|
|
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) =>
|
|
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) =>
|
|
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 =
|
|
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
|
-
|
|
2735
|
-
|
|
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
|
-
|
|
2742
|
-
|
|
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
|
-
|
|
2749
|
-
|
|
2978
|
+
stdin.removeListener?.("drain", onDrain);
|
|
2979
|
+
stdin.removeListener?.("error", onError);
|
|
2750
2980
|
this._drainPending = false;
|
|
2751
2981
|
reject(err);
|
|
2752
2982
|
};
|
|
2753
|
-
|
|
2754
|
-
|
|
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
|
-
|
|
2764
|
-
|
|
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(
|
|
2775
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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
|
|
3960
|
-
|
|
3961
|
-
slot.client.
|
|
3962
|
-
|
|
3963
|
-
|
|
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
|
|
4289
|
-
|
|
4290
|
-
slot.client.
|
|
4291
|
-
|
|
4292
|
-
|
|
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
|
-
|
|
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 (
|
|
4348
|
-
|
|
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
|
-
|
|
4506
|
-
|
|
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
|
-
|
|
4936
|
-
|
|
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
|
|
5264
|
+
let start = 0;
|
|
5265
|
+
let idx = buffer.indexOf("\n", start);
|
|
4956
5266
|
while (idx !== -1) {
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
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 (
|
|
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)
|
|
5292
|
-
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
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
|
|
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;
|
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
|
|
@@ -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
|
|
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
|
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",
|