Tools That Change Things

MCP in Practice, Part 2 — writes, feedback loops, idempotency, polling, and the proactive gap.

Part 2 of a three-part, hands-on series. We keep building the same permit-application server — a citizen applying to use public land — and watch the same MCP primitive flip from reading to writing.

Follow along. This picks up the FastMCP server shape from Part 1 and adds the back half, tool by tool. Part 1's runnable code is in the repo — github.com/roman-romanov-o/mcp-article — while this part focuses on the design and code shape of the next slice.

Part 1 made a promise and kept it: explore all you like, nothing changes until you say so. Four reversible probes, one guarded write.

The back half of the errand turns that inside out. From here, almost every tool writes — and stranger still, some of them are tools you're meant to call more than once.

It's the same primitive as before, the same name-description-schema. But the rhythm flips: from roam, then commit to commit, check, commit again.

The first write of the back half

commit_location left us in ChecklistIssued, and that state arrives carrying a list — stamped onto the application by the same write that advanced it: the documents this permit, on this parcel, requires before it can move. Proof of identity, a site plan, an insurance certificate.

The first tool of the back half is how the model answers that list:

class DocumentKind(str, Enum):
    IDENTITY = "IDENTITY"
    SITE_PLAN = "SITE_PLAN"
    INSURANCE = "INSURANCE"


class DocumentReview(BaseModel):
    accepted: bool
    missing: list[DocumentKind]


@mcp.tool
def submit_documents(filed: list[DocumentKind]) -> DocumentReview:
    """File the documents the application's checklist asks for.

    Review runs immediately. If anything required is missing, the call
    comes back listing exactly what — add it and submit the set again.
    """
    missing = [kind for kind in _application.checklist if kind not in filed]
    if missing:
        return DocumentReview(accepted=False, missing=missing)

    _application.status = "FeesAssessed"
    return DocumentReview(accepted=True, missing=[])

Look at what the body does on the happy path: _application.status = "FeesAssessed". That single line is the inversion made real.

In Part 1, only commit_location ever touched application state, and only once. Here it's routine — the tool's job is to move the machine forward. Call search_location ten times and nothing happens; call submit_documents with a complete set and the application is somewhere new.

And the return type is an old friend wearing new clothes. DocumentReview is Eligibility again — a boolean plus the grounds for it. accepted is the verdict; missing is the reasons list renamed for the occasion.

A refused submission isn't an error to trap and recover from. It's a verdict that hands back what's wrong, in a shape the model already knows how to read from Part 1. Which is the whole reason the docstring can end on a plain instruction — add it and submit the set again — and mean it literally.

A tool you call until it says yes

Take those three words seriously, because they describe a shape Part 1 never had.

Every tool there was call-once: you searched, you checked, you committed, and each call was a finished thought. submit_documents is the first tool whose natural use is a cycle — file what you have, read back what's missing, file that, read again — until the verdict flips to accepted.

The model isn't retrying after a failure. It's working a loop that was designed to be worked.

ChecklistIssuedsubmit_documents → accepted?

  • no (missing: SITE_PLAN) → back to ChecklistIssued
  • yesFeesAssessed

One simplification is hiding in that loop, and it's worth naming. Real document review takes time — a human, or a slower check, parks the application in a waiting state while it runs. We've collapsed that: review happens inline, inside the call, so there's no resting "under review" state to sit in.

That keeps the loop in one place for now. It also quietly sets up a question we'll return to near the end of this part — what happens when a step can't answer immediately, and the model is left waiting on the world.

The loop lives in the state machine — ChecklistIssued is the state you stay in until the documents clear — but nothing about the protocol knows that. There's no "loop" primitive, no retry flag, no notion on the wire that this tool is special.

The cycle is built entirely out of the one thing we control: the return type. accepted tells the model whether to go around again; missing tells it what to change before it does.

Hand back a bare bool and the loop seizes — the model knows it failed but not what to fix, so its only move is to guess, or give up. The structured verdict is what makes the loop converge instead of spin.

The return type isn't reporting on the loop. It is the loop.

The bill, and the smallest possible write

The documents cleared, and the application is in FeesAssessed. The server has already totted up what this permit costs. Two tools live in this state, and they sit at opposite ends of the range we've been walking.

class FeeLine(BaseModel):
    description: str
    amount_eur: int


class FeeSchedule(BaseModel):
    lines: list[FeeLine]
    total_eur: int


class ConsentAck(BaseModel):
    status: str
    message: str


@mcp.tool
def get_fee_schedule() -> FeeSchedule:
    """Show the fees assessed for this application, line by line.

    Relay these to the citizen before acknowledging them.
    """
    return _application.fees


@mcp.tool
def acknowledge_fees() -> ConsentAck:
    """Confirm the citizen has seen the fees and agrees to proceed to payment.

    Takes no arguments — its whole content is that consent. Call it only
    once the citizen has actually agreed.
    """
    _application.status = "AwaitingPayment"
    return ConsentAck(status="AwaitingPayment",
                        message="Fees acknowledged. Ready for payment.")

