Build an MCP Server Out of Nothing but Tools

MCP in Practice, Part 1 — building a working permit-application server with only the Model Context Protocol's simplest piece: the tool.

Part 1 of a three-part, hands-on series. We build a working permit-application server — a citizen applying to use public land — with only the Model Context Protocol's simplest piece: the tool.

Follow along. Everything below is the real implementation, built up one tool at a time — not pseudocode. The full project lives in the repo: github.com/roman-romanov-o/mcp-article.

MCP gives you one primitive worth starting from: a tool — a function the model is allowed to call, with a name, a description, and typed arguments. That's the whole idea. Everything else the protocol adds later — resources, sampling, elicitation, progress — refines cases this one primitive almost handles on its own.

So this guide does the stubborn thing: it refuses the fancy parts and builds with tools alone, to show exactly how far they get you. Further than you'd expect.

Here's what we build — the read-only front half of a real errand. A citizen wants to use a piece of public land, say to throw a weekend festival. Getting permission isn't one action; it's a short journey:

  1. Choose a permit type. A festival, a market stall, building a house, farming — each is a different kind of permission with different rules. First you say which one you're after.
  2. Find a location. Point at a piece of land you have in mind — by address, or by browsing what's available.
  3. Check it's allowed there. Your permit type and that parcel have to be compatible: a concert can't be held where people live.
  4. Commit to the spot. Once a place clears, you lock it in — and only then.

The first three moves are exploration — reversible, poke around all you like. The last is a commitment you make once. Every step is handled by a single tool — five in all, nothing else — and by the end you'll have watched the same primitive play five different roles, with a clear sense of when you'd actually reach for anything more.

What the model actually sees

Before we write a line of Python, look at what a tool is on the wire.

To the model, a tool isn't a function or a framework. It's a small JSON object the server advertises, with exactly three parts. Here's the first of ours, select_permit_type, as the model receives it:

{
  "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"]
  }
}

Three fields — but they aren't equal. They answer two different questions, and the model asks them in order:

name + description → "Should I call this, and when?" inputSchema → "How exactly do I call it?"

  • name and description are the sales pitch. They're all the model has when it decides whether to reach for this tool and when. A vague description is a tool the model misfires or ignores — this is the field you sweat over.
  • inputSchema is the contract. Once the model has decided to call, the schema dictates exactly what a legal call looks like.

And the enum is the star of the contract. It doesn't merely describe the argument — it fences it. The model cannot pass "coffee stand" or "a concert". It can pass COMMERCIAL_STALL, EVENT_USE, or nothing at all.

The menu of valid permits lives inside the type. So when you're tempted to ask "why not publish the catalog as a separate Resource?" — you don't need to. The list of choices is already advertised, automatically, in the schema the model is reading.

Writing the tool

