%%{init: {'securityLevel': 'loose', 'flowchart': {'diagramPadding': 24, 'subGraphTitleMargin': {'top': 6, 'bottom': 22}}, 'themeCSS': 'foreignObject{overflow:visible}span.bot{display:flex;justify-content:flex-start;align-items:flex-start;width:100%;height:0;overflow:visible;line-height:0}span.bot img{display:block!important;width:18px!important;height:18px!important;padding:2px;background:#fff;border-radius:50%;box-shadow:0 0 0 1px #999}span.bot.r img{margin:0;transform:translate(-24px,-24px)}span.bot.d{justify-content:center}span.bot.d img{margin-top:-50%;transform:translateY(-24px)}'}}%%
flowchart LR
subgraph client["Your process — the LangGraph agent"]
LLM["<span class='bot r'><img src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgOFY0SDgiLz48cmVjdCB3aWR0aD0iMTYiIGhlaWdodD0iMTIiIHg9IjQiIHk9IjgiIHJ4PSIyIi8+PHBhdGggZD0iTTIgMTRoMiIvPjxwYXRoIGQ9Ik0yMCAxNGgyIi8+PHBhdGggZD0iTTE1IDEzdjIiLz48cGF0aCBkPSJNOSAxM3YyIi8+PC9zdmc+'/></span>LLM decides"] --> RA["create_agent"]
end
subgraph server["Another process — the MCP server"]
T1["search_docs"]
T2["list_topics"]
end
RA <-->|"stdio: MCP messages"| server
25 MCP: External Tools

Every tool the doc-bot has called so far lived in the same Python process as the graph: the search_docs_node of Part I, the @tool of Chapter 13. That stops being possible the moment someone else owns the tool — the knowledge base belongs to another team, or the same tool has to serve an editor and a desktop assistant as well as your agent. “Just import it” is no longer an option.
The Model Context Protocol1 (MCP) is the standard answer: the server owns a capability and publishes it as tools; the client connects, asks what tools exist, and calls them without hardcoding a signature.
MCP moves a tool out of your process and behind a protocol. Your client discovers the tools at run time and calls them over a transport rather than importing them; once
langchain-mcp-adaptershands them to the agent, the model tool-calls them as it would an in-process@tool.
Chapter 13 listed MCP as the far point on its packaging axis. Here we build it three ways — a server, a raw client that shows the protocol bare, and an agent that lets the model call the same tools — while watching what the boundary costs. The shape spans two processes:
25.1 The server: wrap a capability as tools
A server’s job is to take a capability you have (a database, an API, a knowledge base) and expose it as tools any client can discover. FastMCP2 makes that almost free: write ordinary functions and decorate them. The doc-bot’s capability is its technical-docs knowledge base, so that is what we publish. (Two new packages, both in the companion requirements.txt: pip install mcp==1.29.0 langchain-mcp-adapters==0.3.2 — the MCP SDK and the bridge that turns its tools into LangChain ones.)
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("doc-bot-kb") # the name a connecting client sees
KNOWLEDGE_BASE = {
"kafka": [
"Kafka batch consumption: set spring.kafka.listener.type=batch to enable it.",
"Kafka tuning: pair a batch consumer with max-poll-records to raise throughput.",
],
"redis": ["Redis connection pool: configure it via spring.data.redis.lettuce.pool.*"],
"mysql": ["MySQL indexing: add a B+ tree index on hot query columns to speed up slow queries."],
}The knowledge base is a stubbed dict for the same reason search_docs has been stubbed since Part I: this chapter is about the connector, and anything can sit behind it. In production you replace the body with a real vector store and no client changes, because the tool’s signature is the contract.
# @mcp.tool() registers an ordinary function AS an MCP tool:
# - the function name -> the tool name
# - the argument hints -> the tool's inputSchema (how a client calls it)
# - the return hint -> its outputSchema; every result also carries
# structuredContent alongside the text blocks
# - the docstring -> the tool description (how a client/LLM decides
# whether and how to use it)
@mcp.tool()
def search_docs(query: str) -> list[str]:
"""Search the technical-docs knowledge base and return snippets relevant to query.
Args:
query: the user's question or keywords
"""
q = query.lower()
hits = []
for topic, docs in KNOWLEDGE_BASE.items():
if topic in q:
hits.extend(docs)
return hits or ["(no relevant documents found)"]
@mcp.tool()
def list_topics() -> list[str]:
"""List which topics the knowledge base currently covers."""
return list(KNOWLEDGE_BASE.keys())@mcp.tool() reflects on the function and turns it into protocol metadata: the name becomes the tool’s name, the type hints become the JSON schema a client validates its arguments against, and the docstring becomes the description a client or a model reads. Those are the same three facts an LLM needs to decide whether and how to call a tool, as Chapter 13 laid out, and they are derived from a typed, documented function rather than written by hand. The docstring is therefore not decoration: it is the only thing the model on the far side will ever know about what search_docs does.
Finally, the server has to run. The default transport is stdio, standard in/out:
if __name__ == "__main__":
mcp.run() # stdio by default; Streamable HTTP is the other standard transportYou almost never launch this file yourself: under stdio the client spawns the server as a child process and talks to it over the pipe. Which also means the server’s stdout is the protocol channel — a stray print() in a tool body lands in the middle of an MCP message. Log to stderr in a stdio server.
25.2 The client: discover, then call