get_fee_schedule is a read, and its return is a kind we haven't met yet.

Eligibility and DocumentReview were verdicts for the model — booleans it branches on. A FeeSchedule isn't for the model at all. It's a statement for the model to relay: line items and a total, in the shape a human wants to be shown a bill.

The audience of a return value isn't always the thing that called the tool. A schedule the model "reads" only to pass along is a different design object than a verdict it acts on.

acknowledge_fees is the opposite extreme — the narrowest write in the whole errand. submit_documents carried a payload; commit_location carried a parcel id. This one takes nothing.

Its entire content is the word yes: the citizen saw the bill and agreed to proceed. That's a tool shape worth naming on its own — a pure consent gate, a transition whose only argument is a human's permission.

And it writes on the conversation's word alone. There is no guard it could run, no check it could make, because consent is the one fact a server cannot observe for itself. The model is its only witness.

That's precisely why it earns its own narrow tool, fenced off one step before money moves — nothing should debit a citizen who didn't agree first, and the agreement is exactly the thing only this tool can record.

A write that can fail

Consent given, the application sits in AwaitingPayment, and now the most consequential write so far runs: the one that moves money.

class PaymentResult(BaseModel):
    paid: bool
    reason: str | None


@mcp.tool
def pay(method_token: str) -> PaymentResult:
    """Charge the assessed fees against a payment method.

    May fail for an ordinary reason — a declined card, a timeout. The
    result says which, so you know whether to switch methods or just retry.
    """
    outcome = _payments.charge(_application.fees.total_eur, method_token)
    if not outcome.ok:
        return PaymentResult(paid=False, reason=outcome.reason)

    _application.status = "UnderReview"
    return PaymentResult(paid=True, reason=None)

This is the second loop of the back half, and the surprise is that it is not the first one again.

The document loop converged by changing the input — every trip around fixed something that was missing, and the loop ended because there was nothing left to fix. The payment loop retries the same input: the card didn't change, the timeout wasn't your fault, you simply go again.

Two loops that look identical on a state diagram, and the model's job inside them is opposite — one fixes, one repeats. The only thing that tells it which loop it's in is the return: reason is what separates "declined — ask for another card" from "timed out — just try again."

And notice, once more, when the state actually moves. paid=True and status = "UnderReview" are set on the same line, under the same condition: the payment processor confirmed the charge.

The model is never handed the pen. It cannot tell pay "the payment went through" and have the machine believe it — pay doesn't accept that claim, it attempts a charge and reports what actually happened.

This is commit_location's guard again, with higher stakes: the write trusts its own check, not the conversation. With eligibility, a wrong claim cost you a bad parcel. Here it would cost a citizen real money, or wave through a permit nobody paid for. The stakes are higher, but the pattern is the same one we've had since Part 1.

There's one hazard here the document loop never carried, though, and it's lurking in the word retry.

Safe to call again

Picture the most ordinary failure there is. The charge clears at the processor — the money actually moves — and then the network drops the reply on the way back.

From where the model sits, pay returned nothing. It looks exactly like the timeout you just told it to retry. So the model does the sensible thing, the thing the return type invited it to do: it tries again.

Now the citizen has paid twice for one permit.

Part 1 handed out retries for free — search ten times, check ten times, nothing happens. But that freedom was a property of reads, not a law of nature. A write does not inherit it.

If you want the model to be able to call pay again without fear, you have to build that safety. The tool above only looks safe:

outcome = _payments.charge(
    amount=_application.fees.total_eur,
    method=method_token,
    idempotency_key=_application.id,   # the charge is keyed to the
)                                      # application, not to the call

The key is the whole fix. Tie the charge to something stable about the application rather than to each individual call, and the processor can recognize a repeat: instead of charging a second time, it replays the result of the first.

One honesty check on that key: it has to be stable across retries of one charge yet distinct across charges genuinely meant to differ. Here there's a single fee, paid once, so the application id serves. The moment fees can be re-assessed — and the decision loop later in this errand allows exactly that — you'd key to the assessment, not the application's whole lifetime, or a second, legitimate charge would be silently swallowed as a duplicate.

The model's naive "just try again" becomes harmless — not because the model was disciplined, but because the tool was built to absorb the retry.

That's the tax a looping write pays that a looping read never does. "Idempotent" is just the engineer's word for safe to call again, and it's the property that lets you hand a model a retry loop without also handing it a way to charge a citizen twice. Reads are safe to repeat by nature; writes are safe to repeat only by design.

Waiting on the world

pay came back paid=True, and the application slid into UnderReview. And then — nothing.

For the first time in the whole errand, the model has no tool to reach for, because the next thing that has to happen isn't its to do. The decision belongs to an authority on its own clock — a person, or a committee, or a queue somewhere. It happens off-stage, and it takes as long as it takes.

