Using Exchange¶
Exchange is the object a handler uses to communicate with its caller while a
call is running. Returning a value ends the handler’s work; the exchange lets
it send progress or a question before that final result, receive the answer,
and access the caller’s session.
The library creates one exchange per invocation, with the call ID, client
capabilities, protocol adapter, request, and response channel. These tie
ex.progress(...) and ex.ask(...) to the correct call even when many clients
are using the same handler concurrently. See
the confirmation example
for the full path from client arguments to a question and final result.
Add an annotated Exchange parameter to use these facilities. The library
supplies the object automatically; you do not register an exchange provider or
add it to the argument model. Handlers that just compute and return a value can
omit it.
Arguments belong to the client; the exchange belongs to the server¶
from pydantic import BaseModel, Field
from aiohttp_tiny_mcp import Client, Exchange, Registry
from aiohttp_tiny_mcp.protocol.selection import AdapterSet
registry = Registry("batches", "1.0")
class Work(BaseModel):
steps: int = Field(ge=1, le=100)
@registry.tool(streaming=True)
async def process(args: Work, ex: Exchange) -> str:
"""Process a batch and report how many steps are complete."""
for step in range(args.steps):
# Do one unit of application work here.
await ex.progress(step + 1, args.steps)
return f"processed {args.steps} steps"
The input schema contains only steps. The name ex is a Python convention;
the Exchange annotation is what requests injection. Other annotated
parameters can receive application dependencies in the same
way, after you register their providers.
streaming=True lets this HTTP tool send progress before its final result.
The client can collect these messages with on_notification:
progress = []
async def notification(message):
if message.get("method") == "notifications/progress":
progress.append(message["params"]["progress"])
url = await serve(registry)
adapter = AdapterSet.default().by_version["2025-11-25"]
async with Client(url, adapter, on_notification=notification) as client:
await client.initialize()
tools = await client.list_tools()
result = await client.call_tool("process", {"steps": 3})
assert set(tools[0].input_schema["properties"]) == {"steps"}
assert progress == [1, 2, 3]
assert result.content[0].text == "processed 3 steps"
What to use it for¶
Need |
API |
Details |
|---|---|---|
Report completed work |
|
|
Send a diagnostic message to this client |
|
Requires a response stream and a client log level; see Logging |
Ask for a decision or missing value |
|
Returns an |
Inspect client metadata |
|
Client-reported information and capabilities, not authenticated identity |
Use a transport-provided session |
|
May be |
Open or use an explicit session handle |
|
Async methods; the caller must send the handle on later calls |
Read saved state after a question |
|
State explicitly supplied through |
Access the transport request |
|
An aiohttp request over HTTP; stdio supplies a request-like object |
Observe cancellation |
|
Useful for long-running work |
You do not need an exchange to return a result or raise an ordinary handler exception. Those are handled by the dispatcher.
Lifetime: an invocation can end before the conversation does¶
An exchange is local to the worker executing the handler. It can contain an open stream, resolved dependencies, and cancellation state. It cannot be stored in a database or transferred to another worker.
This matters when asking a question. For a protocol that delivers a question
on the open stream, await ex.ask(...) waits and returns in the same invocation.
For a protocol that delivers it as an input-required result, the handler exits.
The client sends a follow-up call with the answer, potentially to another
worker, and the handler runs again from its first line with a new exchange.
Put irreversible work after the final question, because code before it may run
again. Use stable question keys so the repeated call can match previous answers.
If you need to preserve a computed value, explicitly supply JSON-serializable
state with NeedInput(..., state=...); it will be available as ex.state on the
next invocation. The asking guide
shows this pattern.
For values shared across independent calls, use application storage or sessions. For background resource-change notifications, publish through the hub. Keeping an old exchange would tie that work to a finished request and one worker.
Next: Dependencies explains how to supply database clients and other application objects. Asking the user builds a handler that pauses for a decision.