35 min

Exposing skills over MCP: a tool that moves matter

The protocol is the part you already know. What is new is that this tool is slow, cannot be retried for free, fails physically, and can return success while its postcondition is false.

Where you are. You have skills with typed arguments, and you know they must measure their postcondition rather than assert it. This lesson puts them on a wire. The wire is MCP, the Model Context Protocol, the open standard for handing a language model a set of tools it can call, and it is one you already know, which is exactly why it is worth being careful here.

The call that took forty-one seconds and lied

Your server exposes one tool. It takes {"object": "cup"}. You have shipped this shape of server many times, and the client is the same client.

You send the call. Nothing comes back. Four seconds. Ten. Twenty. You check the process; it is alive and idle. At forty-one seconds the response arrives:

{"status": "success"}

The cup is on the floor.

Three things just happened that never happen when a tool reads a database. The call blocked for forty-one seconds, during which the joint controller underneath it ran somewhere between eight and forty thousand cycles. It changed something no retry can put back. And the return value was not wrong because of a bug: {"status": "success"} is what the documented example returns, and that example has no way to know whether the cup came up with the gripper.

There is a fourth thing, and it is the one that will hurt you. The retry logic in your client, the code that has quietly saved you a hundred times, is now the most dangerous component in the system.

The idea in one paragraph

Nothing about defining the tool changes. A name, a description, a JSON Schema for the arguments, a result: that is the whole surface, and you have written it before. What changes is every assumption underneath it. A skill is slow, seconds rather than milliseconds, which turns the protocol round trip from overhead into the thing that keeps your planner off the robot’s critical path. It cannot be retried for free, so at-least-once delivery, the polite default everywhere else in distributed systems, becomes a second grasp at an object that has already moved. It fails physically, in ways that are ordinary rather than exceptional, and those failures have to come back as results the model can read rather than as errors it cannot. And its postcondition can be false while the call succeeds: the command was accepted, the motion ran to completion, and the cup is on the floor. Every design decision in this lesson is one of those four sentences with consequences attached.

Four assumptions that stop holding

What you assume about a toolWhat is true of a skill
It returns in tens or hundreds of millisecondsIt returns in one to sixty seconds; the motion is the latency
A dropped response is safe to re-issueA dropped response has already moved the arm
Failure is exceptionalFailure is routine. A grasp that works nine times in ten is a good grasp
The result describes the callThe result has to describe the world

Blocking is the feature, not the problem

The instinct from a decade of API design is to make the call return immediately and hand back a job id. Resist it for the common case.

Read that as an architecture rather than an API detail. While the model waits, the robot is moving. The planner is off the real-time critical path by construction, and the model’s next decision is made against a world that has genuinely changed rather than one it is guessing at.

The retry that moves the arm twice

Now the dangerous part. As of the 2026-07-28 revision of the protocol, Streamable HTTP has no resumability: Last-Event-ID is gone and the specification states flatly that streams are not resumable. A broken stream loses the in-flight response, and there is no protocol-level way to ask for it again.

What the specification does say about a dropped stream is the other half of the problem. Closing the response stream is the cancellation signal, and the server should stop work on that request as soon as practical. Should. A robot arm is not a database query you can abandon halfway, and nothing in the protocol makes the motion stop. So you are left with a client holding no result, a server that may or may not have aborted a physical action, and no defined way to ask which. Re-issuing the call is what a client does next, and it is your own decision rather than something the specification prescribes.

For a tool that writes a row, that is correct and unremarkable behaviour. For a tool that closes a gripper, it is a second grasp.

pick(cup) request id 1 the stream drops no resumability · reply lost pick(cup) again re-issued as request id 2 grasp failed the only reply you ever see the arm moves grasp 1 · the cup is lifted the arm moves again grasp 2 · closes on nothing one instruction · two motions · nothing in the protocol prevents it
A dropped response and one retry: the stream is not resumable, the client re-issues the pick under a new request id, and the arm performs the motion twice

Wider than the screen; scroll it sideways.

What you want instead is a call that can be replayed. The protocol’s own guidance points straight at it: cross-call state now lives in server-minted handles passed as ordinary tool arguments, where a creation tool returns an opaque handle, later calls take it as an argument, the description states the retention policy, and a call against an expired handle comes back as a tool execution error the model can recover from.

