mcp-compress-router 1.5.5 → 1.6.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.
|
@@ -112,11 +112,29 @@ export async function runRouter(configPath, verbose) {
|
|
|
112
112
|
logger.info('Loading configuration', { path: resolved });
|
|
113
113
|
const servers = await loadConfig(resolved);
|
|
114
114
|
logger.info('Configuration loaded', { serverCount: servers.length });
|
|
115
|
-
|
|
115
|
+
// Refresh the cached OAuth auth requirements CONCURRENTLY with the
|
|
116
|
+
// downstream connects. Both phases are network-bound, so running them
|
|
117
|
+
// sequentially would stack their worst-case latencies; in parallel the
|
|
118
|
+
// startup time is bounded by the slower of the two — and each default
|
|
119
|
+
// timeout (see timeout.ts) stays well below the 30 s startup budget
|
|
120
|
+
// most MCP hosts allow, so even a hung server cannot blow it.
|
|
121
|
+
// `Promise.allSettled` avoids unhandled rejections when both phases
|
|
122
|
+
// fail; the connect failure is surfaced first because it is the one
|
|
123
|
+
// the host needs to act on.
|
|
116
124
|
logger.info('Connecting to downstream servers', {
|
|
117
125
|
servers: servers.map((s) => s.name),
|
|
118
126
|
});
|
|
119
|
-
const
|
|
127
|
+
const [authRefresh, connectResult] = await Promise.allSettled([
|
|
128
|
+
persistAuthRequirements(resolved, servers, logger),
|
|
129
|
+
connectAllServers(servers, resolved, logger),
|
|
130
|
+
]);
|
|
131
|
+
if (connectResult.status === 'rejected') {
|
|
132
|
+
throw connectResult.reason;
|
|
133
|
+
}
|
|
134
|
+
if (authRefresh.status === 'rejected') {
|
|
135
|
+
throw authRefresh.reason;
|
|
136
|
+
}
|
|
137
|
+
const { connections, discovered } = connectResult.value;
|
|
120
138
|
logger.info('Tools discovered', {
|
|
121
139
|
servers: discovered.map((d) => ({
|
|
122
140
|
name: d.name,
|
|
@@ -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
|
}
|
|
@@ -1,4 +1,60 @@
|
|
|
1
1
|
import { createTimeoutFetch, getAuthDiscoveryTimeoutMs } from '../utils/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Returns true when the thrown error means an endpoint responded with a
|
|
4
|
+
* body that is not JSON (e.g. an HTML page, an SPA catch-all route, or a
|
|
5
|
+
* proxy error page). The SDK parses well-known metadata with
|
|
6
|
+
* `response.json()`, which throws `SyntaxError` on invalid JSON.
|
|
7
|
+
*
|
|
8
|
+
* Such a response is a server's way of saying "no OAuth metadata here",
|
|
9
|
+
* not a probe failure: the endpoint answered, it just is not an OAuth
|
|
10
|
+
* metadata endpoint. These misses must not surface as errors — a plain
|
|
11
|
+
* web page at the well-known path means the server publishes no OAuth
|
|
12
|
+
* metadata (auth requirement `'none'`), not that probing is broken.
|
|
13
|
+
*
|
|
14
|
+
* @param err - The thrown value from an SDK discovery helper.
|
|
15
|
+
* @returns True when the error is a JSON parse failure.
|
|
16
|
+
*/
|
|
17
|
+
function isNonJsonResponse(err) {
|
|
18
|
+
return err instanceof SyntaxError;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Converts a raw `authorization_servers` string into a URL. Returns
|
|
22
|
+
* `undefined` for malformed entries so a broken PRM advertisement is
|
|
23
|
+
* skipped like any other failed candidate instead of aborting discovery.
|
|
24
|
+
*
|
|
25
|
+
* @param value - The advertised authorization server URL string.
|
|
26
|
+
* @returns The parsed URL, or `undefined` when malformed.
|
|
27
|
+
*/
|
|
28
|
+
function toUrl(value) {
|
|
29
|
+
try {
|
|
30
|
+
return new URL(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Races every candidate probe in parallel and resolves with the first
|
|
38
|
+
* candidate that finds metadata. Candidates that miss (or error) are
|
|
39
|
+
* treated as rejections, so a hung endpoint on one candidate can never
|
|
40
|
+
* delay a hit found by another candidate.
|
|
41
|
+
*
|
|
42
|
+
* @param urls - The candidate URLs to probe.
|
|
43
|
+
* @param probe - The per-candidate probe callback (never throws).
|
|
44
|
+
* @returns The first hit, or `undefined` when every candidate missed.
|
|
45
|
+
*/
|
|
46
|
+
async function raceCandidates(urls, probe) {
|
|
47
|
+
if (urls.length === 0) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return Promise.any(urls.map(async (url) => {
|
|
51
|
+
const metadata = await probe(url);
|
|
52
|
+
if (!metadata) {
|
|
53
|
+
throw new Error(`No OAuth metadata at ${url.href}`);
|
|
54
|
+
}
|
|
55
|
+
return { url, metadata };
|
|
56
|
+
})).catch(() => undefined);
|
|
57
|
+
}
|
|
2
58
|
/**
|
|
3
59
|
* Discovers OAuth metadata for a downstream MCP server following the
|
|
4
60
|
* MCP 2025-06-18 authorization spec two-step flow:
|
|
@@ -12,21 +68,29 @@ import { createTimeoutFetch, getAuthDiscoveryTimeoutMs } from '../utils/index.js
|
|
|
12
68
|
* AS), discovery falls back to the server URL and, if that URL has a path,
|
|
13
69
|
* its origin root.
|
|
14
70
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
71
|
+
* The candidates of each group are probed **in parallel** and the first
|
|
72
|
+
* hit wins, so a hung well-known endpoint on one candidate never delays
|
|
73
|
+
* discovery of another. PRM-advertised AS URLs are preferred over the
|
|
74
|
+
* legacy fallback candidates (the fallback group is only probed when no
|
|
75
|
+
* advertised AS yields metadata).
|
|
76
|
+
*
|
|
77
|
+
* A candidate that responds with a non-JSON body (e.g. HTML) is treated as
|
|
78
|
+
* "not an OAuth endpoint" — a clean miss, not a probe failure. All other
|
|
79
|
+
* per-candidate errors (5xx, network, timeout) are swallowed and treated as
|
|
80
|
+
* "not found" so a single flaky endpoint never aborts the whole flow.
|
|
81
|
+
* When no metadata is found anywhere AND at least one candidate threw a
|
|
82
|
+
* genuine error, the last such error is re-thrown so callers can
|
|
83
|
+
* distinguish a clean "no OAuth published" (all 404s / non-JSON) from an
|
|
84
|
+
* actual server/network failure (e.g. the auth probe reports `'unknown'`,
|
|
85
|
+
* the login command throws a guided error).
|
|
22
86
|
*
|
|
23
87
|
* @param serverUrl - The downstream MCP server URL to discover auth for.
|
|
24
88
|
* @returns The discovered resource and/or server metadata plus the AS URL
|
|
25
89
|
* that yielded the metadata. `serverMetadata` is `undefined` when no OAuth
|
|
26
90
|
* endpoints could be discovered.
|
|
27
91
|
* @throws The last discovery error when no metadata was found and at least
|
|
28
|
-
* one candidate endpoint errored. Clean "not found" (all 404s
|
|
29
|
-
* throw.
|
|
92
|
+
* one candidate endpoint errored. Clean "not found" (all 404s or
|
|
93
|
+
* non-JSON responses) does not throw.
|
|
30
94
|
*/
|
|
31
95
|
export async function discoverAuth(serverUrl) {
|
|
32
96
|
const { discoverOAuthProtectedResourceMetadata, discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
|
|
@@ -34,17 +98,20 @@ export async function discoverAuth(serverUrl) {
|
|
|
34
98
|
// that hangs its well-known endpoint would trap discovery forever. Pass
|
|
35
99
|
// a fetch that aborts after a short, configurable budget.
|
|
36
100
|
const fetchFn = createTimeoutFetch(getAuthDiscoveryTimeoutMs());
|
|
37
|
-
// Tracks the last error seen across all candidates so the caller
|
|
38
|
-
// notified when discovery failed entirely (vs. cleanly finding
|
|
101
|
+
// Tracks the last genuine error seen across all candidates so the caller
|
|
102
|
+
// can be notified when discovery failed entirely (vs. cleanly finding
|
|
103
|
+
// nothing). Non-JSON responses are excluded — they are clean misses.
|
|
39
104
|
let lastError;
|
|
40
105
|
// Tolerant AS discovery: any error (404-as-throw, 5xx, network) is
|
|
41
|
-
// recorded and treated as "not found" so the
|
|
106
|
+
// recorded and treated as "not found" so the other candidates are tried.
|
|
42
107
|
const safeDiscoverAs = async (url) => {
|
|
43
108
|
try {
|
|
44
109
|
return await discoverAuthorizationServerMetadata(url, { fetchFn });
|
|
45
110
|
}
|
|
46
111
|
catch (err) {
|
|
47
|
-
|
|
112
|
+
if (!isNonJsonResponse(err)) {
|
|
113
|
+
lastError = err;
|
|
114
|
+
}
|
|
48
115
|
return undefined;
|
|
49
116
|
}
|
|
50
117
|
};
|
|
@@ -59,32 +126,36 @@ export async function discoverAuth(serverUrl) {
|
|
|
59
126
|
catch {
|
|
60
127
|
// No PRM published; fall through to direct AS discovery below.
|
|
61
128
|
}
|
|
62
|
-
// Step 2:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
// Legacy fallback: direct AS discovery at the server URL, then its origin
|
|
73
|
-
// root (for servers that host AS metadata at the root with the MCP endpoint
|
|
74
|
-
// on a subpath and no PRM).
|
|
75
|
-
const candidates = [serverUrl];
|
|
129
|
+
// Step 2: probe the candidates in parallel, first hit wins. Advertised
|
|
130
|
+
// AS URLs take precedence; the legacy fallback group (the server URL
|
|
131
|
+
// and, for subpath URLs, its origin root) is only probed when no
|
|
132
|
+
// advertised AS yields metadata.
|
|
133
|
+
const advertisedUrls = (resourceMetadata?.authorization_servers ?? [])
|
|
134
|
+
.map((value) => toUrl(value))
|
|
135
|
+
.filter((url) => url !== undefined);
|
|
136
|
+
const fallbackUrls = [serverUrl];
|
|
76
137
|
if (serverUrl.pathname !== '/') {
|
|
77
|
-
|
|
138
|
+
fallbackUrls.push(new URL(serverUrl.origin));
|
|
78
139
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
140
|
+
const advertisedHit = await raceCandidates(advertisedUrls, safeDiscoverAs);
|
|
141
|
+
if (advertisedHit) {
|
|
142
|
+
return {
|
|
143
|
+
resourceMetadata,
|
|
144
|
+
serverMetadata: advertisedHit.metadata,
|
|
145
|
+
authorizationServerUrl: advertisedHit.url,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const fallbackHit = await raceCandidates(fallbackUrls, safeDiscoverAs);
|
|
149
|
+
if (fallbackHit) {
|
|
150
|
+
return {
|
|
151
|
+
resourceMetadata,
|
|
152
|
+
serverMetadata: fallbackHit.metadata,
|
|
153
|
+
authorizationServerUrl: fallbackHit.url,
|
|
154
|
+
};
|
|
84
155
|
}
|
|
85
156
|
// No metadata found anywhere. If any candidate actually errored (vs. a
|
|
86
|
-
// clean 404), surface that so callers can report
|
|
87
|
-
// than a misleading "no OAuth supported".
|
|
157
|
+
// clean 404 or a non-JSON response), surface that so callers can report
|
|
158
|
+
// a probe failure rather than a misleading "no OAuth supported".
|
|
88
159
|
if (lastError !== undefined) {
|
|
89
160
|
throw lastError;
|
|
90
161
|
}
|
package/build/utils/timeout.js
CHANGED
|
@@ -7,17 +7,26 @@ import process from 'node:process';
|
|
|
7
7
|
* some Streamable HTTP servers behind CDNs) surfaces a clear error instead
|
|
8
8
|
* of blocking the `tools` command or router startup indefinitely.
|
|
9
9
|
*
|
|
10
|
+
* The value is deliberately kept well below the 30 s startup budget most
|
|
11
|
+
* MCP hosts allow: downstream connects run in parallel, so a single
|
|
12
|
+
* timed-out server costs exactly this budget, and the OAuth metadata
|
|
13
|
+
* probes run concurrently with the connects (see `runRouter`), keeping
|
|
14
|
+
* worst-case startup around 10 s even when a server hangs.
|
|
15
|
+
*
|
|
10
16
|
* @internal Exported for tests only; not part of the public module API.
|
|
11
17
|
*/
|
|
12
|
-
export const DEFAULT_DOWNSTREAM_TIMEOUT_MS =
|
|
18
|
+
export const DEFAULT_DOWNSTREAM_TIMEOUT_MS = 10_000;
|
|
13
19
|
/**
|
|
14
20
|
* Default timeout (ms) for OAuth metadata discovery probes (RFC 9728 /
|
|
15
21
|
* RFC 8414 well-known endpoint fetches). These are best-effort probes;
|
|
16
22
|
* a server that hangs its well-known endpoint must not stall startup.
|
|
23
|
+
* At startup the probes run concurrently with the downstream connects
|
|
24
|
+
* and the per-server candidates are probed in parallel, so this budget
|
|
25
|
+
* is a per-request cap rather than a serialized startup cost.
|
|
17
26
|
*
|
|
18
27
|
* @internal Exported for tests only; not part of the public module API.
|
|
19
28
|
*/
|
|
20
|
-
export const DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS =
|
|
29
|
+
export const DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS = 5_000;
|
|
21
30
|
/**
|
|
22
31
|
* Reads a positive-integer environment variable, returning `undefined`
|
|
23
32
|
* when unset or invalid (non-integer or non-positive). Invalid values
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-compress-router",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
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
|
}
|