Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perky-sloths-say.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/core': patch
---

Allow overriding the default request timeout via `MCP_REQUEST_TIMEOUT_MSEC` environment variable
2 changes: 1 addition & 1 deletion docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
108 changes: 108 additions & 0 deletions packages/core/test/shared/defaultRequestTimeout.test.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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);
});
});
Loading