Skip to content

Commit 6b6a4e0

Browse files
committed
refactor: replace lowlevel Server decorators with on_* constructor kwargs
Replace the decorator-based handler registration on the lowlevel Server with direct on_* keyword arguments on the constructor. Handlers are raw callables with a uniform (ctx, params) -> result signature. - Server constructor takes on_list_tools, on_call_tool, etc. - String-keyed dispatch instead of type-keyed - Remove RequestT generic from Server (transport-specific, not bound at construction) - Delete handler.py and func_inspection.py (no longer needed) - Update ExperimentalHandlers to use callback-based registration - Update MCPServer to pass on_* kwargs via _create_handler_kwargs() - Update migration docs and docstrings
1 parent 239d682 commit 6b6a4e0

File tree

13 files changed

+583
-759
lines changed

13 files changed

+583
-759
lines changed

docs/experimental/index.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@ Tasks are useful for:
2727
Experimental features are accessed via the `.experimental` property:
2828

2929
```python
30-
# Server-side
31-
@server.experimental.get_task()
32-
async def handle_get_task(request: GetTaskRequest) -> GetTaskResult:
33-
...
30+
# Server-side: enable task support (auto-registers default handlers)
31+
server = Server(name="my-server")
32+
server.experimental.enable_tasks()
3433

3534
# Client-side
3635
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})

docs/migration.md

Lines changed: 169 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,6 @@ The nested `RequestParams.Meta` Pydantic model class has been replaced with a to
351351
- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict)
352352
- Attribute access (`meta.progress_token`) → Dictionary access (`meta.get("progress_token")`)
353353
- `progress_token` field changed from `ProgressToken | None = None` to `NotRequired[ProgressToken]`
354-
`
355354

356355
**In request context handlers:**
357356

@@ -364,11 +363,12 @@ async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
364363
await ctx.session.send_progress_notification(ctx.meta.progress_token, 0.5, 100)
365364

366365
# After (v2)
367-
@server.call_tool()
368-
async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
369-
ctx = server.request_context
366+
async def handle_call_tool(
367+
ctx: RequestContext, params: CallToolRequestParams
368+
) -> CallToolResult:
370369
if ctx.meta and "progress_token" in ctx.meta:
371370
await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100)
371+
...
372372
```
373373

374374
### `RequestContext` and `ProgressContext` type parameters simplified
@@ -471,6 +471,158 @@ await client.read_resource("test://resource")
471471
await client.read_resource(str(my_any_url))
472472
```
473473

474+
### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params
475+
476+
The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor.
477+
478+
**Before (v1):**
479+
480+
```python
481+
from mcp.server.lowlevel.server import Server
482+
483+
server = Server("my-server")
484+
485+
@server.list_tools()
486+
async def handle_list_tools():
487+
return [types.Tool(name="my_tool", description="A tool", inputSchema={})]
488+
489+
@server.call_tool()
490+
async def handle_call_tool(name: str, arguments: dict):
491+
return [types.TextContent(type="text", text=f"Called {name}")]
492+
```
493+
494+
**After (v2):**
495+
496+
```python
497+
from mcp.server.lowlevel import Server
498+
from mcp.shared.context import RequestContext
499+
from mcp.types import (
500+
CallToolRequestParams,
501+
CallToolResult,
502+
ListToolsResult,
503+
PaginatedRequestParams,
504+
TextContent,
505+
Tool,
506+
)
507+
508+
async def handle_list_tools(
509+
ctx: RequestContext, params: PaginatedRequestParams | None
510+
) -> ListToolsResult:
511+
return ListToolsResult(tools=[
512+
Tool(name="my_tool", description="A tool", inputSchema={})
513+
])
514+
515+
async def handle_call_tool(
516+
ctx: RequestContext, params: CallToolRequestParams
517+
) -> CallToolResult:
518+
return CallToolResult(
519+
content=[TextContent(type="text", text=f"Called {params.name}")],
520+
is_error=False,
521+
)
522+
523+
server = Server(
524+
"my-server",
525+
on_list_tools=handle_list_tools,
526+
on_call_tool=handle_call_tool,
527+
)
528+
```
529+
530+
**Key differences:**
531+
532+
- 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.
533+
- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`).
534+
- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler.
535+
536+
**Notification handlers:**
537+
538+
```python
539+
from mcp.server.lowlevel import Server
540+
from mcp.shared.context import RequestContext
541+
from mcp.types import ProgressNotificationParams
542+
543+
async def handle_progress(
544+
ctx: RequestContext, params: ProgressNotificationParams
545+
) -> None:
546+
print(f"Progress: {params.progress}/{params.total}")
547+
548+
server = Server(
549+
"my-server",
550+
on_progress=handle_progress,
551+
)
552+
```
553+
554+
### Lowlevel `Server`: `request_context` property removed
555+
556+
The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar still exists but should not be needed — use `ctx` directly instead.
557+
558+
**Before (v1):**
559+
560+
```python
561+
from mcp.server.lowlevel.server import request_ctx
562+
563+
@server.call_tool()
564+
async def handle_call_tool(name: str, arguments: dict):
565+
ctx = server.request_context # or request_ctx.get()
566+
await ctx.session.send_log_message(level="info", data="Processing...")
567+
return [types.TextContent(type="text", text="Done")]
568+
```
569+
570+
**After (v2):**
571+
572+
```python
573+
from mcp.shared.context import RequestContext
574+
from mcp.types import CallToolRequestParams, CallToolResult, TextContent
575+
576+
async def handle_call_tool(
577+
ctx: RequestContext, params: CallToolRequestParams
578+
) -> CallToolResult:
579+
await ctx.session.send_log_message(level="info", data="Processing...")
580+
return CallToolResult(
581+
content=[TextContent(type="text", text="Done")],
582+
is_error=False,
583+
)
584+
```
585+
586+
### `RequestContext`: request-specific fields are now optional
587+
588+
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`.
589+
590+
```python
591+
from mcp.shared.context import RequestContext
592+
593+
# request_id, meta, etc. are available in request handlers
594+
# but None in notification handlers
595+
```
596+
597+
### Experimental: task handler decorators removed
598+
599+
The experimental decorator methods on `ExperimentalHandlers` (`@server.experimental.list_tasks()`, `@server.experimental.get_task()`, etc.) have been removed.
600+
601+
Default task handlers are still registered automatically via `server.experimental.enable_tasks()`.
602+
603+
**Before (v1):**
604+
605+
```python
606+
server = Server("my-server")
607+
server.experimental.enable_tasks(task_store)
608+
609+
@server.experimental.get_task()
610+
async def custom_get_task(request: GetTaskRequest) -> GetTaskResult:
611+
...
612+
```
613+
614+
**After (v2):**
615+
616+
```python
617+
from mcp.server.lowlevel import Server
618+
from mcp.types import GetTaskRequestParams, GetTaskResult
619+
620+
server = Server("my-server")
621+
server.experimental.enable_tasks(task_store)
622+
# Default handlers are registered automatically.
623+
# Custom task handlers are not yet supported via the constructor.
624+
```
625+
474626
## Deprecations
475627

