MCP (Model Context Protocol) · lesson 7 of 13
Beginner
4Intermediate
4Advanced
5Also part of MCP (Model Context Protocol)
5Logging, Progress and Cancellation in MCP
Report progress from a long MCP tool, cancel or time out a call and see the server clean up, and opt in to protocol logging, deprecated as of 2026-07-28.
By SkillByExample · Published
Long Calls Need a Side Channel
A tool that takes a minute leaves a host with nothing to show the user, and no way to stop it. MCP covers that with messages sent alongside a running request: progress notifications from the server, cancellation from the client, and (deprecated in 2026-07-28) log messages. A tool reaches them through Context, which the SDK passes to any tool parameter annotated with that type.
Reporting Progress
ctx.report_progress() sends a progress notification tied to the current call. The client receives it in the callback it passed to call_tool.
# progress.py - a long tool reports progress; the client prints it
import asyncio
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("jobs")
@mcp.tool()
async def export(rows: int, ctx: Context) -> str:
"""Export rows in batches of 100."""
for done in range(100, rows + 1, 100):
await asyncio.sleep(0.05) # stands in for real work
await ctx.report_progress(done, rows, f"exported {done} rows")
return f"exported {rows} rows"
async def on_progress(progress: float, total: float | None, message: str | None):
print(f" {progress:.0f}/{total:.0f} {message}")
async def main():
async with Client(mcp) as client:
result = await client.call_tool("export", {"rows": 300}, progress_callback=on_progress)
print(result.content[0].text)
asyncio.run(main())
$ python progress.py
100/300 exported 100 rows
200/300 exported 200 rows
300/300 exported 300 rows
exported 300 rows
Each update arrived while the call was still running, before call_tool returned. The ctx parameter doesn’t appear in the tool’s inputSchema: the SDK spots the Context annotation and fills it in itself, so a model never sees or sends it. total and message are optional in the protocol, which is why the callback’s type hints allow None. A host uses the numbers for a progress bar and message for a status line.
Cancelling a Call
The client can stop waiting at any time. Here the server runs as a separate process, so you can see the cancellation reach it.
# jobs_server.py - a stdio server with a slow tool that notices cancellation
import asyncio
import sys
from mcp.server import MCPServer
mcp = MCPServer("jobs")
@mcp.tool()
async def reindex() -> str:
"""Rebuild the search index (slow)."""
try:
await asyncio.sleep(30)
return "reindexed"
except asyncio.CancelledError:
print("server: reindex cancelled, cleaning up", file=sys.stderr, flush=True)
raise
@mcp.tool()
def status() -> str:
return "idle"
if __name__ == "__main__":
mcp.run()
The client gives up twice, once with an anyio deadline and once with the SDK’s own read timeout, then makes one more call.
# cancel.py - give up on a slow call, two ways, and keep the connection
import asyncio
import anyio
from mcp import Client, MCPError, StdioServerParameters
server = StdioServerParameters(command="python", args=["jobs_server.py"])
async def main():
async with Client(server) as client:
with anyio.move_on_after(0.5):
await client.call_tool("reindex")
print("client: stopped waiting for reindex")
await asyncio.sleep(0.2)
try:
await client.call_tool("reindex", read_timeout_seconds=0.5)
except MCPError as e:
print("client:", e.error.message)
await asyncio.sleep(0.2)
print("client: status ->", (await client.call_tool("status")).content[0].text)
asyncio.run(main())
$ python -u cancel.py
client: stopped waiting for reindex
server: reindex cancelled, cleaning up
client: Request 'tools/call' timed out
server: reindex cancelled, cleaning up
client: status -> idle
-u turns off output buffering, so the client’s lines and the server’s stderr lines print in the order they happened.
Both times the server’s reindex saw asyncio.CancelledError inside its sleep, about half a second in rather than 30 seconds later. Leaving the move_on_after block or hitting the timeout made the SDK tell the server the request was cancelled. The server then cancelled the task running the tool. The two styles differ only on the client: move_on_after exits quietly, while read_timeout_seconds raises MCPError. Re-raise CancelledError after cleaning up, as reindex does. That’s the standard asyncio rule: a task that swallows its cancellation keeps running when its caller has asked it to stop. The final status call worked, so cancelling one request doesn’t break the connection.
The short asyncio.sleep(0.2) pauses only give the server time to print before the next step. Cancellation itself doesn’t need them.
Log Messages Are Opt-In and Deprecated
ctx.info(), ctx.warning() and friends send log messages to the client. Version 2026-07-28 deprecated this, and a client receives them only if it asks.
# client_logs.py - protocol log messages arrive only when the client opts in
import asyncio
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
mcp = MCPServer("jobs")
@mcp.tool()
async def sync(ctx: Context) -> str:
await ctx.info("sync started")
await ctx.warning("3 records skipped: missing email")
return "synced"
async def on_log(params):
print(f" log [{params.level}] {params.data}")
async def main():
for level in [None, "warning"]:
async with Client(mcp, logging_callback=on_log, log_level=level) as client:
print(f"log_level={level}:", (await client.call_tool("sync")).content[0].text)
asyncio.run(main())
$ python client_logs.py 2>/dev/null
log_level=None: synced
log [warning] 3 records skipped: missing email
log_level=warning: synced
The first client passed a logging_callback but no log_level, and got nothing. Each 2026-07-28 request must opt in to log messages, and the SDK adds that opt-in only when log_level is set. The second client asked for warning and above, so sync started (an info message) was filtered out on the server and only the warning arrived. It printed before the call’s result because it arrived while the call was running.
The 2>/dev/null hid what the server side printed to stderr:
$ python client_logs.py 2>&1 >/dev/null | grep Warning
/w/client_logs.py:13: MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
/usr/local/lib/python3.12/site-packages/mcp/server/mcpserver/context.py:381: MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
/usr/local/lib/python3.12/site-packages/mcp/server/mcpserver/context.py:281: MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
/w/client_logs.py:14: MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
/usr/local/lib/python3.12/site-packages/mcp/server/mcpserver/context.py:386: MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577).
The run happened in a directory called /w, so your paths will differ. Python shows each warning once per call site, so the second loop pass added none. The SDK keeps protocol logging working for older hosts, but new servers shouldn’t rely on it. Write operator logs to stderr with the logging module, and put anything the model or user needs to know in the tool’s result or its progress messages.
Practice
- Change
exportto callctx.report_progress(done)with no total or message. What does the client receive, and what goes wrong withon_progressas written? - Make
reindexprintserver: reindex finishedwhether it completes or is cancelled, without a separateexceptfor each case. - Run
client_logs.pywithlog_level="info". How many log lines does the second client print? - Replace
ctx.info("sync started")with a log line that doesn’t use the deprecated protocol feature and is still visible to whoever runs the server.
Solution 1
The callback receives 100 None None, then 200 and 300, each with None for total and message. The format spec {total:.0f} raises TypeError on None, so write the callback to cope:
async def on_progress(progress, total, message):
print(f" {progress:.0f}" + (f"/{total:.0f}" if total is not None else ""), message or "")
Without a total, a host can show a spinner and a count, but not a percentage.
Solution 2
@mcp.tool()
async def reindex() -> str:
"""Rebuild the search index (slow)."""
try:
await asyncio.sleep(30)
return "reindexed"
finally:
print("server: reindex finished", file=sys.stderr, flush=True)
finally runs on a normal return and on CancelledError, which then keeps propagating on its own. That’s the usual home for cleanup such as releasing a lock or deleting a temp file.
Solution 3
Two: log [info] sync started and log [warning] 3 records skipped: missing email. A level is a minimum, so info lets through info and everything more severe.
Solution 4
import logging
logging.basicConfig(level=logging.INFO) # the default handler writes to stderr
log = logging.getLogger("jobs")
log.info("sync started")
It prints INFO:jobs:sync started to the server’s stderr. Over stdio, the host passes that stream to its own logs or terminal, as the first lesson showed, and stdout stays clean for the protocol.