LangChain MCPAdapter Postmortem
LangChain 1.4.0 moves Model Context Protocol support into the main package. You now reach an MCP server through MCPAdapter (langchain.mcp.MCPAdapter), an asynchronous context manager that wraps a FastMCP client, discovers the tools a server advertises, and hands them back as ordinary LangChain tools.
The standalone langchain-mcp-adapters package is finished. Its MultiServerMCPClient collapses into this one class.
pip install "langchain[mcp]"
from langchain.agents import create_agent
from langchain.mcp import MCPAdapter
async def main():
async with MCPAdapter("https://example.com/mcp") as adapter:
tools = await adapter.list_tools()
agent = create_agent("claude-sonnet-5", tools)
return await agent.ainvoke(
{"messages": [{"role": "user", "content": "What is the forecast for Oslo?"}]}
)
Importing the namespace raises a LangChainBetaWarning once per process. I confirmed that on a clean install of 1.4.0, along with everything else in this post. I read the installed source rather than the docs, because the interesting parts of this adapter live in three private functions nobody writes about.
How Do You Register MCP Tools?
The namespace gives you 2 ways to turn MCP tools into LangChain tools.
- Using the
MCPAdapter(target).list_tools()method, which discovers the whole catalog. - Using the
as_langchain_tool(tool, client)coroutine, which converts one tool you already hold.
The second one carries the old name convert_mcp_tool_to_langchain_tool from the previous package. It became a coroutine on the way over, so you have to await it now.
list_tools() also takes a cache_mode argument. It defaults to use, which serves a cached catalog while the server’s TTL hint holds. Worth knowing that a bare ClientGroup.list_tools() defaults to refresh instead, so the adapter quietly changes the default underneath you.
What Can You Hand the Adapter?
MCPAdapter infers the transport from whatever you pass, so the target is the only thing that changes between a remote server, a local script, and a server running inside your own test process.
from pathlib import Path
from langchain.mcp import MCPAdapter
in_memory = MCPAdapter(server) # a FastMCP instance, no socket
stdio = MCPAdapter(Path("weather_server.py")) # subprocess over stdio
http = MCPAdapter("https://example.com/mcp") # streamable HTTP
A target can also be a pre-configured FastMCP transport, an MCPConfig dict shaped as {"mcpServers": {...}}, a prebuilt fastmcp.Client, or a ClientGroup.
The in-process option deserves more attention than it gets. You can point an adapter at a live FastMCP instance with no subprocess and no socket anywhere, which makes MCP integration tests fast and hermetic. That is how I verified the claims in this article.
Why Does a Plain String Have to Be a URL?
Here is the first piece of the adapter I did not expect.
MCPAdapter("weather_server.py")
# ValueError: MCP target 'weather_server.py' is not a valid URL. To run a local
# MCP server over stdio, ask for it explicitly with `Path('server.py')`, ...
FastMCP resolves a string by testing it as a filesystem path before testing it as a URL. A string naming an existing .py or .js file therefore launches that file as a subprocess. Strings are also the form a target most often arrives in, out of a config file, a request body, or a model’s output. LangChain decided that combination makes the dangerous reading the implicit one, so MCPAdapter narrows the type and throws ValueError on anything that is not http or https.
The check goes further than a scheme comparison. AnyUrl parses the Windows path C:\server.py as the scheme c and accepts it, and on Windows that path resolves, so FastMCP would happily execute it. The adapter rejects that too.
MCPAdapter("C:\\server.py")
# ValueError: ... has scheme 'c', but a string target must be an http or https URL.
MCPAdapter("ws://example.com/mcp")
# ValueError: ... has scheme 'ws', ...
To reach a local server you have to say so out loud, with a Path, an explicit transport, or an MCPConfig entry.
How the Magic Stuff Works?
Two things happen before any tool ever runs. The constructor arms the client, and list_tools() wraps each discovered tool in a StructuredTool that closes over that client.
Arming is the part worth understanding. FastMCP only advertises the elicitation capability when a handler is set, so the adapter installs a sentinel handler whose entire job is to make the capability appear in the handshake. It then stamps a private marker attribute on the client.
If you hand the adapter a client that already carries your own elicitation handler, it leaves that client alone. If it does arm a client you built, it clones it first with client.new() and arms the clone, so your object never gets mutated. I checked both branches.
caller_client = Client("https://example.com/mcp")
adapter = MCPAdapter(caller_client)
adapter.client is caller_client # False, it cloned
Once the model picks a tool, the request flow looks like this.
- The agent’s tool node invokes the StructuredTool that
as_langchain_toolbuilt and awaits its coroutine. - The coroutine enters
async with client. FastMCP clients are reentrant, so this opens a session when none is open and reuses the one you already hold when the adapter’s context is still alive. That reentrancy is exactly why the tools stay callable after theasync with MCPAdapter(...)block exits. - If the adapter armed this client and the server speaks the modern protocol era, control goes to the interrupt loop. Otherwise the coroutine calls
client.call_tool(name, arguments, raise_on_error=False)directly, because the adapter wants the error result rather than an exception. - The result comes back and each MCP content block converts into a LangChain content block. Text, images, and file resources all have equivalents. Audio does not, and hits a NotImplementedError.
- When the result carries
isError=True, the converter raises _MCPToolExecutionError, a deliberately narrow subclass of ToolException that carries the already converted content. - The tool’s
handle_tool_errorhook catches that one class and returns the server’s own content, which LangChain turns into a ToolMessage withstatus="error". Any other exception gets re-raised untouched.
That last bullet is the whole error design in one line. A server that ran and failed talks to the model. A transport that dropped talks to you.
What Happens When a Tool Fails?
The old handle_tool_errors flag is gone, and the behaviour is fixed now. Running a division by zero against an in-process server shows both halves.
err = await divide.ainvoke(
{"name": "divide", "args": {"a": 10, "b": 0}, "id": "2", "type": "tool_call"}
)
err.status
# 'error'
err.content
# [{'type': 'text', 'text': "Error calling tool 'divide': float division by zero", ...}]
The agent reads that message and can correct itself. Kill the connection instead and you get a raised exception, because a model cannot do anything useful with a dead socket.
Successful calls split in two. Model-visible content lands on content_blocks, and any structuredContent the server returned lands on message.artifact as an MCPToolArtifact, which keeps a large structured payload out of the prompt.
msg.artifact
# {'structured_content': {'result': 'deleted 42'}}
Where Does the Tool Metadata Come From?
Every adapted tool carries its provenance under an mcp namespace. Tool level fields sit under mcp.tool, and the identity of the server that served it sits under mcp.server, read off the live connection rather than off the tool itself.
tool.metadata
# {'mcp': {'tool': {'annotations': {'read_only_hint': False,
# 'destructive_hint': True},
# '_meta': {...}},
# 'server': {'name': 'demo', 'version': '2.1.0'}}}
Every nested field is optional, so read it defensively. The payoff is that you can gate approvals on what a server declares about a tool instead of hardcoding tool names, using the destructiveHint annotation and a when predicate on InterruptOnConfig. Add a server tomorrow and the gate covers its destructive tools without a code change.
What if the Server Asks You a Question Mid-Call?
Elicitation is the mechanism a server uses to ask for input in the middle of a tool call. The July 2026 spec revision made the protocol core stateless, which turned that request into an ordinary round the client retries with an answer attached. LangChain answers it with a LangGraph interrupt(), and it is on by default.
paused = await agent.ainvoke(
{"messages": [{"role": "user", "content": "Book a table for 4."}]}, config
)
[question] = paused["__interrupt__"][0].value["requests"]
answer = {"action": "accept", "content": {"date": "2026-09-14"}}
result = await agent.ainvoke(
Command(resume={"responses": {question["key"]: answer}}), config
)
Answers key off the server’s own request key, so you do not track anything across the pause. Each answer takes accept, decline, or cancel. You need a checkpointer, because a paused run has to wait somewhere.
Now the part I would want in a code review, which I have not seen documented anywhere prominent. interrupt() unwinds the entire call. On resume, the loop re-issues the tool call from the first round. A server that asks its question before doing work repeats nothing. A server that does work first repeats that work once per round.
If your MCP tool charges money, writes rows, or sends mail before it elicits, that tool needs to be idempotent or it needs to ask first. Treat this as a design constraint on the server, not a client problem.
Three things get refused rather than half served. Sampling and roots both raise NotImplementedError, because the modern sessionless protocol has no live back-channel for a server to call into mid-request. So does a continuation round, meaning a result that carries state but no actual question. And a legacy server that pushes elicitation the old way trips the sentinel handler, which raises with a message telling you to supply your own elicitation_handler instead.
What if I Want Tools From Several Servers?
You have two shapes, and the difference matters more than it looks.
An MCPConfig dict puts several servers behind one aggregate connection. FastMCP prefixes every tool with its config key, so two servers exposing search arrive as weather_search and calc_search.
CONFIG = {
"mcpServers": {
"weather": {"command": "python", "args": ["/path/to/weather_server.py"]},
"calc": {"url": "http://localhost:8002/mcp"},
}
}
async with MCPAdapter(CONFIG) as adapter:
tools = await adapter.list_tools()
A ClientGroup gives each server its own connection instead, so each keeps its own credentials, its own handlers, and its own negotiated protocol era.
group = ClientGroup({
"billing": Client("https://billing.internal/mcp", mode="legacy", auth="oauth"),
"docs": Client("https://docs.internal/mcp", mode="auto", auth=docs_token),
})
async with MCPAdapter(group) as adapter:
tools = await adapter.list_tools()
Here is the trap. An MCPConfig fleet negotiates a single protocol era across every backend it holds. Add one server that has not upgraded and the whole fleet drops to the legacy era, which costs you caching and interrupt-driven elicitation on servers that were perfectly capable of both. Mixed estates want a ClientGroup.
Migrating From langchain-mcp-adapters
Most of this is mechanical. The official migration guide has the full table, so I will point at the parts that bite.
# Before
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"math": {"transport": "stdio", "command": "python", "args": ["/path/math_server.py"]},
"weather": {"transport": "http", "url": "http://localhost:8000/mcp"},
})
tools = await client.get_tools()
# After
from langchain.mcp import MCPAdapter
config = {"mcpServers": {
"math": {"command": "python", "args": ["/path/math_server.py"]},
"weather": {"url": "http://localhost:8000/mcp"},
}}
async with MCPAdapter(config) as adapter:
tools = await adapter.list_tools()
Four things do not survive the move.
Prompts and resources have no wrapper yet, so load_mcp_prompt and load_mcp_resources have nowhere to go. Reach through to client.get_prompt(...) and client.read_resource(...) on the FastMCP client until LangChain ships something.
Tool interceptors are gone as a concept. Use @wrap_tool_call middleware instead, which wraps every tool your agent runs rather than only the MCP ones, and branch on request.tool.metadata["mcp"] when you need to.
The Callbacks object is gone, but the handlers are not. Set progress_handler and log_handler on a fastmcp.Client and pass that client in.
WebSocket has no FastMCP transport at all. SSE still works through SSETransport, though the MCP spec deprecated it in favour of streamable HTTP, so treat it as a bridge rather than a destination.
MCPToolArtifact and the multimodal content blocks both carry over unchanged, which is the good news if you already parse tool output.
Where Should the Adapter Live?
For a script or a notebook, discover inside the context and let it close. The tools hold the client, so the agent keeps working after the block exits.
For a long-running agent, the same pattern is still right. Each tool call opens its own session and releases it, which keeps an idle connection from being pinned between calls. Keep the adapter’s context open around the agent call only when you actually want one session held across several tool calls.
For a deployment, build the agent inside a graph factory so every run picks up the current catalog, then absorb the cost underneath with a shared httpx pool and cache_mode="use".
async def make_graph(runtime):
user = runtime.user.identity if runtime.user is not None else "anonymous"
auth = BearerAuth(token_for(user))
async with MCPAdapter(Client(CONFIG, auth=auth)) as adapter:
tools = await adapter.list_tools(cache_mode="use")
return create_agent("claude-sonnet-5", tools)
Two rules go with that factory. Mint credentials per user rather than sharing one service token across every caller, and partition the response cache by verified identity, or one user will eventually see another user’s tool catalog.
The namespace is still in beta and the API can move, so pin your version. We build production agent systems on this stack at Contrive Solutions, and if you are working through an MCP rollout or a migration off langchain-mcp-adapters, get in touch and we will talk it through.
Verified against langchain 1.4.0 on Python 3.12, September 2026.
- Founded
- 2014
- Years in business
- 12
- People
- 66
- Headquarters
- Lahore, Pakistan
- Projects delivered
- 250+
- AI systems in production
- 10