For a robot that handle is a motion goal id. begin_pick mints one and starts the motion; await_goal polls it. A lost response then costs you a poll rather than a grasp, and a replayed call returns the truth about the motion that already happened. The exercise at the end of this lesson makes both versions run so you can count the arm movements.

Long motions want to be tasks

Some motions are too long for a blocking call to be honest about: a traverse across a room, a slow insertion, anything you would want to watch. For those the tasks extension is the right primitive.

The shape is familiar. The server returns a task result carrying a taskId, a status, a time-to-live and a suggested poll interval, and the task is durably created before the response is sent. States are working, input_required, completed, failed and cancelled, the last three terminal.

call pick(cup) blocking · one open request 40 s of silence the handle is the connection the client dies and restarts a second later nothing to ask with arm pose unknown call pick(cup) returns a taskId at once status: working durable before the reply the client dies the taskId outlives it poll it and find out working · completed · failed same motion, same crash · only one of them can ask what happened
Two shapes for one motion: a blocking call where a client crash loses everything, and a task where the id outlives the client and can be polled after a restart

Wider than the screen; scroll it sideways.

The property that matters for a robot is durability. The task id survives a client crash. Your planner process can die while the arm is mid-motion, restart, and ask what happened. With a blocking call it comes back knowing nothing, in front of a robot in an unknown pose, which is the single worst state to be in.

Two channels for failure, and only one of them teaches

Protocol errors, meaning an unknown tool, a malformed request or a server fault, come back as JSON-RPC errors. The specification notes that clients may pass these to the model while observing that they are less likely to lead to successful recovery, which is a polite way of saying the model can do nothing useful with “method not found.”

Tool execution errors are the other channel: a normal successful result carrying isError: true and human-readable text. Here the specification says clients should hand them to the model, precisely so it can self-correct.

a skill call fails in one of two ways JSON-RPC error no such tool · arguments off schema little the planner can do the request never reached the robot a result, isError: true grasp failed · cup now on the floor the planner corrects itself reads the sentence, picks a new step almost everything a robot does wrong arrives as a successful call
Two failure channels: protocol errors return as JSON-RPC errors the model cannot act on, while execution failures return as ordinary results flagged isError so the planner can self-correct

Wider than the screen; scroll it sideways.

What happenedWhich channel
The planner called pick_up; your tool is named pickprotocol error
object arrived as a numberprotocol error, from schema validation
The gripper closed on airresult, isError: true
The cup is behind the monitor and out of reachresult, isError: true
The arm is in a fault state and will not moveresult, isError: true

Notice that only the top two are about your server. Everything a robot actually does wrong is in the bottom half, and all of it is a successful call.

Which makes the text of that result an interface, not a log line. You already know a skill has to describe the world rather than assert a status; what the wire adds is where that description goes. It goes in the text the model reads. Not grasp_failed, but grasp failed: the cup slipped and is now on the floor near the left edge of the desk; the gripper is empty and at the pick pose. Put the same facts into structuredContent against a declared outputSchema so your own code can branch on them without parsing prose, and let the two say the same thing.

This is the part with no standard, and you should know that going in. The protocol gives you a flag and a string. What a failed skill owes the planner is entirely your invention. Research systems have proposed richer contracts, treating skills as symbolic operators with declared preconditions and effects so a planner can check feasibility before committing. Nobody has shipped one. You get a flag and a string, and the discipline has to come from you.

What the protocol will not do for you

Five gaps, none of them defects. They are simply the boundary of what a general tool protocol can promise.

  • No units, no frames. A schema types x as a number. It cannot say metres, and it cannot say measured from what, which after Module 1 you know is the more dangerous omission. Push both into the name and the description: x_m_base beats x and costs nothing.
  • No preemption. Nothing in the protocol interrupts a running call.
  • No real-time anything. A round trip at 1 to 2 Hz is comfortable, and latency budgets puts real numbers on that. A control loop is not remotely on the table, and the failure mode when someone tries is the arm oscillating around a target it is being told about far too slowly.
  • Annotations are not a safety property. The specification requires clients to treat tool annotations as untrusted unless they come from a trusted server. “The server declared this tool non-destructive” is a claim made by the server, not a guarantee about the robot.
  • No postcondition standard. Covered above, and worth repeating because it is the one people assume is handled.

The conclusion is a placement rule. This protocol belongs at the planner tier and nowhere else: it is how a slow deliberate model asks for a skill and hears what happened. Below it, the policy and the controller talk to the robot over interfaces built for the job. If you ever find yourself measuring the jitter of a tool call, you have put it in the wrong layer.

