Step 3 · The tools, and an MCP server
Objective
Section titled “Objective”Write the two functions the agent will use, then expose them over MCP so other AI clients can use them too.
Why this step exists
Section titled “Why this step exists”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.
The tools themselves
Section titled “The tools themselves”Open app/tools/wikipedia.py. Two functions, plain httpx:
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.
Expose them over MCP
Section titled “Expose them over MCP”Now the same functions, wrapped as an MCP server:
from fastmcp import FastMCPfrom tools import wikipedia
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.
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.mount("/mcp", mcp_app) # one line — it ships with the APILearn · MCP covers where this is worth it and where it isn’t — the “isn’t” matters as much.
Try it
Section titled “Try it”Call the tools directly, with no agent and no API key. From app/:
uv run python -c "import asynciofrom tools import wikipediaprint(asyncio.run(wikipedia.search('photovoltaic effect')))"Then poke at the MCP server with the official Inspector:
uv run fastmcp dev tools/server.pyThat opens a browser UI where you can list the tools and call them by hand.
Expected result
Section titled “Expected result”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.
What usually goes wrong
Section titled “What usually goes wrong”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.
Where the other tool comes from
Section titled “Where the other tool comes from”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.
What’s next
Section titled “What’s next”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.