A raw MCP client shows the protocol bare, and makes the point the rest of the chapter depends on: the client does not hardcode the tool catalogue. It launches the server, asks what tools exist, and only then calls them. Point it at a different server and it sees that server’s tools, with no code change. Which of them to call is a separate decision. This demo makes it explicitly; the agent later hands it to the model.
The MCP client library is async all the way down, so the listing below is async Python throughout — async def and await as Section 22.7 used them, plus two constructs that chapter had no need for. async with is the async form of with: it opens the pipe to the child process and, however the block exits, closes it again. And asyncio.run(main()) at the bottom is the bridge from an ordinary script into that world — it starts the event loop, runs the coroutine to completion, and shuts the loop down. Read the body as straight-line code that occasionally waits.
import asyncio
import os
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Absolute path so we do not depend on the current working directory.
SERVER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ch23_mcp_server.py")
async def main():
# sys.executable: the interpreter running this client, which is the one
# known to have mcp installed. A bare "python" may resolve to another venv.
server_params = StdioServerParameters(command=sys.executable, args=[SERVER])
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize() # (1) handshake
print("Connected to the MCP server\n")
tools = await session.list_tools() # (2) discover
print("Discovered tools (not hardcoded here; learned from the server):")
for t in tools.tools:
params = list(t.inputSchema.get("properties", {}).keys())
print(f" - {t.name}{params} -- {t.description.splitlines()[0]}")
# (3) call. A list[str] result arrives as one content block per element.
print("\nCall list_topics():")
async with asyncio.timeout(10):
r1 = await session.call_tool("list_topics", {})
print(" ->", [item.text for item in r1.content])
# (4) a query that hits the knowledge base ...
print("\nCall search_docs(query='how do I configure kafka batch consumers'):")
r2 = await session.call_tool(
"search_docs", {"query": "how do I configure kafka batch consumers"}
)
for item in r2.content:
print(" ->", item.text)
# ... and one that misses, to show the empty-result path.
print("\nCall search_docs(query='python decorators'):")
r3 = await session.call_tool("search_docs", {"query": "python decorators"})
for item in r3.content:
print(" ->", item.text)
if __name__ == "__main__":
asyncio.run(main())Four steps, each a named MCP operation. initialize is the handshake; list_tools is discovery, answering with every tool’s name, schema, and description, which is why the loop can print tools it never declared; call_tool runs one and returns its result as a list of content blocks. That last is the first place the boundary shows — the server’s list[str] arrives as protocol content, one text block per element, and the client reads .text off each. The dict under “What the boundary costs” is the same list after langchain-mcp-adapters has put it into a ToolMessage.
Unlike the model-backed run in the next section, this client is fully deterministic — no LLM is involved, so the output is the same every time. What follows is its stdout; the child server writes to stderr too, so the terminal also carries a line like INFO Processing request of type ListToolsRequest per call. That is the subprocess talking, not your client:
Connected to the MCP server
Discovered tools (not hardcoded here; learned from the server):
- search_docs['query'] -- Search the technical-docs knowledge base and return snippets relevant to query.
- list_topics[] -- List which topics the knowledge base currently covers.
Call list_topics():
-> ['kafka', 'redis', 'mysql']
Call search_docs(query='how do I configure kafka batch consumers'):
-> Kafka batch consumption: set spring.kafka.listener.type=batch to enable it.
-> Kafka tuning: pair a batch consumer with max-poll-records to raise throughput.
Call search_docs(query='python decorators'):
-> (no relevant documents found)
In the discovered-tools block, search_docs['query'] and its description came off the wire, not from anything typed in the client. The python decorators query matches no topic, so the tool’s own fallback travels back: a miss is a normal result. An error is different — if a tool raises, the server reports it as a result with isError set rather than raising in your process, so a client that only reads .text prints Error executing tool search_docs: ... as if it were an answer. Across a boundary, the exception is data.
Add a third tool to the server — say count_docs() returning the total number of snippets — and rerun the client without touching it. The new tool shows up in the discovered list and is immediately callable, because the client learns the toolset from list_tools() at run time rather than from any hardcoded list. This is also the Bronze challenge, so try predicting the new discovered-tools block before you run it.
25.3 The same tools, in a graph you wired yourself
The raw client proves the protocol, and nobody calls call_tool by hand inside an agent. But there is a step between that client and a one-liner, and skipping it would leave the wrong impression: that crossing a process boundary means giving up the graph you have been building since Chapter 7.
It does not. langchain-mcp-adapters loads a server’s tools as ordinary LangChain tools — the same BaseTool interface @tool produces, with a name, a description, an args schema — so everything downstream is unchanged. Drop them into the model / ToolNode / router shape from Section 13.4:
def build(tools) -> StateGraph:
1 bound = llm.bind_tools(tools)
async def model(state: State) -> dict:
return {"messages": [await bound.ainvoke(state["messages"])]}
def route(state: State) -> str:
return "tools" if state["messages"][-1].tool_calls else END
g = StateGraph(State)
g.add_node("model", model)
2 g.add_node("tools", ToolNode(tools))
g.add_edge(START, "model")
g.add_conditional_edges("model", route, {"tools": "tools", END: END})
g.add_edge("tools", "model")
return g- 1
-
bind_toolsdoes not care where a tool runs. It reads the name, description and schema off each tool to build the model’s function-calling payload, and an MCP tool has all three. - 2
-
And
ToolNodedispatches them the same way it dispatched the in-process ones. Read the listing again for what is missing: no stdio, no session, nocall_tool, no subprocess. The graph does not know its tools live somewhere else, which is the whole of MCP’s claim.
The run, with the [answer] line shown by excerpt (first line, trimmed — Chapter 12):
Loaded 2 tool(s) over stdio: ['search_docs', 'list_topics']
Type of the first one: BaseTool <- a plain LangChain tool, not an MCP object
Question: How do I configure a Spring Boot Kafka batch consumer?
[model] chose search_docs({'query': 'Spring Boot Kafka batch consumer configuration'})
[tools] returned Kafka batch consumption: set spring.kafka.listener.type=batch to enable it., Kafka tuning: pair a batch consumer with max-poll-records to raise throughput.
[answer] Here's how to configure a Spring Boot Kafka batch consumer ...
One thing genuinely changes, and it is better met here than in production: MCP tools are async. The adapter builds them around an async client session, so the graph is driven with ainvoke and the model node is async def. That is not an MCP tax so much as the truth arriving: a tool that crosses a process boundary is I/O, and Section 25.9 at the end of this chapter is where the choice between async and a thread pool gets taken apart.
Full file: code/ch23_mcp_toolnode.py.
25.4 Into a LangGraph agent, via langchain-mcp-adapters
With the tools proven to be ordinary, the one-liner is the obvious next step rather than a different world. Let the model decide which tool to call, and let create_agent supply the loop you just wired by hand: it connects to one or more servers, loads their tools, and from there everything is Chapter 13’s agent.
import asyncio, os, sys
from langchain.agents import create_agent
from langchain_mcp_adapters.client import MultiServerMCPClient
SERVER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ch23_mcp_server.py")
llm = ChatOpenAI(...) # Chapter 10, A Real LLM's connection block, unchangedThe connection is a small config dict — one entry per server, naming the command that launches it and the transport. The same sys.executable / absolute-path discipline from the raw client applies, for the same reason.
async def main():
# (1) where the tools live; sys.executable for the same reason as in the raw client
1 client = MultiServerMCPClient(
{
"doc-bot-kb": {
"command": sys.executable,
"args": [SERVER],
"transport": "stdio",
}
}
)
# (2) load the server's tools as LangChain tools
tools = await client.get_tools()
print("Tools loaded from the MCP server:", [t.name for t in tools])
print("(Note: these come from ANOTHER process's server, not this file)\n")
# (3) the same one-liner as for in-process @tool functions
2 agent = create_agent(llm, tools)
for q in ["How do I configure a Spring Boot Kafka batch consumer?",
"What topics does the knowledge base cover?"]:
print(f"{'=' * 56}\nQuestion: {q}\n{'=' * 56}")
result = await agent.ainvoke({"messages": [("user", q)]})
for m in result["messages"]:
role = type(m).__name__.replace("Message", "")
if getattr(m, "tool_calls", None):
for tc in m.tool_calls:
print(f" [LLM] decided to call MCP tool -> {tc['name']}({tc['args']})")
elif role == "Tool":
# An MCP tool's ToolMessage.content is a LIST of content blocks,
# one per item, not a string; join their text before printing.
3 blocks = m.content if isinstance(m.content, list) else [m.content]
text = ", ".join(b["text"] if isinstance(b, dict) else str(b) for b in blocks)
print(f" [MCP tool returned] {text}")
elif role == "AI" and m.content:
print(f" [final answer] {excerpt(m.text)}")
print()
if __name__ == "__main__":
asyncio.run(main())- 1
- The only two lines new to the book: one says where the tools live, the other loads them across the process boundary as LangChain tools.
- 2
-
The one-liner from Section 13.5 (the
langchain.agentshome of whatlanggraph.prebuiltused to callcreate_react_agent; that section’s version note has the relationship). - 3
-
Where the boundary surfaced. The first version of this file printed
m.contentdirectly, as every earlier chapter could, and got the[{'type': 'text', ...list of content blocks that Section 25.8 takes apart.
One thing get_tools() does not give you is a lasting connection. Each tool it returns opens its own session when it is called, which under stdio means a fresh subprocess every time: call the same tool twice and the two runs report different PIDs. This server does not notice, because it is a stubbed dict that holds no state between calls — and most tools should be that. But if you find yourself expecting a server to remember something from one call to the next, that is the assumption to check first. Load the tools inside an explicit session (client.session(...) with load_mcp_tools) and put the agent run inside its scope; then the calls share one process.
The agent binds the tools to the model, the model emits tool calls, the agent runs them, feeds results back, and loops until the model answers. The MCP round trip happens inside “runs them.” The adapters hand the model a list of content blocks, which is what the model needs; anything in your code that reads a tool result as a string has to be taught the same lesson.
The output is one representative run (Chapter 12) — the wording and the query the model invents will differ on your machine. What is fixed is the sequence: the model chooses a tool, the MCP tool returns, the model answers from what it got (the [final answer] line is excerpt: first line, trimmed — Chapter 12):
Tools loaded from the MCP server: ['search_docs', 'list_topics']
(Note: these come from ANOTHER process's server, not this file)
========================================================
Question: How do I configure a Spring Boot Kafka batch consumer?
========================================================
[LLM] decided to call MCP tool -> search_docs({'query': 'Spring Boot Kafka batch consumer configuration'})
[MCP tool returned] Kafka batch consumption: set spring.kafka.listener.type=batch to enable it., Kafka tuning: pair a batch consumer with max-poll-records to raise throughput.
[final answer] Here's how to configure a Spring Boot Kafka batch consumer ...
========================================================
Question: What topics does the knowledge base cover?
========================================================
[LLM] decided to call MCP tool -> list_topics({})
[MCP tool returned] kafka, redis, mysql
[final answer] The knowledge base currently covers the following topics ...
The [MCP tool returned] line is the tool’s whole result — two snippets, joined — exactly as it crossed the boundary. The model read the question, decided on its own to call search_docs, invented the query string (nothing in our code told it to search for “Spring Boot Kafka batch consumer configuration”), the call went out over stdio to the other process, the snippet came back, and the model wrote its answer from that snippet. The second question drives the same loop to list_topics instead, because that tool’s description fits it better.
Everything above pins mcp==1.29.0, one major version behind. The SDK’s 2.0 line changed three things, and it is worth knowing which of them is real.
The server class was renamed. FastMCP became MCPServer, and moved from mcp.server.fastmcp to mcp.server.mcpserver. The old path is a stub that raises ModuleNotFoundError pointing at a migration guide, which is friendlier than the bare import error it replaced. The class itself is the same object under a new name: MCPServer("doc-bot-kb"), @mcp.tool(), mcp.run() — identical signatures, same stdio default. Porting this chapter’s server is two lines.
The Python fields went snake_case. inputSchema became input_schema, outputSchema became output_schema, isError became is_error, structuredContent became structured_content. Exactly one line of the raw client touches one of these — the discovery loop’s t.inputSchema — so porting the client is one line.
The protocol did not change at all. The renames are pydantic field names; the aliases still serialize to inputSchema and isError on the wire, because that is what the specification says. Which means the two majors interoperate: a 1.x client drives a 2.x server, and a 2.x client drives a 1.x server, with byte-identical output. That is the chapter’s whole claim: the process boundary really does isolate dependencies, right down to the major version of the library on the other side of the pipe.
So why not upgrade? Not caution — arithmetic. langchain-mcp-adapters declares mcp<2, and there is no release that lifts it. Without the adapters there is no MultiServerMCPClient, and without that there is no third section. Nor is the standalone fastmcp an escape hatch: it pulls mcp 2.x of its own, so it collides with the adapters just the same, and its server hands back a list[str] as one JSON blob in a single content block rather than one block per element — the very behaviour this chapter’s client reads.
The practical rule, then. Pin both packages: the resolution between them is load-bearing, and pip will paper over a conflict rather than report one — it downgrades the SDK underneath you and prints Successfully installed.
And read the scope of those three lines carefully, because it is narrower than it looks. They port the raw client and server, which depend on nothing but mcp itself. The two LangChain sections cannot come with them: the adapters declare mcp<2, so that environment stays on 1.x whatever you do to the rest. If you want to point this chapter’s agent at a 2.x server, the server needs an environment of its own — which means the connection config stops saying sys.executable and names that environment’s interpreter instead.
The two axes from Chapter 13 — packaging and orchestration — still apply, and MCP is a move on the first axis only. This chapter drove the same two MCP tools both ways on the second: the raw client chose list_topics in code; the agent let the model choose it. Reach for MCP to change who decides and you will change only where the code runs.
25.5 stdio or HTTP
Everything so far ran over stdio, and the spec is careful about what that choice does and does not mean: a transport is a binding, and “protocol semantics are identical on every transport.”3 Swapping it changes how the bytes travel. The tools, their schemas, the discovery handshake and everything this chapter showed stay exactly as they are.
stdio is what you have been using: the client launches the server as a child process and speaks newline-delimited JSON-RPC over its standard streams. It suits a tool that is a capability of the machine the client is on — the filesystem, a local index, a CLI you already trust. There is no port, no URL and no authentication story, because there is no network: the trust question is the one Section 25.6 asks instead.
The transport is the three file descriptors every process is born with. The client calls open_process, so the child’s stdin (fd 0) and stdout (fd 1) are pipe ends the parent holds, and stderr (fd 2) is handed somewhere else entirely. The server attaches to two of them and nothing more:
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))The TextIOWrapper re-wraps the raw binary stream to pin UTF-8, because Python’s default text encoding for the standard streams is platform-dependent (Windows especially). anyio.wrap_file then makes that ordinary blocking file awaitable, so writing to stdout cannot stall the event loop — which is why every read and write below is an await.
No socket, no port, no shared memory — full duplex assembled out of two one-way pipes. Framing is a newline, and sending is the whole of it:
await stdout.write(json + "\n")
await stdout.flush()Reading is the same idea plus the one complication a pipe forces:
lines = (buffer + chunk).split("\n")
buffer = lines.pop() # the tail may be half a message; keep itA pipe carries bytes, not messages. One read can land mid-message or bring three at once, so the reader holds the unterminated remainder back for the next chunk. One message is one line of UTF-8 JSON-RPC, with no length prefix — which is where this parts company with LSP and its Content-Length header. It can afford to be that simple because JSON escapes its own newlines, so a raw \n can only ever mean message ends here.
The same three descriptors are also the security model and the failure mode at this boundary. There is no authentication because there is no network: the client asked the OS for the process, and identity and lifetime came with it. And because fd 1 is the protocol, one stray print() in a server puts a line of non-JSON into the stream, which the client splits on and fails to parse. What happens next is the client’s business, not the protocol’s: on the pinned SDK the parse error is recorded and the call still returns, so the damage is a corrupted frame rather than a dead session — but it is corruption you did not intend and cannot rely on being survivable. That is why logging goes to stderr: fd 2 is the descriptor the client deliberately routed somewhere else.
Streamable HTTP is the other one. Each message is an HTTP POST to a single endpoint; the reply comes back as a JSON object, or as an SSE stream scoped to that one request when the server has more than one thing to say. It suits a server somebody else runs, that many clients share, and that outlives the process which called it — and it brings the whole HTTP apparatus with it, authentication and transport security included.
Porting is small on both sides. The server takes an argument:
mcp.run(transport="streamable-http") # "stdio" is the defaultand the client config swaps how-to-launch for where-to-find:
{"transport": "streamable_http", "url": "https://tools.internal/mcp"}Read those two lines again. The server wants "streamable-http" with a hyphen; langchain-mcp-adapters wants "streamable_http" with an underscore. They are the same transport and the strings are checked independently, on the pinned versions (mcp 1.29.0, adapters 0.3.2), so a name that works on one side is rejected on the other.
The pinned SDK also still offers a third value, "sse", from before Streamable HTTP absorbed the job. The package does not mark it deprecated, but the spec now lists two bindings, and SSE appears inside one of them as a way to deliver a reply rather than as a transport of its own. Reach for streamable-http.
25.6 Security
The connection config is a security decision wearing plumbing clothes:
{"command": sys.executable, "args": [SERVER], "transport": "stdio"}That is “run this program.” The client launches the server as a child process, under your user, with your filesystem access. A process boundary is not a trust boundary. It isolates crashes and dependencies; it grants no permissions and takes none away.
The environment is the one place the SDK does help. It does not hand the child yours: on a POSIX system stdio_client passes a six-name allow-list — HOME, LOGNAME, PATH, SHELL, TERM, USER — so an OPENAI_API_KEY in your shell does not cross on its own. (That default list is platform-specific; Windows inherits a different set, so treat “six names” as this platform’s default, not a cross-OS contract.) Anything else arrives only because you put it in env=, which makes that dictionary the thing to read twice.
For this chapter that is fine: the server is a file sitting next to the client, in the same repository. It stops being fine the moment the server is a package somebody else publishes and you update, which is exactly the workflow MCP is built to encourage. Installing a third-party MCP server is installing a dependency that executes, and pinning it is the least you can do. Then the same questions you would ask of any privileged subprocess: what is in the environment you hand it, what can it reach on the network, does it need write access to anything.
Remote transports move the code somewhere you do not control, which trades this problem for authentication and transport security — a different set of answers to the same question, and Section 33.3’s pre-launch list is where the rest of it lives.
25.7 Running it
Four files back this chapter, and you never launch the server yourself — each of the three clients spawns code/ch23_mcp_server.py as a child process over stdio:
code/ch23_mcp_client.py— the raw MCP client. Fully deterministic (no model), so its output is the same every run.code/ch23_mcp_toolnode.py— the same tools in the graph you wire yourself,model/ToolNode/ router.code/ch23_mcp_langchain.py— the same tools loaded into acreate_agentagent.
The last two call the model, so they need the local one from Chapter 12 running, and their output is representative, not always reproducible. Run any of the three; it starts ch23_mcp_server.py for you and talks to it over the pipe.
python code/ch23_mcp_client.py # no model needed
python code/ch23_mcp_toolnode.py
python code/ch23_mcp_langchain.pyOne warning you may see and can ignore: on the pinned mcp + pydantic-settings combination, importing the MCP stack prints an unresolved lifespan forward-reference warning from Pydantic. It is a version-combination artifact in a dependency, not a problem with your server or client. If it bothers you in a real deployment, pin a combination that doesn’t emit it, or filter that one warning the way Section 12.5 silenced the serializer notice.
25.8 What the boundary costs
Run this chapter’s agent with a naive print loop and the tool-result line comes out as [MCP tool returned] [{'type': 'text', 'text': 'Kafka batch consumption: set sp. The tool is declared to return list[str], and an in-process @tool with that signature hands the agent a list of strings. This one was called in another process, and what came back over the wire is a list of content blocks — with langchain-mcp-adapters 0.3.2 the ToolMessage.content is that list. That is not a bug: the value was serialized on one side of a process boundary and deserialized on the other, and the printing loop was written as if there were no boundary.
That is the cheap lesson. The expensive one is failure. An in-process @tool fails the way any Python function fails — an exception, a traceback, a retry policy from Chapter 21. Once the tool lives in another process the failures are of a different kind, and none looks like a Python error in your stack:
- The server does not start. Wrong interpreter, missing package, wrong path: the child exits and the client fails inside
initializewith anExceptionGroupfrom the task group that owned the pipe, two layers from the line that named the path.sys.executableand an absolute path remove the two commonest causes, not the class. - The channel is corrupted. Under stdio the server’s stdout is the protocol. A debugging
print()in a tool body puts bytes between MCP messages. Log to stderr. - A failure has three exits, one per layer. Over the raw
ClientSession.call_tool, alist[str]arrives as text blocks and a tool’s failure arrives as an ordinary result withisErrorset — it does not raise, so you checkisErroryourself. Throughlangchain-mcp-adapters0.3.2, theStructuredToolthe adapter builds reads thatisErrorand raises_MCPToolExecutionError— and then catches it itself: the tool ships with ahandle_tool_errorthat turns the failure into error content, so a directtool.invokereturns it andToolNodehands the model aToolMessage(status="error"). The exception reaches you only if you switch that handler off (tool.handle_tool_error = False), and then it propagates out ofToolNodelike any exception raised in a tool body (Chapter 13); nothing further down catches it for you. A transport or session failure — the child process gone, the pipe closed — is none of these: it raises from the client regardless of any handler. So checkisErrorwhen you hold a raw result, read the errorToolMessagewhen you go through the tools as shipped, and only expect an exception once you have asked for one. Reading a raw result as a plain string is what bit the printing loop above. - The toolset is a run-time fact. A server can be upgraded and its tools renamed without your process noticing until
list_tools()returns something new. Discovery is the feature; a tool vanishing between deployments is that feature from the other side.
The operational rule: treat an MCP tool the way Chapter 21 treated the network — a timeout, a retry policy, and a check of the result’s shape before the model reasons over it. The client file does the first of those on one call, because a rule the example does not follow is a rule the reader will not either:
async with asyncio.timeout(10):
r1 = await session.call_tool("list_topics", {})One call, and deliberately: this is the shape, not a budget. It leaves out the handshake, the discovery round trip, the other two calls and the agent’s path, and a service that means it puts a budget on the session as well as on each call.
Note what a timeout across a boundary you do not own can and cannot do: it abandons the wait, not the work. The child process may still be running, and if the tool had side effects they may still land — which is Chapter 21’s idempotency problem arriving through a different door.
25.9 The async state machine
This chapter’s run goes through the async door — await agent.ainvoke(...), because the MCP client speaks async and everything above it follows, which is Section 23.4’s rule met from the other side: the first async piece in a graph decides the door for every caller. The state machine did not change: same supersteps, same checkpoints, same reducers, and interrupt() pauses the same way. What changed is what the process does while a node waits, and Chapter 23 measured that: a leftover sync node is pushed onto an executor thread so the loop never stalls (Section 23.5), and invoke and ainvoke compute the same answer.
Which raises the classic question: threads or asyncio? At small concurrency they are equivalent and a migration isn’t worth it. At scale the difference shows up in four places:
| Threads | asyncio | |
|---|---|---|
| Overhead | An OS object with a megabyte-scale stack | A Python object on one loop. A thousand runs parked on a model is a thread count the machine notices and a task count the loop does not |
| Cancellation | A thread blocked in a socket read cannot be interrupted; a timeout abandons the result, not the work | A task cancels at its next await — “five seconds, then abort” is asyncio.wait_for around ainvoke, as Section 23.11 measured |
| Shared state | They interleave anywhere, so a counter two nodes touch needs a lock | They interleave only at await |
| Expressing a wait | “First of these three” and “at most 2 s” are a queue, a sentinel and a lock | done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) and await asyncio.wait_for(coro, 2) |
Two footnotes to the table. Row three is about the globals a node reaches for beside the graph — channels and reducers are safe either way. And row four is felt around graphs rather than inside one: a server holding a hundred streaming runs is the loop’s home game, which is why Chapter 22’s streaming endpoint was async def while its one-shot endpoint stayed a plain def.
The practical rule: stay sync until your SDK is async (this chapter) or you need to hold many concurrent, mostly-waiting runs (Section 23.7). Then switch the entry point and let the executor carry whatever sync nodes remain.
We now have tools behind a protocol, discovered and called across a process boundary — the last of the outward-facing production concerns. What is left is inward. The doc-bot has leaned on the checkpointer since Chapter 8, all of it resting on “the state is persisted” as a fact we accepted without opening. Chapter 26 — Inside Persistence opens it: the Checkpoints and Writes tables behind a thread, node-level fault tolerance when a superstep dies mid-flight, why the checkpoint ids are the timeline, and the version maps inside the checkpoint blob that decide what a resume re-runs.
25.10 The same shape elsewhere

“The client does not hardcode the tools” is late binding, and the industry has built this at least three times: gRPC server reflection asks a running server what methods it has, OpenAPI publishes a machine-readable description of an HTTP surface, and a generation earlier WSDL and UDDI did it with more XML. list_tools before call_tool is that handshake.
One difference changes what “good” means. Reflection and OpenAPI describe an interface to whoever asks — a programmer generating a stub, or a tool like grpcurl encoding a request on the fly. What changes in an agent harness is where the description lands: this chapter hands the tool list to a model, inside its prompt, on every turn — and the description is not documentation about the prompt, it is the prompt. A vague description is not a docs problem to fix later; it is a correctness problem that surfaces as the model reaching for the wrong tool.
New pieces in this chapter
| Piece | Usage |
|---|---|
| Type | |
FastMCP |
An MCP server you name and register tools on. mcp = FastMCP("doc-bot-kb"); the name is what a connecting client sees |
StdioServerParameters |
How to launch a stdio server: the command and its args. StdioServerParameters(command=sys.executable, args=[SERVER]); use sys.executable and absolute paths |
ClientSession |
An MCP session over a read/write channel. async with ClientSession(read, write) as session; every MCP operation goes through it |
MultiServerMCPClient |
The langchain-mcp-adapters client; a config dict of servers. MultiServerMCPClient({"doc-bot-kb": {"command": …, "transport": "stdio"}}); one entry per server |
| API | |
@mcp.tool() |
Decorator that registers an ordinary function as an MCP tool. @mcp.tool() above a typed, documented def; name/hints/docstring become the tool’s name/schema/description |
FastMCP.run() |
Start the server on a transport (stdio by default). mcp.run() under if __name__ == "__main__"; you almost never launch it — the client spawns it as a child |
stdio_client() |
Spawn the server child process and open the stdin/stdout channel. async with stdio_client(server_params) as (read, write); the pipe the MCP messages travel on |
ClientSession.initialize() |
The handshake that opens the session. await session.initialize(); step one, before discovery or any call |
ClientSession.list_tools() |
Discovery: ask the server what tools exist. tools = await session.list_tools(); the client hardcodes nothing — a new server tool appears for free |
ClientSession.call_tool() |
Run one tool by name with a dict of args. await session.call_tool("search_docs", {"query": …}); a list result comes back one content block per element |
MultiServerMCPClient.get_tools() |
Load the servers’ tools as LangChain tools. tools = await client.get_tools(); the agent cannot tell these from in-process @tool functions |
agent.ainvoke() |
The async twin of invoke() on the compiled agent. await agent.ainvoke({"messages": [("user", q)]}); the MCP client path is async, so the run is too |
Code in this chapter
| File | What it runs |
|---|---|
code/ch23_mcp_server.py |
A knowledge base wrapped as MCP tools, which any client can discover and call without knowing what is behind them. |
code/ch23_mcp_client.py |
The full round trip: launch the server over stdio, discover its tools, call one, read the content blocks back. |
code/ch23_mcp_toolnode.py |
The same MCP tools in a graph you wired yourself, between the raw client and the one-line agent. |
code/ch23_mcp_langchain.py |
The production shape: langchain-mcp-adapters loading the server’s tools into a LangGraph agent. |
Challenge
Both challenges are hands-on and self-verifying — you confirm each result against the server’s own output — with a worked solution beside each.
🥉 Bronze — a tool the client never heard of
Add a third tool to ch23_mcp_server.py — count_docs(), returning the total number of snippets across the knowledge base — and rerun ch23_mcp_client.py without editing a single line of the client. Predict first: how many tools will the discovery block print, and what will count_docs’s line look like given its name, its (empty) parameter list, and its docstring? Run it and check. Then call your new tool from the client with await session.call_tool("count_docs", {}) and confirm the number matches the stub. The client was never told about count_docs; the toolset is a run-time fact.
Worked solution with tests: code/answers/ch23_a_tool_the_client_never_heard_of.py.
🥈 Silver — same tools, two orchestrators
The Gotcha claimed the raw client and the agent drove the same MCP tools with opposite answers on the orchestration axis. Prove it with your hands. Take a single question — "What topics can you answer about?" — and get an answer two ways. First, you orchestrate: in the raw-client style, call list_topics() yourself and print the result; your code chose the tool. Second, let the model orchestrate: pass the same question to the create_agent agent and inspect result["messages"] to confirm the model chose list_topics on its own. Same server, same tool, same process boundary — the only thing that changed is who decided to call it. Name which axis stayed fixed (packaging) and which flipped (orchestration).
Worked solution with tests: code/answers/ch23_two_orchestrators.py.
Specification and ecosystem: https://modelcontextprotocol.io.↩︎
Two different things answer to this name.
FastMCP1.0’s core was contributed to the official MCP Python SDK, which is why it imports frommcp.server.fastmcphere; the standalone project kept going on its own and is now at 4.x (pip install fastmcp, https://gofastmcp.com), a different package with a different API. The SDK, meanwhile, renamed this class in its 2.0 line — why the pin below says 1.x is the Deep Dive later in this chapter.↩︎https://modelcontextprotocol.io/docs/concepts/transports. The current revision defines two standard bindings, stdio and Streamable HTTP.↩︎