Write a useful function once and every AI assistant should be able to call it: Claude Desktop, your editor, your own agent loop. That is exactly what you get when you build MCP server tooling around code you already trust. In this workshop we wrap the access-log parser and the bookmark Application Programming Interface (API) from earlier posts into one FastMCP server with three tools, a resource, and a prompt, all offline.
“Write the tool once, then let every assistant borrow it. That is the whole point of a protocol.”
The promise behind the Model Context Protocol
Last Updated: July 2026 | Tested on: Python 3.14.6, mcp SDK 1.28 | Difficulty: Advanced | Reading Time: 19 minutes
- What the Model Context Protocol is (this post builds the server that post describes)
- The raw agent loop, which we connect to at the end
- Install the SDK with the CLI extra: pip install “mcp[cli]” pydantic
- The spec version and HTTP transport move fastest. The pieces below were current at the time of writing; pin your versions and check modelcontextprotocol.io before you ship.
Here is the mental model before any code. Think of a hotel with one reception desk. Guests do not wander into the kitchen or the laundry; they ask reception, and reception knows exactly which department can help and how to phrase the request. The Model Context Protocol (MCP) is that reception desk for AI. Your functions are the departments doing real work, and the server is the desk that lists what is on offer and passes requests through in a language every AI host understands. The model never touches your code directly, the same safety boundary you saw in the tool-calling post, just standardized so one server works everywhere.
Table of Contents
What an MCP Server Actually Is
An MCP server is a small program that advertises three kinds of things to any host that connects: tools (functions the model can call), resources (read-only data the model can fetch, like a file or a config), and prompts (reusable message templates). The host runs the model and an MCP client; your server runs the capabilities. They talk over a transport, usually stdio for a local server or HTTP for a remote one, using JSON-RPC messages. The diagram below traces one question from the user, down through the host and client into your server, out to the real functions, and back.
Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.
The reason this matters is reach. Before MCP, if you wanted your log parser available inside three different assistants, you wrote three different integrations. With one MCP server, any compliant host, the Claude desktop app, editors like Cursor or Zed, or the plain agent loop from the tool-calling post, can use it. You write the tool once and every host borrows it. That reach is the payoff every time you build an MCP server instead of another one-off integration.
The Work We Are Wrapping
The smart way to build an MCP server is to wrap work that already exists. We take the parsing logic from the log parser project and the search idea from the FastAPI bookmarks project, and drop them into one plain module with no MCP in sight. Keeping the real logic separate from the protocol is the single most important design choice here: your functions stay testable on their own, and MCP becomes a thin skin on top.
📄 toolkit.py: the real work, borrowed from earlier posts
import re
from collections import Counter
LOG_PATTERN = re.compile(
r'(?P<ip>\d+\.\d+\.\d+\.\d+) \S+ \S+ '
r'\[(?P<time>[^\]]+)\] '
r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]+" '
r'(?P<status>\d{3}) (?P<size>\d+)'
)
def parse_log(text: str) -> list[dict]:
"""Turn raw access-log text into records, skipping junk lines."""
records = []
for line in text.splitlines():
m = LOG_PATTERN.match(line.strip())
if m:
rec = m.groupdict()
rec["status"] = int(rec["status"])
records.append(rec)
return records
def error_report(text: str) -> dict:
"""Overall error rate plus a breakdown by status code."""
records = parse_log(text)
total = len(records)
by_status = Counter(r["status"] for r in records)
errors = sum(c for s, c in by_status.items() if s >= 400)
rate = round(100 * errors / total, 1) if total else 0.0
return {"lines": total, "errors": errors, "error_rate_pct": rate,
"by_status": dict(sorted(by_status.items()))}
def top_ips(text: str, top: int = 3) -> dict:
counts = Counter(r["ip"] for r in parse_log(text))
return {"top": [{"ip": ip, "hits": n} for ip, n in counts.most_common(top)]}
# A tiny in-memory stand-in for the bookmarks API.
BOOKMARKS = [
{"id": 1, "title": "FastAPI docs", "url": "https://fastapi.tiangolo.com", "tags": ["api", "python"]},
{"id": 2, "title": "Pydantic docs", "url": "https://docs.pydantic.dev", "tags": ["validation", "python"]},
{"id": 3, "title": "MCP spec", "url": "https://modelcontextprotocol.io", "tags": ["mcp", "protocol"]},
]
def search_bookmarks(query: str, limit: int = 5) -> dict:
q = query.lower()
hits = [b for b in BOOKMARKS
if q in b["title"].lower() or any(q in t for t in b["tags"])]
return {"query": query, "count": len(hits), "results": hits[:limit]}
def all_tags() -> list[str]:
return sorted({t for b in BOOKMARKS for t in b["tags"]})
What happened here: not one line mentions MCP. These are ordinary functions that take arguments and return dictionaries, exactly like the tools in the raw agent loop. Returning dictionaries matters, because the server will serialize them to JSON for the model. If you can call these functions from a normal Python prompt and get sensible output, they are ready to expose. Protocol code should never be tangled with business logic.
Build an MCP Server with FastMCP
Now the skin. FastMCP, part of the official Python SDK, lets you turn a function into a tool with one decorator. You create a server, decorate each function with @mcp.tool(), and FastMCP reads the type hints and docstring to build the schema the model sees. That docstring is not documentation for humans; it is the description the model reads to decide when to call the tool, the same “description is a prompt” rule from the tool-calling post. Write it like an instruction.
📄 server.py: three tools, each one decorator
from typing import Annotated
from pydantic import Field
from mcp.server.fastmcp import FastMCP
import toolkit
# The name shows up in every host that connects to you.
mcp = FastMCP("devtools")
@mcp.tool()
def log_error_report(
log_text: Annotated[str, Field(description="Raw Apache-style access log text")],
) -> dict:
"""Report the error rate and status-code breakdown for an access log.
Call this when the user asks how healthy a server log looks or how many
requests failed."""
return toolkit.error_report(log_text)
@mcp.tool()
def log_top_ips(
log_text: Annotated[str, Field(description="Raw Apache-style access log text")],
top: Annotated[int, Field(ge=1, le=50, description="How many IPs to return")] = 3,
) -> dict:
"""List the busiest client IP addresses in an access log.
Call this to find who is sending the most traffic or a possible abuser."""
return toolkit.top_ips(log_text, top=top)
@mcp.tool()
def search_bookmarks(
query: Annotated[str, Field(min_length=1, description="Text to match in titles or tags")],
limit: Annotated[int, Field(ge=1, le=20, description="Max results")] = 5,
) -> dict:
"""Search saved bookmarks by title or tag.
Call this when the user is looking for a saved link or a reference."""
return toolkit.search_bookmarks(query, limit=limit)
if __name__ == "__main__":
mcp.run() # stdio transport by default
What happened here: each tool is one decorated function that delegates straight to toolkit. FastMCP inspects the signature and turns it into a JSON Schema the model can read, using your type hints for the field types and the Field descriptions for the hints. The mcp.run() line starts the server over stdio, the transport a local host uses to launch and talk to you. Three functions, three decorators, and you have a working server.
Validating Inputs with Pydantic
The model fills in tool arguments, and a model can get them wrong: an empty query, a top of ten thousand, a number where text belongs. You never want bad input reaching your real function. This is where the Pydantic constraints in those Field calls earn their keep. FastMCP validates every argument against the schema before your function runs, so a bad call is rejected at the door. Let us drive the server in memory and watch it, no host needed.
📄 inspect_server.py: see what a host sees, and try a bad input
import asyncio, json
from server import mcp
async def main():
print("=== Tools this server advertises ===")
for t in await mcp.list_tools():
req = t.inputSchema.get("required", [])
print(f"- {t.name}{tuple(req)}: {t.description.splitlines()[0]}")
print("\n=== Generated JSON schema for the 'top' argument ===")
tool = next(t for t in await mcp.list_tools() if t.name == "log_top_ips")
print(json.dumps(tool.inputSchema["properties"]["top"], indent=2))
print("\n=== Pydantic rejects a bad argument (top=999, max is 50) ===")
try:
await mcp.call_tool("log_top_ips", {"log_text": "x", "top": 999})
except Exception as e:
for line in str(e).splitlines()[:3]:
print(line)
asyncio.run(main())
▶ Output
=== Tools this server advertises ===
- log_error_report('log_text',): Report the error rate and status-code breakdown for an access log.
- log_top_ips('log_text',): List the busiest client IP addresses in an access log.
- search_bookmarks('query',): Search saved bookmarks by title or tag.
=== Generated JSON schema for the 'top' argument ===
{
"default": 3,
"description": "How many IPs to return",
"maximum": 50,
"minimum": 1,
"title": "Top",
"type": "integer"
}
=== Pydantic rejects a bad argument (top=999, max is 50) ===
Error executing tool log_top_ips: 1 validation error for log_top_ipsArguments
top
Input should be less than or equal to 50 [type=less_than_equal, input_value=999, input_type=int]
What happened here: the server reported its three tools with the first line of each description and its required arguments. FastMCP turned Field(ge=1, le=50) into a real JSON Schema with minimum and maximum, which is what the model reads to stay in range. Then the important part: a call with top=999 never reached top_ips. Pydantic caught it and raised a clear validation error naming the field and the rule it broke. That guarantee, bad input stops at the boundary, is why you validate in the schema and not with scattered if checks inside your logic.
Adding a Resource and a Prompt
Tools are actions. The other two capabilities are quieter but just as useful. A resource is read-only data the host can fetch by URI, think of it as a file the model can open: a config, a schema, a list of options. A prompt is a saved message template a user can pick from a menu, so a common task becomes one click instead of retyping instructions. We add one of each with the same decorator style.
📄 add to server.py: one resource, one prompt
@mcp.resource("bookmarks://tags")
def bookmark_tags() -> str:
"""The list of tags a caller can search bookmarks by."""
return "\n".join(toolkit.all_tags())
@mcp.prompt()
def triage_log(log_text: str) -> str:
"""A ready-made prompt that asks the model to triage a log."""
return (
"You are an on-call engineer. Use the log_error_report and log_top_ips "
"tools to inspect this access log, then explain in three lines whether "
f"it is healthy and what to check next.\n\n{log_text}"
)
▶ Output (reading the resource, then rendering the prompt)
=== Reading the resource bookmarks://tags === api mcp protocol python validation === The prompt template, rendered === You are an on-call engineer. Use the log_error_report and log_top_ips tools to inspect t ...
What happened here: the resource is identified by a URI, bookmarks://tags, and returns plain text the host can drop into context so the model knows which tags exist before it searches. The prompt is a function that returns a filled-in instruction, so a user in the host UI can pick “triage_log”, paste a log, and get a consistent, well-worded request every time. Tools do, resources inform, prompts standardize. A server with all three feels finished rather than like a bag of loose functions.
Running It in the MCP Inspector
Before you connect a server to a real assistant, test it in the MCP Inspector, the official debugging UI that ships with the SDK. It launches your server, lists everything it exposes, and lets you click each tool, fill in arguments, and see the raw response, all in a browser. One command starts it.
📄 terminal: launch the inspector against your server
mcp dev server.py
▶ Example output (the inspector startup banner)
Starting MCP inspector... Proxy server listening on 127.0.0.1:6277 MCP Inspector is up and running at http://127.0.0.1:6274
What happened here: the exact banner text depends on your inspector version, so this is representative rather than captured on this machine. Open that URL and you will see your three tools, the bookmarks://tags resource, and the triage_log prompt listed in a sidebar. Click log_error_report, paste a few log lines, run it, and the JSON result appears just like it did in our in-memory test. Take a screenshot of that window: it is the single best artifact to put in your project README, because it proves the server works to anyone who has never run your code.
Wiring the Server to the Raw Agent Loop
A server that only works in the inspector is half a story. The real test is a model driving it. Here we connect our server to the raw agent loop from the tool-calling post with a tiny adapter: it lists the server’s tools, hands their schemas to the model, and calls them through MCP when the model asks. That adapter is, in miniature, everything a host does. The model is scripted so this runs with no API key, but every tool call goes through the real server.
📄 bridge.py: the MCP server driven by an agent loop
import asyncio, json
from dataclasses import dataclass
from server import mcp
SAMPLE = (
'203.0.113.7 - - [09/Jul/2026:14:05:31 +0000] "POST /api/login HTTP/1.1" 500 128 "-" "x"\n'
'198.51.100.2 - - [09/Jul/2026:14:05:32 +0000] "GET /api/orders HTTP/1.1" 200 964 "-" "x"\n'
'203.0.113.7 - - [09/Jul/2026:14:05:33 +0000] "GET /api/orders HTTP/1.1" 500 96 "-" "x"\n'
)
@dataclass
class ToolUse:
name: str
input: dict
async def mcp_tools_as_schemas():
"""Adapt MCP's tool list into the {name, description, input_schema} shape
the raw loop expects. This tiny adapter is all a host really is."""
return [{"name": t.name, "description": t.description, "input_schema": t.inputSchema}
for t in await mcp.list_tools()]
async def call_via_mcp(name, arguments):
blocks = await mcp.call_tool(name, arguments)
blocks = blocks[0] if isinstance(blocks, tuple) else blocks
return json.loads(blocks[0].text)
async def run(user, script):
tools = await mcp_tools_as_schemas()
print(f"Host connected. Model can see: {[t['name'] for t in tools]}")
print(f"\nUser: {user}\n")
for step, turn in enumerate(script, 1):
if isinstance(turn, ToolUse):
shown = {k: (v if len(str(v)) < 30 else f"<{len(v.splitlines())} log lines>")
for k, v in turn.input.items()}
print(f" [round {step}] model calls {turn.name}({shown})")
print(f" -> {await call_via_mcp(turn.name, turn.input)}")
else:
print(f"\nFinal answer:\n{turn}")
# What a real model would send back, written out so it runs offline.
script = [
ToolUse("log_error_report", {"log_text": SAMPLE}),
"This log is unhealthy: 3 requests, 2 of them 500s, a 66.7% error rate. "
"The failures hit /api/login and /api/orders, so check those routes first.",
]
asyncio.run(run("Is this access log healthy?", script))
▶ Output
Host connected. Model can see: ['log_error_report', 'log_top_ips', 'search_bookmarks']
User: Is this access log healthy?
[round 1] model calls log_error_report({'log_text': '<3 log lines>'})
-> {'lines': 3, 'errors': 2, 'error_rate_pct': 66.7, 'by_status': {'200': 1, '500': 2}}
Final answer:
This log is unhealthy: 3 requests, 2 of them 500s, a 66.7% error rate. The failures hit /api/login and /api/orders, so check those routes first.
What happened here: the loop is exactly the four moves from the tool-calling post, only the tools now live behind MCP instead of in the same file. The adapter converted the server’s tool list into the schema shape the loop expects, the model asked for log_error_report, and call_via_mcp ran it through the real server and handed back 66.7%. Swap the scripted model for anthropic.Anthropic() and this is a working agent over MCP. The same server would plug into Claude Desktop with zero code changes, because the protocol is the contract, not your loop.
Packaging and Sharing Your Server
When you build MCP server tools for other people, give the package an entry point in pyproject.toml so they can run it with one command. That turns devtools-mcp into an installable command, which is exactly what a host configuration expects to launch. Pin the SDK version, because the wire protocol has a spec version and it does move.
📄 pyproject.toml: an installable entry point
[project] name = "devtools-mcp" version = "0.1.0" requires-python = ">=3.11" dependencies = ["mcp[cli]>=1.28,<2", "pydantic>=2"] [project.scripts] devtools-mcp = "server:mcp.run"
Then write a README with a table of what the server exposes, so a stranger can understand it in ten seconds without reading code. A tool table plus that inspector screenshot is what turns this from a script into a portfolio piece.
| Capability | Name | What it does |
|---|---|---|
| Tool | log_error_report | Error rate and status breakdown for an access log |
| Tool | log_top_ips | Busiest client IPs, capped at 50 |
| Tool | search_bookmarks | Search saved links by title or tag |
| Resource | bookmarks://tags | The tags you can search by |
| Prompt | triage_log | One-click log triage instruction |
That covers the acceptance criteria for this workshop: three validated tools, one resource, one prompt, an inspector screenshot, and a README table. Version the server itself with semantic versioning, and note in the README which spec version the SDK targets, since that is the piece most likely to shift under you.
Common Mistakes
- Vague tool descriptions: the docstring is the model’s only clue about when to call a tool. “Log tool” makes the model guess; a sentence that names the trigger makes it reliable.
- No input validation: a model can send any argument. Without Pydantic constraints, a bad value reaches your real function and crashes it or does something worse.
- Tangling logic with the protocol: put your real work in a plain module and keep MCP as a thin wrapper. Mixed together, neither is easy to test.
- Printing to stdout in a stdio server: stdio is the transport channel. A stray print corrupts the JSON-RPC stream. Log to stderr or a file instead.
- Not pinning the SDK: the spec version moves. Pin
mcpin your dependencies so an upgrade is a deliberate choice, not a surprise breakage.
Best Practices
- Write descriptions like prompts: tell the model the trigger condition, not just what the function does. This is the most important line in each tool.
- Validate at the boundary: use Pydantic
Fieldconstraints so bad input is rejected before your logic runs, and the model gets a clear error to correct. - Keep the protocol layer thin: real work lives in a testable module; the server just decorates and delegates.
- Test in the inspector before a real host: it is faster to click through the inspector than to debug inside an assistant.
- Gate anything with side effects: read-only tools can run freely, but wrap writes, deletes, and payments behind an approval step, the same rule as any agent tool.
Conclusion
You just built a real MCP server: a plain toolkit of tested functions, wrapped by FastMCP into three validated tools, a resource, and a prompt, tested in memory, checked in the inspector, and driven end to end by the raw agent loop from earlier in this series. The shape you learned is the durable part. Field names and the spec version will shift, HTTP transport will keep maturing, but the pattern, keep your logic clean, wrap it thinly, validate at the edge, describe tools like prompts, stays put whether you use the Python SDK or a future one.
For the whole path from Python basics to shipping AI systems, head back to the Python + AI/ML tutorial series home and pick your next stop.
Frequently Asked Questions
What is an MCP server in simple terms?
An MCP server is a small program that exposes your functions, data, and prompt templates to any AI assistant through the Model Context Protocol. The assistant, called the host, runs the model and connects to your server over a transport like stdio or HTTP. When the model wants to do something, it calls one of your server’s tools, your code runs it, and the result goes back. You write the tool once and any compliant host can use it.
What is the difference between a tool, a resource, and a prompt?
A tool is a function the model can call to take an action, like parsing a log. A resource is read-only data the model can fetch by URI, like a config file or a list of options, so it informs rather than acts. A prompt is a reusable message template a user can pick to standardize a common request. A complete server usually offers all three: tools do, resources inform, prompts standardize.
Do I need FastMCP, or can I use the low-level SDK?
FastMCP is the high-level API in the official Python SDK and is the recommended starting point, because a decorator plus type hints gives you the schema and validation for free. The low-level server API exists for cases where you need fine control over the protocol messages, but most servers never need it. Start with FastMCP and drop lower only if you hit a wall.
How does an MCP server validate the arguments a model sends?
FastMCP builds a JSON Schema from your function’s type hints and any Pydantic Field constraints, then validates every incoming argument against it before your function runs. If the model sends an out-of-range number or a wrong type, the call is rejected with a clear validation error the model can read and correct. This keeps bad input from ever reaching your real logic.
Is MCP tied to one AI provider?
No. The Model Context Protocol is an open standard, so a single server works with any host that speaks it, including desktop assistants, editors, and your own agent loop. That portability is the main reason to build an MCP server instead of a one-off integration. The spec version does evolve, so pin your SDK and check the spec before shipping.
Interview Questions on MCP Servers
Interviewers rarely ask for definitions. They ask what happens in situations like these.
Q: What problem does the Model Context Protocol solve?
It standardizes how AI hosts talk to external capabilities. Before it, wiring your functions into three assistants meant three custom integrations. With MCP you build one server that exposes tools, resources, and prompts over a common protocol, and any compliant host can use it unchanged. It is the same idea as a device driver: write to one interface, work with everything that speaks it.
Q: Walk me through what happens when a model uses a tool on your MCP server.
The host lists your server’s tools and gives their schemas to the model. When the model decides to call one, the host’s MCP client sends a JSON-RPC request over the transport to your server. The server validates the arguments against the tool’s schema, runs your function, and returns the result. The client feeds that back to the model, which either calls another tool or writes its answer. Your code only ever executes and reports; the model decides what and when.
Q: How do you keep a model from sending harmful or malformed arguments to a tool?
Validate at the boundary. In FastMCP you attach Pydantic Field constraints to each argument, and the framework rejects anything out of range or of the wrong type before your function runs, handing the model a clear error to correct. Beyond types, treat every argument as untrusted: gate side effects like writes and payments behind an approval step, and never pass raw arguments into eval or a shell.
Q: What is the difference between a resource and a tool, and when would you use each?
A tool performs an action and can have side effects; a resource is read-only data fetched by URI. Use a tool when the model needs to do something, like search or compute. Use a resource when the model needs to know something up front, like a config, a schema, or the set of valid options. Resources are cheaper and safer because they cannot change state, so prefer a resource when a tool would only be reading.
Q: Why should the tool logic live outside the MCP server code?
Separation of concerns. If your real work sits in a plain module, you can unit-test it with no protocol involved, reuse it outside MCP, and swap the transport or even the SDK without touching business logic. The server becomes a thin layer that decorates and delegates. Tangling the two makes both harder to test and locks your logic to a protocol that will keep evolving.
Further reading: for the full reference, see Model Context Protocol documentation.
Related Posts
Previous: Model Context Protocol (MCP): Connect Your AI to Anything
Next: Python: LangChain vs LlamaIndex vs CrewAI, AI Agent Frameworks Compared
Series Home: Python + AI/ML Tutorial Series

No comment