Skip to content

MCP‐adapter tool timeouts are never applied when invoked via ToolNode #8279

@simozampa

Description

@simozampa

Checked other resources

  • I added a very descriptive title to this issue.
  • I searched the LangChain.js documentation with the integrated search.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain.js rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).

Example Code

import { StateGraph, Annotation }    from "@langchain/langgraph";
import { ToolNode }                  from "@langchain/langgraph/prebuilt";
import { MultiServerMCPClient }      from "@langchain/mcp-adapters";

// 1) Load your MCP tools…
const client = new MultiServerMCPClient({ /*…*/ });
const tools  = await client.getTools();
const toolNode = new ToolNode(tools);

// 2) Build a simple graph that routes every AI message to the tools:
const StateAnn = Annotation.Root({ messages: Annotation() });
async function echoModel(state) {
  return { messages: [state.messages.at(-1)!] };
}
function route(state) { return "tools"; }

const graph = new StateGraph(StateAnn)
  .addNode("agent",    echoModel)
  .addNode("tools",    toolNode)
  .addEdge("__start__", "agent")
  .addEdge("agent",    "tools")
  .addEdge("tools",    "agent")
  .addConditionalEdges("agent", route)
  .compile();

// 3) Bind a timeout+signal on the top‐level runnable…
const runner = graph.withConfig({
  timeout: 10_000,           // 10 seconds
  signal: new AbortController().signal,
});

// 4) Stream… and watch your debug logs inside `ToolNode.run`:
//    you’ll see “config.timeout === undefined”
const stream = await runner.stream(
  { messages: [{ role: "user", content: "…" }] },
  { recursionLimit: 5, streamMode: "messages" }
);

Error Message and Stack Trace (if applicable)

No response

Description

LangChain’s MCP adapters docs claim you can set per‐tool timeouts via the standard RunnableConfig interface:

// either…
const slowToolWithTimeout = slowTool.withConfig({ timeout: 300_000 });
await slowToolWithTimeout.invoke({ /*…*/ });

// or…
await slowTool.invoke({ /*…*/ }, { timeout: 300_000 });

When you do

const toolWithTimeout = tool.withConfig({ timeout: 5000 });

you actually get back a RunnableBinding, not a DynamicStructuredTool. A RunnableBinding does not expose the .invoke(args, config) signature that ToolNode expects, so even trying to wrap the tool in a withConfig before passing it to ToolNode breaks invocation entirely.

In addition, when you embed tools with custom timeout in a graph via ToolNode, any { timeout, signal } in the config is silently dropped. Inside ToolNode.run, the config argument passed down to tool.invoke(...) always has timeout === undefined, so the MCP client falls back to its default 1 minute default

System Info

@langchain/core: ^0.3.57
@langchain/mcp-adapters: v0.5.1
@langchain/langgraph: v0.2.74
@modelcontextprotocol/sdk: ^1.11.2
Node.js: 20.18.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    auto:bugRelated to a bug, vulnerability, unexpected error with an existing feature

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions