MCP for LangGraph Agents, Level by Level: Servers, Transports and the Adapter Bridge
MCP for LangGraph agents, level by level: host, client and server roles, a FastMCP server, stdio vs HTTP transports, langchain-mcp-adapters and pitfalls.
This article is published in English.
Sources: MCP for LangGraph Developers: From Basics to Production
Prerequisites are light: familiarity with a LangGraph project layout and with the idea of a function decorated with @tool. The Model Context Protocol is a new piece of infrastructure and not a new way of thinking about graphs, so the explanation starts from zero on MCP itself and rejoins LangGraph only once the concept is solid.
The scaling problem with plain tools
A tool in LangGraph usually starts like this:
@tool
def search_web(query: str) -> str:
"""Search the web for current information."""
return f"Search results for: {query}"
That is fine until it needs to scale. Suppose five agents in five projects all need a "search the company database" tool. With the pattern above, the same tool gets written five times, in five codebases, perhaps in five languages. A schema change means five edits. Sharing it with a colleague building a different kind of AI app, maybe not on LangGraph at all, fails outright, because that @tool function belongs to LangChain's Python world.
The name for the difficulty is the N×M integration problem. N tools (databases, APIs, file systems) have to connect to M AI applications (a LangGraph agent, a colleague's custom agent, Claude Desktop, an IDE assistant). Without a shared standard, N×M bespoke integrations result.
The Model Context Protocol (MCP) answers it as an open, universal standard for connecting AI applications to tools and data. A tool built once as an MCP server can be used by any MCP-compatible application, whether a LangGraph agent, Claude Desktop, Cursor or a custom agent, with no custom integration code. The usual comparison is a USB-C port: one connector standard, any compatible device at either end.
The route from "what is MCP" to a production-grade server wired into a real LangGraph agent runs through five levels, each adding one idea.
Level 1: what MCP is
Start with a picture before any code. A hotel concierge does not personally know how to book a restaurant, call a cab or buy theatre tickets. The concierge keeps a list of trusted local services, each a specialist. When a guest wants a dinner reservation, the concierge picks up a dedicated line to the booking service, makes the request in a format both sides understand and passes the answer back.
MCP is that standard line and that standard request format. The AI application is the concierge. An MCP server is a specialised service such as a restaurant booker, a cab firm or a ticket office. MCP itself is the common language and connection scheme, so any concierge can talk to any service with no need to learn how each one runs its own switchboard.
The three roles
Every MCP system has exactly three participants, and the rest builds on them.
- Host. The AI application the end user actually uses: here a LangGraph agent, elsewhere Claude Desktop, Cursor or VS Code with a copilot. It runs the conversation, decides when a tool is needed and shows results.
- Client. It lives inside the host, and each client keeps a dedicated one-to-one connection to exactly one server. A host connected to three servers runs three clients internally. The one-to-one mapping is a deliberate security boundary, so a client for the file-system server cannot accidentally leak data to the database server.
- Server. A small, focused program exposing specific capabilities. It might wrap a database, a file system, a web search API or an internal CRM. Notably, it neither knows nor cares which host calls it, so the same server serves a LangGraph agent today and another application tomorrow without changes.
The wire format: JSON-RPC 2.0
Messages between client and server use JSON-RPC 2.0, a simple and well-established format with three message types.
A request asks for something to be done and includes a unique id so the reply can be matched:
{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "search_db", "arguments": {"query": "Q3 revenue"}}}
A response answers a request. It carries either a result for success or an error for failure, with the same id as the request:
{"jsonrpc": "2.0", "id": 1, "result": {"content": [{"type": "text", "text": "Q3 revenue was $4.2M"}]}}
A notification is a one-way message expecting no reply, for example to announce that the list of available tools has changed.
Raw JSON-RPC rarely gets written by hand, because the SDKs handle it. Still, knowing it sits underneath demystifies much of what follows, the way knowing HTTP makes someone a better REST developer even if they never type raw HTTP.
Level 2: the three primitives
A server offers its capabilities through exactly three kinds of primitive, and nearly every design decision in building one is a choice among them.
Tools are actions the model can take. A tool is a function the LLM may decide to call while reasoning, just like a decorated function in plain LangGraph. The difference is where it lives: inside an MCP server and not in the LangGraph codebase, discovered and called over the protocol. Reach for a tool whenever something must happen: a calculation, a database query, an outgoing email or a file edit.
Resources supply read-only context. The application can fetch a resource to hand the model background material, without the model having to decide to call it as it would a tool. Resources are addressed by URI, much as a web page is addressed by URL. Use one for read-only material such as a configuration file, a document, or a list of data sources the model might want to know about before choosing what to search.
Prompts are reusable templates. A prompt is a pre-written structure that starts a particular workflow for a user or the host. Unlike tools, prompts are typically invoked by the user and not the model, so they resemble slash commands. They suit common, repeatable tasks such as "summarise this document" or "review this code for security issues", where consistent, well-engineered instructions beat leaving the phrasing to the user.
When unsure which to use, ask whether the thing changes anything or only provides information. If it creates, updates, deletes, sends or executes, it is a tool. If it is read-only data the model may want as background, it is a resource. If it is an instruction template a user kicks off explicitly, it is a prompt.
Level 3: a first server with FastMCP
FastMCP is the dominant Python framework for building MCP servers. It turns the protocol's considerable machinery (request handling, schema generation, lifecycle) into a few decorators, in the same spirit as @tool for LangChain functions. Installation:
pip install fastmcp
A complete server with a single tool fits in a short file:
# ── math_server.py ───────────────────────────────────────────
from fastmcp import FastMCP
# Create the server - this object is the container for everything below
mcp = FastMCP(name="Math Server")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@mcp.tool
def multiply(a: int, b: int) -> int:
"""Multiply two numbers together."""
return a * b
if __name__ == "__main__":
mcp.run() # Defaults to stdio transport
That is a valid, runnable MCP server. FastMCP reads the function's type hints to generate the input schema and reads the docstring to produce the description the LLM sees. The same rule as for ordinary tools applies, only with higher stakes: a vague docstring leaves the model unsure when to call the tool, and now that vagueness affects every application that ever connects, not just one agent.
Adding a resource takes a decorator with a URI of choice:
# ── Adding to math_server.py ─────────────────────────────────
@mcp.resource("config://server-info")
def server_info() -> dict:
"""Provides metadata about this server's capabilities."""
return {"version": "1.0", "operations": ["add", "multiply"]}
When a client requests config://server-info, FastMCP calls the function and returns the result. It only runs on request, which is lazy evaluation, so nothing gets computed that the model never uses.
Sometimes a resource should depend on an argument, such as one specific record and not a fixed blob of configuration:
@mcp.resource("history://{operation_id}")
def get_calculation(operation_id: str) -> dict:
"""Retrieve a past calculation by its ID."""
# In a real server, this would look up a database record
return {"id": operation_id, "result": "..."}
The {operation_id} placeholder in the URI makes this a resource template. A request for history://abc123 makes FastMCP extract abc123 and pass it to the function automatically.
A prompt is defined in the same way:
@mcp.prompt
def explain_calculation(operation: str, result: str) -> str:
"""Generate a request to explain a calculation in plain English."""
return f"Explain how the operation '{operation}' produced the result '{result}', in simple terms a beginner could follow."
To run the server:
python math_server.py
Before connecting a real AI application, verify it with the MCP Inspector, a browser-based debugging tool shipped with the ecosystem. It lists tools, calls them manually and inspects resources without an LLM in the loop:
fastmcp dev math_server.py
That opens a local web interface, typically at http://127.0.0.1:6274, to click through tools, enter test arguments and watch the JSON-RPC messages that flow back and forth. Test here first. A broken tool is much harder to debug through an LLM's tool-calling behaviour than directly in the Inspector.
Level 4: transports
So far the server ran with the default transport. The choice matters because it determines how a LangGraph agent will connect.
Treat the transport as the physical wire and JSON-RPC as the language carried on it. English works over a telephone or a radio alike: one language, two wires. MCP servers speak the same JSON-RPC 2.0 whatever the transport, but the bytes travel differently.
stdio, for local tools
The default, and what mcp.run() does when called with no arguments:
if __name__ == "__main__":
mcp.run() # stdio is the default
With stdio (standard input and output), the host launches the server as a subprocess and talks to it by writing to its stdin and reading its stdout. No network is involved: it is process-to-process communication on one machine. It suits a server that runs beside the host, such as a file-system tool, a local script runner or a personal utility, and it is the simplest setup and the most common for development.
One constraint matters a great deal. Under stdio, the server's stdout is reserved exclusively for protocol messages, so a stray print() debug line corrupts the stream. Logging has to go to stderr.
Streamable HTTP, for remote tools
For a networked server, pass the transport explicitly:
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
Here the server runs as a standalone web service. Clients connect over the network with HTTP POST requests, with optional Server-Sent Events to stream responses back. That makes the server genuinely remote: it can sit on another machine, in the cloud or behind a load balancer, and serve several host applications simultaneously. Use it when a server must be shared across a team, deployed centrally, or reached from hosts on other machines, including a LangGraph agent running on a server and talking to a database-access MCP server elsewhere.
The rule of thumb: stdio for a local tool, one user and simple setup, typical of development and personal use; Streamable HTTP for a shared tool, many users or applications, production deployment and authentication needs. This is exactly the choice to declare for each server in the LangGraph configuration next.
Level 5: connecting to LangGraph
The bridge is a package named langchain-mcp-adapters, maintained by the LangChain team to make MCP tools usable inside LangGraph agents. It does for MCP what the @tool decorator did for plain functions: it turns an external MCP tool into a BaseTool object that drops straight into a LangGraph ToolNode with no special-casing in the graph. After conversion the agent neither knows nor cares whether a tool came from an MCP server or was written in-process.
Install it with:
pip install langchain-mcp-adapters langgraph "langchain[openai]"
The central object is MultiServerMCPClient, which can connect to several MCP servers at once and mix transports freely:
# ── MODULE 1 (extended): MCP CLIENT CONFIGURATION ───────────
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
# A local server, launched as a subprocess via stdio
"math": {
"command": "python",
"args": ["/absolute/path/to/math_server.py"],
"transport": "stdio",
},
# A remote server, already running, connected over HTTP
"company_data": {
"url": "http://localhost:8000/mcp",
"transport": "http",
},
})
Each dictionary entry is one server. The key ("math" or "company_data") is only a label that keeps tools from different servers organised internally. The entry mirrors the transport decision: stdio servers take a command and args, HTTP servers take a url.
Loading the tools takes one call:
# This single call discovers every tool, on every configured server,
# and converts each one into a LangChain-compatible BaseTool
tools = await client.get_tools()
print([t.name for t in tools])
# ['add', 'multiply', 'search_company_db', ...]
That is the whole integration. client.get_tools() sends the MCP tools/list request to every configured server, takes the returned schemas and wraps each into a tool object that, from the graph's view, is indistinguishable from a hand-written @tool function.
The rest of the wiring looks the same as in any LangGraph agent. MCP changes where tools come from and does not change how the graph is built. A complete template:
# ============================================================
# LANGGRAPH + MCP AGENT — COMPLETE TEMPLATE
# Extends: Part 1 (core structure)
# ============================================================
# ── MODULE 1: IMPORTS & CONFIGURATION ───────────────────────
import asyncio
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import MemorySaver
llm = init_chat_model("openai:gpt-4o")
client = MultiServerMCPClient({
"math": {
"command": "python",
"args": ["./math_server.py"],
"transport": "stdio",
},
"company_data": {
"url": "http://localhost:8000/mcp",
"transport": "http",
},
})
# ── MODULE 2: STATE ──────────────────────────────────────────
class State(MessagesState):
pass
# ── MODULE 4: NODES ──────────────────────────────────────────
# (Module 3, Tools, is now sourced from MCP rather than defined here)
def make_agent_node(llm_with_tools):
"""Factory pattern: tools are loaded asynchronously, so the node
closure is built after the async tool-loading step completes."""
def agent_node(state: State) -> dict:
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
return agent_node
# ── MODULE 6: GRAPH ASSEMBLY (inside an async function) ──────
async def build_graph():
tools = await client.get_tools()
llm_with_tools = llm.bind_tools(tools)
graph_builder = StateGraph(State)
graph_builder.add_node("agent", make_agent_node(llm_with_tools))
graph_builder.add_node("tools", ToolNode(tools))
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges("agent", tools_condition)
graph_builder.add_edge("tools", "agent")
return graph_builder.compile(checkpointer=MemorySaver())
# ── MODULE 7: ENTRYPOINT ──────────────────────────────────────
async def main():
graph = await build_graph()
config = {"configurable": {"thread_id": "session-001"}}
response = await graph.ainvoke(
{"messages": [HumanMessage(content="What's (3 + 5) times 12?")]},
config=config
)
print(response["messages"][-1].content)
if __name__ == "__main__":
asyncio.run(main())
Everything becomes async
MCP introduces one real adjustment. Communication is inherently asynchronous, because every tool call is a network or subprocess round trip, even over stdio. So client.get_tools() must be awaited, graph construction normally sits in an async def, and the compiled graph is invoked with ainvoke and not invoke. Everything else, StateGraph, MessagesState, tools_condition and MemorySaver, stays as it was.
The pre-built router again
Routing effectively disappears from this template. Instead of a custom router it uses tools_condition, the ready-made one: if the last message has tool calls, go to "tools", otherwise finish. The router cannot tell MCP tools from hand-written ones, since both are BaseTool objects by the time the graph sees them.
Level 6: production concerns
A few things separate a working demo from a deployable system, and these come up most often in real MCP and LangGraph setups.
Keep the client connection alive. MultiServerMCPClient is stateless by default: each tool call opens a fresh connection, executes and tears it down. That is fine for one request. For an application serving many, opening a subprocess or HTTP connection on every call adds real latency. In production, open the client once when the process starts and reuse it for the life of the application:
# Hold the connection open across the application's lifetime,
# rather than opening/closing per request
async with MultiServerMCPClient({...}) as client:
tools = await client.get_tools()
graph = await build_graph_with(tools)
# ... serve many requests using this same graph ...
Avoid stdio in a web server. Stdio was designed for local, single-user, same-machine cases such as a desktop app launching a helper. When a LangGraph agent is deployed as a web service for many users, stdio servers become a liability: subprocesses spawn per request, with no natural way to share state or scale out. For any server-side deployment prefer Streamable HTTP, and ask honestly whether a separate MCP process is needed at all. There are cases where a plain @tool function that calls an internal library directly beats the extra hop through the protocol on both simplicity and speed.
Add human approval for sensitive tools. MCP tools are ordinary tools once they reach the graph, so every existing pattern applies. If a server exposes something sensitive, such as sending email, changing a database or spending money, wrap it with the same interrupt() review step used for any other tool call. MCP gives discovery and standardisation and does not give safety by default. That remains the developer's job.
Servers cannot see graph state. MCP servers run as separate processes. They cannot reach into the LangGraph state, the checkpointer or any in-memory Python object. When a call needs personalising, say with a stored user preference, the value has to be passed explicitly as a tool argument, or supplied through interceptors, an advanced hook offered by langchain-mcp-adapters that can inspect and modify a call before it is sent using the graph's runtime context. Passing values as arguments is enough for most agents and far simpler.
Level 7: MCP in a multi-agent system
MCP composes with the supervisor pattern. Several specialist agents each get their tools from their own dedicated server:
# ── Specialist agents, each pulling tools from a DIFFERENT MCP server ──
async def build_multi_agent_system():
client = MultiServerMCPClient({
"research": {"url": "http://localhost:8001/mcp", "transport": "http"},
"code": {"url": "http://localhost:8002/mcp", "transport": "http"},
})
research_tools = await client.get_tools(server_name="research")
code_tools = await client.get_tools(server_name="code")
researcher_llm = llm.bind_tools(research_tools)
coder_llm = llm.bind_tools(code_tools)
# From here, wire researcher_node, coder_node, and a supervisor_node
# exactly as shown in Part 4 - the rest of the supervisor pattern
# is completely unchanged.
...
MCP does not introduce a rival architecture here. A specialist backed by an MCP server is structurally identical to one backed by hand-written tools: the supervisor routes the same way and the specialists report back the same way. MCP changes only where tool definitions live and how many applications can reuse them.
Quick reference
- Architecture. Host is the AI application the user touches (here the LangGraph agent). Client lives in the host, one per connected server, and manages a one-to-one session. Server is the standalone program exposing tools, resources and prompts, unaware of which host connects. JSON-RPC 2.0 is the message format under every exchange: requests, responses and notifications.
- Primitives. Tool is an executable action the model chooses to call, built with
@mcp.tool. Resource is read-only data addressed by URI, controlled by the application or user, built with@mcp.resource("uri://..."). Prompt is a reusable instruction template invoked by the user, built with@mcp.prompt. - FastMCP.
FastMCP(name="...")creates the server, the container for everything.@mcp.toolexposes a function, reading type hints for the schema and the docstring for the description.@mcp.resource("scheme://{param}")declares read-only data, with{param}making a template.@mcp.promptdeclares a template.mcp.run()starts the server, on stdio by default, or withtransport="streamable-http"for network use.fastmcp dev server.pyopens the Inspector for testing without an LLM. - Transports.
stdiois subprocess-based and local, with the host launching the server, and debug output must never go to stdout.streamable-httpis networked, supports many concurrent hosts and is the production default for shared servers. - LangGraph integration.
MultiServerMCPClient({...}), fromlangchain_mcp_adapters.client, connects to one or more servers with mixed transports in one dictionary.await client.get_tools()discovers every tool and converts it to aBaseTool.tools_conditionis the pre-built router and works identically for hand-written and MCP-sourced tools. Everything is async:get_tools(), graph building andainvokeall needawait.
Is MCP needed here?
- One tool, one agent, one codebase. No MCP needed. A plain
@toolfunction is simpler and carries no protocol overhead. - A tool reused across agents or projects, or shared with colleagues on other frameworks. Build it as an MCP server. That is the core value.
- Wanting hundreds of existing community servers (GitHub, Slack, Postgres, Google Drive, Stripe and more) in place of hand-written integration code. Connect through
MultiServerMCPClientand skip writing the integration, often the biggest time saving MCP offers. - A tool on a different machine from the agent, or shared by several hosts at once. Use Streamable HTTP as a standalone service.
- A tool that only runs locally beside one agent for development or personal use. stdio suffices and is easier to set up.
A standard, not a replacement
Learning a new protocol invites the worry that it displaces what is already known. MCP does not. It leaves LangGraph's tools, nodes and graphs alone. What changes is the origin of the tools and the number of applications that can share them. After an MCP tool reaches the ToolNode, it behaves like any hand-written tool, is approved by the same interrupt() patterns and fits the same supervisor architectures.
The progression followed a staircase: concepts and roles, the three primitives, a working server, the transport choice, the LangGraph bridge, production hardening and composition with multi-agent systems. Each level added one idea to a stable base. Combined with canonical graph structure, memory management, human-in-the-loop safety, multi-agent orchestration and retrieval, this covers most of what a serious production LangGraph application needs, and MCP is what lets the tooling half scale beyond a single codebase.