Skip to content

Commit ecb279b

Browse files
jancurnclaude
andauthored
Add per-request timeout configuration support (#38)
* fix: thread --timeout flag through session commands to bridge and MCP client The --timeout flag was parsed from CLI options but never propagated to the actual request. The timeout now flows through the full chain: CLI → withMcpClient → withSessionClient → SessionClient → BridgeClient → Bridge → McpClient. - BridgeClient.request() accepts optional timeout parameter (seconds) - IpcMessage includes timeout field forwarded to bridge process - Bridge applies per-request timeout override on McpClient before each call - SessionClient stores and passes timeout to all BridgeClient requests - withSessionClient/withMcpClient thread timeout from CLI options https://claude.ai/code/session_01HmGaGAavxHEZcSRfNguGeo * test: add E2E integration tests for --timeout flag Tests that --timeout causes tool calls to fail when the server is slower than the timeout, succeeds when generous enough, and produces valid JSON errors in --json mode. https://claude.ai/code/session_01HmGaGAavxHEZcSRfNguGeo * fix: make timeout E2E test resilient to varying error messages The MCP SDK may produce different error messages on timeout depending on timing (timeout, abort, or session-not-found). Relax assertion to check for failure and non-empty stderr rather than specific "timeout" text. Also increase delays for CI reliability. https://claude.ai/code/session_01HmGaGAavxHEZcSRfNguGeo * docs: add --timeout fix to changelog https://claude.ai/code/session_01HmGaGAavxHEZcSRfNguGeo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7d8282 commit ecb279b

File tree

8 files changed

+170
-20
lines changed

8 files changed

+170
-20
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- E2E tests now run under the Bun runtime (in addition to Node.js); use `./test/e2e/run.sh --runtime bun` or `npm run test:e2e:bun`
1313

1414
### Fixed
15+
- `--timeout` flag now correctly propagates to MCP requests via session bridge
1516
- `parseServerArg()` now handles well Windows drive-letter config paths as well as other ambiguous cases
1617

1718
### Changed

src/bridge/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,11 @@ class BridgeProcess {
905905
}
906906

907907
try {
908+
// Apply per-request timeout if provided (from CLI --timeout flag, in seconds → milliseconds)
909+
if (message.timeout !== undefined) {
910+
this.client.setRequestTimeout(message.timeout * 1000);
911+
}
912+
908913
let result: unknown;
909914

910915
// Route to appropriate client method

src/cli/helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export async function withMcpClient<T>(
171171
outputMode?: OutputMode;
172172
verbose?: boolean;
173173
hideTarget?: boolean;
174+
timeout?: number;
174175
},
175176
callback: (client: IMcpClient, context: McpClientContext) => Promise<T>
176177
): Promise<T> {
@@ -205,5 +206,6 @@ export async function withMcpClient<T>(
205206
}
206207

207208
// Use session client (SessionClient implements IMcpClient interface)
208-
return await withSessionClient(target, (client) => callback(client, context));
209+
const sessionOpts = options.timeout !== undefined ? { timeout: options.timeout } : undefined;
210+
return await withSessionClient(target, (client) => callback(client, context), sessionOpts);
209211
}

src/core/mcp-client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ export class McpClient implements IMcpClient {
106106
};
107107
}
108108

109+
/**
110+
* Override request timeout for subsequent requests (in milliseconds)
111+
* Used by bridge to apply per-request timeout from CLI --timeout flag
112+
*/
113+
setRequestTimeout(timeoutMs: number): void {
114+
this.requestTimeout = timeoutMs;
115+
}
116+
109117
/**
110118
* Get request options with timeout if configured
111119
*/

