← Back to Agentic AI map
Lesson 4.4 · Model Context Protocol (MCP)

Connecting an Agent to an MCP Server

Give the LangGraph agent from Lesson 3.5 tools that live on an MCP server. Write a 20-line adapter, watch llama3 fail four different ways, and fix each one - then compare with LangChain’s official adapter.

mcp

What you will be able to do

  • Explain what changes when an agent’s tools live on an MCP server - and what does not
  • Turn MCP tools into LangChain tools with a small adapter
  • Plug them into a LangGraph agent with ToolNode
  • Keep the MCP connection open for as long as the agent runs
  • Make a small local model use the tools reliably: clear descriptions, forgiving parsing, a repeat guard
  • Know LangChain’s official adapter, and its version limit

The idea, in plain English

In Lesson 3.5 you built a LangGraph agent: an agent node that chooses a tool, ToolNode that runs it, and an edge back. The tools were Python functions in the same file. In Lessons 4.1 to 4.3 you built MCP servers. Now we join the two: the same agent, but its tools live on an MCP server.

The good news: the graph does not change. ToolNode needs LangChain tools. So we write a small adapter that asks the server for its tools (tools/list) and wraps each one as a LangChain tool whose job is to send tools/call. From the graph’s point of view, nothing is different.

The interesting part is the model. Our machine has llama3, which cannot make native tool calls in Ollama (we checked again: "llama3:latest does not support tools", and the same for gemma3). So, as in Lesson 3.5, llama3 chooses the next step as structured output, and our agent node turns that into a real tool call. On the way we hit four real problems - and each one teaches something about small models and tools.

Versions: mcp 2.3.0, LangGraph 1.2.14, langchain-core 1.6.9, llama3 on Ollama. A model with native tool calling (such as llama3.1) can use bind_tools instead of our structured-output step; the adapter and the graph stay the same.

Worked example: A LangGraph agent asks the MCP time server for the time in Tokyo, in India and Paris, and on Mars.

workflowA LangGraph agent with MCP toolsstep 1 / 4

1 - The agent asks for India

llama3 chooses get_current_time with {"timezone": "Asia/Kolkata"}. The agent node turns this into a tool call.

action
get_current_time
arguments
Asia/Kolkata
tool lives
on the MCP server
graph changed?
no

A real run: "What time is it in India and in Paris?" Two tool calls, each one an MCP tools/call to the time server. 5.7 seconds.

Words you will see in this lesson

Most words are from Lessons 3.5 and 4.3. Two are new.

Small dictionary
AdapterA small piece of code that makes one thing fit another - here, MCP tools into LangChain tools.
StructuredToolLangChain’s tool class with named arguments. ToolNode runs these.
async withPython’s way to open something (a connection) and close it at the end of the block, even after an error.
Structured outputAsking the model to answer in a fixed shape (Lesson 3.5) - here: action + arguments.
Native tool callingA model that can emit tool calls itself (bind_tools). llama3 in Ollama cannot.

An everyday example: a phone and a call centre

In Lesson 3.5 the agent’s tools were like colleagues at the next desk: you lean over and ask. Now the tools are in a call centre in another building. You do the same job - ask a question, get an answer - but through a phone line.

The adapter is the phone: it turns "ask the colleague" into "call the centre". The phone line must stay connected while you work; hang up, and nobody answers. That is the MCP connection, kept open with async with.

Step 1 - the adapter: MCP tools become LangChain tools

The adapter asks the server for its tools and makes one LangChain StructuredTool for each. The name, the description and the argument schema come straight from the server - we copy nothing by hand. When the agent uses the tool, the tool sends tools/call and returns the text. If the server says isError, we start the text with "ERROR:" so the model can see something went wrong.

One Python detail: _name=t.name in the inner function. Without it, every tool would call the LAST tool’s name, because the inner function would read t only when it runs. The default value freezes the name at the moment each tool is made.

Example 1 - mcp_tools.py
from langchain_core.tools import StructuredTool from mcp import Client async def load_mcp_tools(client: Client) -> list[StructuredTool]: """Turn every tool on an MCP server into a LangChain tool.""" tools = [] for t in (await client.list_tools()).tools: async def call(_name=t.name, **arguments): # runs when the agent uses the tool result = await client.call_tool(_name, arguments) # MCP tools/call text = "\n".join(c.text for c in result.content if c.type == "text") return f"ERROR: {text}" if result.is_error else text tools.append(StructuredTool.from_function( coroutine=call, name=t.name, # same name as on the server description=t.description or "", # the server's docstring args_schema=t.input_schema, # the server's JSON schema, used as-is )) return tools
Test it alone first - step1.py
import asyncio, sys from mcp import Client, StdioServerParameters from mcp_tools import load_mcp_tools async def main(): server = StdioServerParameters(command=sys.executable, args=["time_server.py"]) async with Client(server) as client: tools = await load_mcp_tools(client) t = tools[0] print("name:", t.name) print("description:", t.description) print("args:", t.args) print("ainvoke Asia/Kolkata ->", await t.ainvoke({"timezone": "Asia/Kolkata"})) print("ainvoke {} (default) ->", await t.ainvoke({})) print("ainvoke Mars/Olympus ->", await t.ainvoke({"timezone": "Mars/Olympus"})) asyncio.run(main())
Output
name: get_current_time description: Return the current date and time in an IANA timezone, such as Asia/Kolkata. args: {'timezone': {'default': 'UTC', 'title': 'Timezone', 'type': 'string'}} ainvoke Asia/Kolkata -> 2026-10-09 19:33:58 IST ainvoke {} (default) -> 2026-10-09 14:03:58 UTC ainvoke Mars/Olympus -> ERROR: Error executing tool get_current_time: Unknown timezone 'Mars/Olympus'. Use an IANA name like Asia/Kolkata or Europe/Paris.

Tip: time_server.py here is the Lesson 4.3 server with the ToolError from Step 6: an unknown timezone gives "Unknown timezone ... Use an IANA name like Asia/Kolkata". That message matters later - the model reads it.

Step 2 - keep the connection open

The tools only work while the MCP connection is open. Everything that uses them - building the graph and running it - must happen inside async with Client(...). We tested calling a tool after the block ended: it raised RuntimeError: "Client must be used within an async context manager".

This also means the graph must be run with await graph.ainvoke(...) (or astream), because the tools are async. Lesson 3.9 used astream the same way.

Output - a tool used inside and outside the block
inside: 2026-10-09 13:56:55 UTC outside: RuntimeError: Client must be used within an async context manager

Step 3 - the agent, and four real problems

The graph is Lesson 3.5’s: agent -> ToolNode -> agent, with tools_condition. The agent node asks llama3 for a Step: an action (a tool name or final_answer) and arguments (a JSON object as text). The list of allowed actions is built from the server’s tools, so a new tool on the server appears in the agent automatically.

Our first version did not work. Here are the four problems we hit, in order - each one is a common trap.

Problem 1: we showed llama3 the raw JSON schema of each tool. llama3 copied the schema’s shape into its answer: {"timezone": {"default": "Asia/Tokyo", "title": "Timezone", "type": "string"}}. The server rejected it, and llama3 sent the same thing again until recursion_limit stopped the run. Fix: describe tools in plain words, and show the exact JSON shape to send: {"timezone": "..."}.

Problem 2: llama3 then sent the arguments as plain text - Asia/Tokyo instead of {"timezone": "Asia/Tokyo"}. Our code could not read it as JSON and silently used {} instead. The tool used its default, UTC, and llama3 kept asking. A silent fallback hid the real problem. Fix: when a tool has one argument, plain text IS that argument; when it has none, send {}.

Problem 3: with correct answers coming back, llama3 still asked for Tokyo again and again. Lesson 3.5 saw the same repeats. Fix: a rule in the prompt ("Never repeat an action that already has a result") plus a guard in code - if the same call with the same arguments was already made, ask the model for a final answer instead.

Problem 4: for Paris, llama3 once wrote {'timezone': 'Europe/Paris'} - with single quotes. That is a Python dict, not JSON. Fix: try json.loads, then ast.literal_eval.

Problem 1 - raw schema in the prompt (first run, shortened)
What time is it in Tokyo? ai get_current_time {'timezone': {'default': 'Asia/Tokyo', 'title': 'Timezone', 'type': 'string'}} tool ERROR: Error executing tool get_current_time: 1 validation error for get_current_timeArguments timezone Input should be a valid string [type=string_type, input_value={'default': 'Asia/Tokyo',... ai get_current_time {'timezone': {'default': 'Asia/Tokyo', 'title': 'Timezone', 'type': 'string'}} ... the same call again and again, until GraphRecursionError: Recursion limit of 12 reached
Problems 2 and 3 - what llama3 really sent, and the repeats
raw step: 'get_current_time' 'Asia/Tokyo' <- plain text, not a JSON object before the fix (silent {} fallback): after the fix, before the repeat guard: agent | ('get_current_time', {}) agent | ('get_current_time', {'timezone': 'Asia/Tokyo'}) tools | 2026-10-09 13:54:37 UTC tools | 2026-10-09 22:55:25 JST agent | ('get_current_time', {}) agent | ('get_current_time', {'timezone': 'Asia/Tokyo'}) tools | 2026-10-09 13:54:39 UTC tools | 2026-10-09 22:55:26 JST ... ...

Example 2 - the working agent

Here is the final agent with all four fixes. Read parse_arguments, describe and the repeat guard - those are the parts a small model needs. The graph at the bottom is exactly Lesson 3.5’s.

Example 2 - agent.py
import ast, asyncio, json, sys, time, uuid from typing import Literal from langchain_core.messages import AIMessage, HumanMessage from langchain_ollama import ChatOllama from langgraph.graph import END, START, MessagesState, StateGraph from langgraph.prebuilt import ToolNode, tools_condition from mcp import Client, StdioServerParameters from pydantic import BaseModel, Field, create_model from mcp_tools import load_mcp_tools def parse_arguments(text, tool): """llama3 sends {"timezone": "Asia/Tokyo"}, or {'timezone': 'Asia/Tokyo'}, or just Asia/Tokyo.""" for read in (json.loads, ast.literal_eval): # JSON, then Python-style {'a': 'b'} try: value = read(text) if isinstance(value, dict): return value except (ValueError, SyntaxError): pass if not tool.args: # no arguments at all return {} if len(tool.args) == 1: # one argument: the text IS its value return {next(iter(tool.args)): text} return {"_unparsed": text} # let the server reject it with a clear error def build_graph(tools): names = [t.name for t in tools] # The choices come from the MCP server: its tool names, plus "final_answer". Step = create_model("Step", action=(Literal[tuple(names + ["final_answer"])], ...), arguments=(str, Field(description='JSON object with the tool arguments, e.g. {"timezone": "Asia/Kolkata"}; for final_answer: the answer text'))) decide = ChatOllama(model="llama3", temperature=0).with_structured_output(Step) def describe(t): # plain words, not the raw JSON schema shape = json.dumps({name: "..." for name in t.args}) # e.g. {"name": "...", "text": "..."} return f"- {t.name}: {t.description} Arguments as JSON: {shape}" tool_list = "\n".join(describe(t) for t in tools) system = ("You answer questions using these tools:\n" + tool_list + "\nChoose ONE next action. Use a tool for each piece of information you do not have yet. " "Never repeat an action that already has a result. " "When the results contain everything needed, choose final_answer.") answer_only = ChatOllama(model="llama3", temperature=0) by_name = {t.name: t for t in tools} def agent(state: MessagesState): transcript, done = [], set() # the story so far, and calls already made for m in state["messages"]: if m.type == "human": transcript.append(f"Question: {m.content}") elif m.type == "ai" and m.tool_calls: call = m.tool_calls[0] transcript.append(f"Action: {call['name']}({json.dumps(call['args'])})") done.add((call["name"], json.dumps(call["args"], sort_keys=True))) elif m.type == "tool": transcript.append(f"Result: {m.content}") step = decide.invoke([("system", system), ("human", "\n".join(transcript))]) if step.action != "final_answer": args = parse_arguments(step.arguments, by_name[step.action]) if (step.action, json.dumps(args, sort_keys=True)) in done: # Guard: the model asked again for a result it already has - make it answer. reply = answer_only.invoke("\n".join(transcript) + "\nAnswer the question in one sentence using the results.") return {"messages": [AIMessage(content=reply.content)]} if step.action == "final_answer": return {"messages": [AIMessage(content=step.arguments)]} call = {"name": step.action, "args": args, "id": f"call_{uuid.uuid4().hex[:8]}"} return {"messages": [AIMessage(content="", tool_calls=[call])]} # a real tool call for ToolNode builder = StateGraph(MessagesState) builder.add_node("agent", agent) builder.add_node("tools", ToolNode(tools)) # runs the MCP-backed tools builder.add_edge(START, "agent") builder.add_conditional_edges("agent", tools_condition) builder.add_edge("tools", "agent") return builder.compile() async def main(): server = StdioServerParameters(command=sys.executable, args=["time_server.py"]) async with Client(server) as client: # the MCP connection stays open... graph = build_graph(await load_mcp_tools(client)) for question in sys.argv[1:]: start = time.perf_counter() result = await graph.ainvoke({"messages": [HumanMessage(question)]}, {"recursion_limit": 12}) print(f"\n{question} ({time.perf_counter() - start:.1f} s)") for m in result["messages"][1:]: if m.type == "ai" and m.tool_calls: print(" ai ", m.tool_calls[0]["name"], m.tool_calls[0]["args"]) elif m.type == "tool": print(" tool ", m.content) else: print(" ai ", m.content) # ...and closes here if __name__ == "__main__": asyncio.run(main())
Output - python agent.py "What time is it in Tokyo?" "...India and in Paris?" "...on Mars?"
What time is it in Tokyo? (5.0 s) ai get_current_time {'timezone': 'Asia/Tokyo'} tool 2026-10-09 23:04:01 JST ai According to the current time in Tokyo, it is October 9th, 2026, at 11:04 PM JST. What time is it in India and in Paris? (5.7 s) ai get_current_time {'timezone': 'Asia/Kolkata'} tool 2026-10-09 19:34:05 IST ai get_current_time {'timezone': 'Europe/Paris'} tool 2026-10-09 16:04:07 CEST ai It is 2026-10-09 19:34:05 IST in India and 2026-10-09 16:04:07 CEST in Paris. What time is it on Mars? (8.8 s) ai get_current_time {'timezone': 'Eureka Dinsmore'} tool ERROR: Error executing tool get_current_time: Unknown timezone 'Eureka Dinsmore'. Use an IANA name like Asia/Kolkata or Europe/Paris. ai get_current_time {'timezone': 'Mars'} tool ERROR: Error executing tool get_current_time: Unknown timezone 'Mars'. Use an IANA name like Asia/Kolkata or Europe/Paris. ai get_current_time {'timezone': 'Asia/Kolkata'} tool 2026-10-09 19:34:14 IST ai It is not possible to determine the current time on Mars using the provided tool, as the tool only recognizes IANA time zone names and "Mars" is not a recognized time zone.

Tip: The Mars question shows why clear tool errors matter. The server said "Unknown timezone ... Use an IANA name", llama3 read it, tried other names, and finally answered honestly instead of inventing a time.

LangChain’s official adapter - and its version limit

LangChain has an official package for this job: langchain-mcp-adapters. Its MultiServerMCPClient takes a dict of servers and gives you LangChain tools - the same thing our adapter does, with more features (several servers, resources, HTTP).

There is one catch today. When we installed langchain-mcp-adapters 0.3.2, pip DOWNGRADED our MCP SDK from 2.3.0 to 1.30.0. The package requires mcp<2.0.0. So we put it in its own virtual environment, and pointed it at our SDK-2 server running in the other one.

It worked. A client built on SDK 1.30 used a server built on SDK 2.3.0 without problems - they agreed on an older protocol version. The SDK version of the client and the server do not need to match; only the protocol must overlap.

Example 3 - official_adapter.py (in a venv with langchain-mcp-adapters 0.3.2 and mcp 1.30.0)
import asyncio, sys from langchain_mcp_adapters.client import MultiServerMCPClient SERVER_PYTHON = sys.argv[1] # the Python that has mcp 2.3.0 installed async def main(): client = MultiServerMCPClient({ "time": {"command": SERVER_PYTHON, "args": ["time_server.py"], "transport": "stdio"}, }) tools = await client.get_tools() for t in tools: print("tool:", t.name, "|", t.description) print("call ->", await tools[0].ainvoke({"timezone": "Asia/Kolkata"})) print("bad ->", await tools[0].ainvoke({"timezone": "Mars"})) asyncio.run(main())
Output
$ pip install langchain-mcp-adapters # in the main venv: mcp 2.3.0 -> 1.30.0 (requires mcp<2.0.0) tool: get_current_time | Return the current date and time in an IANA timezone, such as Asia/Kolkata. call -> [{'type': 'text', 'text': '2026-10-09 19:26:47 IST', 'id': 'lc_869a21e4-7093-4cb4-87ea-9f9503f5e4b5'}] bad -> [{'type': 'text', 'text': "Error executing tool get_current_time: Unknown timezone 'Mars'. Use an IANA name like Asia/Kolkata or Europe/Paris.", 'id': 'lc_5fe01faf-9f15-4e02-802a-d901c5a3cc4e'}]

Watch out: Notice that the official adapter returned the error as a normal result, without an "ERROR:" marker. Our adapter adds one. Either way, make sure the model can tell a failed tool from a successful one.

Which adapter should you use?

Our 20-line adapter is good for learning and for small projects: you see every step, and it works with SDK 2. The official adapter saves work when you have several servers or need resources and HTTP - check its MCP version requirement first, and keep it in its own environment if it clashes with your server code.

Our adapter or the official one?
Learning how it worksOur adapter - every line is visible.
MCP SDK 2 in the same environmentOur adapter (today the official one needs mcp<2).
Several servers, HTTP, resourcesThe official MultiServerMCPClient.
A model with native tool callingEither one - then use bind_tools instead of our Step model.

Agent + MCP at a glance

Connect

Start the server and keep the connection open.

async with Client(StdioServerParameters(...)) as client:
Load tools

MCP tools -> LangChain tools.

tools = await load_mcp_tools(client)
Graph

Lesson 3.5, unchanged.

builder.add_node("tools", ToolNode(tools))
Run

The tools are async.

await graph.ainvoke(inputs, {"recursion_limit": 12})
Official adapter

Needs mcp<2 today (0.3.2).

MultiServerMCPClient({...}).get_tools()

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Raw schema

“Change describe() back to showing json.dumps(t.args). Run "What time is it in Tokyo?" and watch Problem 1 happen.”

Silent fallback

“Make parse_arguments return {} for anything that is not JSON. Which wrong answer do you get, and why is it hard to notice?”

A second tool

“Add convert_time to your time server (Lesson 4.3 Try it). Do not change agent.py. Ask "If it is 9:00 in Kolkata, what time is it in Paris?"”

Remove the guard

“Remove the repeat guard and ask about Tokyo. How many calls before recursion_limit stops it?”

What usually goes wrong

Showing a small model the raw JSON schema

llama3 copied the schema’s structure into its arguments. Describe tools in plain words and show the exact JSON shape to send.

✗ f"Arguments: {json.dumps(t.args)}"     # {"timezone": {"default": "UTC", "title": ..., "type": ...}}
✓ f"Arguments as JSON: {json.dumps({n: '...' for n in t.args})}"   # {"timezone": "..."}
A silent fallback

Turning unreadable arguments into {} made the tool answer for UTC - a wrong answer with no error. Make the fallback do something sensible, or let the server reject it loudly.

Using the tools after the connection closed

Everything that calls the tools must run inside async with Client(...).

✗ async with Client(p) as c:
    tools = await load_mcp_tools(c)
await graph.ainvoke(...)     # RuntimeError
✓ async with Client(p) as c:
    tools = await load_mcp_tools(c)
    await graph.ainvoke(...)
Installing the official adapter into your SDK-2 environment

langchain-mcp-adapters 0.3.2 requires mcp<2.0.0 and pip downgrades it without asking. Keep it in its own venv, or check its requirements first.

Freezing the wrong tool name

An inner function that reads t.name when it runs gets the last tool’s name. Freeze it with a default argument.

✗ async def call(**arguments): await client.call_tool(t.name, arguments)
✓ async def call(_name=t.name, **arguments): await client.call_tool(_name, arguments)

Key points

  • An agent with MCP tools is the same graph - only the tools come from a server.
  • An adapter turns each MCP tool into a LangChain tool: name, description and schema come from the server.
  • All tool use must happen inside async with Client(...); run the graph with ainvoke.
  • Small models need help: plain descriptions, the exact JSON shape, forgiving parsing and a repeat guard.
  • Never hide bad arguments behind a silent default - it creates wrong answers without errors.
  • Clear tool errors from the server let the model correct itself.
  • langchain-mcp-adapters 0.3.2 needs mcp<2; client and server SDK versions do not have to match.

Quick check before you move on

What changes in the LangGraph graph when tools move to an MCP server?
Nothing. ToolNode still gets LangChain tools; the adapter makes them send tools/call to the server.
Why must the graph run inside async with Client(...)?
The tools use the open MCP connection. After the block closes, calling a tool raises RuntimeError.
What went wrong when llama3 saw the raw JSON schema?
It copied the schema’s structure ({"default": ..., "title": ..., "type": ...}) as the argument value.
Do the client and server need the same MCP SDK version?
No. Our SDK 1.30 client used our SDK 2.3.0 server; only the protocol version must overlap.

Quiz

  1. 1.

    llama3 sends "Asia/Tokyo" instead of {"timezone": "Asia/Tokyo"}. What does parse_arguments do?

  2. 2.

    Why did the repeat guard ask a plain model for the answer instead of calling the tool again?

  3. 3.

    What happened to the mcp package when we installed langchain-mcp-adapters 0.3.2?

  4. 4.

    How did the agent answer "What time is it on Mars?"

Interview questions

How do you give a LangGraph agent tools from an MCP server?

Open an MCP client session, list the server’s tools, wrap each as a LangChain tool whose execution sends tools/call (or use langchain-mcp-adapters), and pass them to ToolNode or bind_tools. Keep the session open for the agent’s lifetime and run the graph asynchronously.

What extra care does a small local model need with tools?

Short plain-language tool descriptions with an example argument shape, tolerant parsing of its output, guards against repeated identical calls, a recursion limit, and tool errors that say how to fix the input.

What are the risks of an adapter layer?

Silently swallowing errors or bad arguments, losing the isError signal, stale connections, and dependency conflicts - as with an adapter pinned to an older SDK major version.

Comments

Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.

Loading comments...