-
Notifications
You must be signed in to change notification settings - Fork 3k
sketch: refactor lowlevel server to handler pattern #1968
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Design sketch for a new low-level server API using RequestHandler and
NotificationHandler classes with @overload-based typing. Key features:
- Literal method strings ("tools/call", "notifications/progress", etc.)
paired with strongly-typed handler callables via overloads
- Generic context (RequestContext[ServerSession, LifespanResultT, RequestT])
flowing through handler signatures
- Custom handlers supported via str fallback overload
- Server builds dispatch dicts at init with duplicate detection
- add_handler() for post-construction registration (replaces silently)
…cationHandler pattern - Extract RequestHandler class into request_handler.py with typed overloads - Extract NotificationHandler class into notification_handler.py with typed overloads - Refactor Server to dispatch by method string instead of request type - Remove decorator methods, tool cache, and request_ctx contextvar - Delete new_server.py sketch (superseded by extracted handler files) Known breakages: MCPServer, ExperimentalHandlers, tests, examples
- HandlerContext (base): session, lifespan_context, experimental - RequestHandlerContext: adds request_id, meta, request, SSE callbacks - NotificationHandlerContext: empty subclass for notifications - Rename Ctx aliases to RequestCtx / NotificationCtx - Remove request_ctx contextvar and request_context property - Tighten string fallback handler signatures to Callable[[Ctx, Any], ...]
… migration docs - ExperimentalHandlers takes add_handler/has_handler callbacks instead of dicts - Remove decorator methods and func_inspection dependency from ExperimentalHandlers - Default task handlers use (ctx, params) signature with proper types - Add has_handler() method to Server - Update migration docs with handler pattern, context changes, typed examples
…ecorators Replace decorator-based handler registration with explicit RequestHandler objects registered via add_handler(). Each handler manages its own contextvar for request context propagation. Key changes: - Add _mcp_server_ctx contextvar (replaces lowlevel server's request_ctx) - Rewrite _setup_handlers() with RequestHandler objects for all 7 methods - Rewrite get_context() to read from _mcp_server_ctx - Rewrite completion() decorator to create RequestHandler inline - Update Context class to use RequestHandlerContext (was RequestContext) - Handle CallToolResult pass-through and CombinationContent tuples - Re-raise MCPError in call_tool handler (UrlElicitationRequiredError) - Apply default MIME types for resources (text/plain, application/octet-stream) - Match old json.dumps indent=2 for structured tool output
docs/migration.md
Outdated
| - Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`). | ||
| - Registration uses method strings (`"tools/call"`) instead of request types (`CallToolRequest`). | ||
| - Handlers can be added after construction with `server.add_handler()` (silently replaces existing handlers for the same method). | ||
| - `server.has_handler(method)` checks if a handler is registered for a given method string. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do you need to expose this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yea fair not needed
docs/migration.md
Outdated
|
|
||
| **Key differences:** | ||
|
|
||
| - Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `RequestHandlerContext` (for requests) or `NotificationHandlerContext` (for notifications) with `session`, `lifespan_context`, and `experimental` fields. `params` is the typed request params object. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need 2 different context objects?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And what is experimental for?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Combined the two context objects into one again.
experimental is for keeping all the Tasks related stuff out of Server
docs/migration.md
Outdated
| The `RequestContext` class in `mcp.shared.context` has been replaced with a three-class hierarchy: | ||
|
|
||
| - `HandlerContext` — base class with `session`, `lifespan_context`, `experimental` | ||
| - `RequestHandlerContext(HandlerContext)` — adds `request_id`, `meta`, `request`, `close_sse_stream`, `close_standalone_sse_stream` | ||
| - `NotificationHandlerContext(HandlerContext)` — empty subclass for notifications |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The experience would be better with a single object, and with exceptions if accessed out of the proper context.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Exceptions feel weird for property access. I was splitting it up so it can be more strictly typed since some fields are request specific.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've put it back together and made them optional, lemme know if you're thinking something else
| add_handler: Callable[[RequestHandler[Any, Any] | NotificationHandler[Any, Any]], None], | ||
| has_handler: Callable[[str], bool], | ||
| ) -> None: | ||
| self._add_handler = add_handler | ||
| self._has_handler = has_handler |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is ugly. We can improve it. What are you trying to achieve here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Basically just keeping all of the tasks related stuff out of the main Server class and not touching it as much as possible haha
Merge HandlerContext, RequestHandlerContext, and NotificationHandlerContext into a single RequestContext class. Request-specific fields (request_id, meta, etc.) are now optional with None defaults, so the same class works for both request and notification handlers. Also: - Make Server.add_handler/has_handler private (_add_handler/_has_handler) - MCPServer._setup_handlers() -> _create_handlers() returning a list passed to Server(handlers=...) constructor - Update migration docs to reflect single context class
Place private helper after the public API for better readability.
…andler.py Create Handler base class with RequestHandler and NotificationHandler subclasses in a single handler.py file. This prepares for future middleware support. Also: - Update Server.__init__ to use Sequence[Handler] = () instead of list[RequestHandler | NotificationHandler] | None - Drop explicit [Any, Any] generic params where defaults suffice - Export Handler from lowlevel __init__
…handler boilerplate Set request_ctx once in _handle_request and _handle_notification instead of wrapping every MCPServer handler closure with token set/reset. MCPServer's get_context() now reads request_ctx directly.
- Remove stray backtick in RequestParamsMeta section - Update 'After (v2)' example to use new handler pattern instead of removed @server.call_tool() decorator and server.request_context
Kludex
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to know if a request is a request or a notification in the Server itself? Isn't it enough to know that we have the method and the handler for it?
src/mcp/shared/context.py
Outdated
| experimental: Any = field(default=None, kw_only=True) | ||
| request_id: RequestId | None = field(default=None, kw_only=True) | ||
| meta: RequestParamsMeta | None = field(default=None, kw_only=True) | ||
| request: RequestT | None = field(default=None, kw_only=True) | ||
| close_sse_stream: CloseSSEStreamCallback | None = field(default=None, kw_only=True) | ||
| close_standalone_sse_stream: CloseSSEStreamCallback | None = field(default=None, kw_only=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| experimental: Any = field(default=None, kw_only=True) | |
| request_id: RequestId | None = field(default=None, kw_only=True) | |
| meta: RequestParamsMeta | None = field(default=None, kw_only=True) | |
| request: RequestT | None = field(default=None, kw_only=True) | |
| close_sse_stream: CloseSSEStreamCallback | None = field(default=None, kw_only=True) | |
| close_standalone_sse_stream: CloseSSEStreamCallback | None = field(default=None, kw_only=True) | |
| experimental: Any = None # What is the right type? | |
| request_id: RequestId | None = None # Isn't the request id always present??? | |
| meta: RequestParamsMeta | None = None | |
| request: RequestT | None = None # This one is always populated, right? | |
| close_sse_stream: CloseSSEStreamCallback | None = None | |
| close_standalone_sse_stream: CloseSSEStreamCallback | None = None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
experimentalyea added the correct typerequest_id- no, notifications don't always have a RequestId, this is why I was having two contexts, one for Requets and one for Notifications originally (but you said to move back to one class)requestis actually badly named, this seems to be the HTTP Request context from the transport layer which is only included if you're using SHTTP
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This RequestContext is a bit weird that it has information related to some transports. Do you know, or can you find out where those 2 callbacks are actually called?
Please don't say "in a next PR", this is a great chance of getting a lot of non sense out.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They're SSE polling callbacks for SEP-1699, which allows tool handlers to close connections mid request.
They're added in originally here: https://github.com/modelcontextprotocol/python-sdk/blob/5e1bf86/src/mcp/server/streamable_http.py#L225-L250
They're also exposed by MCPserver here: https://github.com/modelcontextprotocol/python-sdk/blob/5e1bf86/src/mcp/server/mcpserver/server.py#L1279-L1309
| # TODO(maxisbey): remove private access — completion needs post-construction | ||
| # handler registration, find a better pattern for this | ||
| self._lowlevel_server._add_handler( # pyright: ignore[reportPrivateUsage] | ||
| RequestHandler("completion/complete", handler=handler) | ||
| ) | ||
| return func |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On main the MCPServer.completion() just uses the lowlevel server's completion decorator, but since we don't have that anymore it has to make its own decorator.
python-sdk/src/mcp/server/mcpserver/server.py
Line 490 in b38716e
| return self._lowlevel_server.completion() |
It has to be at registration time so that when the server's capabilities are figured out it only includes completion if a completion endpoint is actually registered.
I think ideally we figure out another way for a server to define it's capabilities than how it's done now, but probably not in this PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think ideally we figure out another way for a server to define it's capabilities than how it's done now
How would that possibly be?
…rly, remove redundant guard - RequestContext: use @DataClass(kw_only=True) instead of per-field kw_only - RequestContext: type experimental as Experimental | None via TYPE_CHECKING - Server.__init__: remove redundant 'if handlers:' guard - Server: use keyword args for RequestContext construction
Well we need the server to know whether or not to expect a return type to pass back? Requests give responses, Notifications don't give responses. |
…cstrings - Server.__init__: register default ping handler after user handlers so it can be overridden via constructor - handler.py: add typed overloads for experimental task request methods (tasks/get, tasks/result, tasks/list, tasks/cancel) and task notification (notifications/tasks/status) - Update stale docstrings in task_result_handler.py, request_context.py, and helpers.py to reflect new handler pattern - Update docs/experimental/index.md example - Add task handler migration section to docs/migration.md
docs/experimental/index.md
Outdated
| # Server-side: register a custom task handler | ||
| server = Server( | ||
| name="my-server", | ||
| handlers=[ | ||
| RequestHandler("tasks/get", handler=handle_get_task), | ||
| ], | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this what you want?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nope it's not haha, fixed
| add_handler: Callable[[Handler], None], | ||
| has_handler: Callable[[str], bool], | ||
| ) -> None: | ||
| self._add_handler = add_handler | ||
| self._has_handler = has_handler |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What are you trying to achieve with this? If a handler doesn't exist, you add a handler?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Originally you just passed in the request/notification handler dicts in directly and this class would then add in the default task related handlers to get task augmentation working. I was trying to clean this up by adding in callbacks.
My idea when making it like this originally was to have all tasks related stuff under server.experimental., so you could just call server.experimental.enable_tasks() and it would register the handlers onto your server. What do you think is best here? Just switch back to passing in the request/notification handler dicts directly instead of callbacks?
src/mcp/shared/context.py
Outdated
| experimental: Any = field(default=None, kw_only=True) | ||
| request_id: RequestId | None = field(default=None, kw_only=True) | ||
| meta: RequestParamsMeta | None = field(default=None, kw_only=True) | ||
| request: RequestT | None = field(default=None, kw_only=True) | ||
| close_sse_stream: CloseSSEStreamCallback | None = field(default=None, kw_only=True) | ||
| close_standalone_sse_stream: CloseSSEStreamCallback | None = field(default=None, kw_only=True) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This RequestContext is a bit weird that it has information related to some transports. Do you know, or can you find out where those 2 callbacks are actually called?
Please don't say "in a next PR", this is a great chance of getting a lot of non sense out.
| # TODO(maxisbey): remove private access — completion needs post-construction | ||
| # handler registration, find a better pattern for this | ||
| self._lowlevel_server._add_handler( # pyright: ignore[reportPrivateUsage] | ||
| RequestHandler("completion/complete", handler=handler) | ||
| ) | ||
| return func |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think ideally we figure out another way for a server to define it's capabilities than how it's done now
How would that possibly be?
Co-authored-by: Marcelo Trylesinski <[email protected]>
I was thinking because currently capabilities are calculated based on which handlers are registered, but not all capabilities can be inferred just from which method handlers exist. For example
|
Replaces decorator-based handler registration with
RequestHandler/NotificationHandlerclasses.Changes
request_handler.pyandnotification_handler.pywith typed overloaded constructorstype(req)→req.method(string-based dispatch)dict[type, Callable]→dict[str, RequestHandler]/dict[str, NotificationHandler]handlerslist +add_handler()methodRequestContextbuilt and passed directly to handlers;request_ctxcontextvar removed_make_error_result,_get_cached_tool_definition,new_server.pysketchKnown breakages
MCPServer._setup_handlers()— calls removed decorator methodsExperimentalHandlers— writes to handler dicts with type keys