Skip to content

Commit 8100870

Browse files
committed
refactor: collapse context hierarchy into single RequestContext class
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
1 parent 17c459c commit 8100870

File tree

7 files changed

+67
-81
lines changed

7 files changed

+67
-81
lines changed

docs/migration.md

Lines changed: 15 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ params = CallToolRequestParams(
456456

457457
### Lowlevel `Server`: decorator-based handlers replaced with `RequestHandler`/`NotificationHandler`
458458

459-
The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are `RequestHandler` and `NotificationHandler` objects passed to the constructor or added via `add_handler()`.
459+
The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are `RequestHandler` and `NotificationHandler` objects passed to the constructor.
460460

461461
**Before (v1):**
462462

@@ -478,7 +478,7 @@ async def handle_call_tool(name: str, arguments: dict):
478478

479479
```python
480480
from mcp.server.lowlevel import Server, RequestHandler
481-
from mcp.shared.context import RequestHandlerContext
481+
from mcp.shared.context import RequestContext
482482
from mcp.types import (
483483
CallToolRequestParams,
484484
CallToolResult,
@@ -489,14 +489,14 @@ from mcp.types import (
489489
)
490490

491491
async def handle_list_tools(
492-
ctx: RequestHandlerContext, params: PaginatedRequestParams | None
492+
ctx: RequestContext, params: PaginatedRequestParams | None
493493
) -> ListToolsResult:
494494
return ListToolsResult(tools=[
495495
Tool(name="my_tool", description="A tool", inputSchema={})
496496
])
497497

498498
async def handle_call_tool(
499-
ctx: RequestHandlerContext, params: CallToolRequestParams
499+
ctx: RequestContext, params: CallToolRequestParams
500500
) -> CallToolResult:
501501
return CallToolResult(
502502
content=[TextContent(type="text", text=f"Called {params.name}")],
@@ -514,21 +514,19 @@ server = Server(
514514

515515
**Key differences:**
516516

517-
- 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.
517+
- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `RequestContext` with `session`, `lifespan_context`, and `experimental` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object.
518518
- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`).
519519
- Registration uses method strings (`"tools/call"`) instead of request types (`CallToolRequest`).
520-
- Handlers can be added after construction with `server.add_handler()` (silently replaces existing handlers for the same method).
521-
- `server.has_handler(method)` checks if a handler is registered for a given method string.
522520

523521
**Notification handlers:**
524522

525523
```python
526524
from mcp.server.lowlevel import NotificationHandler
527-
from mcp.shared.context import NotificationHandlerContext
525+
from mcp.shared.context import RequestContext
528526
from mcp.types import ProgressNotificationParams
529527

530528
async def handle_progress(
531-
ctx: NotificationHandlerContext, params: ProgressNotificationParams
529+
ctx: RequestContext, params: ProgressNotificationParams
532530
) -> None:
533531
print(f"Progress: {params.progress}/{params.total}")
534532

@@ -559,11 +557,11 @@ async def handle_call_tool(name: str, arguments: dict):
559557
**After (v2):**
560558

561559
```python
562-
from mcp.shared.context import RequestHandlerContext
560+
from mcp.shared.context import RequestContext
563561
from mcp.types import CallToolRequestParams, CallToolResult, TextContent
564562

