mcp-compress-router 1.5.6 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import { Agent, fetch as undiciFetch } from 'undici';
2
+ /**
3
+ * Max concurrent connections per origin for the dedicated agent behind a
4
+ * Streamable HTTP transport. MUST be greater than one: the MCP streamable
5
+ * HTTP spec keeps a long-lived GET SSE stream open while POSTing
6
+ * messages, and a single-connection pool would deadlock — the SSE holds
7
+ * the only socket and every message POST queues behind it forever.
8
+ * Four connections cover the SSE stream plus concurrent message POSTs and
9
+ * the occasional session DELETE without sharing a pool with other
10
+ * servers.
11
+ */
12
+ const DEDICATED_FETCH_CONNECTIONS = 4;
13
+ /**
14
+ * Idle timeout (ms) for sockets owned by a dedicated agent. Kept short so
15
+ * sockets from a closed transport drain within a second instead of
16
+ * holding the process alive at undici's default 4 s keep-alive.
17
+ */
18
+ const DEDICATED_FETCH_KEEP_ALIVE_MS = 1_000;
19
+ /**
20
+ * Builds a fetch implementation backed by a dedicated undici `Agent`.
21
+ *
22
+ * Every `StreamableHTTPClientTransport` gets its own agent, so all of a
23
+ * downstream server's traffic — the SSE stream and the message POSTs —
24
+ * runs on connections drawn from a private pool for that transport
25
+ * instead of the shared default fetch pool. This isolates one server's
26
+ * connections from the OAuth metadata probes and from every other
27
+ * server: a probe or another server can never reuse a socket that
28
+ * belongs to this transport (and a connection-sensitive downstream or
29
+ * proxy never sees unrelated requests on the same TCP connection).
30
+ *
31
+ * The agent is intentionally left open-ended: it keeps per-origin
32
+ * keep-alive sockets that drain after {@link DEDICATED_FETCH_KEEP_ALIVE_MS}
33
+ * (the router force-exits on shutdown, so no explicit close is needed).
34
+ *
35
+ * @returns A fetch-compatible function for the caller's transport.
36
+ */
37
+ export function createDedicatedFetch() {
38
+ const agent = new Agent({
39
+ connections: DEDICATED_FETCH_CONNECTIONS,
40
+ keepAliveTimeout: DEDICATED_FETCH_KEEP_ALIVE_MS,
41
+ });
42
+ return (url, init) => undiciFetch(url, {
43
+ // undici v8's `RequestInit`/`Response` and the global fetch types come
44
+ // from different type generations (TS 7 ArrayBuffer variance), so the
45
+ // bridge casts below are required; the runtime objects are identical.
46
+ ...init,
47
+ dispatcher: agent,
48
+ });
49
+ }
@@ -2,6 +2,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
2
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
3
3
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
4
4
  import { getDownstreamTimeoutMs } from '../utils/index.js';
5
+ import { createDedicatedFetch } from './dedicated-fetch.js';
5
6
  /** JSON-RPC error code for "Method not found". */
6
7
  const METHOD_NOT_FOUND = -32601;
7
8
  /**
@@ -131,5 +132,8 @@ export function createTransport(server, getAuthProvider) {
131
132
  return new StreamableHTTPClientTransport(new URL(server.url), {
132
133
  requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
133
134
  authProvider,
135
+ // Route all of this server's traffic through its own undici agent so
136
+ // it uses a private connection pool instead of the shared default one.
137
+ fetch: createDedicatedFetch(),
134
138
  });
135
139
  }
@@ -47,13 +47,39 @@ function serializeCacheWrite(cachePath, task) {
47
47
  function isNodeError(err) {
48
48
  return err instanceof Error && typeof err.code === 'string';
49
49
  }
50
+ /**
51
+ * Sanitizes a parsed cache store: keeps only entries that are plain
52
+ * objects carrying a `tools` array, and drops everything else. A cache
53
+ * file that is damaged in parts therefore degrades to the subset of
54
+ * still-valid entries instead of being rejected wholesale; a root that
55
+ * is not a plain object degrades to an empty store.
56
+ *
57
+ * @param parsed - The raw JSON.parse() result of the cache file.
58
+ * @returns A validated store containing only well-formed entries.
59
+ */
60
+ function sanitizeStore(parsed) {
61
+ const store = {};
62
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
63
+ return store;
64
+ }
65
+ for (const [serverName, entry] of Object.entries(parsed)) {
66
+ const candidate = entry;
67
+ if (candidate !== null && typeof candidate === 'object' && Array.isArray(candidate.tools)) {
68
+ store[serverName] = candidate;
69
+ }
70
+ }
71
+ return store;
72
+ }
50
73
  /**
51
74
  * Reads the full tool cache store from disk. Returns an empty object
52
- * when the file does not exist.
75
+ * when the file does not exist. A corrupted cache file (invalid JSON,
76
+ * a non-object root, or entries without a `tools` array) never throws:
77
+ * it is treated as empty or partially empty so a damaged cache can
78
+ * never break the router. The next write simply rebuilds the file.
53
79
  *
54
80
  * @param configPath - Absolute path to the mcp.json file.
55
- * @returns The tool cache store, or empty object if file is absent.
56
- * @throws If the file exists but contains invalid JSON.
81
+ * @returns The tool cache store (possibly empty or partial).
82
+ * @throws Only on unexpected I/O errors (e.g. permission denied).
57
83
  */
