Handle tool call lifecycle
For: plugin authors.
Handle cancellation, timeouts, idempotency and a busy service.
Cancels, time limits and the idempotency key
A call can be stopped while it runs: its job is cancelled, a kill switch stops its tool, or it runs past its tool's timeout. The node then cancels the MCP request, which sends your server notifications/cancelled (MCP's cancellation), and stops the job's own server when the call ran in a sandbox. The job shows cancelling until the call stopped and CANCELED after, never before.
Make your functions cancellable: async functions that await are cancelled where they wait, as the Python MCP SDK cancels the request's task. A synchronous function that blocks cannot be stopped on the plugin's shared server, and what it does after the cancel still lands.
Every call carries an idempotency key in its _meta, _meta["oz0/idempotency_key"], the same on each attempt of one call. Use it to make a write safe to repeat: keep the key with what you did, and answer a call with a key you have seen with what you answered then. Say that you do in the function's own _meta, and the platform retries the function's failed calls when it is also idempotent_hint=True. The key is:
<job>/<activity>for most calls: different for every call (with anr<k>/before the activity after a long job continued as new).
<job>/s<n>/<function>#<digest>/<k>
Use this key for a harness session's call of a function that says idempotent_hint=True: the k-th call of the function with the same arguments (<digest>, 32 hex digits of their SHA-256) in the job's n-th session, with r<m>/ before s<n> after the job continued as new. A fresh session that starts after its node was lost makes the lost session's calls again and gets the same keys, so a server that keeps its keys does nothing twice: that is what lets such a session start again by itself.
Two calls with the same arguments in one session, such as set_status(open) after a set_status(closed) that followed the first set_status(open), still get keys of their own.
from mcp.server.mcpserver import Context
from mcp.types import ToolAnnotations
@server.tool(
annotations=ToolAnnotations(idempotent_hint=True, destructive_hint=False, open_world_hint=True),
meta={"oz0/idempotency_key": True},
)
def charge(order_id: str, amount: int, ctx: Context) -> str:
key = (ctx.request_context.meta or {})["oz0/idempotency_key"]
return billing.charge(order_id, amount, idempotency_key=key) # the provider's own idempotency key
Here idempotent_hint=True says what MCP's hint says: the same call made again has no further effect. In a harness session, a second charge("o-1", 500) is still a second charge (its key ends in /2); only a fresh session's repeat of the lost session's call is answered from your record. Leave the hint out of a function where even a fresh start's repeat must act again.
Progress, and retries that resume
A call the platform may make again (a read-only function's, or one whose server honours the key) asks its server for MCP's progress notifications, and the node keeps the last progress (its value and total) with the call while it runs. Never the notification's message, which is your server's own text. When an attempt fails, such as one that ran past the tool's time limit, the next attempt reads where the one before got to; an attempt that fails before its server reported anything passes on what the attempt before it left.
MCP's progress has no way to resume: a progress token names one request only. So resuming is a convention between the node and a remote server (a url: tool on a connection) that says it takes part. The server says so in the function's listing, _meta: {"oz0/resume": true}, and puts a token of its own in each progress notification's _meta, "oz0/resume": "<token>" (letters, digits and ._:~+/=-, at most 256 characters). The next attempt of the call then carries _meta["oz0/resume"] = {"token": "<the last token>", "progress": <its progress>}: answer it from where that token says. Any other retry starts over.
The job's trace says which, on the call's step: "resumed from progress 2 of 4", or why it started over. A plugin's own server shares one session among its calls, so the node keeps its progress, but takes no resume token from it.
@server.tool(annotations=ToolAnnotations(read_only_hint=True), meta={"oz0/resume": True})
async def search(q: str, ctx: Context) -> str:
meta = ctx.request_context.meta or {}
start = (meta.get("oz0/resume") or {}).get("token") # where an attempt before this one got to
...
Attempts of one call never overlap: an attempt stops itself a little before its time limit (the limit less two seconds, or less a tenth of a short one), counted from when the attempt started on the node, so time a call waited for a free slot is not taken from it; the next attempt starts only after.
Report a busy service, never retry it
When the service behind a function answers that it is busy (HTTP 429, or a 503 that names a wait), answer the call with
an error result that says so, in the _meta the platform reads, and do not try again inside the tool:
from mcp.types import CallToolResult, TextContent
def busy(retry_after: float | None) -> CallToolResult:
meta = {"oz0/retryable": True}
if retry_after is not None:
meta["oz0/retry_after"] = retry_after # seconds, from the provider's Retry-After
return CallToolResult(
content=[TextContent(type="text", text="The billing API is busy (429); try again later (retryable)")],
is_error=True,
_meta=meta,
)
Before each retry, the platform checks the current tool policy again. If the call is now refused or needs a new approval, it stops before another attempt.
The platform has one retry layer, the tool call's own. A tool that waits and retries inside itself holds a slot of the
node while it sleeps, hides the wait from the job's trace, and multiplies the attempts the platform already makes; a
write it retries without the call's idempotency key may happen twice.
With oz0/retryable:
- a function that may be made again (it only reads, or it is idempotent and says it honours the key) gets its next attempt after the wait, under the same key, while it has attempts left and the wait is at most a minute;
- any other function gets one attempt, and the model reads your error, with the wait in your words, as it reads any error result. So does the last attempt of the others, and a wait longer than a minute.
A remote server you only wrap (url:) cannot set _meta the platform reads, so the node does it: when the server answers the call's own request with HTTP 429, the call's result is such an error, with the server's Retry-After, and nothing was made. A 429 on the session's other requests says nothing about the call, so a call that then fails stays a failure whose outcome is unknown.
The same rules then apply as to your own server's functions, once the tenant approved the server's listing: a function that only reads, or is idempotent and honours the key, is called again after the wait, under the same key; any other gets one attempt. oz0 plugin test --drill checks the other half, that a write made again under its key is made once: see a failure drill.