@wrongstack/mcp 0.295.0 → 0.296.2

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.
Files changed (39) hide show
  1. package/dist/client-process.d.ts +10 -0
  2. package/dist/client-process.d.ts.map +1 -0
  3. package/dist/client-protocol-helpers.d.ts +11 -0
  4. package/dist/client-protocol-helpers.d.ts.map +1 -0
  5. package/dist/client.d.ts +2 -19
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/index.js +517 -408
  8. package/dist/index.js.map +4 -4
  9. package/dist/manifest-cache.d.ts.map +1 -1
  10. package/dist/operations.d.ts.map +1 -1
  11. package/dist/read-body.d.ts +21 -0
  12. package/dist/read-body.d.ts.map +1 -0
  13. package/dist/registry-authorization.d.ts +14 -0
  14. package/dist/registry-authorization.d.ts.map +1 -0
  15. package/dist/registry-catalog.d.ts +20 -0
  16. package/dist/registry-catalog.d.ts.map +1 -0
  17. package/dist/registry-disconnect.d.ts +6 -0
  18. package/dist/registry-disconnect.d.ts.map +1 -0
  19. package/dist/registry-health.d.ts +5 -0
  20. package/dist/registry-health.d.ts.map +1 -0
  21. package/dist/registry-operations.d.ts +7 -0
  22. package/dist/registry-operations.d.ts.map +1 -0
  23. package/dist/registry-reconnect.d.ts +16 -0
  24. package/dist/registry-reconnect.d.ts.map +1 -0
  25. package/dist/registry-slots.d.ts +35 -0
  26. package/dist/registry-slots.d.ts.map +1 -0
  27. package/dist/registry-types.d.ts +26 -0
  28. package/dist/registry-types.d.ts.map +1 -0
  29. package/dist/registry.d.ts +5 -56
  30. package/dist/registry.d.ts.map +1 -1
  31. package/dist/server.d.ts.map +1 -1
  32. package/dist/tool-schema.d.ts.map +1 -1
  33. package/dist/transport-jsonrpc.d.ts +2 -2
  34. package/dist/transport-jsonrpc.d.ts.map +1 -1
  35. package/dist/transport-sse.d.ts.map +1 -1
  36. package/dist/transport-streamable.d.ts.map +1 -1
  37. package/dist/transport.d.ts +2 -2
  38. package/dist/transport.d.ts.map +1 -1
  39. package/package.json +3 -4
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ function parseMcpBearerChallenge(header, resource) {
47
47
  if (!header) return challenge;
48
48
  const bearer = /(?:^|,)\s*Bearer(?:\s+|$)/i.exec(header);
49
49
  if (!bearer) return challenge;
50
- const parameters = header.slice((bearer.index ?? 0) + bearer[0].length);
50
+ const parameters = header.slice(bearer.index + bearer[0].length);
51
51
  const resourceMetadata = challengeParameter(parameters, "resource_metadata");
52
52
  if (resourceMetadata) {
53
53
  const metadataUrl = validateMetadataUrl(resourceMetadata);
@@ -395,7 +395,7 @@ async function requestPinnedJson(rawUrl, options) {
395
395
  };
396
396
  const requestFn = url.protocol === "https:" ? https.request : http.request;
397
397
  const request3 = requestFn(requestOptions, (response) => {
398
- const status = response.statusCode ?? 0;
398
+ const status = response.statusCode;
399
399
  if (status === 404 || status === 410) {
400
400
  response.resume();
401
401
  finish(void 0, void 0);
@@ -738,9 +738,8 @@ function boundedServerName(value) {
738
738
  }
739
739
 
740
740
  // src/client.ts
741
- import { spawn } from "node:child_process";
742
- import { buildChildEnv } from "@wrongstack/core/utils";
743
- import { toErrorMessage } from "@wrongstack/core/utils";
741
+ import { spawn as spawn2 } from "node:child_process";
742
+ import { buildChildEnv, toErrorMessage } from "@wrongstack/core/utils";
744
743
 
745
744
  // src/constants.ts
746
745
  var MCP_CONSTANTS = Object.freeze({
@@ -997,13 +996,15 @@ function normalizeMCPTools(value) {
997
996
  if (typeof t.name !== "string" || t.name.trim().length === 0) continue;
998
997
  const inputSchema = t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema) ? t.inputSchema : { type: "object", properties: {} };
999
998
  if (!t.inputSchema || typeof t.inputSchema !== "object" || Array.isArray(t.inputSchema)) {
1000
- console.warn(JSON.stringify({
1001
- level: "warn",
1002
- event: "mcp.tool_schema_invalid",
1003
- tool: t.name,
1004
- message: "no/invalid inputSchema \u2014 defaulting to empty object",
1005
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1006
- }));
999
+ console.warn(
1000
+ JSON.stringify({
1001
+ level: "warn",
1002
+ event: "mcp.tool_schema_invalid",
1003
+ tool: t.name,
1004
+ message: "no/invalid inputSchema \u2014 defaulting to empty object",
1005
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1006
+ })
1007
+ );
1007
1008
  }
1008
1009
  tools.push({
1009
1010
  name: t.name,
@@ -1014,96 +1015,8 @@ function normalizeMCPTools(value) {
1014
1015
  return tools;
1015
1016
  }
1016
1017
 
1017
- // src/transport-jsonrpc.ts
1018
- import { ToolError } from "@wrongstack/core/types";
1019
- function isJsonRpcResult(v) {
1020
- if (typeof v !== "object" || v === null) return false;
1021
- const r = v;
1022
- if (r["jsonrpc"] !== "2.0" || typeof r["id"] !== "number") return false;
1023
- if (Object.hasOwn(r, "method")) return false;
1024
- const hasResult = Object.hasOwn(r, "result");
1025
- const hasError = Object.hasOwn(r, "error");
1026
- if (hasResult === hasError) return false;
1027
- if (hasError) {
1028
- const error = r["error"];
1029
- return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
1030
- }
1031
- return true;
1032
- }
1033
- function isJsonRpcMethodEnvelope(v) {
1034
- if (typeof v !== "object" || v === null) return false;
1035
- const envelope = v;
1036
- if (envelope["jsonrpc"] !== "2.0" || typeof envelope["method"] !== "string") return false;
1037
- const id = envelope["id"];
1038
- return id === void 0 || typeof id === "number" || typeof id === "string";
1039
- }
1040
- function extractJsonRpcEnvelopes(text) {
1041
- const out = [];
1042
- let dataBuf = [];
1043
- const flush = () => {
1044
- if (dataBuf.length === 0) return;
1045
- const joined = dataBuf.join("\n").trim();
1046
- dataBuf = [];
1047
- if (!joined) return;
1048
- try {
1049
- const parsed = JSON.parse(joined);
1050
- if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
1051
- } catch {
1052
- }
1053
- };
1054
- for (const raw of text.split("\n")) {
1055
- const line = raw.replace(/\r$/, "");
1056
- if (line === "") {
1057
- flush();
1058
- continue;
1059
- }
1060
- if (line.startsWith(":")) continue;
1061
- if (line.startsWith("data:")) {
1062
- let v = line.slice(5);
1063
- if (v.startsWith(" ")) v = v.slice(1);
1064
- dataBuf.push(v);
1065
- continue;
1066
- }
1067
- if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
1068
- continue;
1069
- }
1070
- const trimmed = line.trim();
1071
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
1072
- try {
1073
- const parsed = JSON.parse(trimmed);
1074
- if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
1075
- } catch {
1076
- }
1077
- }
1078
- }
1079
- flush();
1080
- return out;
1081
- }
1082
- function extractJsonRpcResults(text) {
1083
- return extractJsonRpcEnvelopes(text).filter(isJsonRpcResult);
1084
- }
1085
- function assertMatchingJsonRpcResult(data, expectedId, method) {
1086
- if (!isJsonRpcResult(data)) {
1087
- throw new ToolError({
1088
- message: "Invalid JSON-RPC response: not a JSON-RPC 2.0 envelope",
1089
- code: "TOOL_EXECUTION_FAILED",
1090
- toolName: "mcp_transport_jsonrpc",
1091
- context: { method, expectedId, reason: "not-jsonrpc-envelope" }
1092
- });
1093
- }
1094
- if (data.id !== expectedId) {
1095
- throw new ToolError({
1096
- message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
1097
- code: "TOOL_EXECUTION_FAILED",
1098
- toolName: "mcp_transport_jsonrpc",
1099
- context: { method, expectedId, actualId: data.id, reason: "id-mismatch" }
1100
- });
1101
- }
1102
- return data;
1103
- }
1104
-
1105
1018
  // src/sse-reader.ts
1106
- import { ToolError as ToolError2 } from "@wrongstack/core/types";
1019
+ import { ToolError } from "@wrongstack/core/types";
1107
1020
  var SSE_READER_MAX_BUFFER = 256 * 1024;
1108
1021
  var SSE_READER_MAX_DATA_LINES = 1024;
1109
1022
  var SSEReader = class {
@@ -1119,7 +1032,7 @@ var SSEReader = class {
1119
1032
  }
1120
1033
  feed(chunk) {
1121
1034
  if (chunk.length > SSE_READER_MAX_BUFFER) {
1122
- throw new ToolError2({
1035
+ throw new ToolError({
1123
1036
  message: `SSE: chunk size ${chunk.length} exceeds max buffer ${SSE_READER_MAX_BUFFER} \u2014 refusing to accumulate`,
1124
1037
  code: "TOOL_EXECUTION_FAILED",
1125
1038
  toolName: "mcp_transport_sse_reader",
@@ -1128,7 +1041,7 @@ var SSEReader = class {
1128
1041
  }
1129
1042
  this.buffer += chunk;
1130
1043
  if (this.buffer.length > SSE_READER_MAX_BUFFER) {
1131
- throw new ToolError2({
1044
+ throw new ToolError({
1132
1045
  message: `SSE: pending line exceeds ${SSE_READER_MAX_BUFFER} bytes \u2014 upstream is not framing events`,
1133
1046
  code: "TOOL_EXECUTION_FAILED",
1134
1047
  toolName: "mcp_transport_sse_reader",
@@ -1163,7 +1076,7 @@ var SSEReader = class {
1163
1076
  if (field === "event") {
1164
1077
  } else if (field === "data") {
1165
1078
  if (this.dataLines.length >= SSE_READER_MAX_DATA_LINES) {
1166
- throw new ToolError2({
1079
+ throw new ToolError({
1167
1080
  message: `SSE: exceeded ${SSE_READER_MAX_DATA_LINES} data lines per event \u2014 upstream is not sending blank-line delimiters`,
1168
1081
  code: "TOOL_EXECUTION_FAILED",
1169
1082
  toolName: "mcp_transport_sse_reader",
@@ -1453,9 +1366,133 @@ var BaseHTTPTransport = class {
1453
1366
  }
1454
1367
  };
1455
1368
 
1369
+ // src/transport-jsonrpc.ts
1370
+ import { ToolError as ToolError2 } from "@wrongstack/core/types";
1371
+ function isJsonRpcResult(v) {
1372
+ if (typeof v !== "object" || v === null) return false;
1373
+ const r = v;
1374
+ if (r["jsonrpc"] !== "2.0" || typeof r["id"] !== "number") return false;
1375
+ if (Object.hasOwn(r, "method")) return false;
1376
+ const hasResult = Object.hasOwn(r, "result");
1377
+ const hasError = Object.hasOwn(r, "error");
1378
+ if (hasResult === hasError) return false;
1379
+ if (hasError) {
1380
+ const error = r["error"];
1381
+ return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
1382
+ }
1383
+ return true;
1384
+ }
1385
+ function isJsonRpcMethodEnvelope(v) {
1386
+ if (typeof v !== "object" || v === null) return false;
1387
+ const envelope = v;
1388
+ if (envelope["jsonrpc"] !== "2.0" || typeof envelope["method"] !== "string") return false;
1389
+ const id = envelope["id"];
1390
+ return id === void 0 || typeof id === "number" || typeof id === "string";
1391
+ }
1392
+ function extractJsonRpcEnvelopes(text) {
1393
+ const out = [];
1394
+ let dataBuf = [];
1395
+ const flush = () => {
1396
+ if (dataBuf.length === 0) return;
1397
+ const joined = dataBuf.join("\n").trim();
1398
+ dataBuf = [];
1399
+ if (!joined) return;
1400
+ try {
1401
+ const parsed = JSON.parse(joined);
1402
+ if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
1403
+ } catch {
1404
+ }
1405
+ };
1406
+ for (const raw of text.split("\n")) {
1407
+ const line = raw.replace(/\r$/, "");
1408
+ if (line === "") {
1409
+ flush();
1410
+ continue;
1411
+ }
1412
+ if (line.startsWith(":")) continue;
1413
+ if (line.startsWith("data:")) {
1414
+ let v = line.slice(5);
1415
+ if (v.startsWith(" ")) v = v.slice(1);
1416
+ dataBuf.push(v);
1417
+ continue;
1418
+ }
1419
+ if (line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
1420
+ continue;
1421
+ }
1422
+ const trimmed = line.trim();
1423
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
1424
+ try {
1425
+ const parsed = JSON.parse(trimmed);
1426
+ if (isJsonRpcResult(parsed) || isJsonRpcMethodEnvelope(parsed)) out.push(parsed);
1427
+ } catch {
1428
+ }
1429
+ }
1430
+ }
1431
+ flush();
1432
+ return out;
1433
+ }
1434
+ function extractJsonRpcResults(text) {
1435
+ return extractJsonRpcEnvelopes(text).filter(isJsonRpcResult);
1436
+ }
1437
+ function assertMatchingJsonRpcResult(data, expectedId, method) {
1438
+ if (!isJsonRpcResult(data)) {
1439
+ throw new ToolError2({
1440
+ message: "Invalid JSON-RPC response: not a JSON-RPC 2.0 envelope",
1441
+ code: "TOOL_EXECUTION_FAILED",
1442
+ toolName: "mcp_transport_jsonrpc",
1443
+ context: { method, expectedId, reason: "not-jsonrpc-envelope" }
1444
+ });
1445
+ }
1446
+ if (data.id !== expectedId) {
1447
+ throw new ToolError2({
1448
+ message: `Invalid JSON-RPC response: id mismatch for ${method} (expected ${expectedId}, got ${data.id})`,
1449
+ code: "TOOL_EXECUTION_FAILED",
1450
+ toolName: "mcp_transport_jsonrpc",
1451
+ context: { method, expectedId, actualId: data.id, reason: "id-mismatch" }
1452
+ });
1453
+ }
1454
+ return data;
1455
+ }
1456
+
1456
1457
  // src/transport-sse.ts
1457
1458
  import { randomBytes as randomBytes2 } from "node:crypto";
1459
+ import { ToolError as ToolError4 } from "@wrongstack/core/types";
1460
+
1461
+ // src/read-body.ts
1458
1462
  import { ToolError as ToolError3 } from "@wrongstack/core/types";
1463
+ var MAX_MCP_HTTP_BODY_BYTES = 16 * 1024 * 1024;
1464
+ async function readBodyCapped(res, maxBytes = MAX_MCP_HTTP_BODY_BYTES) {
1465
+ const body = res.body;
1466
+ if (!body || typeof body.getReader !== "function") {
1467
+ return await res.text();
1468
+ }
1469
+ const reader = body.getReader();
1470
+ const chunks = [];
1471
+ let total = 0;
1472
+ try {
1473
+ for (; ; ) {
1474
+ const { done, value } = await reader.read();
1475
+ if (done) break;
1476
+ if (!value) continue;
1477
+ total += value.byteLength;
1478
+ if (total > maxBytes) {
1479
+ await reader.cancel().catch(() => void 0);
1480
+ throw new ToolError3({
1481
+ message: `MCP response body exceeded ${maxBytes} bytes \u2014 refusing to buffer`,
1482
+ code: "TOOL_EXECUTION_FAILED",
1483
+ toolName: "mcp_transport",
1484
+ context: { maxBytes, received: total }
1485
+ });
1486
+ }
1487
+ chunks.push(value);
1488
+ }
1489
+ } finally {
1490
+ reader.releaseLock?.();
1491
+ }
1492
+ return Buffer.concat(chunks).toString("utf8");
1493
+ }
1494
+
1495
+ // src/transport-sse.ts
1459
1496
  var SSETransport = class extends BaseHTTPTransport {
1460
1497
  _nextId = 1;
1461
1498
  readerDone = false;
@@ -1502,7 +1539,7 @@ var SSETransport = class extends BaseHTTPTransport {
1502
1539
  this.applyTlsAgent(fetchOpts);
1503
1540
  const response = await this.fetchWithAuthorization(sseUrl, fetchOpts, signal);
1504
1541
  if (!response.ok) {
1505
- throw new ToolError3({
1542
+ throw new ToolError4({
1506
1543
  message: `SSE connect HTTP ${response.status}: ${response.statusText}`,
1507
1544
  code: "TOOL_EXECUTION_FAILED",
1508
1545
  toolName: "mcp_transport_sse_connect",
@@ -1510,7 +1547,7 @@ var SSETransport = class extends BaseHTTPTransport {
1510
1547
  });
1511
1548
  }
1512
1549
  if (!response.body) {
1513
- throw new ToolError3({
1550
+ throw new ToolError4({
1514
1551
  message: "SSE response has no body",
1515
1552
  code: "TOOL_EXECUTION_FAILED",
1516
1553
  toolName: "mcp_transport_sse_connect",
@@ -1543,7 +1580,7 @@ var SSETransport = class extends BaseHTTPTransport {
1543
1580
  clientInfo: MCP_CONSTANTS.CLIENT_INFO
1544
1581
  });
1545
1582
  if (initRes.error) {
1546
- throw new ToolError3({
1583
+ throw new ToolError4({
1547
1584
  message: `initialize failed: ${initRes.error.message}`,
1548
1585
  code: "TOOL_EXECUTION_FAILED",
1549
1586
  toolName: "mcp_transport_initialize",
@@ -1618,7 +1655,7 @@ var SSETransport = class extends BaseHTTPTransport {
1618
1655
  const body2 = await res.text();
1619
1656
  const cap = MCP_CONSTANTS.REQUEST_LOG_CAP;
1620
1657
  const snippet = body2.length > cap ? `${body2.slice(0, cap)}\u2026 [${body2.length} bytes total]` : body2;
1621
- throw new ToolError3({
1658
+ throw new ToolError4({
1622
1659
  message: `HTTP ${res.status}: ${snippet}`,
1623
1660
  code: "TOOL_EXECUTION_FAILED",
1624
1661
  toolName: method,
@@ -1627,9 +1664,9 @@ var SSETransport = class extends BaseHTTPTransport {
1627
1664
  }
1628
1665
  let data;
1629
1666
  try {
1630
- data = await res.json();
1667
+ data = JSON.parse(await readBodyCapped(res));
1631
1668
  } catch (err) {
1632
- throw new ToolError3({
1669
+ throw new ToolError4({
1633
1670
  message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
1634
1671
  code: "TOOL_EXECUTION_FAILED",
1635
1672
  toolName: method,
@@ -1654,7 +1691,7 @@ var SSETransport = class extends BaseHTTPTransport {
1654
1691
  }
1655
1692
  async callTool(name, input, opts) {
1656
1693
  if (this.state !== "connected") {
1657
- throw new ToolError3({
1694
+ throw new ToolError4({
1658
1695
  message: `SSE transport not connected (state=${this.state})`,
1659
1696
  code: "TOOL_EXECUTION_FAILED",
1660
1697
  toolName: name,
@@ -1691,7 +1728,7 @@ var SSETransport = class extends BaseHTTPTransport {
1691
1728
  try {
1692
1729
  const res = await this.fetchWithAuthorization(this.url, fetchOpts, timeoutSignal.signal);
1693
1730
  if (!res.ok) {
1694
- throw new ToolError3({
1731
+ throw new ToolError4({
1695
1732
  message: `HTTP ${res.status}: ${res.statusText}`,
1696
1733
  code: "TOOL_EXECUTION_FAILED",
1697
1734
  toolName: method,
@@ -1705,9 +1742,9 @@ var SSETransport = class extends BaseHTTPTransport {
1705
1742
  }
1706
1743
  let data;
1707
1744
  try {
1708
- data = await res.json();
1745
+ data = JSON.parse(await readBodyCapped(res));
1709
1746
  } catch (err) {
1710
- throw new ToolError3({
1747
+ throw new ToolError4({
1711
1748
  message: `Invalid JSON-RPC response: ${err instanceof Error ? err.message : "parse failed"}`,
1712
1749
  code: "TOOL_EXECUTION_FAILED",
1713
1750
  toolName: method,
@@ -1767,7 +1804,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
1767
1804
  }
1768
1805
  }
1769
1806
  const responses = envelopes.filter(isJsonRpcResult);
1770
- return responses.find((envelope) => envelope.id === requestId) ?? responses.find((envelope) => envelope.id !== void 0) ?? responses[0];
1807
+ return responses.find((envelope) => envelope.id === requestId) ?? responses[0];
1771
1808
  }
1772
1809
  handleNotification(method) {
1773
1810
  if (method === "notifications/resources/list_changed") {
@@ -1886,9 +1923,9 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
1886
1923
  }
1887
1924
  if (method.startsWith("notifications/")) {
1888
1925
  await res.text().catch(() => void 0);
1889
- return { jsonrpc: "2.0" };
1926
+ return { jsonrpc: "2.0", id };
1890
1927
  }
1891
- const match = this.consumeResponseText(await res.text(), id);
1928
+ const match = this.consumeResponseText(await readBodyCapped(res), id);
1892
1929
  if (match) {
1893
1930
  return assertMatchingJsonRpcResult(match, id, method);
1894
1931
  }
@@ -1935,7 +1972,7 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
1935
1972
  await res.text().catch(() => void 0);
1936
1973
  return { jsonrpc: "2.0", id };
1937
1974
  }
1938
- const parsed = this.consumeResponseText(await res.text(), id);
1975
+ const parsed = this.consumeResponseText(await readBodyCapped(res), id);
1939
1976
  if (parsed) {
1940
1977
  return {
1941
1978
  jsonrpc: "2.0",
@@ -1981,19 +2018,62 @@ var StreamableHTTPTransport = class extends BaseHTTPTransport {
1981
2018
  }
1982
2019
  };
1983
2020
 
1984
- // src/client.ts
1985
- function isJsonRpcResponse(value) {
1986
- if (typeof value !== "object" || value === null) return false;
1987
- const response = value;
1988
- if (response["jsonrpc"] !== "2.0" || typeof response["id"] !== "number") return false;
1989
- if (Object.hasOwn(response, "method")) return false;
1990
- const hasResult = Object.hasOwn(response, "result");
1991
- const hasError = Object.hasOwn(response, "error");
1992
- if (hasResult === hasError) return false;
1993
- if (!hasError) return true;
1994
- const error = response["error"];
1995
- return typeof error === "object" && error !== null && typeof error["code"] === "number" && typeof error["message"] === "string";
2021
+ // src/client-protocol-helpers.ts
2022
+ function quoteWindowsArg(arg) {
2023
+ if (!/[\s"]/.test(arg)) return arg;
2024
+ return `"${arg.replace(/"/g, '""')}"`;
2025
+ }
2026
+ var MAX_PROTOCOL_INPUT_CHARS = 8192;
2027
+ function validateProtocolString(value, label, allowEmpty = false) {
2028
+ if (typeof value !== "string" || !allowEmpty && value.length === 0) {
2029
+ throw new Error(`MCP ${label} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
2030
+ }
2031
+ if (value.length > MAX_PROTOCOL_INPUT_CHARS) {
2032
+ throw new Error(`MCP ${label} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
2033
+ }
2034
+ }
2035
+ function pageParams(cursor, label) {
2036
+ if (cursor === void 0) return {};
2037
+ validateProtocolString(cursor, label);
2038
+ return { cursor };
2039
+ }
2040
+ function parseEmptyResult(value) {
2041
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2042
+ throw new Error("Malformed MCP empty result: expected object");
2043
+ }
1996
2044
  }
2045
+
2046
+ // src/client-process.ts
2047
+ import { spawn } from "node:child_process";
2048
+ function forceKillTree(child) {
2049
+ if (child.pid === void 0) {
2050
+ try {
2051
+ child.kill("SIGKILL");
2052
+ } catch {
2053
+ }
2054
+ return;
2055
+ }
2056
+ if (process.platform === "win32") {
2057
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
2058
+ stdio: "ignore",
2059
+ windowsHide: true
2060
+ });
2061
+ killer.once("error", () => {
2062
+ try {
2063
+ child.kill("SIGKILL");
2064
+ } catch {
2065
+ }
2066
+ });
2067
+ killer.unref();
2068
+ return;
2069
+ }
2070
+ try {
2071
+ child.kill("SIGKILL");
2072
+ } catch {
2073
+ }
2074
+ }
2075
+
2076
+ // src/client.ts
1997
2077
  var MCPClient = class _MCPClient {
1998
2078
  constructor(opts) {
1999
2079
  this.opts = opts;
@@ -2108,14 +2188,14 @@ var MCPClient = class _MCPClient {
2108
2188
  const rawArgs = this.opts.args ?? [];
2109
2189
  const spawnEnv = buildChildEnv({ extra: extraEnv });
2110
2190
  const stdio = ["pipe", "pipe", "pipe"];
2111
- const child = isWin ? spawn([this.opts.command, ...rawArgs].map(quoteWindowsArg).join(" "), {
2191
+ const child = isWin ? spawn2([this.opts.command, ...rawArgs].map(quoteWindowsArg).join(" "), {
2112
2192
  env: spawnEnv,
2113
2193
  stdio,
2114
2194
  shell: true,
2115
2195
  // Without this every MCP server spawned from a console-less host
2116
2196
  // (WebUI server, scheduled runs) opens a visible console window.
2117
2197
  windowsHide: true
2118
- }) : spawn(this.opts.command, rawArgs, { env: spawnEnv, stdio, windowsHide: true });
2198
+ }) : spawn2(this.opts.command, rawArgs, { env: spawnEnv, stdio, windowsHide: true });
2119
2199
  this.child = child;
2120
2200
  child.stdout?.on("data", (chunk) => this.onData(chunk.toString()));
2121
2201
  child.stderr?.on("data", () => {
@@ -2386,10 +2466,7 @@ var MCPClient = class _MCPClient {
2386
2466
  new Promise((resolve) => setTimeout(() => resolve("timeout"), GRACEFUL_MS))
2387
2467
  ]);
2388
2468
  if (gracefulRace === "timeout") {
2389
- try {
2390
- child.kill("SIGKILL");
2391
- } catch {
2392
- }
2469
+ forceKillTree(child);
2393
2470
  await Promise.race([
2394
2471
  exitPromise,
2395
2472
  new Promise((resolve) => setTimeout(resolve, FORCE_TIMEOUT_MS))
@@ -2618,11 +2695,12 @@ var MCPClient = class _MCPClient {
2618
2695
  }
2619
2696
  return;
2620
2697
  }
2621
- if (!isJsonRpcResponse(msg)) return;
2622
- if (this.pending.has(msg.id)) {
2623
- const entry = this.pending.get(msg.id);
2624
- this.pending.delete(msg.id);
2625
- entry?.resolve(msg);
2698
+ if (!isJsonRpcResult(msg)) return;
2699
+ const response = msg;
2700
+ if (this.pending.has(response.id)) {
2701
+ const entry = this.pending.get(response.id);
2702
+ this.pending.delete(response.id);
2703
+ entry?.resolve(response);
2626
2704
  }
2627
2705
  }
2628
2706
  handleServerRequest(request3) {
@@ -2689,29 +2767,6 @@ var MCPClient = class _MCPClient {
2689
2767
  }
2690
2768
  }
2691
2769
  };
2692
- function quoteWindowsArg(arg) {
2693
- if (!/[\s"]/.test(arg)) return arg;
2694
- return `"${arg.replace(/"/g, '""')}"`;
2695
- }
2696
- var MAX_PROTOCOL_INPUT_CHARS = 8192;
2697
- function validateProtocolString(value, label, allowEmpty = false) {
2698
- if (typeof value !== "string" || !allowEmpty && value.length === 0) {
2699
- throw new Error(`MCP ${label} must be ${allowEmpty ? "a string" : "a non-empty string"}`);
2700
- }
2701
- if (value.length > MAX_PROTOCOL_INPUT_CHARS) {
2702
- throw new Error(`MCP ${label} exceeds ${MAX_PROTOCOL_INPUT_CHARS} characters`);
2703
- }
2704
- }
2705
- function pageParams(cursor, label) {
2706
- if (cursor === void 0) return {};
2707
- validateProtocolString(cursor, label);
2708
- return { cursor };
2709
- }
2710
- function parseEmptyResult(value) {
2711
- if (!value || typeof value !== "object" || Array.isArray(value)) {
2712
- throw new Error("Malformed MCP empty result: expected object");
2713
- }
2714
- }
2715
2770
 
2716
2771
  // src/content-selection.ts
2717
2772
  var DEFAULT_MCP_INSERTION_MAX_BYTES = 256 * 1024;
@@ -2856,7 +2911,7 @@ async function writeConfig(path2, cfg) {
2856
2911
  try {
2857
2912
  await fs.rename(tmp, path2);
2858
2913
  } catch (err) {
2859
- await fs.rm(tmp, { force: true }).catch(() => void 0);
2914
+ await fs.rm(tmp, { force: true });
2860
2915
  throw err;
2861
2916
  }
2862
2917
  }
@@ -3096,7 +3151,7 @@ function forgetRegistryState(registry, name) {
3096
3151
  }
3097
3152
 
3098
3153
  // src/manifest-cache.ts
3099
- import { createHash as createHash2 } from "node:crypto";
3154
+ import { createHash as createHash2, randomBytes as randomBytes4 } from "node:crypto";
3100
3155
  import * as fs2 from "node:fs/promises";
3101
3156
  import * as path from "node:path";
3102
3157
  function manifestConfigHash(cfg) {
@@ -3144,9 +3199,14 @@ async function writeCapabilityManifest(cacheDir, name, configHash, manifest) {
3144
3199
  const file = manifestFile(cacheDir, name);
3145
3200
  await fs2.mkdir(path.dirname(file), { recursive: true });
3146
3201
  const body = { version: 2, configHash, ...manifest };
3147
- const tmp = `${file}.tmp`;
3148
- await fs2.writeFile(tmp, JSON.stringify(body, null, 2), "utf8");
3149
- await fs2.rename(tmp, file);
3202
+ const tmp = `${file}.${process.pid}.${randomBytes4(6).toString("hex")}.tmp`;
3203
+ try {
3204
+ await fs2.writeFile(tmp, JSON.stringify(body, null, 2), "utf8");
3205
+ await fs2.rename(tmp, file);
3206
+ } catch (err) {
3207
+ await fs2.rm(tmp, { force: true });
3208
+ throw err;
3209
+ }
3150
3210
  } catch {
3151
3211
  }
3152
3212
  }
@@ -3207,7 +3267,10 @@ function evaluateHealthThresholds(operations, thresholds) {
3207
3267
  if (!thresholds) return [];
3208
3268
  const checks = [];
3209
3269
  if (thresholds.connectionLatencyP95Ms !== void 0 && operations.connectionSamples.length > 0) {
3210
- const value = percentile([...operations.connectionSamples].sort((a, b) => a - b), 0.95);
3270
+ const value = percentile(
3271
+ [...operations.connectionSamples].sort((a, b) => a - b),
3272
+ 0.95
3273
+ );
3211
3274
  checks.push({
3212
3275
  name: "connection-latency-p95",
3213
3276
  passed: value <= thresholds.connectionLatencyP95Ms,
@@ -3216,7 +3279,10 @@ function evaluateHealthThresholds(operations, thresholds) {
3216
3279
  });
3217
3280
  }
3218
3281
  if (thresholds.discoveryLatencyP95Ms !== void 0 && operations.discoverySamples.length > 0) {
3219
- const value = percentile([...operations.discoverySamples].sort((a, b) => a - b), 0.95);
3282
+ const value = percentile(
3283
+ [...operations.discoverySamples].sort((a, b) => a - b),
3284
+ 0.95
3285
+ );
3220
3286
  checks.push({
3221
3287
  name: "discovery-latency-p95",
3222
3288
  passed: value <= thresholds.discoveryLatencyP95Ms,
@@ -3225,7 +3291,10 @@ function evaluateHealthThresholds(operations, thresholds) {
3225
3291
  });
3226
3292
  }
3227
3293
  if (thresholds.callLatencyP95Ms !== void 0 && operations.callSamples.length > 0) {
3228
- const value = percentile([...operations.callSamples].sort((a, b) => a - b), 0.95);
3294
+ const value = percentile(
3295
+ [...operations.callSamples].sort((a, b) => a - b),
3296
+ 0.95
3297
+ );
3229
3298
  checks.push({
3230
3299
  name: "call-latency-p95",
3231
3300
  passed: value <= thresholds.callLatencyP95Ms,
@@ -3275,6 +3344,230 @@ function percentile(sorted, ratio) {
3275
3344
  // src/registry.ts
3276
3345
  import { expectDefined } from "@wrongstack/core/utils";
3277
3346
 
3347
+ // src/registry-catalog.ts
3348
+ var MAX_CATALOG_PAGES = 100;
3349
+ var MAX_CATALOG_ITEMS = 1e4;
3350
+ async function collectCatalogPages(load, select) {
3351
+ const items = [];
3352
+ const seenCursors = /* @__PURE__ */ new Set();
3353
+ let cursor;
3354
+ for (let pageNumber = 0; pageNumber < MAX_CATALOG_PAGES; pageNumber++) {
3355
+ const page = await load(cursor);
3356
+ items.push(...select(page));
3357
+ if (items.length > MAX_CATALOG_ITEMS) {
3358
+ throw new Error(`MCP catalog exceeds ${MAX_CATALOG_ITEMS} items`);
3359
+ }
3360
+ const next = page.nextCursor;
3361
+ if (!next) return items;
3362
+ if (seenCursors.has(next)) throw new Error(`MCP catalog repeated cursor "${next}"`);
3363
+ seenCursors.add(next);
3364
+ cursor = next;
3365
+ }
3366
+ throw new Error(`MCP catalog exceeds ${MAX_CATALOG_PAGES} pages`);
3367
+ }
3368
+ function cloneCatalogRecords(records) {
3369
+ return structuredClone(records);
3370
+ }
3371
+ function registryCatalogSnapshot(slot) {
3372
+ return {
3373
+ name: slot.cfg.name,
3374
+ state: slot.state,
3375
+ serverMetadata: slot.serverMetadata ? structuredClone(slot.serverMetadata) : void 0,
3376
+ resources: slot.resources ? cloneCatalogRecords(slot.resources) : void 0,
3377
+ resourceTemplates: slot.resourceTemplates ? cloneCatalogRecords(slot.resourceTemplates) : void 0,
3378
+ prompts: slot.prompts ? cloneCatalogRecords(slot.prompts) : void 0
3379
+ };
3380
+ }
3381
+
3382
+ // src/registry-health.ts
3383
+ function buildRegistryOperationalHealth(servers, disabledServers) {
3384
+ const active = Array.from(servers).map((slot) => {
3385
+ const op = slot.operations;
3386
+ const baseHealth = healthStateFor(slot.state, op, slot.cfg.enabled !== false);
3387
+ const checks = evaluateHealthThresholds(op, slot.cfg.health?.thresholds);
3388
+ return {
3389
+ name: slot.cfg.name,
3390
+ connectionState: slot.state,
3391
+ healthState: applyHealthThresholds(baseHealth, checks),
3392
+ lastSuccessAt: op.lastSuccessAt,
3393
+ lastFailureAt: op.lastFailureAt,
3394
+ lastFailureKind: op.lastFailureKind,
3395
+ lastReason: op.lastReason,
3396
+ consecutiveFailures: op.consecutiveFailures,
3397
+ failures: { ...op.failures },
3398
+ reconnectCount: op.reconnectCount,
3399
+ wakeCount: op.wakeCount,
3400
+ sleepCount: op.sleepCount,
3401
+ restartCount: op.restartCount,
3402
+ connectionLatency: summarizeLatency(op.connectionSamples),
3403
+ discoveryLatency: summarizeLatency(op.discoverySamples),
3404
+ callLatency: summarizeLatency(op.callSamples),
3405
+ inFlightCalls: op.inFlightCalls,
3406
+ peakInFlightCalls: op.peakInFlightCalls,
3407
+ recentEvents: op.recentEvents.map((event) => ({ ...event })),
3408
+ healthChecks: checks
3409
+ };
3410
+ });
3411
+ const disabled = Array.from(disabledServers).map((cfg) => {
3412
+ const operations = createMCPServerOperationState();
3413
+ return {
3414
+ name: cfg.name,
3415
+ connectionState: "idle",
3416
+ healthState: "disabled",
3417
+ consecutiveFailures: 0,
3418
+ failures: { ...operations.failures },
3419
+ reconnectCount: 0,
3420
+ wakeCount: 0,
3421
+ sleepCount: 0,
3422
+ restartCount: 0,
3423
+ connectionLatency: summarizeLatency([]),
3424
+ discoveryLatency: summarizeLatency([]),
3425
+ callLatency: summarizeLatency([]),
3426
+ inFlightCalls: 0,
3427
+ peakInFlightCalls: 0,
3428
+ recentEvents: [],
3429
+ healthChecks: []
3430
+ };
3431
+ });
3432
+ return [...active, ...disabled];
3433
+ }
3434
+
3435
+ // src/registry-authorization.ts
3436
+ function requireAuthorizationManager(manager) {
3437
+ if (!manager) {
3438
+ throw new Error("MCP authorization management is not configured for this host");
3439
+ }
3440
+ return manager;
3441
+ }
3442
+ function requireHttpServerConfig(servers, disabledServers, name) {
3443
+ const cfg = servers.get(name)?.cfg ?? disabledServers.get(name);
3444
+ if (!cfg) throw new Error(`MCP server "${name}" not registered`);
3445
+ if (cfg.transport === "stdio" || !cfg.url) {
3446
+ throw new Error(`MCP server "${name}" does not use an HTTP transport`);
3447
+ }
3448
+ return cfg;
3449
+ }
3450
+ async function beginRegistryAuthorization(manager, cfg, name, input) {
3451
+ return requireAuthorizationManager(manager).begin({
3452
+ serverName: name,
3453
+ resource: cfg.url,
3454
+ ...input
3455
+ });
3456
+ }
3457
+ async function completeRegistryAuthorization(manager, cfg, name, callbackUrl, signal) {
3458
+ return requireAuthorizationManager(manager).complete({
3459
+ serverName: name,
3460
+ resource: cfg.url,
3461
+ callbackUrl,
3462
+ signal
3463
+ });
3464
+ }
3465
+
3466
+ // src/registry-operations.ts
3467
+ function operationsForSlot(slot) {
3468
+ if (!slot.operations) slot.operations = createMCPServerOperationState();
3469
+ return slot.operations;
3470
+ }
3471
+ function recordRegistrySuccess(slot, resetFailures = true) {
3472
+ const operations = operationsForSlot(slot);
3473
+ operations.lastSuccessAt = Date.now();
3474
+ if (resetFailures) operations.consecutiveFailures = 0;
3475
+ }
3476
+ function recordRegistryFailure(slot, listeners, failureKind, reason, durationMs) {
3477
+ const operations = operationsForSlot(slot);
3478
+ const safeReason = safeOperationReason(reason);
3479
+ operations.lastFailureAt = Date.now();
3480
+ operations.lastFailureKind = failureKind;
3481
+ operations.lastReason = safeReason;
3482
+ operations.consecutiveFailures++;
3483
+ operations.failures[failureKind]++;
3484
+ recordRegistryOperation(slot, listeners, "failure", safeReason, failureKind, durationMs);
3485
+ }
3486
+ function recordRegistryOperation(slot, listeners, kind, reason, failureKind, durationMs, retain = true) {
3487
+ const operations = operationsForSlot(slot);
3488
+ const baseHealth = healthStateFor(slot.state, operations, slot.cfg.enabled !== false);
3489
+ const checks = evaluateHealthThresholds(operations, slot.cfg.health?.thresholds);
3490
+ const event = {
3491
+ serverName: slot.cfg.name,
3492
+ kind,
3493
+ at: Date.now(),
3494
+ connectionState: slot.state,
3495
+ healthState: applyHealthThresholds(baseHealth, checks)
3496
+ };
3497
+ if (reason !== void 0) event.reason = safeOperationReason(reason);
3498
+ if (failureKind !== void 0) event.failureKind = failureKind;
3499
+ if (durationMs !== void 0) event.durationMs = Math.max(0, Math.round(durationMs));
3500
+ if (retain) pushBounded(operations.recentEvents, event, MCP_OPERATION_LIMITS.RECENT_EVENTS);
3501
+ for (const listener of listeners) {
3502
+ try {
3503
+ listener({ ...event });
3504
+ } catch {
3505
+ }
3506
+ }
3507
+ }
3508
+
3509
+ // src/registry-disconnect.ts
3510
+ function resetDisconnectedSlotTools(slot, toolRegistry) {
3511
+ for (const t of slot.toolNames) {
3512
+ try {
3513
+ toolRegistry.unregister(t);
3514
+ } catch {
3515
+ }
3516
+ }
3517
+ slot.toolNames = [];
3518
+ slot.lazyTools = [];
3519
+ slot.serverMetadata = void 0;
3520
+ slot.resources = void 0;
3521
+ slot.resourceTemplates = void 0;
3522
+ slot.prompts = void 0;
3523
+ }
3524
+ function markLazySlotDormant(slot, events, reason) {
3525
+ slot.client = void 0;
3526
+ slot.state = "dormant";
3527
+ events.emit("mcp.server.disconnected", {
3528
+ name: slot.cfg.name,
3529
+ reason: `${reason} (dormant)`
3530
+ });
3531
+ }
3532
+
3533
+ // src/registry-reconnect.ts
3534
+ function scheduleRegistryReconnect({
3535
+ slot,
3536
+ events,
3537
+ log,
3538
+ maxReconnectCycles,
3539
+ baseReconnectDelayMs,
3540
+ maxReconnectDelayMs,
3541
+ recordReconnectExhausted,
3542
+ attemptReconnect
3543
+ }) {
3544
+ if (slot.reconnectPending) return;
3545
+ if (slot.reconnectCycles >= maxReconnectCycles) {
3546
+ slot.state = "failed";
3547
+ recordReconnectExhausted(slot);
3548
+ log.error(
3549
+ `MCP server "${slot.cfg.name}" giving up after ${slot.reconnectCycles} reconnect cycles. Use \`/mcp restart ${slot.cfg.name}\` to retry.`
3550
+ );
3551
+ events.emit("mcp.server.disconnected", {
3552
+ name: slot.cfg.name,
3553
+ reason: `reconnect-exhausted:${slot.reconnectCycles}`
3554
+ });
3555
+ return;
3556
+ }
3557
+ slot.reconnectPending = true;
3558
+ if (slot.reconnectTimer) {
3559
+ clearTimeout(slot.reconnectTimer);
3560
+ slot.reconnectTimer = void 0;
3561
+ }
3562
+ const base = Math.min(baseReconnectDelayMs * 2 ** slot.reconnectCycles, maxReconnectDelayMs);
3563
+ const jitter = base * MCP_CONSTANTS.RECONNECT.JITTER_FACTOR * (Math.random() * 2 - 1);
3564
+ const delay = Math.max(100, Math.round(base + jitter));
3565
+ slot.reconnectTimer = setTimeout(() => {
3566
+ slot.reconnectTimer = void 0;
3567
+ void attemptReconnect(slot);
3568
+ }, delay);
3569
+ }
3570
+
3278
3571
  // src/wrap-tool.ts
3279
3572
  import { ToolCapabilities } from "@wrongstack/core/security";
3280
3573
  var MUTATING_RE = /create|update|delete|write|send|set|put|post|patch|remove|rename|move/i;
@@ -3372,42 +3665,31 @@ var MCPRegistry = class _MCPRegistry {
3372
3665
  return slot;
3373
3666
  }
3374
3667
  async beginAuthorization(name, input) {
3375
- const manager = this.requireAuthorizationManager();
3376
3668
  const cfg = this.requireHttpServerConfig(name);
3377
- return manager.begin({
3378
- serverName: name,
3379
- resource: cfg.url,
3380
- ...input
3381
- });
3669
+ return beginRegistryAuthorization(this.authorizationManager, cfg, name, input);
3382
3670
  }
3383
3671
  async completeAuthorization(name, callbackUrl, signal) {
3384
- const manager = this.requireAuthorizationManager();
3385
3672
  const cfg = this.requireHttpServerConfig(name);
3386
- return manager.complete({ serverName: name, resource: cfg.url, callbackUrl, signal });
3673
+ return completeRegistryAuthorization(
3674
+ this.authorizationManager,
3675
+ cfg,
3676
+ name,
3677
+ callbackUrl,
3678
+ signal
3679
+ );
3387
3680
  }
3388
3681
  async authorizationStatus(name) {
3389
- const manager = this.requireAuthorizationManager();
3682
+ const manager = requireAuthorizationManager(this.authorizationManager);
3390
3683
  const cfg = this.requireHttpServerConfig(name);
3391
3684
  return manager.status(name, cfg.url);
3392
3685
  }
3393
3686
  async disconnectAuthorization(name) {
3394
- const manager = this.requireAuthorizationManager();
3687
+ const manager = requireAuthorizationManager(this.authorizationManager);
3395
3688
  const cfg = this.requireHttpServerConfig(name);
3396
3689
  return manager.disconnect(name, cfg.url);
3397
3690
  }
3398
- requireAuthorizationManager() {
3399
- if (!this.authorizationManager) {
3400
- throw new Error("MCP authorization management is not configured for this host");
3401
- }
3402
- return this.authorizationManager;
3403
- }
3404
3691
  requireHttpServerConfig(name) {
3405
- const cfg = this.servers.get(name)?.cfg ?? this.disabledServers.get(name);
3406
- if (!cfg) throw new Error(`MCP server "${name}" not registered`);
3407
- if (cfg.transport === "stdio" || !cfg.url) {
3408
- throw new Error(`MCP server "${name}" does not use an HTTP transport`);
3409
- }
3410
- return cfg;
3692
+ return requireHttpServerConfig(this.servers, this.disabledServers, name);
3411
3693
  }
3412
3694
  async start(cfg) {
3413
3695
  if (cfg.enabled === false) {
@@ -3461,11 +3743,7 @@ var MCPRegistry = class _MCPRegistry {
3461
3743
  * cache yet, do a one-time cold discovery connect to learn + cache the tools.
3462
3744
  */
3463
3745
  async startLazy(slot) {
3464
- const cacheDir = this.cacheDir;
3465
- if (!cacheDir) {
3466
- await this.attemptConnect(slot);
3467
- return;
3468
- }
3746
+ const cacheDir = expectDefined(this.cacheDir);
3469
3747
  const hash = manifestConfigHash(slot.cfg);
3470
3748
  const cached = await readCapabilityManifest(cacheDir, slot.cfg.name, hash);
3471
3749
  if (cached) {
@@ -3627,84 +3905,36 @@ var MCPRegistry = class _MCPRegistry {
3627
3905
  }
3628
3906
  /** Detailed, defensively-copied operational snapshots for CLI/WebUI/HQ. */
3629
3907
  operationalHealth() {
3630
- const active = Array.from(this.servers.values()).map((slot) => {
3631
- const op = slot.operations;
3632
- const baseHealth = healthStateFor(slot.state, op, slot.cfg.enabled !== false);
3633
- const checks = evaluateHealthThresholds(op, slot.cfg.health?.thresholds);
3634
- return {
3635
- name: slot.cfg.name,
3636
- connectionState: slot.state,
3637
- healthState: applyHealthThresholds(baseHealth, checks),
3638
- lastSuccessAt: op.lastSuccessAt,
3639
- lastFailureAt: op.lastFailureAt,
3640
- lastFailureKind: op.lastFailureKind,
3641
- lastReason: op.lastReason,
3642
- consecutiveFailures: op.consecutiveFailures,
3643
- failures: { ...op.failures },
3644
- reconnectCount: op.reconnectCount,
3645
- wakeCount: op.wakeCount,
3646
- sleepCount: op.sleepCount,
3647
- restartCount: op.restartCount,
3648
- connectionLatency: summarizeLatency(op.connectionSamples),
3649
- discoveryLatency: summarizeLatency(op.discoverySamples),
3650
- callLatency: summarizeLatency(op.callSamples),
3651
- inFlightCalls: op.inFlightCalls,
3652
- peakInFlightCalls: op.peakInFlightCalls,
3653
- recentEvents: op.recentEvents.map((event) => ({ ...event })),
3654
- healthChecks: checks
3655
- };
3656
- });
3657
- const disabled = Array.from(this.disabledServers.values()).map((cfg) => {
3658
- const operations = createMCPServerOperationState();
3659
- return {
3660
- name: cfg.name,
3661
- connectionState: "idle",
3662
- healthState: "disabled",
3663
- consecutiveFailures: 0,
3664
- failures: { ...operations.failures },
3665
- reconnectCount: 0,
3666
- wakeCount: 0,
3667
- sleepCount: 0,
3668
- restartCount: 0,
3669
- connectionLatency: summarizeLatency([]),
3670
- discoveryLatency: summarizeLatency([]),
3671
- callLatency: summarizeLatency([]),
3672
- inFlightCalls: 0,
3673
- peakInFlightCalls: 0,
3674
- recentEvents: [],
3675
- healthChecks: []
3676
- };
3677
- });
3678
- return [...active, ...disabled];
3908
+ return buildRegistryOperationalHealth(this.servers.values(), this.disabledServers.values());
3679
3909
  }
3680
3910
  getCatalog(name) {
3681
3911
  const slot = this.servers.get(name);
3682
3912
  if (!slot) return void 0;
3683
- return catalogSnapshot(slot);
3913
+ return registryCatalogSnapshot(slot);
3684
3914
  }
3685
3915
  async listResources(name, opts = {}) {
3686
3916
  const slot = this.requireSlot(name);
3687
- if (!opts.refresh && slot.resources) return cloneRecords(slot.resources);
3917
+ if (!opts.refresh && slot.resources) return cloneCatalogRecords(slot.resources);
3688
3918
  const client = await this.ensureConnected(name);
3689
3919
  if (!client.getServerMetadata()?.capabilities.resources) return [];
3690
- slot.resources = await collectPages(
3920
+ slot.resources = await collectCatalogPages(
3691
3921
  (cursor) => client.listResources(cursor ? { cursor } : {}),
3692
3922
  (page) => page.resources
3693
3923
  );
3694
3924
  await this.persistCapabilityManifest(slot);
3695
- return cloneRecords(slot.resources);
3925
+ return cloneCatalogRecords(slot.resources);
3696
3926
  }
3697
3927
  async listResourceTemplates(name, opts = {}) {
3698
3928
  const slot = this.requireSlot(name);
3699
- if (!opts.refresh && slot.resourceTemplates) return cloneRecords(slot.resourceTemplates);
3929
+ if (!opts.refresh && slot.resourceTemplates) return cloneCatalogRecords(slot.resourceTemplates);
3700
3930
  const client = await this.ensureConnected(name);
3701
3931
  if (!client.getServerMetadata()?.capabilities.resources) return [];
3702
- slot.resourceTemplates = await collectPages(
3932
+ slot.resourceTemplates = await collectCatalogPages(
3703
3933
  (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
3704
3934
  (page) => page.resourceTemplates
3705
3935
  );
3706
3936
  await this.persistCapabilityManifest(slot);
3707
- return cloneRecords(slot.resourceTemplates);
3937
+ return cloneCatalogRecords(slot.resourceTemplates);
3708
3938
  }
3709
3939
  async readResource(name, uri) {
3710
3940
  return (await this.ensureConnected(name)).readResource(uri);
@@ -3720,15 +3950,15 @@ var MCPRegistry = class _MCPRegistry {
3720
3950
  }
3721
3951
  async listPrompts(name, opts = {}) {
3722
3952
  const slot = this.requireSlot(name);
3723
- if (!opts.refresh && slot.prompts) return cloneRecords(slot.prompts);
3953
+ if (!opts.refresh && slot.prompts) return cloneCatalogRecords(slot.prompts);
3724
3954
  const client = await this.ensureConnected(name);
3725
3955
  if (!client.getServerMetadata()?.capabilities.prompts) return [];
3726
- slot.prompts = await collectPages(
3956
+ slot.prompts = await collectCatalogPages(
3727
3957
  (cursor) => client.listPrompts(cursor ? { cursor } : {}),
3728
3958
  (page) => page.prompts
3729
3959
  );
3730
3960
  await this.persistCapabilityManifest(slot);
3731
- return cloneRecords(slot.prompts);
3961
+ return cloneCatalogRecords(slot.prompts);
3732
3962
  }
3733
3963
  async getPrompt(serverName, promptName, args) {
3734
3964
  return (await this.ensureConnected(serverName)).getPrompt(promptName, args);
@@ -3808,7 +4038,7 @@ var MCPRegistry = class _MCPRegistry {
3808
4038
  const capabilities = slot.serverMetadata?.capabilities;
3809
4039
  if (capabilities?.resources) {
3810
4040
  try {
3811
- slot.resources = await collectPages(
4041
+ slot.resources = await collectCatalogPages(
3812
4042
  (cursor) => client.listResources(cursor ? { cursor } : {}),
3813
4043
  (page) => page.resources
3814
4044
  );
@@ -3818,7 +4048,7 @@ var MCPRegistry = class _MCPRegistry {
3818
4048
  this.log.warn(`MCP server "${slot.cfg.name}" resource discovery failed`, err);
3819
4049
  }
3820
4050
  try {
3821
- slot.resourceTemplates = await collectPages(
4051
+ slot.resourceTemplates = await collectCatalogPages(
3822
4052
  (cursor) => client.listResourceTemplates(cursor ? { cursor } : {}),
3823
4053
  (page) => page.resourceTemplates
3824
4054
  );
@@ -3833,7 +4063,7 @@ var MCPRegistry = class _MCPRegistry {
3833
4063
  }
3834
4064
  if (capabilities?.prompts) {
3835
4065
  try {
3836
- slot.prompts = await collectPages(
4066
+ slot.prompts = await collectCatalogPages(
3837
4067
  (cursor) => client.listPrompts(cursor ? { cursor } : {}),
3838
4068
  (page) => page.prompts
3839
4069
  );
@@ -4012,27 +4242,11 @@ var MCPRegistry = class _MCPRegistry {
4012
4242
  const slot = this.servers.get(name);
4013
4243
  if (!slot) return;
4014
4244
  if (slot.lazy) {
4015
- slot.client = void 0;
4016
- slot.state = "dormant";
4017
4245
  this.recordFailure(slot, "transport", "process-exit-lazy");
4018
- this.events.emit("mcp.server.disconnected", {
4019
- name,
4020
- reason: `exit:${code ?? "unknown"} (dormant)`
4021
- });
4246
+ markLazySlotDormant(slot, this.events, `exit:${code ?? "unknown"}`);
4022
4247
  return;
4023
4248
  }
4024
- for (const t of slot.toolNames) {
4025
- try {
4026
- this.toolRegistry.unregister(t);
4027
- } catch {
4028
- }
4029
- }
4030
- slot.toolNames = [];
4031
- slot.lazyTools = [];
4032
- slot.serverMetadata = void 0;
4033
- slot.resources = void 0;
4034
- slot.resourceTemplates = void 0;
4035
- slot.prompts = void 0;
4249
+ resetDisconnectedSlotTools(slot, this.toolRegistry);
4036
4250
  slot.state = "disconnected";
4037
4251
  this.recordFailure(slot, "transport", "process-exit");
4038
4252
  this.events.emit("mcp.server.disconnected", { name, reason: `exit:${code ?? "unknown"}` });
@@ -4043,69 +4257,30 @@ var MCPRegistry = class _MCPRegistry {
4043
4257
  const slot = this.servers.get(name);
4044
4258
  if (!slot) return;
4045
4259
  if (slot.lazy) {
4046
- slot.client = void 0;
4047
- slot.state = "dormant";
4048
4260
  this.recordFailure(slot, "transport", "http-disconnect-lazy");
4049
- this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect (dormant)" });
4261
+ markLazySlotDormant(slot, this.events, "http-disconnect");
4050
4262
  return;
4051
4263
  }
4052
- for (const t of slot.toolNames) {
4053
- try {
4054
- this.toolRegistry.unregister(t);
4055
- } catch {
4056
- }
4057
- }
4058
- slot.toolNames = [];
4059
- slot.lazyTools = [];
4060
- slot.serverMetadata = void 0;
4061
- slot.resources = void 0;
4062
- slot.resourceTemplates = void 0;
4063
- slot.prompts = void 0;
4264
+ resetDisconnectedSlotTools(slot, this.toolRegistry);
4064
4265
  slot.state = "disconnected";
4065
4266
  this.recordFailure(slot, "transport", "http-disconnect");
4066
4267
  this.events.emit("mcp.server.disconnected", { name, reason: "http-disconnect" });
4067
4268
  this.scheduleReconnect(slot);
4068
4269
  };
4069
- /**
4070
- * L2-B: maximum number of reconnect cycles before staying `failed`.
4071
- * One cycle = one full `attemptConnect` (which itself may try up to 3
4072
- * times). Caps total reconnect storm at ~5 cycles, then the slot
4073
- * needs an explicit `restart()` to re-engage.
4074
- */
4075
4270
  static MAX_RECONNECT_CYCLES = MCP_CONSTANTS.RECONNECT.MAX_CYCLES;
4076
- /** Base delay between cycles, in ms. Real delay adds jitter. */
4077
4271
  static BASE_RECONNECT_DELAY_MS = MCP_CONSTANTS.RECONNECT.BASE_DELAY_MS;
4078
- /** Hard ceiling on the inter-cycle delay so the user doesn't wait minutes. */
4079
4272
  static MAX_RECONNECT_DELAY_MS = 3e4;
4080
4273
  scheduleReconnect(slot) {
4081
- if (slot.reconnectPending) return;
4082
- if (slot.reconnectCycles >= _MCPRegistry.MAX_RECONNECT_CYCLES) {
4083
- slot.state = "failed";
4084
- this.recordFailure(slot, "transport", "reconnect-exhausted");
4085
- this.log.error(
4086
- `MCP server "${slot.cfg.name}" giving up after ${slot.reconnectCycles} reconnect cycles. Use \`/mcp restart ${slot.cfg.name}\` to retry.`
4087
- );
4088
- this.events.emit("mcp.server.disconnected", {
4089
- name: slot.cfg.name,
4090
- reason: `reconnect-exhausted:${slot.reconnectCycles}`
4091
- });
4092
- return;
4093
- }
4094
- slot.reconnectPending = true;
4095
- if (slot.reconnectTimer) {
4096
- clearTimeout(slot.reconnectTimer);
4097
- slot.reconnectTimer = void 0;
4098
- }
4099
- const base = Math.min(
4100
- _MCPRegistry.BASE_RECONNECT_DELAY_MS * 2 ** slot.reconnectCycles,
4101
- _MCPRegistry.MAX_RECONNECT_DELAY_MS
4102
- );
4103
- const jitter = base * MCP_CONSTANTS.RECONNECT.JITTER_FACTOR * (Math.random() * 2 - 1);
4104
- const delay = Math.max(100, Math.round(base + jitter));
4105
- slot.reconnectTimer = setTimeout(() => {
4106
- slot.reconnectTimer = void 0;
4107
- void this.attemptReconnect(slot);
4108
- }, delay);
4274
+ scheduleRegistryReconnect({
4275
+ slot,
4276
+ events: this.events,
4277
+ log: this.log,
4278
+ maxReconnectCycles: _MCPRegistry.MAX_RECONNECT_CYCLES,
4279
+ baseReconnectDelayMs: _MCPRegistry.BASE_RECONNECT_DELAY_MS,
4280
+ maxReconnectDelayMs: _MCPRegistry.MAX_RECONNECT_DELAY_MS,
4281
+ recordReconnectExhausted: (target) => this.recordFailure(target, "transport", "reconnect-exhausted"),
4282
+ attemptReconnect: (target) => this.attemptReconnect(target)
4283
+ });
4109
4284
  }
4110
4285
  async attemptReconnect(slot) {
4111
4286
  slot.reconnectPending = false;
@@ -4115,48 +4290,21 @@ var MCPRegistry = class _MCPRegistry {
4115
4290
  await this.attemptConnect(slot);
4116
4291
  }
4117
4292
  recordSuccess(slot, resetFailures = true) {
4118
- const operations = this.operationsFor(slot);
4119
- operations.lastSuccessAt = Date.now();
4120
- if (resetFailures) operations.consecutiveFailures = 0;
4293
+ recordRegistrySuccess(slot, resetFailures);
4121
4294
  }
4122
4295
  recordFailure(slot, failureKind, reason, durationMs) {
4123
- const operations = this.operationsFor(slot);
4124
- const safeReason = safeOperationReason(reason);
4125
- operations.lastFailureAt = Date.now();
4126
- operations.lastFailureKind = failureKind;
4127
- operations.lastReason = safeReason;
4128
- operations.consecutiveFailures++;
4129
- operations.failures[failureKind]++;
4130
- this.recordOperation(slot, "failure", safeReason, failureKind, durationMs);
4296
+ recordRegistryFailure(slot, this.operationListeners, failureKind, reason, durationMs);
4131
4297
  }
4132
4298
  recordOperation(slot, kind, reason, failureKind, durationMs, retain = true) {
4133
- const operations = this.operationsFor(slot);
4134
- const baseHealth = healthStateFor(slot.state, operations, slot.cfg.enabled !== false);
4135
- const checks = evaluateHealthThresholds(operations, slot.cfg.health?.thresholds);
4136
- const event = {
4137
- serverName: slot.cfg.name,
4299
+ recordRegistryOperation(
4300
+ slot,
4301
+ this.operationListeners,
4138
4302
  kind,
4139
- at: Date.now(),
4140
- connectionState: slot.state,
4141
- healthState: applyHealthThresholds(baseHealth, checks)
4142
- };
4143
- if (reason !== void 0) event.reason = safeOperationReason(reason);
4144
- if (failureKind !== void 0) event.failureKind = failureKind;
4145
- if (durationMs !== void 0) event.durationMs = Math.max(0, Math.round(durationMs));
4146
- if (retain) {
4147
- pushBounded(operations.recentEvents, event, MCP_OPERATION_LIMITS.RECENT_EVENTS);
4148
- }
4149
- for (const listener of this.operationListeners) {
4150
- try {
4151
- listener({ ...event });
4152
- } catch {
4153
- }
4154
- }
4155
- }
4156
- /** Keeps private-method unit fixtures from needing to duplicate every slot field. */
4157
- operationsFor(slot) {
4158
- if (!slot.operations) slot.operations = createMCPServerOperationState();
4159
- return slot.operations;
4303
+ reason,
4304
+ failureKind,
4305
+ durationMs,
4306
+ retain
4307
+ );
4160
4308
  }
4161
4309
  async attemptConnect(slot) {
4162
4310
  const MAX_ATTEMPTS = MCP_CONSTANTS.RECONNECT.MAX_ATTEMPTS;
@@ -4267,44 +4415,10 @@ var MCPRegistry = class _MCPRegistry {
4267
4415
  }
4268
4416
  }
4269
4417
  };
4270
- var MAX_CATALOG_PAGES = 100;
4271
- var MAX_CATALOG_ITEMS = 1e4;
4272
- async function collectPages(load, select) {
4273
- const items = [];
4274
- const seenCursors = /* @__PURE__ */ new Set();
4275
- let cursor;
4276
- for (let pageNumber = 0; pageNumber < MAX_CATALOG_PAGES; pageNumber++) {
4277
- const page = await load(cursor);
4278
- items.push(...select(page));
4279
- if (items.length > MAX_CATALOG_ITEMS) {
4280
- throw new Error(`MCP catalog exceeds ${MAX_CATALOG_ITEMS} items`);
4281
- }
4282
- const next = page.nextCursor;
4283
- if (!next) return items;
4284
- if (seenCursors.has(next)) throw new Error(`MCP catalog repeated cursor "${next}"`);
4285
- seenCursors.add(next);
4286
- cursor = next;
4287
- }
4288
- throw new Error(`MCP catalog exceeds ${MAX_CATALOG_PAGES} pages`);
4289
- }
4290
- function cloneRecords(records) {
4291
- return structuredClone(records);
4292
- }
4293
- function catalogSnapshot(slot) {
4294
- return {
4295
- name: slot.cfg.name,
4296
- state: slot.state,
4297
- serverMetadata: slot.serverMetadata ? structuredClone(slot.serverMetadata) : void 0,
4298
- resources: slot.resources ? cloneRecords(slot.resources) : void 0,
4299
- resourceTemplates: slot.resourceTemplates ? cloneRecords(slot.resourceTemplates) : void 0,
4300
- prompts: slot.prompts ? cloneRecords(slot.prompts) : void 0
4301
- };
4302
- }
4303
4418
 
4304
4419
  // src/server.ts
4305
4420
  import { createServer } from "node:http";
4306
- import { expectDefined as expectDefined2 } from "@wrongstack/core/utils";
4307
- import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
4421
+ import { expectDefined as expectDefined2, toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
4308
4422
  var PARSE_ERROR = -32700;
4309
4423
  var INVALID_REQUEST = -32600;
4310
4424
  var METHOD_NOT_FOUND = -32601;
@@ -4629,8 +4743,7 @@ function serveHttp(server, opts = {}) {
4629
4743
  httpServer.once("error", reject);
4630
4744
  httpServer.listen(port, host, () => {
4631
4745
  httpServer.removeListener("error", reject);
4632
- const addr = httpServer.address();
4633
- const boundPort = typeof addr === "object" && addr ? addr.port : port;
4746
+ const boundPort = httpServer.address().port;
4634
4747
  const displayHost = host === "::1" ? "[::1]" : host;
4635
4748
  resolve({
4636
4749
  port: boundPort,
@@ -4662,18 +4775,14 @@ async function handleHttpRequest(server, req, res, token, log) {
4662
4775
  }
4663
4776
  }
4664
4777
  let body = "";
4665
- let tooLarge = false;
4666
4778
  req.on("data", (chunk) => {
4667
- if (tooLarge) return;
4668
4779
  body += chunk.toString("utf8");
4669
4780
  if (body.length > HTTP_BODY_CAP) {
4670
- tooLarge = true;
4671
4781
  send(413, JSON.stringify({ error: "payload too large" }));
4672
4782
  req.destroy();
4673
4783
  }
4674
4784
  });
4675
4785
  req.on("end", () => {
4676
- if (tooLarge) return;
4677
4786
  void server.handleMessage(body).then((out) => {
4678
4787
  if (out === null) return send(202, "");
4679
4788
  return send(200, out);