mcp-compress-router 1.5.4 → 1.5.5
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.
|
@@ -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,4 @@
|
|
|
1
|
+
import { createTimeoutFetch, getAuthDiscoveryTimeoutMs } from '../utils/index.js';
|
|
1
2
|
/**
|
|
2
3
|
* Discovers OAuth metadata for a downstream MCP server following the
|
|
3
4
|
* MCP 2025-06-18 authorization spec two-step flow:
|
|
@@ -29,6 +30,10 @@
|
|
|
29
30
|
*/
|
|
30
31
|
export async function discoverAuth(serverUrl) {
|
|
31
32
|
const { discoverOAuthProtectedResourceMetadata, discoverAuthorizationServerMetadata } = await import('@modelcontextprotocol/sdk/client/auth.js');
|
|
33
|
+
// The SDK discovery helpers use a raw fetch with no timeout; a server
|
|
34
|
+
// that hangs its well-known endpoint would trap discovery forever. Pass
|
|
35
|
+
// a fetch that aborts after a short, configurable budget.
|
|
36
|
+
const fetchFn = createTimeoutFetch(getAuthDiscoveryTimeoutMs());
|
|
32
37
|
// Tracks the last error seen across all candidates so the caller can be
|
|
33
38
|
// notified when discovery failed entirely (vs. cleanly finding nothing).
|
|
34
39
|
let lastError;
|
|
@@ -36,7 +41,7 @@ export async function discoverAuth(serverUrl) {
|
|
|
36
41
|
// recorded and treated as "not found" so the next candidate is tried.
|
|
37
42
|
const safeDiscoverAs = async (url) => {
|
|
38
43
|
try {
|
|
39
|
-
return await discoverAuthorizationServerMetadata(url);
|
|
44
|
+
return await discoverAuthorizationServerMetadata(url, { fetchFn });
|
|
40
45
|
}
|
|
41
46
|
catch (err) {
|
|
42
47
|
lastError = err;
|
|
@@ -49,7 +54,7 @@ export async function discoverAuth(serverUrl) {
|
|
|
49
54
|
// legacy-server path, not a probe failure.
|
|
50
55
|
let resourceMetadata;
|
|
51
56
|
try {
|
|
52
|
-
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl);
|
|
57
|
+
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {}, fetchFn);
|
|
53
58
|
}
|
|
54
59
|
catch {
|
|
55
60
|
// No PRM published; fall through to direct AS discovery below.
|
|
@@ -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
|
-
|
|
222
|
-
|
|
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,
|
package/build/utils/index.js
CHANGED
|
@@ -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,75 @@
|
|
|
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
|
+
* @internal Exported for tests only; not part of the public module API.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_DOWNSTREAM_TIMEOUT_MS = 30_000;
|
|
13
|
+
/**
|
|
14
|
+
* Default timeout (ms) for OAuth metadata discovery probes (RFC 9728 /
|
|
15
|
+
* RFC 8414 well-known endpoint fetches). These are best-effort probes;
|
|
16
|
+
* a server that hangs its well-known endpoint must not stall startup.
|
|
17
|
+
*
|
|
18
|
+
* @internal Exported for tests only; not part of the public module API.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS = 10_000;
|
|
21
|
+
/**
|
|
22
|
+
* Reads a positive-integer environment variable, returning `undefined`
|
|
23
|
+
* when unset or invalid (non-integer or non-positive). Invalid values
|
|
24
|
+
* silently fall back to the default rather than aborting startup.
|
|
25
|
+
*/
|
|
26
|
+
function readPositiveIntEnv(name) {
|
|
27
|
+
const raw = process.env[name];
|
|
28
|
+
if (raw === undefined) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const value = Number(raw);
|
|
32
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolves the downstream connect/listTools timeout in milliseconds.
|
|
39
|
+
* Override with `MCP_COMPRESS_ROUTER_DOWNSTREAM_TIMEOUT_MS` (a positive
|
|
40
|
+
* integer); invalid values fall back to
|
|
41
|
+
* {@link DEFAULT_DOWNSTREAM_TIMEOUT_MS}.
|
|
42
|
+
*/
|
|
43
|
+
export function getDownstreamTimeoutMs() {
|
|
44
|
+
return (readPositiveIntEnv('MCP_COMPRESS_ROUTER_DOWNSTREAM_TIMEOUT_MS') ?? DEFAULT_DOWNSTREAM_TIMEOUT_MS);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolves the OAuth discovery probe timeout in milliseconds. Override
|
|
48
|
+
* with `MCP_COMPRESS_ROUTER_AUTH_DISCOVERY_TIMEOUT_MS` (a positive
|
|
49
|
+
* integer); invalid values fall back to
|
|
50
|
+
* {@link DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS}.
|
|
51
|
+
*/
|
|
52
|
+
export function getAuthDiscoveryTimeoutMs() {
|
|
53
|
+
return (readPositiveIntEnv('MCP_COMPRESS_ROUTER_AUTH_DISCOVERY_TIMEOUT_MS') ??
|
|
54
|
+
DEFAULT_AUTH_DISCOVERY_TIMEOUT_MS);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Wraps the global `fetch` so each request aborts after `timeoutMs`.
|
|
58
|
+
* The caller's own `init.signal` (if any) is honored alongside the
|
|
59
|
+
* timeout signal via `AbortSignal.any`.
|
|
60
|
+
*
|
|
61
|
+
* Used to add a request-level timeout to SDK helpers (OAuth metadata
|
|
62
|
+
* discovery) that accept a custom `fetchFn` but expose no timeout option
|
|
63
|
+
* of their own — without it, a server that hangs the well-known endpoint
|
|
64
|
+
* traps discovery forever.
|
|
65
|
+
*
|
|
66
|
+
* @param timeoutMs - Per-request timeout in milliseconds.
|
|
67
|
+
* @returns A fetch-compatible function that aborts on timeout.
|
|
68
|
+
*/
|
|
69
|
+
export function createTimeoutFetch(timeoutMs) {
|
|
70
|
+
return (input, init) => {
|
|
71
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
72
|
+
const signal = init?.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
|
|
73
|
+
return fetch(input, { ...init, signal });
|
|
74
|
+
};
|
|
75
|
+
}
|