Start with Tools
Part 1 of A Hands-On Deep Dive into MCP Servers: a working permit-application server built from nothing but tools.
Part 1 of A Hands-On Deep Dive into MCP Servers. We build a permit-application server, a citizen applying to use public land, using only the Model Context Protocol's simplest piece: the tool.
The code below is the real implementation, not pseudocode. This part's four tools and their tests live in the repo: github.com/roman-romanov-o/mcp-article.
MCP has one core primitive: the tool. A function the model can call, with a name, a description, and typed arguments. The protocol has more pieces (resources, sampling, elicitation), but you'll get to them later. That's what this part does: build something real with tools only.
The example is a citizen who wants a permit to use public land, say for a weekend festival. The process: pick a permit type, find a location, check the combination is allowed, lock the location in. The first three steps are freely repeatable: nothing is filed, nothing is locked, and the citizen can change their mind until they commit. The last one advances the application. Each step is one tool, four total, and each tool ends up playing a different role.
What the model sees
Before the Python, here's what a tool looks like on the wire. It's just a JSON object the server advertises:
{
"name": "select_permit_type",
"description": "Choose which kind of permit the citizen is applying for. Call this once the citizen has decided what they want to do with the land.",
"inputSchema": {
"type": "object",
"properties": {
"permit_type": {
"type": "string",
"enum": ["COMMERCIAL_STALL", "RESIDENTIAL_BUILD", "EVENT_USE", "FARMING"],
"description": "Which permit to apply for. Must be one of the advertised types."
}
},
"required": ["permit_type"]
}
}Two things matter here.
name and description are all the model has when deciding whether to call the tool. If the description is vague, the model calls it at the wrong time or not at all. This is the part worth spending time on.
inputSchema defines what a legal call looks like. The enum does real work: the model can't pass "coffee stand" or "a concert", only one of the four listed values. The four names ship with the schema, so the model has seen them before it calls anything.
Writing the tool
In FastMCP it's a plain decorated function:
from enum import Enum
from fastmcp import FastMCP
from pydantic import BaseModel
mcp = FastMCP("land-permits")
class PermitType(str, Enum):
COMMERCIAL_STALL = "COMMERCIAL_STALL"
RESIDENTIAL_BUILD = "RESIDENTIAL_BUILD"
EVENT_USE = "EVENT_USE"
FARMING = "FARMING"
class PermitSelection(BaseModel):
permit_type: PermitType
message: str
@mcp.tool
def select_permit_type(permit_type: PermitType) -> PermitSelection:
"""Choose which kind of permit the citizen is applying for.
Call this once the citizen has decided what they want to do with the land.
COMMERCIAL_STALL sells goods from a temporary stand on public land.
RESIDENTIAL_BUILD puts a private dwelling on a plot.
EVENT_USE covers a temporary public event, such as a concert or festival.
FARMING grows crops or grazes livestock.
"""
_application.permit_type = permit_type
return PermitSelection(
permit_type=permit_type,
message=f"Permit type set to {permit_type.value}.",
)The docstring becomes the description and the type hints become the schema. FastMCP generates the JSON through Pydantic, so the enum you use in Python is the same fence the model sees on the wire. You write it once.
_application is the in-memory record this server keeps for the citizen's filing, and that single assignment is the whole reason this tool exists. Nothing is filed by it and no location is locked, so the choice stays free until the commit: call it again with a different type and the old one is simply gone. What it buys is scope. The server can use one choice to narrow the search and prepare the right checklist later.
That's role one, an action.
The tool that isn't here
The obvious next move is a list_permit_types, a tool returning the four types with a label and a summary each, so the model can see the catalog before it picks. Almost every server I've read has one. It's wasted work.
The values are already in the schema. They ship with the tool definition, so the model has them before it calls anything, and there is nothing left to discover. The meanings are in the docstring, which arrives in the same payload. A list tool would add a second definition to the model's context and a round trip to the conversation, then hand back what the model was already holding.
The part that's genuinely missing from JSON Schema is per-value description. There's no slot for it: an enum is a list of bare strings. So when a value is opaque, PT-3 rather than EVENT_USE, the gloss does have to go somewhere. The docstring is that somewhere, and it costs nothing at runtime.
A tool earns its place when the list is too long to sit in a description, or when it comes from data you didn't author. Which is the next problem.
When the list won't fit
Permit types fit in an enum because there are four of them and they never change. Locations don't fit. There are too many parcels, they change, and they live in a database rather than in your code. So the model searches instead:
class Parcel(BaseModel):
id: str
address: str
zoning: str # "residential", "commercial", "agricultural", "mixed"
area_sqm: int
protected: bool # conservation or heritage restriction
# Some permits only sit on one kind of land. The rest go anywhere.
ZONING: dict[PermitType, str] = {
PermitType.RESIDENTIAL_BUILD: "residential",
PermitType.FARMING: "agricultural",
}
def zoning_ok(permit_type: PermitType, parcel: Parcel) -> bool:
required = ZONING.get(permit_type)
return required is None or parcel.zoning == required
@mcp.tool
def search_location(hint: str) -> list[Parcel]:
"""Find land parcels matching a street, address, or area.
Use this to look up candidate locations before committing to one.
The hint is matched loosely, a street name or neighborhood is enough.
Results are narrowed to parcels zoned for the permit type already
selected. Without a selection the search runs across everything.
"""
candidates = _parcel_index.search(hint)
if _application.permit_type is None:
return candidates
return [p for p in candidates if zoning_ok(_application.permit_type, p)]Same shape as the enum, a typed list to pick from, except the model composes the query itself and the results come from data you didn't author. This is the case a schema can't cover, which is why it's a tool and permit types aren't.
This is also where the earlier selection starts paying. A citizen after farmland doesn't want the residential plots on the same street, and the model shouldn't have to filter them out by hand or discover the mismatch three calls later. The permit type was recorded once and now quietly shapes every search that follows.
Note what it doesn't do: it filters on zoning only, not on whether a parcel would actually clear. Zoning is a fixed property of the land, so it's a fair way to narrow a list. Whether this parcel suits this application still needs asking, and that's the next tool.
Also worth noticing: this tool writes nothing. Call it ten times and the application is where it was. The model can explore freely because exploring costs nothing.
That's role two, a read-only query.
What fields to return
The real design decision here is what Parcel carries. A real parcel has dozens of attributes: owner, lot number, utility hookups, flood zone, tax band. The return has five. My rule: include a field if the model needs it to choose between candidates, drop it otherwise.
zoning, area_sqm, and protected are in because they decide whether a permit can go ahead. A festival needs room and no conservation flag; a house needs residential zoning. The tax band decides nothing, so it's out.
Both failure modes are real. Too few fields and the model wastes a round-trip asking for something you already knew. Too many and the three fields that matter drown in the forty that don't.
The verdict
The model has a permit type and some candidate parcels. Now the question that matters: is this permit allowed on this parcel?
class Eligibility(BaseModel):
eligible: bool
reasons: list[str]
def evaluate(permit_type: PermitType, parcel: Parcel | None) -> Eligibility:
if parcel is None:
return Eligibility(eligible=False, reasons=["No such parcel."])
reasons: list[str] = []
if not zoning_ok(permit_type, parcel):
reasons.append(
f"{permit_type.value} needs {ZONING[permit_type]} zoning; this parcel is {parcel.zoning}."
)
if permit_type == PermitType.EVENT_USE and parcel.protected:
reasons.append("Event use isn't allowed on protected land.")
if parcel.area_sqm < 50:
reasons.append("Parcel is too small for any permitted use (minimum 50 m²).")
return Eligibility(eligible=not reasons, reasons=reasons)
@mcp.tool
def check_eligibility(permit_type: PermitType, parcel_id: str) -> Eligibility:
"""Check whether a permit type is allowed on a given parcel.
Run this on any candidate before committing to it. It only reports —
it does not reserve or set the location.
"""
return evaluate(permit_type, _parcel_index.get(parcel_id))Two things about this one.
The rules live in evaluate, a plain function, with the tool as a one-line wrapper. That's because a decorated tool isn't an ordinary Python callable anymore, and the write is about to need the same check. The zoning half sits one level further out in zoning_ok because the search needs it too. One rule, written once, consulted by a filter, a probe, and a write.
And the return isn't a bool. A bare False tells the model it's stuck without saying why, and then it can't do anything useful about it. Eligibility carries the reasons, so the model can tell the citizen "this land is protected" or just move on to the next candidate. eligible is derived from the reasons list, so the two can't disagree.
Role three: a verdict.
The commit
Three tools in, and the application still hasn't moved. A permit type is pencilled in and nothing else. Time for the tool that advances it:
class DocumentKind(str, Enum):
IDENTITY = "IDENTITY"
SITE_PLAN = "SITE_PLAN"
INSURANCE = "INSURANCE"
# What each permit has to prove before it can be reviewed.
CHECKLIST: dict[PermitType, list[DocumentKind]] = {
PermitType.COMMERCIAL_STALL: [DocumentKind.IDENTITY, DocumentKind.SITE_PLAN,
DocumentKind.INSURANCE],
PermitType.RESIDENTIAL_BUILD: [DocumentKind.IDENTITY, DocumentKind.SITE_PLAN],
PermitType.EVENT_USE: [DocumentKind.IDENTITY, DocumentKind.SITE_PLAN,
DocumentKind.INSURANCE],
PermitType.FARMING: [DocumentKind.IDENTITY],
}
@mcp.tool
def commit_location(parcel_id: str) -> Eligibility:
"""Lock in a parcel as the application's location.
Call this once a candidate has cleared check_eligibility. It records the
location, issues the document checklist, and advances the application.
It re-checks eligibility itself and refuses if the parcel doesn't clear.
"""
if _application.permit_type is None:
return Eligibility(eligible=False, reasons=["No permit type selected yet."])
verdict = evaluate(_application.permit_type, _parcel_index.get(parcel_id))
if verdict.eligible:
_application.location = parcel_id
_application.checklist = CHECKLIST[_application.permit_type]
_application.status = "ChecklistIssued"
return verdictThe line to look at is the re-check. The model was told to call check_eligibility before committing. Why check again?
Because a docstring is advice, not a constraint. The model can skip it, misread it, or rely on a stale answer (the parcel's protected flag might have flipped since the probe). A write can't trust the conversation, so it verifies for itself and only then mutates. That's why evaluate is a separate function: same rules, used by the probe and by the write.
If the parcel doesn't clear, nothing happens and the model gets back the same Eligibility shape it already knows, with the reasons. A blocked commit isn't an error, it's an answer.
The other line worth a look is the checklist. The commit doesn't only record where the festival goes, it works out what the citizen now owes: identity, a site plan, an insurance certificate. That list falls straight out of the permit type chosen back at the first tool, which is the last thing that selection buys. One decision, made once, that narrowed the search, fed the verdict, and now sets the work.
Role four: a guarded write.
Four roles, one primitive
So, one primitive playing four roles:
select_permit_type, an actionsearch_location, a read-only querycheck_eligibility, a verdictcommit_location, a guarded write
No resources, no prompts, no sampling, no elicitation. To run it, clone the repo and, from code/:
uv run land-permits # serves these four tools over stdio
uv run --extra dev pytest -q # drives the whole errand end to end — 19 testsPart 2 covers the back half of the process, where most tools write and some are meant to be called more than once.