Skip to content

Commit 2e5ad70

Browse files
fix(mcp-adapters): preserve timeout from RunnableConfig in MCP tool calls (#9165)
Co-authored-by: Hunter Lovell <[email protected]>
1 parent ece5c09 commit 2e5ad70

File tree

4 files changed

+94
-2
lines changed

4 files changed

+94
-2
lines changed

.changeset/stale-carrots-trade.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@langchain/core": patch
3+
"@langchain/mcp-adapters": patch
4+
---
5+
6+
fix(mcp-adapters): preserve timeout from RunnableConfig in MCP tool calls

libs/langchain-core/src/runnables/config.ts

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

libs/langchain-mcp-adapters/src/tests/client.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,43 @@ describe("MultiServerMCPClient Integration Tests", () => {
12471247
});
12481248

12491249
describe("Timeout Configuration", () => {
1250+
it("http smoke test: should honor timeout when shorter than sleepMsec", async () => {
1251+
const { baseUrl } = await testServers.createHTTPServer(
1252+
"timeout-smoke-test",
1253+
{
1254+
disableStreamableHttp: false,
1255+
supportSSEFallback: false,
1256+
}
1257+
);
1258+
1259+
const client = new MultiServerMCPClient({
1260+
"timeout-server": {
1261+
transport: "http",
1262+
url: `${baseUrl}/mcp`,
1263+
},
1264+
});
1265+
1266+
try {
1267+
const tools = await client.getTools();
1268+
const testTool = tools.find((t) => t.name.includes("sleep_tool"));
1269+
expect(testTool).toBeDefined();
1270+
1271+
// Set a timeout that is lower than the sleep duration and ensure it is honored.
1272+
await expect(
1273+
testTool!.invoke(
1274+
{ sleepMsec: 500 },
1275+
{
1276+
timeout: 10,
1277+
}
1278+
)
1279+
).rejects.toThrowError(
1280+
/TimeoutError: The operation was aborted due to timeout/
1281+
);
1282+
} finally {
1283+
await client.close();
1284+
}
1285+
});
1286+
12501287
it.each(["http", "sse"] as const)(
12511288
"%s should respect RunnableConfig timeout for tool calls",
12521289
async (transport: "http" | "sse") => {
@@ -1378,6 +1415,39 @@ describe("MultiServerMCPClient Integration Tests", () => {
13781415
}
13791416
);
13801417

1418+
it.each(["http", "sse"] as const)(
1419+
"%s should pass explicit per-call timeout through RunnableConfig",
1420+
async (transport) => {
1421+
const { baseUrl } = await testServers.createHTTPServer("timeout-test", {
1422+
disableStreamableHttp: transport === "sse",
1423+
supportSSEFallback: transport === "sse",
1424+
});
1425+
1426+
const client = new MultiServerMCPClient({
1427+
"timeout-server": {
1428+
transport,
1429+
url: `${baseUrl}/${transport === "http" ? "mcp" : "sse"}`,
1430+
},
1431+
});
1432+
1433+
try {
1434+
const tools = await client.getTools();
1435+
const testTool = tools.find((t) => t.name.includes("sleep_tool"));
1436+
expect(testTool).toBeDefined();
1437+
1438+
// Set a per-call timeout longer than the server default to ensure it is honored
1439+
// The server sleep is 1500ms; we set timeout to 2000ms so it should succeed
1440+
const result = await testTool!.invoke(
1441+
{ sleepMsec: 1500 },
1442+
{ timeout: 2000 }
1443+
);
1444+
expect(result).toContain("done");
1445+
} finally {
1446+
await client.close();
1447+
}
1448+
}
1449+
);
1450+
13811451
it.each(["http", "sse"] as const)(
13821452
"%s should throw timeout error when tool call exceeds configured timeout from constructor options",
13831453
async (transport) => {

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,8 +600,13 @@ async function _callTool({
600600
debugLog(`INFO: Calling tool ${toolName}(${JSON.stringify(args)})`);
601601

602602
// Extract timeout from RunnableConfig and pass to MCP SDK
603+
// Note: ensureConfig() converts timeout into an AbortSignal and deletes the timeout field.
604+
// To preserve the numeric timeout for SDKs that accept an explicit timeout value, we read
605+
// it from metadata.timeoutMs if present, falling back to any direct timeout.
606+
const numericTimeout =
607+
(config?.metadata?.timeoutMs as number | undefined) ?? config?.timeout;
603608
const requestOptions: RequestOptions = {
604-
...(config?.timeout ? { timeout: config.timeout } : {}),
609+
...(numericTimeout ? { timeout: numericTimeout } : {}),
605610
...(config?.signal ? { signal: config.signal } : {}),
606611
...(onProgress
607612
? {

0 commit comments

Comments
 (0)