Skip to content

Commit b1a4d04

Browse files
fix(mcp-adapters): preserve timeout from RunnableConfig in MCP tool calls
The timeout value from RunnableConfig was being lost when calling MCP tools because ensureConfig() converts it to an AbortSignal and removes the numeric timeout field. This prevented the MCP SDK from receiving explicit timeout values. Changes: - Store original timeout in metadata.__lc_originalTimeoutMs in ensureConfig() - Retrieve and pass numeric timeout to MCP SDK RequestOptions in _callTool() - Add integration test verifying per-call timeout is properly honored This ensures MCP tools respect timeouts passed through RunnableConfig, allowing callers to override default timeouts on a per-call basis.
1 parent e44c529 commit b1a4d04

File tree

3 files changed

+52
-2
lines changed

3 files changed

+52
-2
lines changed

langchain-core/src/runnables/config.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,18 @@ export function ensureConfig<CallOptions extends RunnableConfig>(
179179
if (empty.timeout <= 0) {
180180
throw new Error("Timeout must be a positive number");
181181
}
182-
const timeoutSignal = AbortSignal.timeout(empty.timeout);
182+
const originalTimeoutMs = empty.timeout;
183+
const timeoutSignal = AbortSignal.timeout(originalTimeoutMs);
184+
// Preserve the numeric timeout for downstream consumers that need to pass
185+
// an explicit timeout value to underlying SDKs in addition to an AbortSignal.
186+
// We store it in metadata to avoid changing the public config shape.
187+
if (!empty.metadata) {
188+
empty.metadata = {};
189+
}
190+
// Do not overwrite if already set upstream.
191+
if (empty.metadata.__lc_originalTimeoutMs === undefined) {
192+
empty.metadata.__lc_originalTimeoutMs = originalTimeoutMs;
193+
}
183194
if (empty.signal !== undefined) {
184195
if ("any" in AbortSignal) {
185196
// eslint-disable-next-line @typescript-eslint/no-explicit-any

libs/langchain-mcp-adapters/__tests__/client.int.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,39 @@ describe("MultiServerMCPClient Integration Tests", () => {
13521352
}
13531353
);
13541354

1355+
it.each(["http", "sse"] as const)(
1356+
"%s should pass explicit per-call timeout through RunnableConfig",
1357+
async (transport) => {
1358+
const { baseUrl } = await testServers.createHTTPServer("timeout-test", {
1359+
disableStreamableHttp: transport === "sse",
1360+
supportSSEFallback: transport === "sse",
1361+
});
1362+
1363+
const client = new MultiServerMCPClient({
1364+
"timeout-server": {
1365+
transport,
1366+
url: `${baseUrl}/${transport === "http" ? "mcp" : "sse"}`,
1367+
},
1368+
});
1369+
1370+
try {
1371+
const tools = await client.getTools();
1372+
const testTool = tools.find((t) => t.name.includes("sleep_tool"));
1373+
expect(testTool).toBeDefined();
1374+
1375+
// Set a per-call timeout longer than the server default to ensure it is honored
1376+
// The server sleep is 1500ms; we set timeout to 2000ms so it should succeed
1377+
const result = await testTool!.invoke(
1378+
{ sleepMsec: 1500 },
1379+
{ timeout: 2000 }
1380+
);
1381+
expect(result).toContain("done");
1382+
} finally {
1383+
await client.close();
1384+
}
1385+
}
1386+
);
1387+
13551388
it.each(["http", "sse"] as const)(
13561389
"%s should throw timeout error when tool call exceeds configured timeout from server options",
13571390
async (transport) => {

libs/langchain-mcp-adapters/src/tools.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,14 @@ async function _callTool({
433433
getDebugLog()(`INFO: Calling tool ${toolName}(${JSON.stringify(args)})`);
434434

435435
// Extract timeout from RunnableConfig and pass to MCP SDK
436+
// Note: ensureConfig() converts timeout into an AbortSignal and deletes the timeout field.
437+
// To preserve the numeric timeout for SDKs that accept an explicit timeout value, we read
438+
// it from metadata.__lc_originalTimeoutMs if present, falling back to any direct timeout.
439+
const numericTimeout =
440+
(config?.metadata?.__lc_originalTimeoutMs as number | undefined) ??
441+
config?.timeout;
436442
const requestOptions: RequestOptions = {
437-
...(config?.timeout ? { timeout: config.timeout } : {}),
443+
...(numericTimeout ? { timeout: numericTimeout } : {}),
438444
...(config?.signal ? { signal: config.signal } : {}),
439445
};
440446

0 commit comments

Comments
 (0)