That's a different kind of wait from any we've engineered. The document loop waited, but it waited on the citizen to file the right papers — work the model could drive. The payment loop waited, but a retry was always a move the model could make. Here the model has run clean out of moves.

And the thing it's waiting for doesn't even exist yet. Every tool until now either changed state or read state that was already sitting there. But approved or denied hasn't been written by anyone, and won't be until someone, somewhere, decides.

This is the waiting state we collapsed away back at document review, arriving for real — the step that simply cannot answer the moment it's called.

The only move left: ask again

If the model can't make the decision happen, it can at least keep asking whether it has.

That's the last tool of the errand, and it's a read — the cleanest one since search_location, back at the very start of Part 1. After a whole sequence of writes, the journey ends where it began, on a tool that only looks:

class Decision(str, Enum):
    PENDING = "PENDING"
    APPROVED = "APPROVED"
    DENIED = "DENIED"
    MORE_INFO = "MORE_INFO"


class StatusReport(BaseModel):
    decision: Decision
    detail: str | None     # the rationale once decided; what's still needed if MORE_INFO


@mcp.tool
def check_status() -> StatusReport:
    """Check whether the authority has reached a decision yet.

    Safe to call any time, as often as you like. While it's pending the
    report says so; once a decision lands, it carries the outcome and why.
    """
    return _application.report()

Three things this small tool gets right, each one a callback.

First, it's safe to call again for free — and now you can see why that phrase took a whole section to earn over at pay. There it was engineering; here it's just what a read is. Polling leans entirely on that property: the model will call this dozens of times, and a read costs nothing to repeat.

Second, PENDING is a verdict, not an absence. The lazy version returns None until there's news; this one returns a decision whose value happens to be "not yet," and the model branches on it just as it did on eligible and accepted. Keep waiting is an answer, not an error.

Third, look where MORE_INFO points: straight back into the document loop. The state machine was never a line — it's a graph, and the far end can fold the citizen back to ChecklistIssued to file what the authority now wants. The "fix the input and resubmit" loop from the front of this part can be re-entered from the very end.

Put the whole back half on one canvas and you can see all of it at once — the two loops that converge for opposite reasons, and the fold-back that makes the machine a graph:

And with that, the errand is complete. Ten tools, across two parts:

list_permit_types, select_permit_type, search_location, check_eligibility, commit_location, submit_documents, get_fee_schedule, acknowledge_fees, pay, check_status.

A citizen can walk in wanting to throw a festival and walk out with an approval or a reasoned no. Nothing but functions with names, descriptions, and typed contracts carried the whole journey.

And yet there's one thing those ten tools still can't do.

The one move no tool can make

Go back to what the citizen actually wanted at the end. Not "give me a way to check" — nobody dreams of refreshing a page for three weeks. What they wanted was simpler and entirely reasonable: tell me when it's decided.

And that — the very last wish in the errand — is the one thing none of our ten tools can grant.

The reason isn't a flaw in check_status; that tool is flawless at its job. It's a property of the primitive itself. A tool runs only when it's called. By construction it is a thing that responds — it cannot originate.

Every move in this entire series has been the model reaching for a tool. Not once has a tool reached back on its own. Polling exists precisely because of that asymmetry: the model has to keep asking, because nothing on the other side can tell it.

"Tell me when it's ready" needs someone on the far side to speak first, unprompted — and speaking first is the one verb the tool vocabulary simply does not contain.

You might expect the protocol to have an escape hatch, and it half does. An MCP server can emit notifications. But trace where a notification actually goes: server → client. Not server → human.

The client — some app, some agent runtime — is free to log it, batch it, drop it on the floor, or surface it three hours later when the user next happens to look. There is no channel in MCP for "reach the person, out of band, the moment this lands." The server can speak; what it cannot do is guarantee it is heard by the one who's waiting.

That final hop — from the system to the human who actually wanted to know — is the one the protocol doesn't carry. Polling is the workaround we settle for because that push can't be made to land, not because it's a tidier design.

The newer Tasks utility doesn't erase that distinction. Tasks make long-running work durable and give a requestor a structured thing to poll later, which is useful for exactly this kind of waiting. But the shape is still requestor-driven retrieval: someone comes back and asks for the result. It improves the polling story; it doesn't create an address for an absent human or wake them up out of band.

That is the proactive gap, and it is the thread the rest of this series pulls on. It isn't a rough edge to be filed down in a point release. It's the shape of what tools — and, it turns out, much of MCP as it stands — fundamentally are. They are superb at being asked. The errand's last inch asks them to speak unbidden, and that inch is the one they can't cross.

Where it stops carrying you

Tools didn't run out of road at the document loop, or the retry, or the long wait on the authority. They carried the whole errand, first move to last. Where they stop is that final inch: the message that has to travel from the server to a waiting human who didn't ask for it.

That's not a tool-shaped problem. Part 3 is about the parts of MCP that exist for the moves a tool can't make — how close they get to closing the proactive gap, and where it stays open.