chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
# Progress
|
||||
|
||||
A tool that takes thirty seconds and says nothing for thirty seconds looks broken.
|
||||
|
||||
**Progress notifications** fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line.
|
||||
|
||||
## Report it from the tool
|
||||
|
||||
Take a **`Context`** parameter and call `report_progress`:
|
||||
|
||||
```python title="server.py" hl_lines="8 11"
|
||||
--8<-- "docs_src/progress/tutorial001.py"
|
||||
```
|
||||
|
||||
Three arguments, and you decide what they mean:
|
||||
|
||||
* `progress`: how far you are. The spec requires it to **increase** with every report; never repeat a value or go backwards.
|
||||
* `total`: how much there is in total, if you know. Optional.
|
||||
* `message`: one human-readable line about *this* step. Optional.
|
||||
|
||||
`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **[The Context](context.md)** page is all about that object; progress is one of the things it gives you.
|
||||
|
||||
## Listen for it from the client
|
||||
|
||||
The client opts in **per call**, by passing `progress_callback=` to `call_tool`:
|
||||
|
||||
```python title="client.py" hl_lines="7 16"
|
||||
import anyio
|
||||
from mcp import Client
|
||||
|
||||
from server import mcp
|
||||
|
||||
|
||||
async def show(progress: float, total: float | None, message: str | None) -> None:
|
||||
print(f"{message} ({progress}/{total})")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool(
|
||||
"import_catalog",
|
||||
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
|
||||
progress_callback=show,
|
||||
)
|
||||
print(result.structured_content)
|
||||
|
||||
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`.
|
||||
|
||||
!!! info
|
||||
`Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)**
|
||||
page is built on. `progress_callback` is the same parameter whatever transport the `Client`
|
||||
uses; the *timing* you are about to see is the in-memory connection's. It runs your callback
|
||||
inline, so every report lands before `call_tool` returns. Over a real transport the
|
||||
notifications race the result, and a slow callback can still be running after `call_tool` has
|
||||
returned.
|
||||
|
||||
### Try it
|
||||
|
||||
Put `client.py` next to `server.py` and run it:
|
||||
|
||||
```console
|
||||
python client.py
|
||||
```
|
||||
|
||||
```text
|
||||
Imported https://example.com/a.json (1/2)
|
||||
Imported https://example.com/b.json (2/2)
|
||||
{'result': 'Imported 2 records.'}
|
||||
```
|
||||
|
||||
Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working.
|
||||
|
||||
!!! warning
|
||||
`progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument
|
||||
for it, because different calls want different callbacks: one drives a download bar, the next
|
||||
one a log line.
|
||||
|
||||
!!! check
|
||||
Now delete `progress_callback=show` and run it again:
|
||||
|
||||
```text
|
||||
{'result': 'Imported 2 records.'}
|
||||
```
|
||||
|
||||
No error, no warning, same result. `report_progress` is a **no-op when the caller didn't ask
|
||||
for progress**, so you report unconditionally and never have to wonder whether anyone is
|
||||
listening.
|
||||
|
||||
## When you don't know the total
|
||||
|
||||
`total` is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header.
|
||||
|
||||
Leave it out:
|
||||
|
||||
```python title="server.py" hl_lines="20"
|
||||
--8<-- "docs_src/progress/tutorial002.py"
|
||||
```
|
||||
|
||||
The callback receives `total=None`. A client can still show *activity* ("3 imported so far...") but it can't show a percentage. Don't invent a total to get a prettier bar.
|
||||
|
||||
!!! tip
|
||||
`progress` doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the
|
||||
user would recognise, and only promise a `total` you can keep.
|
||||
|
||||
## Recap
|
||||
|
||||
* `await ctx.report_progress(progress, total=None, message=None)` from any tool that takes a `Context`.
|
||||
* The client passes `progress_callback=` to `call_tool`: per call, never on the `Client`.
|
||||
* The callback is `async (progress, total, message) -> None` and fires while the tool is still running.
|
||||
* No callback on the call means `report_progress` does nothing. Report unconditionally.
|
||||
* Omit `total` when you don't know it; the callback gets `None`.
|
||||
|
||||
Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**.
|
||||
Reference in New Issue
Block a user