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
|
|
56
|
-
* @throws
|
|
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
|
-
|
|
97
|
+
return sanitizeStore(JSON.parse(raw));
|
|
73
98
|
}
|
|
74
|
-
catch
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
}
|