Skip to content

Commit ebcc08e

Browse files
committed
Let EXECUTOR_MCP_DISCOVERY_TIMEOUT_MS override the MCP discovery timeout
1 parent 8d7cbda commit ebcc08e

4 files changed

Lines changed: 90 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Set `EXECUTOR_MCP_DISCOVERY_TIMEOUT_MS` to change how long Executor waits for an MCP server to connect and list its tools (default 30000). Use it for servers that take longer than the default to start.

‎apps/docs/hosted/docker.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ the container defaults.
7070
| `EXECUTOR_ORG_SLUG` | `default` | URL slug for that org. |
7171
| `EXECUTOR_ALLOW_LOCAL_NETWORK` | `false` | Allow sandboxed code to reach loopback / private addresses. Keep off unless you trust the code. |
7272
| `EXECUTOR_DISABLE_AUTH_RATE_LIMIT` | `false` | Turn off sign-in rate limiting. Only when a proxy or WAF in front of Executor limits instead. |
73+
| `EXECUTOR_MCP_DISCOVERY_TIMEOUT_MS` | `30000` | How long to wait for an MCP server to connect and list its tools before marking it degraded. |
7374

7475
Tracing is configured separately, and off unless you turn it on — see
7576
[Tracing](/hosted/tracing).
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { Effect } from "effect";
3+
4+
import type { McpConnector } from "./connection";
5+
import { discoverTools, resolveDiscoverTimeoutMs } from "./discover";
6+
7+
const ENV = "EXECUTOR_MCP_DISCOVERY_TIMEOUT_MS";
8+
9+
// A server that never answers the handshake, so only the deadline ends discovery.
10+
const neverConnects: McpConnector = Effect.never;
11+
12+
const withEnv = <A, E, R>(value: string | undefined, effect: Effect.Effect<A, E, R>) =>
13+
Effect.acquireUseRelease(
14+
Effect.sync(() => {
15+
const previous = process.env[ENV];
16+
if (value === undefined) delete process.env[ENV];
17+
else process.env[ENV] = value;
18+
return previous;
19+
}),
20+
() => effect,
21+
(previous) =>
22+
Effect.sync(() => {
23+
if (previous === undefined) delete process.env[ENV];
24+
else process.env[ENV] = previous;
25+
}),
26+
);
27+
28+
describe("MCP discovery timeout", () => {
29+
it.effect("defaults to 30 seconds", () =>
30+
withEnv(
31+
undefined,
32+
Effect.sync(() => expect(resolveDiscoverTimeoutMs()).toBe(30_000)),
33+
),
34+
);
35+
36+
it.effect("ignores a value that is not a positive integer", () =>
37+
Effect.gen(function* () {
38+
for (const value of ["", "abc", "0", "-5", "1.5"]) {
39+
const resolved = yield* withEnv(value, Effect.sync(resolveDiscoverTimeoutMs));
40+
expect(resolved).toBe(30_000);
41+
}
42+
}),
43+
);
44+
45+
it.live("uses EXECUTOR_MCP_DISCOVERY_TIMEOUT_MS when discovery has no explicit timeout", () =>
46+
withEnv(
47+
"250",
48+
Effect.gen(function* () {
49+
const failure = yield* discoverTools(neverConnects).pipe(Effect.flip);
50+
expect(failure.message).toBe("MCP discovery timed out after 250ms");
51+
expect(failure.timedOut).toBe(true);
52+
}),
53+
),
54+
);
55+
56+
it.live("an explicit timeout wins over the environment", () =>
57+
withEnv(
58+
"60000",
59+
Effect.gen(function* () {
60+
const failure = yield* discoverTools(neverConnects, 100).pipe(Effect.flip);
61+
expect(failure.message).toBe("MCP discovery timed out after 100ms");
62+
}),
63+
),
64+
);
65+
});

‎packages/plugins/mcp/src/sdk/discover.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ const MAX_LIST_TOOLS_PAGES = 100;
3232
// catalog on first connect.
3333
const DEFAULT_DISCOVER_TIMEOUT = Duration.seconds(30);
3434

35+
// Operators with servers that legitimately take longer (or that want a probe
36+
// to give up sooner) override the default without a rebuild.
37+
const DISCOVER_TIMEOUT_ENV = "EXECUTOR_MCP_DISCOVERY_TIMEOUT_MS";
38+
39+
/** The discovery deadline in milliseconds: `EXECUTOR_MCP_DISCOVERY_TIMEOUT_MS`
40+
* when it is a positive integer, otherwise `DEFAULT_DISCOVER_TIMEOUT`. Read on
41+
* every call so a changed environment applies without a restart, and guarded
42+
* for runtimes without `process` (Workers). */
43+
export const resolveDiscoverTimeoutMs = (): number => {
44+
const raw =
45+
typeof process === "undefined" ? undefined : process.env?.[DISCOVER_TIMEOUT_ENV]?.trim();
46+
const parsed = raw ? Number(raw) : Number.NaN;
47+
return Number.isInteger(parsed) && parsed > 0
48+
? parsed
49+
: Duration.toMillis(DEFAULT_DISCOVER_TIMEOUT);
50+
};
51+
3552
// Teardown is best-effort and paid for by the request that performed discovery.
3653
// A remote transport may accept close and then never settle, so use the same
3754
// bound as the invocation connection pool instead of stranding the caller in an
@@ -165,7 +182,7 @@ export const discoverToolsFromInput = (
165182
* Connect to an MCP server and discover all available tools.
166183
* Returns the parsed manifest containing server metadata and tool entries.
167184
*
168-
* Bounded by `timeoutMs` (default `DEFAULT_DISCOVER_TIMEOUT`): every caller
185+
* Bounded by `timeoutMs` (default `resolveDiscoverTimeoutMs()`): every caller
169186
* is dialing a server we don't control, and neither the connect handshake
170187
* nor `listTools` has a deadline of its own, so an unresponsive endpoint
171188
* (dead loopback port, wedged mid-handshake) would otherwise hang the
@@ -183,7 +200,7 @@ export const discoverToolsFromInput = (
183200
*/
184201
export const discoverTools = (
185202
connector: McpConnector,
186-
timeoutMs: number = Duration.toMillis(DEFAULT_DISCOVER_TIMEOUT),
203+
timeoutMs: number = resolveDiscoverTimeoutMs(),
187204
): Effect.Effect<McpToolManifest, McpToolDiscoveryError> =>
188205
Effect.uninterruptibleMask((restore) =>
189206
Effect.gen(function* () {

0 commit comments

Comments
 (0)