Skip to content

Conversation

@maxisbey
Copy link
Contributor

Replaces decorator-based handler registration with RequestHandler/NotificationHandler classes.

Changes

  • New files: request_handler.py and notification_handler.py with typed overloaded constructors
  • Server dispatch: type(req)req.method (string-based dispatch)
  • Handler dicts: dict[type, Callable]dict[str, RequestHandler] / dict[str, NotificationHandler]
  • Registration: Decorator methods → constructor handlers list + add_handler() method
  • Context: RequestContext built and passed directly to handlers; request_ctx contextvar removed
  • Removed: All 12 decorator methods, tool cache, _make_error_result, _get_cached_tool_definition, new_server.py sketch

Known breakages

  • MCPServer._setup_handlers() — calls removed decorator methods
  • ExperimentalHandlers — writes to handler dicts with type keys
  • All lowlevel server tests — use decorator pattern
  • All examples — use decorator pattern

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
@maxisbey maxisbey requested a review from Kludex January 28, 2026 15:22
- 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.
Copy link
Member

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea fair not needed


**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.
Copy link
Member

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?

Copy link
Member

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?

Copy link
Contributor Author

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

Comment on lines 529 to 533
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
Copy link
Member

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.

Copy link
Contributor Author

@maxisbey maxisbey Jan 28, 2026

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.

Copy link
Contributor Author

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

Comment on lines 52 to 56
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
Copy link
Member

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?

Copy link
Contributor Author

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
@maxisbey maxisbey requested a review from Kludex January 28, 2026 20:58
Copy link
Member

@Kludex Kludex left a 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?

Comment on lines 31 to 36
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • experimental yea added the correct type
  • request_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)
  • request is actually badly named, this seems to be the HTTP Request context from the transport layer which is only included if you're using SHTTP

Copy link
Member

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.

Copy link
Contributor Author

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

Comment on lines +578 to +583
# 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is that?

Copy link
Contributor Author

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.

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?

Copy link
Member

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
@maxisbey
Copy link
Contributor Author

@Kludex: 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?

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.

@maxisbey maxisbey requested a review from Kludex January 29, 2026 16:25
…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
Comment on lines 30 to 36
# Server-side: register a custom task handler
server = Server(
name="my-server",
handlers=[
RequestHandler("tasks/get", handler=handle_get_task),
],
)
Copy link
Member

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?

Copy link
Contributor Author

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

Comment on lines +51 to +55
add_handler: Callable[[Handler], None],
has_handler: Callable[[str], bool],
) -> None:
self._add_handler = add_handler
self._has_handler = has_handler
Copy link
Member

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?

Copy link
Contributor Author

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?

Comment on lines 31 to 36
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)
Copy link
Member

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.

Comment on lines +578 to +583
# 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
Copy link
Member

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?

@maxisbey
Copy link
Contributor Author

maxisbey commented Jan 30, 2026

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?

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 listChanged booleans in prompts.listChanged, resources.listChanged, and tools.listChanged. There's client -> server method handler related to this, this is just server -> client communication.

tasks.requests.tools.call is another, you can technically support the rest of the tasks methods and just not this one. Although this is isn't a realistic usecase.

logging is another one where the server can specify whether it supports it or not, and it can't be inferred just from whether the method handler exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants