Skip to main content
MCP (Model Context Protocol) · lesson 7 of 13
MCP (Model Context Protocol) intermediate Lesson 7 of 13

Logging, 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

mcp model-context-protocol progress cancellation logging python

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

  1. Change export to call ctx.report_progress(done) with no total or message. What does the client receive, and what goes wrong with on_progress as written?
  2. Make reindex print server: reindex finished whether it completes or is cancelled, without a separate except for each case.
  3. Run client_logs.py with log_level="info". How many log lines does the second client print?
  4. 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.

Frequently Asked Questions

How does an MCP tool report progress?
The tool calls ctx.report_progress(progress, total, message) on its Context, and the server sends a progress notification tied to that request. The client receives each one in the progress_callback it passed to call_tool. total and message are optional, so a client must handle progress that arrives without a known total.
How do I cancel an MCP tool call?
Stop waiting on the client side, for example with anyio.move_on_after() or a read timeout, and the SDK tells the server the request is cancelled. On the server, the running tool sees asyncio.CancelledError at its next await, so a try/except or finally block there can clean up. The connection stays usable for later calls.
Is MCP logging deprecated?
Yes. As of protocol version 2026-07-28 the logging capability is deprecated (SEP-2577). In the Python SDK, ctx.info() and similar calls still work but emit MCPDeprecationWarning, and messages reach a client only if it opted in with log_level. For operator logs, write to stderr or a normal logging handler instead.
Why does my MCP client get no log messages from the server?
On 2026-07-28 connections a server sends log notifications only for requests that opt in, and only at or above the requested level. With the Python SDK, passing logging_callback alone isn't enough: also set log_level, for example Client(server, logging_callback=on_log, log_level="info").