58
84
  async function readCacheStore(configPath) {
59
85
  const cachePath = getCachePath(configPath);
@@ -67,17 +93,36 @@ async function readCacheStore(configPath) {
67
93
  }
68
94
  throw new Error(`Failed to read tool cache file: ${cachePath}`, { cause: err });
69
95
  }
70
- let parsed;
71
96
  try {
72
- parsed = JSON.parse(raw);
97
+ return sanitizeStore(JSON.parse(raw));
73
98
  }
74
- catch (err) {
75
- throw new Error(`Tool cache file contains invalid JSON: ${cachePath}`, { cause: err });
99
+ catch {
100
+ // Invalid JSON: treat the whole cache as empty rather than throwing.
101
+ // loadToolCache reports "no cache" and saveToolCache rebuilds it.
102
+ return {};
76
103
  }
77
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
78
- throw new Error(`Tool cache file must contain a JSON object: ${cachePath}`);
104
+ }
105
+ /**
106
+ * Writes `data` to `cachePath` atomically: the content is first written
107
+ * to a unique temporary sibling file and then renamed over the target.
108
+ * Renaming within the same directory is atomic on POSIX, so a reader
109
+ * (or a crash mid-write) can never observe a truncated or half-written
110
+ * cache file — it sees either the previous complete file or the new
111
+ * complete file.
112
+ *
113
+ * @param cachePath - Target cache file path.
114
+ * @param data - Full file content to write.
115
+ */
116
+ async function writeCacheFileAtomic(cachePath, data) {
117
+ const tempPath = `${cachePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
118
+ try {
119
+ await fs.writeFile(tempPath, data);
120
+ await fs.rename(tempPath, cachePath);
121
+ }
122
+ catch (err) {
123
+ await fs.unlink(tempPath).catch(() => { });
124
+ throw err;
79
125
  }
80
- return parsed;
81
126
  }
82
127
  /**
83
128
  * Saves discovered tools to the on-disk tool cache for a single server.
@@ -98,7 +143,7 @@ export async function saveToolCache(configPath, serverName, tools) {
98
143
  tools,
99
144
  cachedAt: new Date().toISOString(),
100
145
  };
101
- await fs.writeFile(cachePath, JSON.stringify(store, null, 2) + '\n');
146
+ await writeCacheFileAtomic(cachePath, JSON.stringify(store, null, 2) + '\n');
102
147
  });
103
148
  }
104
149
  /**
@@ -108,8 +153,8 @@ export async function saveToolCache(configPath, serverName, tools) {
108
153
  *
109
154
  * @param configPath - Absolute path to the mcp.json file.
110
155
  * @param serverName - The server whose cached tools to load.
111
- * @returns The cached tool descriptors, or `undefined` when not cached.
112
- * @throws If the cache file exists but contains invalid JSON.
156
+ * @returns The cached tool descriptors, or `undefined` when not cached
157
+ * (including when the cache file is missing or corrupted).
113
158
  */
114
159
  export async function loadToolCache(configPath, serverName) {
115
160
  const store = await readCacheStore(configPath);
@@ -143,7 +188,7 @@ export async function clearToolCache(configPath, serverName) {
143
188
  await fs.unlink(cachePath).catch(() => { });
144
189
  }
145
190
  else {
146
- await fs.writeFile(cachePath, JSON.stringify(store, null, 2) + '\n');
191
+ await writeCacheFileAtomic(cachePath, JSON.stringify(store, null, 2) + '\n');
147
192
  }
148
193
  });
149
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "1.5.6",
3
+ "version": "1.6.1",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -68,6 +68,7 @@
68
68
  "dotenv": "17.4.2",
69
69
  "jsonc-parser": "3.3.1",
70
70
  "picomatch": "4.0.5",
71
+ "undici": "8.10.0",
71
72
  "zod": "4.4.3"
72
73
  }
73
74
  }