mcp-compress-router 1.5.4 → 1.5.6

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
- await persistAuthRequirements(resolved, servers, logger);
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 { connections, discovered } = await connectAllServers(servers, resolved, logger);
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,
@@ -1,6 +1,7 @@
1
1
  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
+ import { getDownstreamTimeoutMs } from '../utils/index.js';
4
5
  /** JSON-RPC error code for "Method not found". */
5
6
  const METHOD_NOT_FOUND = -32601;
6
7
  /**
@@ -21,12 +22,14 @@ function isMethodNotFound(err) {
21
22
  * propagated as a connection failure.
22
23
  *
23
24
  * @param client - A connected MCP client.
25
+ * @param options - Optional {@link RequestOptions} (e.g. `timeout`)
26
+ * forwarded to `client.listTools()`.
24
27
  * @returns The list-tools result (possibly empty).
25
28
  * @throws Any non-"Method not found" error from `listTools()`.
26
29
  */
27
- export async function listToolsOrEmpty(client) {
30
+ export async function listToolsOrEmpty(client, options) {
28
31
  try {
29
- return await client.listTools();
32
+ return await client.listTools(undefined, options);
30
33
  }
31
34
  catch (err) {
32
35
  if (isMethodNotFound(err)) {
@@ -59,9 +62,14 @@ export async function discoverSingleServer(server, logger, getAuthProvider) {
59
62
  type: server.type,
60
63
  });
61
64
  const transport = createTransport(server, getAuthProvider);
65
+ // Cap the initialize handshake and tools/list call so a server that
66
+ // accepts the connection but never replies surfaces a clear error
67
+ // instead of hanging the command indefinitely (the SDK's own default
68
+ // is 60s; we use a shorter, configurable budget).
69
+ const requestOptions = { timeout: getDownstreamTimeoutMs() };
62
70
  try {
63
- await client.connect(transport);
64
- const listResult = await listToolsOrEmpty(client);
71
+ await client.connect(transport, requestOptions);
72
+ const listResult = await listToolsOrEmpty(client, requestOptions);
65
73
  logger.info(`Connected to "${server.name}" — ${listResult.tools.length} tools discovered`, {
66
74
  server: server.name,
67
75
  toolCount: listResult.tools.length,
@@ -1,3 +1,60 @@
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
+ }
1
58
  /**
2
59
  * Discovers OAuth metadata for a downstream MCP server following the
3
60
  * MCP 2025-06-18 authorization spec two-step flow:
@@ -11,35 +68,50 @@
11
68
  * AS), discovery falls back to the server URL and, if that URL has a path,
12
69
  * its origin root.
13
70
  *
14
- * All per-candidate discovery errors are swallowed and treated as "not
15
- * found" so a single flaky endpoint never aborts the whole flow — discovery
16
- * falls through to the next candidate. When no metadata is found anywhere
17
- * AND at least one candidate threw, the last error is re-thrown so callers
18
- * can distinguish a clean "no OAuth published" (all 404s) from an actual
19
- * server/network failure (e.g. the auth probe reports `'unknown'`, the
20
- * login command throws a guided error).
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).
21
86
  *
22
87
  * @param serverUrl - The downstream MCP server URL to discover auth for.
23
88
  * @returns The discovered resource and/or server metadata plus the AS URL
24
89
  * that yielded the metadata. `serverMetadata` is `undefined` when no OAuth
25
90
  * endpoints could be discovered.
26
91
  * @throws The last discovery error when no metadata was found and at least
27
- * one candidate endpoint errored. Clean "not found" (all 404s) does not
28
- * throw.
92
+ * one candidate endpoint errored. Clean "not found" (all 404s or
93
+ * non-JSON responses) does not throw.
29
94
  */
30
95
  export async function discoverAuth(serverUrl) {
31
96
  const { discoverOAuthProtectedResourceMetadata, discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
32
- // Tracks the last error seen across all candidates so the caller can be
33
- // notified when discovery failed entirely (vs. cleanly finding nothing).
97
+ // The SDK discovery helpers use a raw fetch with no timeout; a server
98
+ // that hangs its well-known endpoint would trap discovery forever. Pass
99
+ // a fetch that aborts after a short, configurable budget.
100
+ const fetchFn = createTimeoutFetch(getAuthDiscoveryTimeoutMs());
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.
34
104
  let lastError;
35
105
  // Tolerant AS discovery: any error (404-as-throw, 5xx, network) is
36
- // recorded and treated as "not found" so the next candidate is tried.
106
+ // recorded and treated as "not found" so the other candidates are tried.
37
107
  const safeDiscoverAs = async (url) => {
38
108
  try {
39
- return await discoverAuthorizationServerMetadata(url);
109
+ return await discoverAuthorizationServerMetadata(url, { fetchFn });
40
110
  }
41
111
  catch (err) {
42
- lastError = err;
112
+ if (!isNonJsonResponse(err)) {
113
+ lastError = err;
114
+ }
43
115
  return undefined;
44
116
  }
45
117
  };
@@ -49,37 +121,41 @@ export async function discoverAuth(serverUrl) {
49
121
  // legacy-server path, not a probe failure.
50
122
  let resourceMetadata;
51
123
  try {
52
- resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
124
+ resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {}, fetchFn);
53
125
  }
54
126
  catch {
55
127
  // No PRM published; fall through to direct AS discovery below.
56
128
  }
57
- // Step 2: AS metadata at each advertised authorization server.
58
- if (resourceMetadata?.authorization_servers?.length) {
59
- for (const asUrlString of resourceMetadata.authorization_servers) {
60
- const asUrl = new URL(asUrlString);
61
- const metadata = await safeDiscoverAs(asUrl);
62
- if (metadata) {
63
- return { resourceMetadata, serverMetadata: metadata, authorizationServerUrl: asUrl };
64
- }
65
- }
66
- }
67
- // Legacy fallback: direct AS discovery at the server URL, then its origin
68
- // root (for servers that host AS metadata at the root with the MCP endpoint
69
- // on a subpath and no PRM).
70
- 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];
71
137
  if (serverUrl.pathname !== '/') {
72
- candidates.push(new URL(serverUrl.origin));
138
+ fallbackUrls.push(new URL(serverUrl.origin));
73
139
  }
74
- for (const candidate of candidates) {
75
- const metadata = await safeDiscoverAs(candidate);
76
- if (metadata) {
77
- return { resourceMetadata, serverMetadata: metadata, authorizationServerUrl: candidate };
78
- }
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
+ };
79
155
  }
80
156
  // No metadata found anywhere. If any candidate actually errored (vs. a
81
- // clean 404), surface that so callers can report a probe failure rather
82
- // 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".
83
159
  if (lastError !== undefined) {
84
160
  throw lastError;
85
161
  }
@@ -3,6 +3,7 @@ import { createTransport, listToolsOrEmpty } from './discovery.js';
3
3
  import { OAuthCredentialManager } from './oauth.js';
4
4
  import { saveToolCache, loadToolCache } from './tool-cache.js';
5
5
  import { isAuthError } from './index.js';
6
+ import { getDownstreamTimeoutMs } from '../utils/index.js';
6
7
  /**
7
8
  * 30-second cooldown after a failed reconnect attempt. Subsequent
8
9
  * `invokeWithRecovery` calls within this window return the cached
@@ -218,8 +219,12 @@ export class ServerConnection {
218
219
  ? (_s) => this.authProvider
219
220
  : undefined;
220
221
  const transport = createTransport(this.server, getAuthProvider);
221
- await this.client.connect(transport);
222
- const listResult = await listToolsOrEmpty(this.client);
222
+ // Cap the initialize handshake and tools/list call so a hung
223
+ // downstream server fails fast instead of stalling startup (or
224
+ // self-recovery) for the SDK's 60s default.
225
+ const requestOptions = { timeout: getDownstreamTimeoutMs() };
226
+ await this.client.connect(transport, requestOptions);
227
+ const listResult = await listToolsOrEmpty(this.client, requestOptions);
223
228
  return listResult.tools.map((t) => ({
224
229
  name: t.name,
225
230
  description: t.description,
@@ -6,3 +6,4 @@ export { expandEnvField } from './expand-env.js';
6
6
  export { Logger } from './logger.js';
7
7
  export { parseJsonc } from './parse-jsonc.js';
8
8
  export { VALID_COMPRESSION_LEVELS, isCompressionLevel } from './compression-level.js';
9
+ export { getDownstreamTimeoutMs, getAuthDiscoveryTimeoutMs, createTimeoutFetch, } from './timeout.js';
@@ -0,0 +1,84 @@
1
+ import process from 'node:process';
2
+ /**
3
+ * Default timeout (ms) for a downstream server's `initialize` request and
4
+ * `tools/list` call during discovery. The MCP SDK's own default is 60s;
5
+ * we cap these fast handshake operations sooner so a server that accepts
6
+ * the TCP/TLS connection but never replies (a real-world hang observed on
7
+ * some Streamable HTTP servers behind CDNs) surfaces a clear error instead
8
+ * of blocking the `tools` command or router startup indefinitely.
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
+ *
16
+ * @internal Exported for tests only; not part of the public module API.
17
+ */
18
+ export const DEFAULT_DOWNSTREAM_TIMEOUT_MS = 10_000;
19
+ /**
20
+ * Default timeout (ms) for OAuth metadata discovery probes (RFC 9728 /
21
+ * RFC 8414 well-known endpoint fetches). These are best-effort probes;
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.
26
+ *
27
+ * @internal Exported for tests only; not part of the public module API.
28
+ */
29
+ export const DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS = 5_000;
30
+ /**
31
+ * Reads a positive-integer environment variable, returning `undefined`
32
+ * when unset or invalid (non-integer or non-positive). Invalid values
33
+ * silently fall back to the default rather than aborting startup.
34
+ */
35
+ function readPositiveIntEnv(name) {
36
+ const raw = process.env[name];
37
+ if (raw === undefined) {
38
+ return undefined;
39
+ }
40
+ const value = Number(raw);
41
+ if (!Number.isInteger(value) || value <= 0) {
42
+ return undefined;
43
+ }
44
+ return value;
45
+ }
46
+ /**
47
+ * Resolves the downstream connect/listTools timeout in milliseconds.
48
+ * Override with `MCP_COMPRESS_ROUTER_DOWNSTREAM_TIMEOUT_MS` (a positive
49
+ * integer); invalid values fall back to
50
+ * {@link DEFAULT_DOWNSTREAM_TIMEOUT_MS}.
51
+ */
52
+ export function getDownstreamTimeoutMs() {
53
+ return (readPositiveIntEnv('MCP_COMPRESS_ROUTER_DOWNSTREAM_TIMEOUT_MS') ?? DEFAULT_DOWNSTREAM_TIMEOUT_MS);
54
+ }
55
+ /**
56
+ * Resolves the OAuth discovery probe timeout in milliseconds. Override
57
+ * with `MCP_COMPRESS_ROUTER_AUTH_DISCOVERY_TIMEOUT_MS` (a positive
58
+ * integer); invalid values fall back to
59
+ * {@link DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS}.
60
+ */
61
+ export function getAuthDiscoveryTimeoutMs() {
62
+ return (readPositiveIntEnv('MCP_COMPRESS_ROUTER_AUTH_DISCOVERY_TIMEOUT_MS') ??
63
+ DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS);
64
+ }
65
+ /**
66
+ * Wraps the global `fetch` so each request aborts after `timeoutMs`.
67
+ * The caller's own `init.signal` (if any) is honored alongside the
68
+ * timeout signal via `AbortSignal.any`.
69
+ *
70
+ * Used to add a request-level timeout to SDK helpers (OAuth metadata
71
+ * discovery) that accept a custom `fetchFn` but expose no timeout option
72
+ * of their own — without it, a server that hangs the well-known endpoint
73
+ * traps discovery forever.
74
+ *
75
+ * @param timeoutMs - Per-request timeout in milliseconds.
76
+ * @returns A fetch-compatible function that aborts on timeout.
77
+ */
78
+ export function createTimeoutFetch(timeoutMs) {
79
+ return (input, init) => {
80
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
81
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
82
+ return fetch(input, { ...init, signal });
83
+ };
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-compress-router",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "Compress all connected MCP servers into a single router MCP to save tokens",
5
5
  "license": "MIT",
6
6
  "type": "module",