diff --git a/.changeset/perky-sloths-say.md b/.changeset/perky-sloths-say.md new file mode 100644 index 000000000..ce29f2daa --- /dev/null +++ b/.changeset/perky-sloths-say.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Allow overriding the default request timeout via `MCP_REQUEST_TIMEOUT_MSEC` environment variable diff --git a/docs/client.md b/docs/client.md index b5086f531..8163370f8 100644 --- a/docs/client.md +++ b/docs/client.md @@ -539,7 +539,7 @@ client.onclose = () => { ### Timeouts -All requests have a 60-second default timeout. Pass a custom `timeout` in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: +All requests have a 60-second default timeout. You can override it globally by setting the `MCP_REQUEST_TIMEOUT_MSEC` environment variable (read once at module load; must be a positive integer up to 43,200,000 / 12 hours), or pass a custom `timeout` in the options per request. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}: ```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_timeout" try { diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab6932..e4ba0148a 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -94,10 +94,28 @@ export type ProtocolOptions = { tasks?: TaskManagerOptions; }; +const MAX_REQUEST_TIMEOUT_MSEC = 43_200_000; // 12 hours +const DEFAULT_TIMEOUT_FALLBACK = 60_000; + +/** + * Resolves the request timeout from the environment variable `MCP_REQUEST_TIMEOUT_MSEC`. + * Exported for testing; not part of the public API. + * @internal + */ +export function resolveRequestTimeout(): number { + const raw = typeof process !== 'undefined' && process?.env ? process.env.MCP_REQUEST_TIMEOUT_MSEC : undefined; + const parsed = Number.parseInt(raw ?? '', 10); + return parsed > 0 && parsed <= MAX_REQUEST_TIMEOUT_MSEC ? parsed : DEFAULT_TIMEOUT_FALLBACK; +} + /** * The default request timeout, in milliseconds. + * + * Can be overridden via the `MCP_REQUEST_TIMEOUT_MSEC` environment variable. + * The value is read once at module load time; changes after import have no effect. + * Must be a positive integer no greater than 43,200,000 (12 hours). */ -export const DEFAULT_REQUEST_TIMEOUT_MSEC = 60_000; +export const DEFAULT_REQUEST_TIMEOUT_MSEC = resolveRequestTimeout(); /** * Options that can be given per request. diff --git a/packages/core/test/shared/defaultRequestTimeout.test.ts b/packages/core/test/shared/defaultRequestTimeout.test.ts new file mode 100644 index 000000000..a1a919ee2 --- /dev/null +++ b/packages/core/test/shared/defaultRequestTimeout.test.ts @@ -0,0 +1,108 @@ +import { vi, describe, test, expect, afterEach } from 'vitest'; +import { resolveRequestTimeout } from '../../src/shared/protocol.js'; + +/** + * DEFAULT_REQUEST_TIMEOUT_MSEC is computed once at module load via an IIFE, + * so each scenario needs a fresh import. We use `vi.resetModules()` + + * dynamic `import()` to re-evaluate the module with different env state. + * + * For tests that stub `process` itself (undefined/null), we call + * `resolveRequestTimeout()` directly — a full dynamic import would fail + * because transitive dependencies (e.g. zod) also read `process`. + */ + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +async function loadDefault(): Promise { + const mod = await import('../../src/shared/protocol.js'); + return mod.DEFAULT_REQUEST_TIMEOUT_MSEC; +} + +describe('DEFAULT_REQUEST_TIMEOUT_MSEC', () => { + test('falls back to 60_000 when env var is not set', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', ''); + expect(await loadDefault()).toBe(60_000); + }); + + test('uses valid numeric env var', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', '120000'); + expect(await loadDefault()).toBe(120_000); + }); + + test('falls back to 60_000 for empty string', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', ''); + expect(await loadDefault()).toBe(60_000); + }); + + test('falls back to 60_000 for non-numeric string', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', 'abc'); + expect(await loadDefault()).toBe(60_000); + }); + + test('falls back to 60_000 for negative number', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', '-5000'); + expect(await loadDefault()).toBe(60_000); + }); + + test('falls back to 60_000 for zero', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', '0'); + expect(await loadDefault()).toBe(60_000); + }); + + test('falls back to 60_000 for undefined', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', undefined); + expect(await loadDefault()).toBe(60_000); + }); + + test('falls back to 60_000 for null', async () => { + // @ts-expect-error -- testing runtime behavior with null + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', null); + expect(await loadDefault()).toBe(60_000); + }); + + test('falls back to 60_000 for value exceeding Number.MAX_SAFE_INTEGER', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', '9007199254740993'); + expect(await loadDefault()).toBe(60_000); + }); + + test('caps at 12-hour upper bound', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', '43200001'); + expect(await loadDefault()).toBe(60_000); + }); + + test('accepts exactly 12 hours (43200000)', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', '43200000'); + expect(await loadDefault()).toBe(43_200_000); + }); + + test('falls back to 60_000 for extremely large value', async () => { + vi.stubEnv('MCP_REQUEST_TIMEOUT_MSEC', '999999999'); + expect(await loadDefault()).toBe(60_000); + }); + + test('falls back to 60_000 when process is undefined', () => { + vi.stubGlobal('process', undefined); + expect(resolveRequestTimeout()).toBe(60_000); + }); + + test('falls back to 60_000 when process is null', () => { + vi.stubGlobal('process', null); + expect(resolveRequestTimeout()).toBe(60_000); + }); + + test('falls back to 60_000 when process.env is undefined', () => { + const original = globalThis.process; + vi.stubGlobal('process', { ...original, env: undefined }); + expect(resolveRequestTimeout()).toBe(60_000); + }); + + test('falls back to 60_000 when process.env is null', () => { + const original = globalThis.process; + vi.stubGlobal('process', { ...original, env: null }); + expect(resolveRequestTimeout()).toBe(60_000); + }); +});