src/lib/bridge-client.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,9 @@ export class BridgeClient extends EventEmitter {
184184

185185
/**
186186
* Send a request to the bridge and wait for response
187-
* Uses 3-minute timeout for MCP operations
187+
* Uses 3-minute timeout for MCP operations by default, or custom timeout if provided
188188
*/
189-
async request(method: string, params?: unknown): Promise<unknown> {
189+
async request(method: string, params?: unknown, timeout?: number): Promise<unknown> {
190190
if (!this.socket) {
191191
throw new NetworkError('Not connected to bridge');
192192
}
@@ -198,16 +198,20 @@ export class BridgeClient extends EventEmitter {
198198
id,
199199
method,
200200
params,
201+
...(timeout !== undefined && { timeout }),
201202
};
202203

203204
logger.debug('Sending request:', { id, method });
204205

206+
// Use custom timeout (in seconds, convert to ms) or default
207+
const timeoutMs = timeout !== undefined ? timeout * 1000 : REQUEST_TIMEOUT;
208+
205209
// Create promise for response
206210
const promise = new Promise<unknown>((resolve, reject) => {
207211
const timeoutId = setTimeout(() => {
208212
this.pendingRequests.delete(id);
209213
reject(new NetworkError(`Request timeout: ${method}`));
210-
}, REQUEST_TIMEOUT);
214+
}, timeoutMs);
211215

212216
this.pendingRequests.set(id, { resolve, reject, timeoutId });
213217
});

src/lib/session-client.ts

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const logger = createLogger('session-client');
4242
export class SessionClient extends EventEmitter implements IMcpClient {
4343
private bridgeClient: BridgeClient;
4444
private sessionName: string;
45+
private requestTimeout?: number; // Per-request timeout in seconds
4546

4647
constructor(sessionName: string, bridgeClient: BridgeClient) {
4748
super();
@@ -50,6 +51,13 @@ export class SessionClient extends EventEmitter implements IMcpClient {
5051
this.setupNotificationForwarding();
5152
}
5253

54+
/**
55+
* Set request timeout for all subsequent requests (in seconds)
56+
*/
57+
setRequestTimeout(timeout: number): void {
58+
this.requestTimeout = timeout;
59+
}
60+
5361
/**
5462
* Set up notification forwarding from bridge client
5563
*/
@@ -112,34 +120,56 @@ export class SessionClient extends EventEmitter implements IMcpClient {
112120
// Server info (single IPC call for all server information)
113121
async getServerDetails(): Promise<ServerDetails> {
114122
return this.withRetry(
115-
() => this.bridgeClient.request('getServerDetails') as Promise<ServerDetails>,
123+
() =>
124+
this.bridgeClient.request(
125+
'getServerDetails',
126+
undefined,
127+
this.requestTimeout
128+
) as Promise<ServerDetails>,
116129
'getServerDetails'
117130
);
118131
}
119132

120133
// MCP operations
121134
async ping(): Promise<void> {
122-
return this.withRetry(() => this.bridgeClient.request('ping').then(() => undefined), 'ping');
135+
return this.withRetry(
136+
() => this.bridgeClient.request('ping', undefined, this.requestTimeout).then(() => undefined),
137+
'ping'
138+
);
123139
}
124140

125141
async listTools(cursor?: string): Promise<ListToolsResult> {
126142
return this.withRetry(
127-
() => this.bridgeClient.request('listTools', cursor) as Promise<ListToolsResult>,
143+
() =>
144+
this.bridgeClient.request(
145+
'listTools',
146+
cursor,
147+
this.requestTimeout
148+
) as Promise<ListToolsResult>,
128149
'listTools'
129150
);
130151
}
131152

132153
async callTool(name: string, args?: Record<string, unknown>): Promise<CallToolResult> {
133154
return this.withRetry(
134155
() =>
135-
this.bridgeClient.request('callTool', { name, arguments: args }) as Promise<CallToolResult>,
156+
this.bridgeClient.request(
157+
'callTool',
158+
{ name, arguments: args },
159+
this.requestTimeout
160+
) as Promise<CallToolResult>,
136161
'callTool'
137162
);
138163
}
139164

140165
async listResources(cursor?: string): Promise<ListResourcesResult> {
141166
return this.withRetry(
142-
() => this.bridgeClient.request('listResources', cursor) as Promise<ListResourcesResult>,
167+
() =>
168+
this.bridgeClient.request(
169+
'listResources',
170+
cursor,
171+
this.requestTimeout
172+
) as Promise<ListResourcesResult>,
143173
'listResources'
144174
);
145175
}
@@ -149,54 +179,78 @@ export class SessionClient extends EventEmitter implements IMcpClient {
149179
() =>
150180
this.bridgeClient.request(
151181
'listResourceTemplates',
152-
cursor
182+
cursor,
183+
this.requestTimeout
153184
) as Promise<ListResourceTemplatesResult>,
154185
'listResourceTemplates'
155186
);
156187
}
157188

158189
async readResource(uri: string): Promise<ReadResourceResult> {
159190
return this.withRetry(
160-
() => this.bridgeClient.request('readResource', { uri }) as Promise<ReadResourceResult>,
191+
() =>
192+
this.bridgeClient.request(
193+
'readResource',
194+
{ uri },
195+
this.requestTimeout
196+
) as Promise<ReadResourceResult>,
161197
'readResource'
162198
);
163199
}
164200

165201
async subscribeResource(uri: string): Promise<void> {
166202
return this.withRetry(
167-
() => this.bridgeClient.request('subscribeResource', { uri }).then(() => undefined),
203+
() =>
204+
this.bridgeClient
205+
.request('subscribeResource', { uri }, this.requestTimeout)
206+
.then(() => undefined),
168207
'subscribeResource'
169208
);
170209
}
171210

172211
async unsubscribeResource(uri: string): Promise<void> {
173212
return this.withRetry(
174-
() => this.bridgeClient.request('unsubscribeResource', { uri }).then(() => undefined),
213+
() =>
214+
this.bridgeClient
215+
.request('unsubscribeResource', { uri }, this.requestTimeout)
216+
.then(() => undefined),
175217
'unsubscribeResource'
176218
);
177219
}
178220

179221
async listPrompts(cursor?: string): Promise<ListPromptsResult> {
180222
return this.withRetry(
181-
() => this.bridgeClient.request('listPrompts', cursor) as Promise<ListPromptsResult>,
223+
() =>
224+
this.bridgeClient.request(
225+
'listPrompts',
226+
cursor,
227+
this.requestTimeout
228+
) as Promise<ListPromptsResult>,
182229
'listPrompts'
183230
);
184231
}
185232

186233
async getPrompt(name: string, args?: Record<string, string>): Promise<GetPromptResult> {
187234
return this.withRetry(
188235
() =>
189-
this.bridgeClient.request('getPrompt', {
190-
name,
191-
arguments: args,
192-
}) as Promise<GetPromptResult>,
236+
this.bridgeClient.request(
237+
'getPrompt',
238+
{
239+
name,
240+
arguments: args,
241+
},
242+
this.requestTimeout
243+
) as Promise<GetPromptResult>,
193244
'getPrompt'
194245
);
195246
}
196247

197248
async setLoggingLevel(level: LoggingLevel): Promise<void> {
198249
return this.withRetry(
199-
() => this.bridgeClient.request('setLoggingLevel', level).then(() => undefined),
250+
() =>
251+
this.bridgeClient
252+
.request('setLoggingLevel', level, this.requestTimeout)
253+
.then(() => undefined),
200254
'setLoggingLevel'
201255
);
202256
}
@@ -231,10 +285,15 @@ export async function createSessionClient(sessionName: string): Promise<SessionC
231285
*/
232286
export async function withSessionClient<T>(
233287
sessionName: string,
234-
callback: (client: IMcpClient) => Promise<T>
288+
callback: (client: IMcpClient) => Promise<T>,
289+
options?: { timeout?: number }
235290
): Promise<T> {
236291
const client = await createSessionClient(sessionName);
237292

293+
if (options?.timeout !== undefined) {
294+
client.setRequestTimeout(options.timeout);
295+
}
296+
238297
try {
239298
return await callback(client);
240299
} finally {

src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export interface IpcMessage {
231231
id?: string; // Request ID for correlation
232232
method?: string; // MCP method name
233233
params?: unknown; // Method parameters
234+
timeout?: number; // Per-request timeout in seconds (overrides default)
234235
result?: unknown; // Response result
235236
notification?: NotificationData; // Notification data (for type='notification')
236237
authCredentials?: AuthCredentials; // Auth credentials (for type='set-auth-credentials')
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/bin/bash
2+
# Test: --timeout flag causes requests to fail when server is too slow
3+
4+
source "$(dirname "$0")/../../lib/framework.sh"
5+
test_init "basic/timeout" --isolated
6+
7+
# Start test server
8+
start_test_server
9+
10+
# =============================================================================
11+
# Test: tools-call with --timeout shorter than server delay
12+
# =============================================================================
13+
14+
SESSION=$(create_session "$TEST_SERVER_URL" "timeout-1")
15+
16+
test_case "tools-call times out when server is slower than --timeout"
17+
run_mcpc "$SESSION" tools-call slow ms:=10000 --timeout 2
18+
assert_failure
19+
# The error may mention "timeout", "abort", or "session not found" depending on
20+
# how the SDK handles the cancellation. The key assertion is that it fails.
21+
assert_not_empty "$STDERR" "stderr should have an error message"
22+
test_pass
23+
24+
# Close and recreate session since timeout may have disrupted bridge state
25+
run_mcpc "$SESSION" close 2>/dev/null || true
26+
27+
# =============================================================================
28+
# Test: tools-call succeeds when --timeout is generous enough
29+
# =============================================================================
30+
31+
SESSION2=$(create_session "$TEST_SERVER_URL" "timeout-2")
32+
33+
test_case "tools-call succeeds when --timeout is long enough"
34+
run_mcpc "$SESSION2" tools-call slow ms:=500 --timeout 10
35+
assert_success
36+
assert_contains "$STDOUT" "Waited 500ms"
37+
test_pass
38+
39+
# =============================================================================
40+
# Test: tools-list with --timeout works (fast response, no timeout)
41+
# =============================================================================
42+
43+
test_case "tools-list succeeds with short --timeout (fast server response)"
44+
run_mcpc "$SESSION2" tools-list --timeout 10
45+
assert_success
46+
assert_contains "$STDOUT" "echo"
47+
test_pass
48+
49+
# =============================================================================
50+
# Test: ping with --timeout
51+
# =============================================================================
52+
53+
test_case "ping succeeds with reasonable --timeout"
54+
run_mcpc "$SESSION2" ping --timeout 10
55+
assert_success
56+
test_pass
57+
58+
# =============================================================================
59+
# Test: --timeout with --json outputs valid JSON error
60+
# =============================================================================
61+
62+
SESSION3=$(create_session "$TEST_SERVER_URL" "timeout-3")
63+
64+
test_case "timeout error with --json outputs valid JSON to stderr"
65+
run_mcpc "$SESSION3" --json tools-call slow ms:=10000 --timeout 2
66+
assert_failure
67+
assert_json_valid "$STDERR" "timeout error should be valid JSON in --json mode"
68+
test_pass
69+
70+
test_done

0 commit comments

Comments
 (0)