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
13 changes: 12 additions & 1 deletion libs/langchain-core/src/runnables/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,18 @@ export function ensureConfig<CallOptions extends RunnableConfig>(
if (empty.timeout <= 0) {
throw new Error("Timeout must be a positive number");
}
const timeoutSignal = AbortSignal.timeout(empty.timeout);
const originalTimeoutMs = empty.timeout;
const timeoutSignal = AbortSignal.timeout(originalTimeoutMs);
// Preserve the numeric timeout for downstream consumers that need to pass
// an explicit timeout value to underlying SDKs in addition to an AbortSignal.
// We store it in metadata to avoid changing the public config shape.
if (!empty.metadata) {
empty.metadata = {};
}
// Do not overwrite if already set upstream.
if (empty.metadata.__lc_originalTimeoutMs === undefined) {
empty.metadata.__lc_originalTimeoutMs = originalTimeoutMs;
}
if (empty.signal !== undefined) {
if ("any" in AbortSignal) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down
33 changes: 33 additions & 0 deletions libs/langchain-mcp-adapters/src/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,39 @@ describe("MultiServerMCPClient Integration Tests", () => {
}
);

it.each(["http", "sse"] as const)(
"%s should pass explicit per-call timeout through RunnableConfig",
async (transport) => {
const { baseUrl } = await testServers.createHTTPServer("timeout-test", {
disableStreamableHttp: transport === "sse",
supportSSEFallback: transport === "sse",
});

const client = new MultiServerMCPClient({
"timeout-server": {
transport,
url: `${baseUrl}/${transport === "http" ? "mcp" : "sse"}`,
},
});

try {
const tools = await client.getTools();
const testTool = tools.find((t) => t.name.includes("sleep_tool"));
expect(testTool).toBeDefined();

// Set a per-call timeout longer than the server default to ensure it is honored
// The server sleep is 1500ms; we set timeout to 2000ms so it should succeed
const result = await testTool!.invoke(
{ sleepMsec: 1500 },
{ timeout: 2000 }
);
expect(result).toContain("done");
} finally {
await client.close();
}
}
);

it.each(["http", "sse"] as const)(
"%s should throw timeout error when tool call exceeds configured timeout from constructor options",
async (transport) => {
Expand Down
8 changes: 7 additions & 1 deletion libs/langchain-mcp-adapters/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,14 @@ async function _callTool({
debugLog(`INFO: Calling tool ${toolName}(${JSON.stringify(args)})`);

// Extract timeout from RunnableConfig and pass to MCP SDK
// Note: ensureConfig() converts timeout into an AbortSignal and deletes the timeout field.
// To preserve the numeric timeout for SDKs that accept an explicit timeout value, we read
// it from metadata.__lc_originalTimeoutMs if present, falling back to any direct timeout.
const numericTimeout =
(config?.metadata?.__lc_originalTimeoutMs as number | undefined) ??
config?.timeout;
const requestOptions: RequestOptions = {
...(config?.timeout ? { timeout: config.timeout } : {}),
...(numericTimeout ? { timeout: numericTimeout } : {}),
...(config?.signal ? { signal: config.signal } : {}),
...(onProgress
? {
Expand Down