Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/max-total-timeout-enforced.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

`maxTotalTimeout` is now enforced as a hard cap. The pending timer is armed with whichever budget runs out first, the per-request `timeout` or what is left of `maxTotalTimeout`, and a progress reset re-arms it with the remaining total budget instead of the full `timeout`. A request that never receives a progress notification now rejects with `Maximum total timeout exceeded` at the cap rather than waiting out `timeout`.
41 changes: 33 additions & 8 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,12 +530,12 @@ export type ClientContext = BaseContext;
* Information about a request's timeout state
*/
type TimeoutInfo = {
timeoutId: ReturnType<typeof setTimeout>;
timeoutId?: ReturnType<typeof setTimeout>;
startTime: number;
timeout: number;
maxTotalTimeout?: number;
resetTimeoutOnProgress: boolean;
onTimeout: () => void;
onTimeout: (reason: 'timeout' | 'maxTotalTimeout', elapsed: number) => void;
};

/*
Expand Down Expand Up @@ -771,17 +771,33 @@ export abstract class Protocol<ContextT extends BaseContext> {
messageId: number,
timeout: number,
maxTotalTimeout: number | undefined,
onTimeout: () => void,
onTimeout: (reason: 'timeout' | 'maxTotalTimeout', elapsed: number) => void,
resetTimeoutOnProgress: boolean = false
) {
this._timeoutInfo.set(messageId, {
timeoutId: setTimeout(onTimeout, timeout),
const info: TimeoutInfo = {
startTime: Date.now(),
timeout,
maxTotalTimeout,
resetTimeoutOnProgress,
onTimeout
});
};
this._armTimeout(info);
this._timeoutInfo.set(messageId, info);
}

/**
* Arms the pending timer with whichever budget runs out first: the
* per-request `timeout`, or what is left of the `maxTotalTimeout` cap.
*/
private _armTimeout(info: TimeoutInfo) {
const elapsed = Date.now() - info.startTime;
const remainingTotal = info.maxTotalTimeout === undefined ? Number.POSITIVE_INFINITY : info.maxTotalTimeout - elapsed;
const delay = Math.max(0, Math.min(info.timeout, remainingTotal));
info.timeoutId = setTimeout(() => {
const firedAt = Date.now() - info.startTime;
const reason = info.maxTotalTimeout !== undefined && firedAt >= info.maxTotalTimeout ? 'maxTotalTimeout' : 'timeout';
info.onTimeout(reason, firedAt);
}, delay);
}

private _resetTimeout(messageId: number): boolean {
Expand All @@ -798,7 +814,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
}

clearTimeout(info.timeoutId);
info.timeoutId = setTimeout(info.onTimeout, info.timeout);
this._armTimeout(info);
return true;
}

Expand Down Expand Up @@ -1657,7 +1673,16 @@ export abstract class Protocol<ContextT extends BaseContext> {
options?.signal?.addEventListener('abort', onAbort, { once: true });

const timeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC;
const timeoutHandler = () => cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout }), 'timeout');
const timeoutHandler = (reason: 'timeout' | 'maxTotalTimeout', elapsed: number) =>
reason === 'maxTotalTimeout'
? cancel(
new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', {
maxTotalTimeout: options?.maxTotalTimeout,
totalElapsed: elapsed
}),
'timeout'
)
: cancel(new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout }), 'timeout');

this._setupTimeout(messageId, timeout, options?.maxTotalTimeout, timeoutHandler, options?.resetTimeoutOnProgress ?? false);

Expand Down
71 changes: 71 additions & 0 deletions packages/core-internal/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,77 @@ describe('protocol tests', () => {
expect(onProgressMock).toHaveBeenCalledTimes(1);
});

test('should enforce maxTotalTimeout when no progress notification arrives', async () => {
await protocol.connect(transport);
const request = { method: 'example', params: {} };
const mockSchema: ZodType<{ result: string }> = z.object({
result: z.string()
});
let settled: Error | undefined;
void testRequest(protocol, request, mockSchema, {
timeout: 1000,
maxTotalTimeout: 150
}).catch((error: Error) => {
settled = error;
});

await vi.advanceTimersByTimeAsync(151);

expect(settled?.message).toBe('Maximum total timeout exceeded');
});

test('should re-arm the timeout within the remaining maxTotalTimeout budget', async () => {
await protocol.connect(transport);
const request = { method: 'example', params: {} };
const mockSchema: ZodType<{ result: string }> = z.object({
result: z.string()
});
const onProgressMock = vi.fn();
let settled: Error | undefined;
void testRequest(protocol, request, mockSchema, {
timeout: 1000,
maxTotalTimeout: 300,
resetTimeoutOnProgress: true,
onprogress: onProgressMock
}).catch((error: Error) => {
settled = error;
});

await vi.advanceTimersByTimeAsync(200);
transport.onmessage?.({
jsonrpc: '2.0',
method: 'notifications/progress',
params: { progressToken: 0, progress: 50, total: 100 }
});
await Promise.resolve();
expect(onProgressMock).toHaveBeenCalledTimes(1);

// 200ms elapsed, 100ms of the total budget left: the re-armed timer
// must not outlive the cap.
await vi.advanceTimersByTimeAsync(101);

expect(settled?.message).toBe('Maximum total timeout exceeded');
});

test('should still report the per-request timeout when it is shorter than maxTotalTimeout', async () => {
await protocol.connect(transport);
const request = { method: 'example', params: {} };
const mockSchema: ZodType<{ result: string }> = z.object({
result: z.string()
});
let settled: Error | undefined;
void testRequest(protocol, request, mockSchema, {
timeout: 100,
maxTotalTimeout: 1000
}).catch((error: Error) => {
settled = error;
});

await vi.advanceTimersByTimeAsync(101);

expect(settled?.message).toBe('Request timed out');
});

test('should timeout if no progress received within timeout period', async () => {
await protocol.connect(transport);
const request = { method: 'example', params: {} };
Expand Down
Loading