Logging
Log from a tool the way you log from any other Python function: with the standard library.
MCP has a protocol-level logging capability: a server could push its log messages to the client as notifications, through methods on the Context object. The 2026-07-28 revision of the spec deprecates that capability and does not replace it, so this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in Deprecated features.
What you do instead is what you do in every other Python program: the standard library.
A tool that logs
import logging
from mcp.server import MCPServer
logger = logging.getLogger(__name__)
mcp = MCPServer("Bookshop")
@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog by title or author."""
logger.info("Searching for %r", query)
return f"Found 3 books matching {query!r}."
logging.getLogger(__name__)gives you a logger named after your module. Create it once, at the top.- Inside the tool you call
logger.info(...)like in any other function. Nothing to inject, nothing toawait, nothing MCP-specific.
Check
Call the tool and look at the whole result:
result.content # [TextContent(text="Found 3 books matching 'dune'.")]
result.structured_content # {'result': "Found 3 books matching 'dune'."}
The log line is nowhere in it. Logging is for you, the person operating the server. The model
never sees it. If the model should read something, return it.
Where it goes
For a stdio server this question matters more than usual. The host launched your server as a subprocess and is reading MCP messages from its stdout. Standard error is yours.
The standard library already does the right thing: log output goes to sys.stderr by default. Your logger.info(...) lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean.
Tip
Never print() in a stdio server. print writes to stdout, and stdout is the wire: one stray
line and the client is trying to parse it as JSON-RPC.
logger.debug("got here") is the same one line of effort and goes to the right place.
The level
You don't have to call logging.basicConfig() yourself. Constructing an MCPServer already did, with a handler pointed at standard error, at the level you pass as log_level=, so MCPServer("Bookshop", log_level="DEBUG") is all it takes to see your logger.debug(...) lines.
The default is "INFO".
logging.basicConfig() never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.
Try it
Run the server with the MCP Inspector:
uv run mcp dev server.py
Call search_books from the Tools tab. The Inspector shows you the result: only the return value. The line
Searching for 'dune'
went to standard error: the terminal, not the wire.
Info
If what you actually want is tracing (every request, how long it took, whether it failed), you don't want log lines, you want spans. Your server already emits them: the SDK traces every message with OpenTelemetry out of the box. See OpenTelemetry.
Recap
- The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
logger = logging.getLogger(__name__)at module level,logger.info(...)in the tool. That's the whole pattern.- Log output never reaches the model. Only the value you
returndoes. - Standard error is yours; stdout belongs to the protocol. Never
print()in a stdio server. MCPServer(..., log_level="DEBUG")sets the level, and a logging configuration you made first is left alone.
Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in Testing.