4 · MCP
The last page ended with a question: our tools are Python functions in our own repo, so how would anyone else’s AI use them?
That’s the problem MCP exists for. It is not a bigger, better version of tool calling — it’s the same idea with a boundary in the middle.
What it is
Section titled “What it is”MCP — the Model Context Protocol — is an agreed format for describing tools to an AI client that has never seen your code.
An MCP server exposes tools. An MCP client — Claude Desktop, Claude Code, or your own agent — connects to it and asks what’s available. Neither side needs to know anything about the other in advance.
Without MCP: your agent ──► your tools (one program, one hardcoded set of functions)With MCP: your agent ─┐ Claude Desktop ─┼──► MCP server ──► your tools Claude Code ─┘ (a described, anyone else discoverable interface)The comparison people usually want is with plain tool calling from page 3, so here it is directly:
| A tool in your own code | The same tool over MCP | |
|---|---|---|
| Who knows it exists? | only the program you wrote | any MCP client that connects |
| Who writes the schema? | you, by hand, per framework | the server, once, for everyone |
| Discovery | none — it’s hardcoded | the client asks “what have you got?” |
| Cost | a function call | a protocol, a transport, a process |
When it’s worth it, and when it isn’t
Section titled “When it’s worth it, and when it isn’t”That last row is the honest one, and it leads to a rule worth remembering:
If one program is calling one function, MCP buys you nothing.
It’s a standard for crossing a boundary. No boundary, no benefit.
Our own agent proves the point. It calls wikipedia.search() as a plain Python
function, in-process — no protocol, no network hop — because the agent and the
tools live in the same file tree. Adding MCP between them would be pure
overhead.
So why does this project ship an MCP server at all?
Because deployment creates a boundary that didn’t exist before. Once your app is
live, https://your-app.onrender.com/mcp is a public address, and anyone’s AI
client can be pointed at it. Your Wikipedia tools show up inside Claude Desktop
as tools it can use.
And it costs one line, because the MCP server is mounted inside the FastAPI app that was already going to production:
mcp = FastMCP(name="research-tools", instructions="...")
@mcp.toolasync def wiki_search(query: str, limit: int = 3) -> list[str]: """Search Wikipedia and return matching page titles.""" return await wikipedia.search(query, limit=limit)# One line. The MCP server ships with the API.app.mount("/mcp", mcp_app)That’s the honest justification, and it’s as much a deployment lesson as a protocol one: no second service, no second thing to keep awake.
The other direction: someone else’s server
Section titled “The other direction: someone else’s server”Everything above is us as the server. The protocol has a second half, and this project uses that too — because a protocol you only ever serve is a protocol you’ve half learned.
Our agent has a gap. Wikipedia is written after the fact, so ask about this week and there’s nothing there, and the model falls back on training data that ended months ago. It will answer anyway, fluently and wrongly. Fixing that means searching the live web, and we are not about to build a web crawler.
Tavily runs a search API for exactly this, and they host an MCP server for it. So we connect to theirs:
toolset = MCPToolset( "https://mcp.tavily.com/mcp", headers={"Authorization": f"Bearer {config.TAVILY_API_KEY}"}, process_tool_call=_log_web_search,)# Their server also offers crawl, map and extract. We take one tool.return toolset.filtered(lambda ctx, tool: tool.name == "tavily_search")Count what we didn’t write: no HTTP calls, no request shape, no response
parsing, and — the part that matters — no tool descriptions for the model.
Their server describes its own tools, our agent asks what’s available, and the
model gets a menu nobody had to write twice. Compare tools/wikipedia.py, where
every one of those is ours to maintain forever.
That is the payoff the table above is really pointing at. The plumbing is the same however many servers you connect to, and Tavily wrote none of it for us.
One more thing, and it’s the reason this isn’t just a config change. Our own
tools log a step each before they work — that’s what makes the UI say “Agent is
searching…” instead of going quiet. We didn’t write Tavily’s tool, so it can’t
do that. process_tool_call is the hook that sits between the agent and their
server and lets us log it anyway:
async def _log_web_search(ctx, call_tool, name, tool_args) -> ToolResult: await ctx.deps.steps.log("Searching the web", tool_args.get("query", "")) return await call_tool(name, tool_args)A tool you don’t own shouldn’t be exempt from your observability. Crossing a boundary is not a reason to go blind at it.
Where it would be the wrong choice
Section titled “Where it would be the wrong choice”Deliberately not exposed over MCP in this project:
app/db.py— reading and writing runs and steps. Internal plumbing. An outside model has no business calling it, and wrapping it would put a protocol between two functions in the same process.app/config.py— configuration is not a capability.app/agent/steps.py— step logging is how we observe the agent, not something the agent should be able to invoke.
Notice the pattern: the two Wikipedia functions are capabilities someone outside might legitimately want. Everything else is machinery that happens to be callable.
How we use this in the demo
Section titled “How we use this in the demo”Our own two functions, exposed twice:
- In-process, via
@agent.tool, for our own agent — fast, no hop. - Over MCP at
/mcp, for anyone else.
One implementation, two doors. Build step 3 has you call it with the MCP Inspector, and step 8 has you connect Claude Desktop to your own deployed instance.
And, in the other direction, somebody else’s function used through the same
protocol — Tavily’s tavily_search, which is how the agent answers anything
that happened recently. Leave TAVILY_API_KEY unset and it runs on Wikipedia
alone; GET /health reports which of the two you’ve got.
So this one app is a server and a client. That symmetry is the whole point of there being a protocol at all.
app/tools/wikipedia.py — one implementation.
app/tools/server.py — the MCP door, outward.
app/agent/research.py — the in-process door, and the MCP door inward.
Where this leaves us
Section titled “Where this leaves us”Tools are settled. The agent can act, and now other clients can borrow that ability too.
Which brings us back to something page 2 raised and left hanging: every one of those tool results has to be carried forward by hand, because the model forgets everything between turns. A run that reads five Wikipedia pages is re-sending five page summaries on every subsequent call.
That’s memory — the part with no magic in it and a surprising amount of consequence.
Next: Memory and RAG →