Review

The protocol is the part you already know

Nothing about defining the tool changes. A name, a description, a schema for the arguments, a result: that is the whole surface, and you have written it before. What changes is every assumption underneath it. A skill is slow, seconds rather than milliseconds, so the round trip stops being overhead and becomes the thing that keeps your planner off the robot’s critical path. It cannot be retried for free, so at-least- once delivery, the polite default everywhere else in distributed systems, becomes a second grasp at an object that has already moved. It fails physically, in ways that are ordinary rather than exceptional. And its postcondition can be false while the call succeeds: the command was accepted, the motion ran to completion, and the cup is on the floor.

Failure is routine, and it has to be readable

A grasp that works nine times in ten is a good grasp. That single sentence rearranges the design. Failure is not exceptional here, so it cannot come back as an error the model cannot read; it has to come back as a result the planner can reason about, naming what was attempted and what the world looks like now. Two channels exist and only one of them teaches. An exception tells the planner that something went wrong and gives it nothing to plan with. A structured result that says the precondition held, the motion ran, and the postcondition is false tells it which of the three layers broke, which is the difference between retrying blindly and re-grounding the scene.

Check yourself

1. Why is "behavior": "BLOCKING" a latency feature rather than a latency cost?

Because the robot is moving during the wait. The planner’s thinking time overlaps the motion instead of adding to it, which puts the model off the real-time critical path by construction. It also means the model’s next decision is made against a world that has actually changed rather than one it is predicting. The rule that follows is to make every skill last longer than one planner round trip; skills shorter than that spend the system’s time asking permission for things that already finished.

2. A client’s stream drops mid-pick. The stream is not resumable, so the client re-issues the call. What is wrong, and why is guarding on gripper state not the fix?

The first pick already ran, or is still running: the dropped stream is a cancellation signal the server should honour, and honouring it is not the same as the arm having stopped. The re-issue is a second physical grasp at an object that has moved, or a grasp at nothing. The guard “refuse if the gripper is full” turns that into a different failure: when the first pick succeeded, the retry reports an error while the cup is in fact held, so the model replans around an object it already has. You have swapped a duplicated motion for a corrupted world model. The fix is a replayable call, meaning a goal id minted by the server and passed as an argument, so a replay is a lookup of what already happened.

3. Sort these into protocol errors and tool execution errors: (a) the model called place_object, your tool is place; (b) the gripper closed on air; (c) target was null; (d) the arm is in a fault state.

Protocol errors: (a) and (c), because the request itself is malformed or names something that does not exist. Tool execution errors, returned as ordinary results with isError: true: (b) and (d), because the call was well formed, was executed, and produced a bad outcome. The split matters because the specification says clients should hand the second kind to the model for self-correction, while the first kind is unlikely to produce a useful recovery. Nearly everything a robot does wrong is a successful call.

4. Why does a long motion want a task id rather than a blocking call, given that blocking is otherwise the recommended shape?

Durability. A task is created durably before the response is sent and its id survives a client crash or disconnect, so a planner that dies mid-motion can restart and ask what happened. A blocking call gives you nothing to ask with: the process comes back with no handle, in front of a robot in an unknown pose. The trade is polling complexity, so it is worth it for motions long enough that a crash during them is plausible, and not for a two-second grasp.

5. Your pick returns {"status": "success"} and the postcondition “the cup is in the gripper” is false. Nothing in the stack is broken. Where did the information go missing?

It was never collected. The skill reported that the command completed, which is a fact about the call, and the postcondition is a fact about the world; the only way to know the second is to go and check it, with the gripper’s own state, a force reading, or a fresh look. A result payload that carries no world state cannot carry a failed postcondition, so this failure is silent by construction. That silence is the seed of a much larger problem, and it is what three ways a plan dies is about.

6. Why is tasks/cancel not a stop button?

Because it is cooperative. The specification is explicit that the server acknowledges the intent to cancel and is not obligated to stop, and the same is true of a dropped stream, which is an unambiguous cancellation signal at the protocol level and is nothing at all to an arm mid-swing. Anything that must stop the robot lives in hardware, on a circuit that works when the software is wedged and the network is gone.

Do this

No robot and no server. The claims in this lesson are about payloads and retries, so a fake desk in one file exercises every one of them, and you can run it in under a minute.

import random