565563
async def handle_call_tool(
566-
ctx: RequestHandlerContext, params: CallToolRequestParams
564+
ctx: RequestContext, params: CallToolRequestParams
567565
) -> CallToolResult:
568566
await ctx.session.send_log_message(level="info", data="Processing...")
569567
return CallToolResult(
@@ -572,24 +570,15 @@ async def handle_call_tool(
572570
)
573571
```
574572

575-
### `RequestContext` split into `HandlerContext`, `RequestHandlerContext`, `NotificationHandlerContext`
573+
### `RequestContext`: request-specific fields are now optional
576574

577-
The `RequestContext` class in `mcp.shared.context` has been replaced with a three-class hierarchy:
578-
579-
- `HandlerContext` — base class with `session`, `lifespan_context`, `experimental`
580-
- `RequestHandlerContext(HandlerContext)` — adds `request_id`, `meta`, `request`, `close_sse_stream`, `close_standalone_sse_stream`
581-
- `NotificationHandlerContext(HandlerContext)` — empty subclass for notifications
582-
583-
**Before (v1):**
575+
The `RequestContext` class now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`.
584576

585577
```python
586578
from mcp.shared.context import RequestContext
587-
```
588-
589-
**After (v2):**
590579

591-
```python
592-
from mcp.shared.context import HandlerContext, RequestHandlerContext, NotificationHandlerContext
580+
# request_id, meta, etc. are available in request handlers
581+
# but None in notification handlers
593582
```
594583

595584
## New Features
@@ -600,11 +589,11 @@ The `streamable_http_app()` method is now available directly on the lowlevel `Se
600589

601590
```python
602591
from mcp.server.lowlevel import Server, RequestHandler
603-
from mcp.shared.context import RequestHandlerContext
592+
from mcp.shared.context import RequestContext
604593
from mcp.types import ListToolsResult, PaginatedRequestParams
605594

606595
async def handle_list_tools(
607-
ctx: RequestHandlerContext, params: PaginatedRequestParams | None
596+
ctx: RequestContext, params: PaginatedRequestParams | None
608597
) -> ListToolsResult:
609598
return ListToolsResult(tools=[...])
610599

src/mcp/server/lowlevel/experimental.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from mcp.server.experimental.task_support import TaskSupport
1313
from mcp.server.lowlevel.notification_handler import NotificationHandler
1414
from mcp.server.lowlevel.request_handler import RequestHandler
15-
from mcp.shared.context import RequestHandlerContext
15+
from mcp.shared.context import RequestContext
1616
from mcp.shared.exceptions import MCPError
1717
from mcp.shared.experimental.tasks.helpers import cancel_task
1818
from mcp.shared.experimental.tasks.in_memory_task_store import InMemoryTaskStore
@@ -125,7 +125,7 @@ def _register_default_task_handlers(self) -> None:
125125
if not self._has_handler("tasks/get"):
126126

127127
async def _default_get_task(
128-
ctx: RequestHandlerContext[Any, Any, Any], params: GetTaskRequestParams
128+
ctx: RequestContext[Any, Any, Any], params: GetTaskRequestParams
129129
) -> GetTaskResult:
130130
task = await support.store.get_task(params.task_id)
131131
if task is None:
@@ -145,8 +145,9 @@ async def _default_get_task(
145145
if not self._has_handler("tasks/result"):
146146

147147
async def _default_get_task_result(
148-
ctx: RequestHandlerContext[Any, Any, Any], params: GetTaskPayloadRequestParams
148+
ctx: RequestContext[Any, Any, Any], params: GetTaskPayloadRequestParams
149149
) -> GetTaskPayloadResult:
150+
assert ctx.request_id is not None
150151
req = GetTaskPayloadRequest(params=params)
151152
result = await support.handler.handle(req, ctx.session, ctx.request_id)
152153
return result
@@ -156,7 +157,7 @@ async def _default_get_task_result(
156157
if not self._has_handler("tasks/list"):
157158

158159
async def _default_list_tasks(
159-
ctx: RequestHandlerContext[Any, Any, Any], params: PaginatedRequestParams | None
160+
ctx: RequestContext[Any, Any, Any], params: PaginatedRequestParams | None
160161
) -> ListTasksResult:
161162
cursor = params.cursor if params else None
162163
tasks, next_cursor = await support.store.list_tasks(cursor)
@@ -167,7 +168,7 @@ async def _default_list_tasks(
167168
if not self._has_handler("tasks/cancel"):
168169

169170
async def _default_cancel_task(
170-
ctx: RequestHandlerContext[Any, Any, Any], params: CancelTaskRequestParams
171+
ctx: RequestContext[Any, Any, Any], params: CancelTaskRequestParams
171172
) -> CancelTaskResult:
172173
result = await cancel_task(support.store, params.task_id)
173174
return result

src/mcp/server/lowlevel/notification_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing_extensions import TypeVar
77

88
from mcp.server.session import ServerSession
9-
from mcp.shared.context import NotificationHandlerContext
9+
from mcp.shared.context import RequestContext
1010
from mcp.types import (
1111
CancelledNotificationParams,
1212
NotificationParams,
@@ -16,7 +16,7 @@
1616
LifespanResultT = TypeVar("LifespanResultT", default=Any)
1717
RequestT = TypeVar("RequestT", default=Any)
1818

19-
NotificationCtx = NotificationHandlerContext[ServerSession, LifespanResultT]
19+
NotificationCtx = RequestContext[ServerSession, LifespanResultT, Any]
2020

2121

2222
class NotificationHandler(Generic[LifespanResultT, RequestT]):

src/mcp/server/lowlevel/request_handler.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing_extensions import TypeVar
77

88
from mcp.server.session import ServerSession
9-
from mcp.shared.context import RequestHandlerContext
9+
from mcp.shared.context import RequestContext
1010
from mcp.types import (
1111
CallToolRequestParams,
1212
CallToolResult,
@@ -31,7 +31,7 @@
3131
LifespanResultT = TypeVar("LifespanResultT", default=Any)
3232
RequestT = TypeVar("RequestT", default=Any)
3333

34-
RequestCtx = RequestHandlerContext[ServerSession, LifespanResultT, RequestT]
34+
RequestCtx = RequestContext[ServerSession, LifespanResultT, RequestT]
3535

3636

3737
class RequestHandler(Generic[LifespanResultT, RequestT]):

src/mcp/server/lowlevel/server.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def main():
6868
from mcp.server.streamable_http import EventStore
6969
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
7070
from mcp.server.transport_security import TransportSecuritySettings
71-
from mcp.shared.context import NotificationHandlerContext, RequestHandlerContext
71+
from mcp.shared.context import RequestContext
7272
from mcp.shared.exceptions import MCPError
7373
from mcp.shared.message import ServerMessageMetadata, SessionMessage
7474
from mcp.shared.session import RequestResponder
@@ -155,7 +155,7 @@ def __init__(
155155
else:
156156
raise TypeError(f"Unknown handler type: {type(handler)}")
157157

158-
def add_handler(self, handler: RequestHandler[Any, Any] | NotificationHandler[Any, Any]) -> None:
158+
def _add_handler(self, handler: RequestHandler[Any, Any] | NotificationHandler[Any, Any]) -> None:
159159
"""Add a handler, silently replacing any existing handler for the same method."""
160160
if isinstance(handler, RequestHandler):
161161
self._request_handlers[handler.method] = handler
@@ -164,7 +164,7 @@ def add_handler(self, handler: RequestHandler[Any, Any] | NotificationHandler[An
164164
else:
165165
raise TypeError(f"Unknown handler type: {type(handler)}")
166166

167-
def has_handler(self, method: str) -> bool:
167+
def _has_handler(self, method: str) -> bool:
168168
"""Check if a handler is registered for the given method."""
169169
return method in self._request_handlers or method in self._notification_handlers
170170

@@ -253,8 +253,8 @@ def experimental(self) -> ExperimentalHandlers:
253253
# We create this inline so we only add these capabilities _if_ they're actually used
254254
if self._experimental_handlers is None:
255255
self._experimental_handlers = ExperimentalHandlers(
256-
add_handler=self.add_handler,
257-
has_handler=self.has_handler,
256+
add_handler=self._add_handler,
257+
has_handler=self._has_handler,
258258
)
259259
return self._experimental_handlers
260260

@@ -377,7 +377,7 @@ async def _handle_request(
377377
task_metadata = None
378378
if hasattr(req, "params") and req.params is not None:
379379
task_metadata = getattr(req.params, "task", None)
380-
ctx = RequestHandlerContext(
380+
ctx = RequestContext(
381381
session,
382382
lifespan_context,
383383
experimental=Experimental(
@@ -421,7 +421,7 @@ async def _handle_notification(
421421
try:
422422
client_capabilities = session.client_params.capabilities if session.client_params else None
423423
task_support = self._experimental_handlers.task_support if self._experimental_handlers else None
424-
ctx = NotificationHandlerContext(
424+
ctx = RequestContext(
425425
session,
426426
lifespan_context,
427427
experimental=Experimental(

src/mcp/server/mcpserver/server.py

Lines changed: 26 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, UrlElicitationResult, elicit_with_validation
3232
from mcp.server.elicitation import elicit_url as _elicit_url
3333
from mcp.server.lowlevel.helper_types import ReadResourceContents
34+
from mcp.server.lowlevel.notification_handler import NotificationHandler
3435
from mcp.server.lowlevel.request_handler import RequestHandler
3536
from mcp.server.lowlevel.server import LifespanResultT, Server
3637
from mcp.server.lowlevel.server import lifespan as default_lifespan
@@ -46,7 +47,7 @@
4647
from mcp.server.streamable_http import EventStore
4748
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
4849
from mcp.server.transport_security import TransportSecuritySettings
49-
from mcp.shared.context import LifespanContextT, RequestHandlerContext, RequestT
50+
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
5051
from mcp.shared.exceptions import MCPError
5152
from mcp.types import (
5253
Annotations,
@@ -76,7 +77,7 @@
7677

7778
_CallableT = TypeVar("_CallableT", bound=Callable[..., Any])
7879

79-
_mcp_server_ctx: contextvars.ContextVar[RequestHandlerContext[ServerSession, Any, Any]] = contextvars.ContextVar(
80+
_mcp_server_ctx: contextvars.ContextVar[RequestContext[ServerSession, Any, Any]] = contextvars.ContextVar(
8081
"_mcp_server_ctx"
8182
)
8283

@@ -159,6 +160,9 @@ def __init__(
159160
auth=auth,
160161
)
161162

163+
self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools)
164+
self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources)
165+
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
162166
self._lowlevel_server = Server(
163167
name=name or "mcp-server",
164168
title=title,
@@ -167,13 +171,11 @@ def __init__(
167171
website_url=website_url,
168172
icons=icons,
169173
version=version,
174+
handlers=self._create_handlers(),
170175
# TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer and Server.
171176
# We need to create a Lifespan type that is a generic on the server type, like Starlette does.
172177
lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore
173178
)
174-
self._tool_manager = ToolManager(tools=tools, warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools)
175-
self._resource_manager = ResourceManager(warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources)
176-
self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts)
177179
# Validate auth configuration
178180
if self.settings.auth is not None:
179181
if auth_server_provider and token_verifier: # pragma: no cover
@@ -191,9 +193,6 @@ def __init__(
191193
self._token_verifier = ProviderTokenVerifier(auth_server_provider)
192194
self._custom_starlette_routes: list[Route] = []
193195

194-
# Set up MCP protocol handlers
195-
self._setup_handlers()
196-
197196
# Configure logging
198197
configure_logging(self.settings.log_level)
199198

@@ -290,8 +289,8 @@ def run(
290289
case "streamable-http": # pragma: no cover
291290
anyio.run(lambda: self.run_streamable_http_async(**kwargs))
292291

293-
def _setup_handlers(self) -> None:
294-
"""Set up core MCP protocol handlers."""
292+
def _create_handlers(self) -> list[RequestHandler[Any, Any] | NotificationHandler[Any, Any]]:
293+
"""Create core MCP protocol handlers."""
295294

296295
async def handle_list_tools(ctx: Any, params: Any) -> ListToolsResult:
297296
token = _mcp_server_ctx.set(ctx)
@@ -382,15 +381,15 @@ async def handle_get_prompt(ctx: Any, params: Any) -> GetPromptResult:
382381
finally:
383382
_mcp_server_ctx.reset(token)
384383

385-
self._lowlevel_server.add_handler(RequestHandler("tools/list", handler=handle_list_tools))
386-
self._lowlevel_server.add_handler(RequestHandler("tools/call", handler=handle_call_tool))
387-
self._lowlevel_server.add_handler(RequestHandler("resources/list", handler=handle_list_resources))
388-
self._lowlevel_server.add_handler(RequestHandler("resources/read", handler=handle_read_resource))
389-
self._lowlevel_server.add_handler(
390-
RequestHandler("resources/templates/list", handler=handle_list_resource_templates)
391-
)
392-
self._lowlevel_server.add_handler(RequestHandler("prompts/list", handler=handle_list_prompts))
393-
self._lowlevel_server.add_handler(RequestHandler("prompts/get", handler=handle_get_prompt))
384+
return [
385+
RequestHandler("tools/list", handler=handle_list_tools),
386+
RequestHandler("tools/call", handler=handle_call_tool),
387+
RequestHandler("resources/list", handler=handle_list_resources),
388+
RequestHandler("resources/read", handler=handle_read_resource),
389+
RequestHandler("resources/templates/list", handler=handle_list_resource_templates),
390+
RequestHandler("prompts/list", handler=handle_list_prompts),
391+
RequestHandler("prompts/get", handler=handle_get_prompt),
392+
]
394393

395394
async def list_tools(self) -> list[MCPTool]:
396395
"""List all available tools."""
@@ -614,7 +613,11 @@ async def handler(ctx: Any, params: Any) -> CompleteResult:
614613
finally:
615614
_mcp_server_ctx.reset(token)
616615

617-
self._lowlevel_server.add_handler(RequestHandler("completion/complete", handler=handler))
616+
# TODO(maxisbey): remove private access — completion needs post-construction
617+
# handler registration, find a better pattern for this
618+
self._lowlevel_server._add_handler( # pyright: ignore[reportPrivateUsage]
619+
RequestHandler("completion/complete", handler=handler)
620+
)
618621
return func
619622

620623
return decorator
@@ -1136,13 +1139,13 @@ def my_tool(x: int, ctx: Context) -> str:
11361139
The context is optional - tools that don't need it can omit the parameter.
11371140
"""
11381141

1139-
_request_context: RequestHandlerContext[ServerSessionT, LifespanContextT, RequestT] | None
1142+
_request_context: RequestContext[ServerSessionT, LifespanContextT, RequestT] | None
11401143
_mcp_server: MCPServer | None
11411144

11421145
def __init__(
11431146
self,
11441147
*,
1145-
request_context: (RequestHandlerContext[ServerSessionT, LifespanContextT, RequestT] | None) = None,
1148+
request_context: (RequestContext[ServerSessionT, LifespanContextT, RequestT] | None) = None,
11461149
mcp_server: MCPServer | None = None,
11471150
**kwargs: Any,
11481151
):
@@ -1160,7 +1163,7 @@ def mcp_server(self) -> MCPServer:
11601163
@property
11611164
def request_context(
11621165
self,
1163-
) -> RequestHandlerContext[ServerSessionT, LifespanContextT, RequestT]:
1166+
) -> RequestContext[ServerSessionT, LifespanContextT, RequestT]:
11641167
"""Access to the underlying request context."""
11651168
if self._request_context is None: # pragma: no cover
11661169
raise ValueError("Context is not available outside of a request")

0 commit comments

Comments
 (0)