MCP: Connecting Agents to External Servers
Every ability Aria has so far — checking the inbox, searching the web — came from a tool you wrote, by hand, in your own codebase. That's fine for a handful of tools. But what happens when you want Aria to use a capability that someone else has already built — a company's internal system, a public service, a tool maintained by a completely different team? Rewriting all of that yourself, every time, doesn't scale.
This is exactly the problem the Model Context Protocol (MCP) solves. In this article, you'll build a tiny MCP server of your own, connect Aria to it, and then connect her to a public MCP server you didn't write a single line of — using the exact same connection code both times.
🟡 Skill level: Intermediate.
Quick Reference
When to use this: Whenever you want an agent to use tools that live outside your own codebase — your own internal services, or services built and maintained by someone else entirely.
Basic syntax:
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"my_server": {
"transport": "stdio",
"command": "python",
"args": ["my_mcp_server.py"],
}
})
tools = await client.get_tools()
agent = create_agent(model="gpt-5-nano", tools=tools)
Common patterns:
- MCP servers expose tools (and optionally resources and prompts) using a standard protocol
MultiServerMCPClientcan connect to multiple servers at once, local or remote- Tools fetched from an MCP server are used by the agent exactly the same way as the
@tool-decorated functions from Article 2
Gotchas:
- ⚠️ MCP client operations are asynchronous — you'll need
async/awaitandasyncio.run(...)in a standalone script, since plain scripts can't use top-levelawaitthe way Jupyter notebooks can. - ⚠️
"stdio"transport means a local process, not a remote server — don't confuse it with a network connection.
See also: Tool Calling: Giving Agents Abilities
What You Need to Know First
- Everything from Articles 1–5, especially tool calling from Article 2
- Basic familiarity with Python's
async/awaitsyntax is helpful but not required — we'll explain what's needed as we go - uv installed (we'll also use its
uvxcommand in this article)
What We'll Cover in This Article
- What MCP is and why it exists
- How to build a small MCP server of your own
- How to connect an agent to your own server
- How to connect an agent to a public MCP server someone else built
What We'll Explain Along the Way
- Client/server, in this specific context
- What "transport" means for an MCP connection
- Why standalone scripts need
asyncio.run(...)where notebooks don't
What Is MCP, and Why Does It Exist?
Think back to before USB-C became standard: every device had its own proprietary charging cable. Want to charge five different gadgets? You needed five different cables, because every manufacturer built their own plug shape. USB-C fixed this by defining one standard connector that any compliant device can use — manufacturers build to the standard once, and everything just plugs in.
MCP (Model Context Protocol) does the same thing for AI agents and external tools. Before MCP, connecting an agent to an external service meant writing custom integration code for that specific service, every single time — exactly like the proprietary-cable problem. MCP defines one standard way for a "server" (something exposing tools, data, or prompt templates) to talk to a "client" (your agent's connection to that server). Build a server once, following the standard, and any MCP-compatible agent can connect to it — yours or someone else's.
Diagram: One client connection method works the same way regardless of who built the server on the other end — your own, a public one, or an internal company one.
The "client" here is MultiServerMCPClient, from the langchain-mcp-adapters package — it's the thing that knows how to speak the MCP standard and fetch tools from any compliant server, local or remote.
Building a Tiny MCP Server
Let's build a small MCP server for Aria — a stand-in for an internal company directory she can look people up in. This would normally be a real internal system; we're using a small fixed dictionary to keep focus on the MCP mechanics themselves.
Save this as its own file, directory_server.py:
# Purpose: A minimal MCP server exposing one tool: looking up a colleague
# Context: Stands in for a real internal company system Aria could connect to
# Input: (runs as a standalone server process, no direct input here)
# Output: Serves tool requests over stdio when an MCP client connects
from mcp.server.fastmcp import FastMCP
# Create the MCP server, giving it a name
mcp = FastMCP("directory_server")
# A tiny in-memory stand-in for a real internal directory lookup
COLLEAGUES = {
"jane": "jane@example.com, Marketing",
"tom": "tom@example.com, Engineering",
}
@mcp.tool()
def lookup_colleague(name: str) -> str:
"""Look up a colleague's email and department by their first name."""
result = COLLEAGUES.get(name.lower())
if result is None:
return f"No colleague found with the name '{name}'."
return result
if __name__ == "__main__":
# "stdio" means this server runs as a local process, communicating
# with its client over standard input/output — not over a network.
mcp.run(transport="stdio")
Notice @mcp.tool() looks almost identical to the @tool decorator from Article 2 — same idea, same reliance on a clear docstring, just registered with an MCP server instead of handed directly to an agent.
Connecting to Your Local Server
Now let's connect Aria to this server. Since talking to an MCP server involves real I/O (starting a process, communicating with it), this has to happen asynchronously — which is the one new piece of Python syntax in this article.
# Purpose: Connect to the local MCP server and use its tool through an agent
# Context: First time Aria gets a capability from outside her own codebase
# Input: A question that requires looking up a colleague
# Output: An answer using real data from the MCP server
import asyncio
from dotenv import load_dotenv
load_dotenv()
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain.agents import create_agent
from langchain.messages import HumanMessage
async def main():
# Step 1: Configure the client to start our server as a local process
client = MultiServerMCPClient({
"directory_server": {
"transport": "stdio",
"command": "python",
"args": ["directory_server.py"],
}
})
# Step 2: Fetch the tools this server exposes — this starts the server
# process and asks it what tools are available
tools = await client.get_tools()
# Step 3: Build an agent with those tools, exactly like Article 2
agent = create_agent(model="gpt-5-nano", tools=tools)
# Step 4: Ask a question that requires the tool
question = HumanMessage(content="What's Jane's email and department?")
response = await agent.ainvoke({"messages": [question]})
print(response["messages"][-1].content)
# asyncio.run(...) is what lets us use "await" in a regular script.
# (Jupyter notebooks allow "await" directly at the top level; plain
# Python scripts need this wrapper instead.)
asyncio.run(main())
Notice we used agent.ainvoke(...) instead of agent.invoke(...) — the a prefix means the async version, which we need here since we're already inside an async def main(): function. Other than that, this should feel completely familiar: fetch tools, hand them to create_agent, ask a question. The only real difference from Article 2 is where the tool came from.
Connecting to a Server You Didn't Write
Here's where MCP actually proves its value: let's connect to mcp-server-time, a small, free, publicly available MCP server that tells you the current time in a given timezone — built and maintained by someone else entirely. We'll run it using uvx, a tool (included with uv) that runs a Python package without you having to install it into your project first.
# Purpose: Connect to a public MCP server, written by someone else, with
# the exact same connection code used for our own server
# Context: Demonstrates the actual point of a standard protocol
# Input: A simple time-related question
# Output: The current time, fetched from a real external server
import asyncio
from dotenv import load_dotenv
load_dotenv()
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain.agents import create_agent
from langchain.messages import HumanMessage
async def main():
client = MultiServerMCPClient({
"time": {
"transport": "stdio",
"command": "uvx",
"args": ["mcp-server-time", "--local-timezone=America/New_York"],
}
})
tools = await client.get_tools()
agent = create_agent(model="gpt-5-nano", tools=tools)
question = HumanMessage(content="What time is it?")
response = await agent.ainvoke({"messages": [question]})
print(response["messages"][-1].content)
asyncio.run(main())
Compare this to the previous example: the only thing that changed is the command and args — uvx mcp-server-time instead of python directory_server.py. The connection pattern, the client.get_tools() call, the way the agent uses the resulting tools — all identical. That consistency, regardless of who built the server, is the entire point of MCP.
💡 MCP servers can also expose resources (read-only data, like a file) and prompts (reusable prompt templates), not just tools. We're not covering those in depth here to stay focused on the core connection pattern, but they exist if you want to explore the official MCP documentation.
Common Misconceptions
❌ Misconception: "stdio" transport means the server is running remotely
Reality: "stdio" (standard input/output) means the server runs as a local process on your own machine, communicating with the client through your terminal's input/output streams — it's not a network connection at all.
Why this matters: A genuinely remote server (one running on someone else's infrastructure, reached over the internet) uses a different transport, like "streamable_http", with a URL instead of a command. Confusing the two will lead to wrong assumptions about where your data and processing actually happen.
Example:
# "stdio" — a local process you (or uvx) are running on your own machine
{"transport": "stdio", "command": "uvx", "args": ["mcp-server-time"]}
# "streamable_http" — a genuinely remote server, reached over the network
{"transport": "streamable_http", "url": "https://example.com/mcp"}
❌ Misconception: MCP tools work fundamentally differently from @tool functions
Reality: Once fetched via client.get_tools(), MCP tools are used by the agent in exactly the same way as the @tool-decorated functions from Article 2 — same tools=[...] list, same decision-making process about when to call them.
Why this matters: MCP changes where the tool's code lives and who maintains it, not how the agent decides to use it. Everything you learned about tool calling in Article 2 still applies directly here.
Troubleshooting Common Issues
Problem: RuntimeWarning: coroutine was never awaited or similar async errors
Symptoms: Code involving client.get_tools() fails or warns about coroutines, especially outside a Jupyter notebook.
Common Causes:
- Calling
await client.get_tools()outside of anasync deffunction in a plain script (most common) - Forgetting to wrap your top-level call in
asyncio.run(...)
Diagnostic Steps:
# ❌ This fails in a plain .py script — no top-level await allowed
tools = await client.get_tools()
# ✅ This works — wrapped in an async function and run with asyncio.run
async def main():
tools = await client.get_tools()
asyncio.run(main())
Solution: Wrap any code using await inside an async def function, and call that function with asyncio.run(...) at the bottom of your script.
Prevention: If you're working in a plain .py file (not a Jupyter notebook), assume you'll need this async def main(): ... / asyncio.run(main()) structure any time you see await in MCP-related code.
Problem: FileNotFoundError or "command not found" when connecting to the local server
Symptoms: The client fails to start, mentioning it can't find python or uvx.
Common Causes:
directory_server.pyisn't in the same folder you're running your script from (for the local server example)uv/uvxisn't installed or isn't on your system's PATH (for the public server example)
Solution: Use the correct relative or absolute path to directory_server.py in args, and confirm uvx --version works in your terminal before troubleshooting further.
Check Your Understanding
Quick Quiz
-
What problem does MCP actually solve?
Show Answer
It standardizes how agents connect to external tools and services, so a server only needs to be built once (by anyone) and any MCP-compatible agent can connect to it, without custom integration code for each individual service.
-
What's the difference between
"stdio"and"streamable_http"transport?Show Answer
"stdio"runs the server as a local process on your own machine."streamable_http"connects to a genuinely remote server over the network, using a URL instead of a local command. -
Once you've fetched tools from an MCP server with
client.get_tools(), how do they differ from the@toolfunctions in Article 2?Show Answer
From the agent's perspective, they don't — they're used identically, passed into the same
tools=[...]list, and the agent applies the same relevance judgment to decide when to call them. The difference is purely about where the underlying code lives and who maintains it.
Hands-On Exercise
Challenge: Add a second tool to directory_server.py — list_departments() -> str, returning a list of all unique departments from the COLLEAGUES dictionary — and confirm Aria can answer "What departments do we have?" using it.
Show Solution
# Add this to directory_server.py, alongside lookup_colleague
@mcp.tool()
def list_departments() -> str:
"""List all departments that have colleagues in the directory."""
departments = set()
for entry in COLLEAGUES.values():
department = entry.split(", ")[-1]
departments.add(department)
return ", ".join(sorted(departments))
Explanation: No changes are needed on the client side at all — client.get_tools() automatically picks up every tool the server exposes, including new ones you add later.
Summary: Key Takeaways
- MCP standardizes how agents connect to external tools, the same way USB-C standardized device charging
- A server only needs to be built once; any MCP-compatible client can connect to it
MultiServerMCPClientfetches tools from one or more servers — local, internal, or public"stdio"transport means a local process;"streamable_http"means a genuinely remote server- Once fetched, MCP tools behave exactly like the
@toolfunctions from Article 2 — the agent's decision-making process doesn't change - Standalone scripts need
asyncio.run(...)for MCP's async operations, where Jupyter notebooks allow top-levelawaitdirectly
Version Information
Tested with:
- Python:
>=3.10, <4.0 langchain:>=1.1.3(latest stable as of writing:1.3.4)mcp:>=1.21.1langchain-mcp-adapters:>=0.1.13uv/uvx: required for the public MCP server example (install via docs.astral.sh/uv)
Known issues:
- ⚠️ Public MCP servers run via
uvxdownload and cache the package on first run — the first call may be noticeably slower than subsequent ones.
What's Next?
You now understand how to connect an agent to tools that live entirely outside your own code — your own internal servers, or ones built by anyone else.
The natural next step is Runtime Context: Injecting User-Specific Data — so far, every tool Aria has used works the same way no matter who's asking. That article covers giving Aria information specific to who she's currently helping.
References
- LangChain Academy: Introduction to LangChain (Python) — this section is inspired by and adapted from this course
- Model Context Protocol — Official Documentation — the MCP specification and conceptual guides
- LangChain Docs: MCP Adapters — official guide to
langchain-mcp-adapters mcpon PyPI — the official Python MCP SDK, used forFastMCPlangchain-mcp-adapterson PyPI — latest version and release history- uv Documentation — covers
uvxand package management