class Desk:
    """Two objects, one gripper, and a grasp that slips three times in ten."""
    def __init__(self, seed=0):
        self.rng = random.Random(seed)
        self.where = {"cup": "desk", "pen": "desk"}
        self.held = None
        self.motions = 0                  # every real arm movement counts here
        self.goals = {}

    def close_on(self, obj):              # the part that moves matter
        self.motions += 1
        if self.rng.random() < 0.3:
            self.where[obj] = "floor"     # knocked off, gripper empty
        else:
            self.where[obj] = "gripper"
            self.held = obj

def reply(text, error=False, **world):
    return {"content": [{"type": "text", "text": text}],
            "structuredContent": world, "isError": error}

def pick_stub(desk, obj):                 # what the documented example does
    desk.close_on(obj)
    return {"status": "success"}

def pick(desk, obj):                      # what a skill owes the planner
    if desk.held is not None:
        return reply(f"gripper already holds {desk.held}", error=True, held=desk.held)
    desk.close_on(obj)
    if desk.held == obj:
        return reply(f"holding {obj}", held=obj, **desk.where)
    return reply(f"grasp failed: {obj} fell to the floor, gripper empty",
                 error=True, held=None, **desk.where)

def pick_goal(desk, obj, goal_id):        # the same skill, replayable
    if goal_id not in desk.goals:
        desk.goals[goal_id] = pick(desk, obj)
    return desk.goals[goal_id]

lies = 0
for seed in range(200):
    desk = Desk(seed)
    pick_stub(desk, "cup")
    if desk.held != "cup":
        lies += 1
print(f"1. the stub reported success 200 times, {lies} of them false")

for seed in (0, 1):                       # a dropped response, then a retry
    desk = Desk(seed)
    first, retry = pick(desk, "cup"), pick(desk, "cup")
    print(f"2. seed {seed}: first={first['content'][0]['text']!r}, "
          f"retry={retry['content'][0]['text']!r}, motions={desk.motions}")

for seed in (0, 1):                       # the same two runs, replayed by goal id
    desk = Desk(seed)
    pick_goal(desk, "cup", "g1")
    retry = pick_goal(desk, "cup", "g1")
    print(f"3. seed {seed}: retry={retry['content'][0]['text']!r}, "
          f"motions={desk.motions}")

Run it. You should see this:

1. the stub reported success 200 times, 62 of them false
2. seed 0: first='holding cup', retry='gripper already holds cup', motions=1
2. seed 1: first='grasp failed: cup fell to the floor, gripper empty', retry='holding cup', motions=2
3. seed 0: retry='holding cup', motions=1
3. seed 1: retry='grasp failed: cup fell to the floor, gripper empty', motions=1

Read the three blocks in order and make sure you can say what each one proves.

  1. Nearly a third of the stub’s success reports were false, and no amount of care in the client would catch it. The information was never in the payload.
  2. Two seeds, two different ways a retry hurts. Seed 0’s first pick succeeded, so the retry reports a failure that did not happen. Seed 1’s first pick failed, so the retry moves the arm a second time. Both come from one line of entirely ordinary client code, and nothing in the protocol stands in its way.
  3. One motion in every case, and the replayed answer is the truth about the attempt that actually ran. Note that pick_goal takes its id from the caller, which is the idempotency-key form. The specification’s handle is minted by the server, which is what lets the server own the retention policy and expire the handle on its own terms. Same protection, different owner.

Then extend it. Give Desk a place(obj, target) skill and decide, before you write it, what it returns when the gripper is empty; make pick return the cup’s position as well as its name, and see how much better the text reads as a sentence to a model; and add a ttl to the goal ids so a replay after expiry becomes an error the planner has to handle rather than a stale answer it silently trusts. That last one is the retention policy the specification tells you to document, and writing it is how you find out what your skill actually promises.

What you can now do

You can put a robot skill behind a tool call without lying to the model. You can say why blocking is the right default and why a skill shorter than a planner round trip is a design error; you can make a call replayable with a goal id so a dropped response costs a poll instead of a grasp; you can choose between a blocking call and a durable task by asking whether a crash mid-motion is plausible; and you can route a failure to the channel the model can learn from, with a payload that describes the desk rather than the request. You can also list what the protocol will never give you: units, frames, preemption, real-time guarantees, a trustworthy annotation, or any opinion at all about whether your postcondition held.

What you can now do

You can put a robot skill behind a tool call without lying to the model: blocking calls, replayable goal ids, tasks for long motions, and a failure report the planner can actually reason about.