476628
<!-- Add deprecations below -->
@@ -506,16 +658,20 @@ params = CallToolRequestParams(
506658
The `streamable_http_app()` method is now available directly on the lowlevel `Server` class, not just `MCPServer`. This allows using the streamable HTTP transport without the MCPServer wrapper.
507659

508660
```python
509-
from mcp.server.lowlevel.server import Server
510-
511-
server = Server("my-server")
512-
513-
# Register handlers...
514-
@server.list_tools()
515-
async def list_tools():
516-
return [...]
661+
from mcp.server.lowlevel import Server
662+
from mcp.shared.context import RequestContext
663+
from mcp.types import ListToolsResult, PaginatedRequestParams
664+
665+
async def handle_list_tools(
666+
ctx: RequestContext, params: PaginatedRequestParams | None
667+
) -> ListToolsResult:
668+
return ListToolsResult(tools=[...])
669+
670+
server = Server(
671+
"my-server",
672+
on_list_tools=handle_list_tools,
673+
)
517674

518-
# Create a Starlette app for streamable HTTP
519675
app = server.streamable_http_app(
520676
streamable_http_path="/mcp",
521677
json_response=False,

src/mcp/server/experimental/request_context.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,7 @@ async def run_task(
160160
RuntimeError: If task support is not enabled or task_metadata is missing
161161
162162
Example:
163-
@server.call_tool()
164-
async def handle_tool(name: str, args: dict):
165-
ctx = server.request_context
166-
163+
async def handle_tool(ctx: RequestContext, params: CallToolRequestParams) -> CallToolResult:
167164
async def work(task: ServerTaskContext) -> CallToolResult:
168165
result = await task.elicit(
169166
message="Are you sure?",

src/mcp/server/experimental/task_result_handler.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,12 @@ class TaskResultHandler:
4747
# Create handler with store and queue
4848
handler = TaskResultHandler(task_store, message_queue)
4949
50-
# Register it with the server
51-
@server.experimental.get_task_result()
52-
async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult:
53-
ctx = server.request_context
54-
return await handler.handle(req, ctx.session, ctx.request_id)
55-
56-
# Or use the convenience method
57-
handler.register(server)
50+
# Register as a handler with the lowlevel server
51+
async def handle_task_result(ctx, params):
52+
return await handler.handle(
53+
GetTaskPayloadRequest(params=params), ctx.session, ctx.request_id
54+
)
55+
server = Server(on_call_tool=..., on_list_tools=...)
5856
"""
5957

6058
def __init__(
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
from .server import NotificationOptions, Server
22

3-
__all__ = ["Server", "NotificationOptions"]
3+
__all__ = ["NotificationOptions", "Server"]

0 commit comments

Comments
 (0)