Now the Python. The whole of select_permit_type in FastMCP is a plain, decorated function — nothing in it betrays that there's a wire protocol underneath:

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.
    """
    # Recording this against the application is a later beat — for now,
    # just confirm the choice landed.
    return PermitSelection(
        permit_type=permit_type,
        message=f"Permit type set to {permit_type.value}.",
    )

That's the whole tool. Three ordinary Python habits are quietly doing the protocol's work.

The function name and docstring are the tool's sales pitch — the only thing the model reads when it decides whether and when to call. So the docstring isn't a courtesy here. It's the user-facing copy.

The type hint is the contract. The moment you reach for an Enum instead of a bare str, you fence the argument: the model can pass COMMERCIAL_STALL, or nothing, but never "coffee stand".

And you don't write that JSON schema by hand. FastMCP generates it from exactly these. Python's type system and MCP's wire schema are the same fence, written once.

The discovery half

We built the destination first. But a model can't call select_permit_type as its opening move — it has no way of knowing that EVENT_USE is a thing, or that a festival maps to it rather than to COMMERCIAL_STALL.

It needs to see the menu before it can order. That's the other tool, and the first one any agent actually reaches for:

class PermitOption(BaseModel):
    id: PermitType
    label: str
    summary: str


@mcp.tool
def list_permit_types() -> list[PermitOption]:
    """List the kinds of permit a citizen can apply for.

    Call this first, before select_permit_type, to see what's available
    and how each one is described.
    """
    return [
        PermitOption(id=PermitType.COMMERCIAL_STALL, label="Commercial stall",
                     summary="Sell goods from a temporary stand on public land."),
        PermitOption(id=PermitType.RESIDENTIAL_BUILD, label="Residential build",
                     summary="Build a private dwelling on a plot."),
        PermitOption(id=PermitType.EVENT_USE, label="Event use",
                     summary="Hold a temporary public event — a concert, market, or festival."),
        PermitOption(id=PermitType.FARMING, label="Farming",
                     summary="Grow crops or graze livestock on public land."),
    ]

Look at what id is: a member of the same PermitType enum that select_permit_type accepts. That's the whole handshake.

The model reads the menu, picks an id, and hands that exact value back — into a parameter that, by its type, cannot accept anything else:

list_permit_types() → returns ids + descriptions → model picks one id → select_permit_type(EVENT_USE)

The PermitType enum defines the ids on one end and fences the argument on the other.

One enum, two tools, no coordination beyond it. The output of discovery is the vocabulary of action — and because both ends are bound to the same type, you cannot wire them up wrong.

That's hat #1 and hat #2: a tool as a menu (list_permit_types), and a tool as an action (select_permit_type), latched together by a type.

Now the interesting question is what happens when the menu isn't a fixed enum you wrote in advance, but the world — every parcel of land in the city.

When the menu is the world

list_permit_types could fence its whole world inside an enum because that world was small and known: four permit types, decided the moment you wrote the code.

Location is the opposite kind of menu. You cannot enumerate every parcel of land in the city as enum members — there are too many, you don't know them in advance, and they change. The catalog now lives outside your code, and the model has to ask for the slice it wants.

That's the next tool. The model hands it a hint — a street name, a neighborhood, a fragment of an address — and gets back the parcels that match:

class Parcel(BaseModel):
    id: str
    address: str
    zoning: str        # "residential", "commercial", "agricultural", "mixed"
    area_sqm: int
    protected: bool    # conservation or heritage restriction


@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.
    """
    return _parcel_index.search(hint)

It's the same shape as list_permit_types: a function that returns a typed list the model can pick from. But two things have changed, and both matter.

The first is the argument. list_permit_types took none — the menu was fixed, so there was nothing to ask. search_location takes a hint, because the menu is too big to hand over whole. The model has to compose the query, narrowing an open world down to a handful of candidates. The return type is no longer a closed enum you authored; it's a Parcel model describing data that lives somewhere else and arrived at runtime.

The second is what the tool doesn't do. It reads; it changes nothing. Call it ten times with ten different hints and the application is exactly where it started — no permit chosen, no location set, no state touched.

That's deliberate, and it's the whole point of this stage: the model is free to roam, because roaming is free.

This is hat #3 — a tool as a stateless query against the world — and it's the first of ours that reaches past your own code to answer.

The shape of an open-world return

Go back to that Parcel model for a moment, because it hides the real design decision of this whole stage.

With the enum, there was no decision: the type was the data. PermitType.EVENT_USE is the entire fact, and the model already knows what to do with it.

But a parcel in the real world has dozens of attributes — owner, lot number, utility hookups, flood zone, tax band, the year the curb was last repaved — and almost none of them belong in the return. Choosing what Parcel carries is choosing what the model gets to reason about.

The test is concrete: include a field if the model needs it to tell two candidates apart, and leave it out otherwise.

address lets the model confirm it found the right place. zoning, area_sqm, and protected are there because they're exactly what decides whether a permit can go ahead — a festival wants room and doesn't want a conservation flag; a residential build needs residential zoning. Every field is a question the model can now answer on its own. The lot number and the tax band answer nothing it will ever be asked, so they stay out.

Get this wrong in the lean direction and you force a round-trip: the model finds a promising parcel, then has to call another tool just to learn whether it's protected — latency and tokens spent recovering data you already had in hand.

Get it wrong in the fat direction and you bury the signal: a forty-field blob where the three fields that matter are lost in the noise, and the model reasons worse for having been told more.

The structured return is a curated answer, not a database dump. When the data comes from the world, the shape of the return is where you do the thinking on the model's behalf — and the fields you choose here are precisely the ones the next tool will weigh.

The verdict

The model now has a permit type and a fistful of candidate parcels. The question it can't yet answer is the one that matters: is this permit allowed on this parcel?

Not every place supports every use — a concert can't be held where people live, farming wants agricultural land, nothing at all fits on a parcel the size of a parking space. That ruling is its own tool:

class Eligibility(BaseModel):
    eligible: bool
    reasons: list[str]


@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.
    """
    parcel = _parcel_index.get(parcel_id)
    if parcel is None:
        return Eligibility(eligible=False, reasons=["No such parcel."])

    reasons: list[str] = []

    if permit_type == PermitType.RESIDENTIAL_BUILD and parcel.zoning != "residential":
        reasons.append(f"Residential builds need residential zoning; this parcel is {parcel.zoning}.")
    if permit_type == PermitType.FARMING and parcel.zoning != "agricultural":
        reasons.append(f"Farming needs agricultural 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)

Same two properties as search_location, both still true. It takes arguments the model assembles — a permit_type fenced by the enum, a parcel_id handed back from the search — and it changes nothing. Run it on all five candidates if you like; the application doesn't move.

This is the third stateless probe in a row: explore as much as you want, for free, before anything is decided.

What makes this hat #4 rather than another search_location is the return.

The obvious type for "is this allowed?" is bool — and it would be a trap. A bare False tells the model it's stuck without telling it why, and a model that doesn't know why can't recover.

The Eligibility verdict carries the reasons, so a rejection becomes actionable: the model can relay "this one's protected, the festival can't go here" to the citizen, or quietly move to the next candidate that clears.

Notice, too, that the reasons aren't decoration bolted onto the answer — they are the answer. eligible is just whether the list of blocking reasons came back empty. The verdict and its justification are computed as one thing, which is why they can't drift apart.

And the rules read exactly three fields: zoning, area_sqm, protected — the same three we argued into Parcel one section ago. That's why we chose them.

That's a tool as a verdict: a stateless ruling that hands back not just a decision but the grounds for it.

The commitment

Four tools in, nothing has changed. List, select, search, check — the model has roamed the whole errand and the application is still an empty draft.

That was the deal: exploration is free. But an application that never commits to anything is just a conversation. Sooner or later the model has to do the thing the errand promised — lock in the spot, and only then:

@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 and advances the application — the first tool here that changes
    anything. It re-checks eligibility itself and refuses if the parcel
    doesn't clear.
    """
    verdict = check_eligibility(_application.permit_type, parcel_id)
    if verdict.eligible:
        _application.location = parcel_id
        _application.status = "ChecklistIssued"
    return verdict

This is the first tool that writes. It moves the application off the draft and into ChecklistIssued — the next stage of the process opens only because this line ran. After four reversible probes, here is the one move you make once.

Now look at the first line of the body, because it's the whole point.

We already have check_eligibility. The model was told to run it before committing. So why does the commit run it again?

Because a tool description is advice, not a constraint. "Call this once a candidate has cleared check_eligibility" is a sentence the model can skip, misread, or honor against a stale answer — a parcel's protected flag could flip between the probe and the commit, and the model would never know.

The write cannot trust that the conversation did the right thing. So it doesn't ask the model whether the parcel is eligible; it checks, and gates the mutation on its own check.

(In the runnable repo, commit_location and check_eligibility both call one shared rule function rather than one tool invoking the other — a decorated tool is a wire object, not a plain Python callable. Same check, written once, run from both.)

Eligibility is a guard, not a state. An ineligible parcel was never a place to get stuck — it's simply a write that doesn't happen.

That also explains the return type. commit_location hands back the same Eligibility verdict as the probe: if it committed, you get eligible=True; if it refused, you get the reasons why, in the exact shape the model already knows how to read. A blocked commit isn't an error to recover from — it's the same verdict, telling the model to pick another parcel.

That's hat #5: a tool as a guarded write — the one tool that changes the world, and the one that trusts nothing but its own check before it does.

Five hats, one primitive

And that's the five. One primitive — a function with a name, a description, and a typed contract — wearing five hats across a single errand:

  • a menulist_permit_types
  • an actionselect_permit_type
  • a stateless querysearch_location
  • a verdictcheck_eligibility
  • a guarded writecommit_location

Here is the whole errand as one picture — the model driving five tools, four reversible probes and the single write that re-checks itself. Pan and zoom it:

We never reached for a Resource, a prompt, sampling, or elicitation. The whole front half of the journey — explore freely, then commit once — fell out of tools and types alone.

That's how far one primitive carries you through the front half. The back half asks more of it: most of these tools will write, and some you're meant to call more than once. Same primitive, different rhythm.

That's Part 2.