Middleware - MCP Python SDK
https://py.sdk.modelcontextprotocol.io/advanced/middleware/ • 63 KB fetched
Open original page
Middleware - MCP Python SDK
Skip to content
MCP Python SDK
Middleware
*
en - English
*
de - Deutsch
*
es - español
*
fr - français
*
hi - हिन्दी
*
ja - 日本語
*
ko - 한국어
*
pt - português (Brasil)
*
ru - русский язык
*
tr - Türkçe
*
uk - українська мова
*
zh - 简体中文
*
zh-hant - 繁體中文
Search
modelcontextprotocol/python-sdk
MCP Python SDK
modelcontextprotocol/python-sdk
*
MCP Python SDK
*
What's new in v2
*
Get started
Get started
*
Installation
*
First steps
*
Connect to a real host
*
Testing
*
Servers
Servers
*
Tools
*
Structured Output
*
Resources
*
URI templates
*
Prompts
*
Completions
*
Images, audio & icons
*
Handling errors
*
Inside your handler
Inside your handler
*
The Context
*
Dependencies
*
Lifespan
*
Elicitation
*
Multi-round-trip requests
*
Sampling and roots
*
Progress
*
Logging
*
Subscriptions
*
Running your server
Running your server
*
Add to an existing app
*
Deploy & scale
*
Authorization
*
OpenTelemetry
*
Serving legacy clients
*
Clients
Clients
*
Callbacks
*
Transports
*
OAuth
*
Identity assertion
*
Multiple servers
*
Subscriptions
*
Caching
*
Protocol versions
*
Deprecated features
*
Advanced
Advanced
*
The low-level Server
*
Pagination
*
Middleware
Middleware
On this page
*
A timing middleware
*
Try it
*
What you can do inside one
*
The one middleware that ships on by default
*
Recap
*
Extensions
*
MCP Apps
*
Troubleshooting
*
Translations
*
Migration Guide
*
API Reference
API Reference
*
mcp
*
mcp_types
On this page
*
A timing middleware
*
Try it
*
What you can do inside one
*
The one middleware that ships on by default
*
Recap
*
MCP Python SDK
*
Advanced
Middleware
A middleware is one async function that wraps every message your server receives.
You write it as async (ctx, call_next) and append it to server.middleware . That is the whole API.
Warning
The middleware list is marked provisional in the source: its signature and semantics may
change in a 2.x minor release. Use it to observe (timing, logging, tracing) and to
refuse messages; do not make it the foundation your server stands on.
MCPServer takes the list at construction ( MCPServer(name, middleware=[...]) ) and exposes it as
mcp.middleware ; the low-level Server exposes the same list as server.middleware . The example
below uses the low-level Server ; if Server(name, on_call_tool=...) is new to you, read
The low-level Server first.
A timing middleware
One server, one tool, one middleware that logs how long each message took:
server.py import logging
import time
from mcp.server import Server , ServerRequestContext
from mcp.server.context import CallNext , HandlerResult
from mcp.types import (
CallToolRequestParams ,
CallToolResult ,
ListToolsResult ,
PaginatedRequestParams ,
TextContent ,
Tool ,
)
logger = logging . getLogger ( __name__ )
async def on_list_tools ( ctx : ServerRequestContext , params : PaginatedRequestParams | None ) -> ListToolsResult :
return ListToolsResult (
tools = [
Tool (
name = "search_books" ,
description = "Search the catalog by title or author." ,
input_schema = {
"type" : "object" ,
"properties" : { "query" : { "type" : "string" }},
"required" : [ "query" ],
},
)
]
)
async def on_call_tool ( ctx : ServerRequestContext , params : CallToolRequestParams ) -> CallToolResult :
query = ( params . arguments or {})[ "query" ]
return CallToolResult ( content = [ TextContent ( type = "text" , text = f "Found 3 books matching { query !r} ." )])
async def log_timing ( ctx : ServerRequestContext , call_next : CallNext ) -> HandlerResult :
start = time . perf_counter ()
try :
return await call_next ( ctx )
finally :
elapsed_ms = ( time . perf_counter () - start ) * 1000
logger . info ( " %s took %.1f ms" , ctx . method , elapsed_ms )
server = Server ( "Bookshop" , on_list_tools = on_list_tools , on_call_tool = on_call_tool )
server . middleware . append ( log_timing )
* ctx is the same ServerRequestContext your handlers receive. ctx.method is the raw
method string; ctx.params are the raw params, before any validation.
* call_next(ctx) runs the rest of the chain: validation, the handler lookup, your handler.
Return what it returned and the response is untouched.
* The try / finally is deliberate: a handler that raises is still timed, because the failure
reaches your middleware as the exception out of call_next .
* server.middleware.append(...) registers it. The list runs outermost-first, so
middleware[0] is the one closest to the wire.
Try it
Connect a client, list the tools, call one. Your log has three lines:
server/discover took 18.3 ms
tools/list took 0.1 ms
tools/call took 0.1 ms
You made two calls and got three lines. The first is server/discover : the request the
client sent to set the connection up, before you asked for anything.
That is the point. Middleware wraps every inbound message:
* The connection setup: server/discover , or initialize and notifications/initialized
on a legacy session.
* Every request and every notification that reaches the server. For a notification,
ctx.request_id is None , call_next(ctx) returns None , and whatever you return is discarded.
(On the 2026-07-28 streamable-HTTP path a client's notification POST is acknowledged 202 at
the transport and never dispatched, so it does not reach middleware either; that revision
defines no client-to-server notifications over HTTP.)
* Even a method the server has no handler for: call_next raises the
MCPError(-32601, "Method not found") through your middleware on its way to the client.
What you can do inside one
In increasing order of how much you should hesitate:
* Observe. Time it, count it, log it. The example above.
* Refuse. Raise an MCPError instead of calling call_next(ctx) and that one message is
answered with a JSON-RPC error. The connection stays up; the next message goes through. This is
how a server gates subscriptions/listen per caller:
Deciding who may watch on the
Subscriptions page walks through it.
* Rewrite. ctx is a dataclass: await call_next(dataclasses.replace(ctx, params=...))
hands the rest of the chain different params than the client sent. Never do this to
initialize : the result the client gets back is built from your rewritten params, but the
server commits its connection state from the original wire params. The two sides can finish
the handshake disagreeing about what they negotiated.
* Answer. Return a result without calling call_next(ctx) and it goes to the client as
your response. call_next hands you the finished wire form, and the pipeline never patches
what you return, so the whole envelope is yours: on a 2026-era connection that includes the
serverInfo _meta stamp, which the SDK adds to handler results but not to yours.
Check
initialize is one of the things middleware wraps, and it is the only hook you get
for it. Try to take it over with add_request_handler and the SDK refuses:
ValueError: 'initialize' is handled by the server runner and cannot be overridden;
use Server.middleware to observe or wrap initialization
Warning
initialize is handled inline: the server reads no further inbound messages until your
middleware chain returns. Awaiting a server-to-client request ( ctx.session.send_request(...) ,
an elicitation) while handling initialize therefore deadlocks the connection : the
response you are waiting for can never be read. Fire-and-forget notifications are fine.
The one middleware that ships on by default
The SDK ships exactly one middleware, and it is already on your server's list: the one that
emits an OpenTelemetry span for every message. You don't append it, and most of the time you
don't think about it. It is a no-op until you install an exporter, and it has its own page:
OpenTelemetry .
Info
If you have written ASGI middleware, you already know this shape. Starlette's
(scope, receive, send) became (ctx, call_next) , and it runs after the transport, on
the decoded message instead of the raw HTTP request. The two compose: Starlette middleware
on streamable_http_app() sees HTTP; this sees MCP.
Recap
* A middleware is async (ctx, call_next) -> result , passed as MCPServer(middleware=[...]) (or
appended to mcp.middleware ), and appended to server.middleware on the low-level Server .
* It wraps every inbound message that reaches the server ( server/discover , initialize ,
requests, notifications, unknown methods) and runs outermost-first.
* ctx.request_id is None is how you tell a notification from a request.
* Raise instead of calling call_next to refuse one message; the connection survives.
* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See
OpenTelemetry .
* The whole surface is provisional. Observe with it; don't build on it.
That is everything that wraps a request. Authorization is what decides whether the request
gets to run at all.
Back to top
Previous
Pagination
Next
Extensions
Made with
Zensical
Links found on this page
- Skip to content [direct]
- en - English [direct]
- de - Deutsch [direct]
- es - español [direct]
- fr - français [direct]
- hi - हिन्दी [direct]
- ja - 日本語 [direct]
- ko - 한국어 [direct]
- pt - português (Brasil) [direct]
- ru - русский язык [direct]
- tr - Türkçe [direct]
- uk - українська мова [direct]
- zh - 简体中文 [direct]
- zh-hant - 繁體中文 [direct]
- modelcontextprotocol/python-sdk [direct]
- What's new in v2 [direct]
- Get started [direct]
- Installation [direct]
- First steps [direct]
- Connect to a real host [direct]
- Testing [direct]
- Servers [direct]
- Tools [direct]
- Structured Output [direct]
- Resources [direct]
- URI templates [direct]
- Prompts [direct]
- Completions [direct]
- Images, audio & icons [direct]
- Handling errors [direct]
- Inside your handler [direct]
- The Context [direct]
- Dependencies [direct]
- Lifespan [direct]
- Elicitation [direct]
- Multi-round-trip requests [direct]
- Sampling and roots [direct]
- Progress [direct]
- Logging [direct]
- Subscriptions [direct]
- Running your server [direct]
- Add to an existing app [direct]
- Deploy & scale [direct]
- Authorization [direct]
- OpenTelemetry [direct]
- Serving legacy clients [direct]
- Clients [direct]
- Callbacks [direct]
- Transports [direct]
- OAuth [direct]
- Identity assertion [direct]
- Multiple servers [direct]
- Subscriptions [direct]
- Caching [direct]
- Protocol versions [direct]
- Deprecated features [direct]
- Advanced [direct]
- The low-level Server [direct]
- Pagination [direct]
- Extensions [direct]
- MCP Apps [direct]
- Troubleshooting [direct]
- Translations [direct]
- Migration Guide [direct]
- mcp [direct]
- mcp_types [direct]
- Zensical [direct]