Skip to content

Step 3 · The tools, and an MCP server

Step 3 of 8Tools over MCP

Write the two functions the agent will use, then expose them over MCP so other AI clients can use them too.

Tools before agent, deliberately. A tool is just a function — you can write it, call it, and test it with no model involved at all. Getting them working first means that when the agent misbehaves in step 5, you already know the tools aren’t the problem.

Open app/tools/wikipedia.py. Two functions, plain httpx:

app/tools/wikipedia.py
async def search(query: str, limit: int = 3) -> list[str]:
"""Find Wikipedia page titles matching a query."""
try:
response = await _client.get(WIKI_API, params={
"action": "query", "list": "search",
"srsearch": query, "srlimit": limit, "format": "json",
})
response.raise_for_status()
results = response.json().get("query", {}).get("search", [])
return [item["title"] for item in results]
except Exception:
return []
async def read_page(title: str) -> dict | None:
"""Fetch a short summary of one page."""
...
  • Fail politely. Returning [] lets the agent try a different query. Raising would end the whole run. This one line is why a flaky network degrades the answer instead of destroying it.

Two rules doing the heavy lifting:

Tools should be narrow. These are read-only and hit one public API. The worst a confused agent can do is read the wrong page. Compare that to handing it execute_sql(query).

Fetch summaries, not whole pages. Feeding an agent full articles is how you burn a token budget in three steps.

Now the same functions, wrapped as an MCP server:

app/tools/server.py
from fastmcp import FastMCP
from tools import wikipedia
mcp = FastMCP(name="research-tools", instructions="...")
@mcp.tool
async def wiki_search(query: str, limit: int = 3) -> list[str]:
"""Search Wikipedia and return matching page titles.
Args:
query: What to search for. Specific, factual queries work better
than broad ones.
limit: How many titles to return.
"""
return await wikipedia.search(query, limit=limit)

Why bother, when the agent can just call the function?

Section titled “Why bother, when the agent can just call the function?”

It can, and in step 5 it will — in-process, no protocol, no network hop.

MCP is here for a different reason: once this is deployed, anyone’s MCP client can use your tools. Claude Desktop, Claude Code, or a colleague’s agent. And because the server is mounted inside the FastAPI app, that costs you no extra deployment.

app/main.py
app.mount("/mcp", mcp_app) # one line — it ships with the API

Learn · MCP covers where this is worth it and where it isn’t — the “isn’t” matters as much.

Call the tools directly, with no agent and no API key. From app/:

Terminal window
uv run python -c "
import asyncio
from tools import wikipedia
print(asyncio.run(wikipedia.search('photovoltaic effect')))
"

Then poke at the MCP server with the official Inspector:

Terminal window
uv run fastmcp dev tools/server.py

That opens a browser UI where you can list the tools and call them by hand.

From the direct call:

['Photovoltaic effect', 'Photovoltaics', 'Solar cell']

From the Inspector: two tools listed — wiki_search and wiki_read — with their arguments and descriptions, and a Run button that returns real results.

With the server running, /mcp is also live on your local API.

fastmcp dev won’t open / Inspector won’t connect

The Inspector is a Node tool that fastmcp fetches on demand, so it needs Node installed and network access. It’s optional — if it won’t run, the direct python -c call above proves the same thing, and step 5 exercises the tools properly.

Search returns [] for everything

That’s the polite-failure path hiding a real error. Temporarily change the except Exception: return [] to raise and run it again to see what’s actually wrong — usually no network, or a proxy.

ModuleNotFoundError: No module named ‘tools’

Run commands from inside app/, not the repo root. That’s the uv project root, so it’s what uv run resolves imports against.

Two of the agent’s three tools are the ones you just wrote. The third, tavily_search, you don’t write at all — it arrives from Tavily’s hosted MCP server, which the agent connects to as a client. Same protocol you just stood up here, pointed the other way.

It’s optional and it’s four lines, so it lives in app/agent/research.py rather than in this step. Set TAVILY_API_KEY in .env if you want the agent to answer questions about anything recent — Wikipedia, being written after the fact, can’t. See Learn · MCP.

You have tools. Now something has to decide when to use them — and the clearest way to understand that is to write the loop yourself first.