This document outlines the plan to refactor the low-level Server class (src/mcp/server/lowlevel/server.py) from a decorator-based approach to a constructor-based callback approach.
server = Server("my-server")
@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
return [...]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> dict:
return {"result": "..."}All handlers receive context as the first parameter and params as the second parameter, and return a properly typed result object.
async def handle_list_tools(
ctx: RequestContext[ServerSession, Any, Any],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[...])
async def handle_call_tool(
ctx: RequestContext[ServerSession, Any, Any],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
return types.CallToolResult(content=[...])
server = Server(
name="my-server",
on_list_tools=handle_list_tools,
on_call_tool=handle_call_tool,
)File: src/mcp/server/lowlevel/server.py
Add new handler parameters with inline types (no type aliases). Each handler follows the pattern:
- First parameter:
RequestContext[ServerSession, LifespanResultT, RequestT] - Second parameter: The specific
*Paramstype for that request - Return type: The specific
*Resulttype for that request
from mcp.shared.context import RequestContext
from mcp.server.session import ServerSession
class Server(Generic[LifespanResultT, RequestT]):
def __init__(
self,
name: str,
version: str | None = None,
title: str | None = None,
description: str | None = None,
instructions: str | None = None,
website_url: str | None = None,
icons: list[types.Icon] | None = None,
lifespan: Callable[
[Server[LifespanResultT, RequestT]],
AbstractAsyncContextManager[LifespanResultT],
] = lifespan,
*,
on_list_prompts: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.PaginatedRequestParams | None],
Awaitable[types.ListPromptsResult],
] | None = None,
on_get_prompt: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.GetPromptRequestParams],
Awaitable[types.GetPromptResult],
] | None = None,
# Resources
on_list_resources: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.PaginatedRequestParams | None],
Awaitable[types.ListResourcesResult],
] | None = None,
on_list_resource_templates: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.PaginatedRequestParams | None],
Awaitable[types.ListResourceTemplatesResult],
] | None = None,
on_read_resource: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.ReadResourceRequestParams],
Awaitable[types.ReadResourceResult],
] | None = None,
on_subscribe_resource: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.SubscribeRequestParams],
Awaitable[types.EmptyResult],
] | None = None,
on_unsubscribe_resource: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.UnsubscribeRequestParams],
Awaitable[types.EmptyResult],
] | None = None,
# Tools
on_list_tools: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.PaginatedRequestParams | None],
Awaitable[types.ListToolsResult],
] | None = None,
on_call_tool: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.CallToolRequestParams],
Awaitable[types.CallToolResult],
] | None = None,
# Logging
on_set_logging_level: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.SetLevelRequestParams],
Awaitable[types.EmptyResult],
] | None = None,
# Completions
on_completion: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.CompleteRequestParams],
Awaitable[types.CompleteResult],
] | None = None,
# Notifications
on_progress_notification: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.ProgressNotificationParams],
Awaitable[None],
] | None = None,
):| Current Decorator | Constructor Parameter | Params Type | Result Type |
|---|---|---|---|
@server.list_prompts() |
on_list_prompts |
PaginatedRequestParams | None |
ListPromptsResult |
@server.get_prompt() |
on_get_prompt |
GetPromptRequestParams |
GetPromptResult |
@server.list_resources() |
on_list_resources |
PaginatedRequestParams | None |
ListResourcesResult |
@server.list_resource_templates() |
on_list_resource_templates |
PaginatedRequestParams | None |
ListResourceTemplatesResult |
@server.read_resource() |
on_read_resource |
ReadResourceRequestParams |
ReadResourceResult |
@server.subscribe_resource() |
on_subscribe_resource |
SubscribeRequestParams |
EmptyResult |
@server.unsubscribe_resource() |
on_unsubscribe_resource |
UnsubscribeRequestParams |
EmptyResult |
@server.list_tools() |
on_list_tools |
PaginatedRequestParams | None |
ListToolsResult |
@server.call_tool() |
on_call_tool |
CallToolRequestParams |
CallToolResult |
@server.set_logging_level() |
on_set_logging_level |
SetLevelRequestParams |
EmptyResult |
@server.completion() |
on_completion |
CompleteRequestParams |
CompleteResult |
@server.progress_notification() |
on_progress_notification |
ProgressNotificationParams |
None |
File: src/mcp/server/lowlevel/server.py
In __init__, register handlers passed via constructor parameters. Each handler wrapper:
- Sets up the request context
- Calls the user's handler with
(context, params) - Returns the result directly (no transformation needed since handler returns proper result type)
def __init__(self, ...):
# ... existing initialization ...
# Register handlers from constructor parameters
if on_list_prompts is not None:
self._register_list_prompts_handler(on_list_prompts)
if on_get_prompt is not None:
self._register_get_prompt_handler(on_get_prompt)
if on_list_resources is not None:
self._register_list_resources_handler(on_list_resources)
if on_list_resource_templates is not None:
self._register_list_resource_templates_handler(on_list_resource_templates)
if on_read_resource is not None:
self._register_read_resource_handler(on_read_resource)
if on_subscribe_resource is not None:
self._register_subscribe_resource_handler(on_subscribe_resource)
if on_unsubscribe_resource is not None:
self._register_unsubscribe_resource_handler(on_unsubscribe_resource)
if on_list_tools is not None:
self._register_list_tools_handler(on_list_tools)
if on_call_tool is not None:
self._register_call_tool_handler(on_call_tool)
if on_set_logging_level is not None:
self._register_set_logging_level_handler(on_set_logging_level)
if on_completion is not None:
self._register_completion_handler(on_completion)
if on_progress_notification is not None:
self._register_progress_notification_handler(on_progress_notification)The key change is that the internal handlers now pass the context and params to the user's callback:
def _register_list_tools_handler(
self,
func: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.PaginatedRequestParams | None],
Awaitable[types.ListToolsResult],
],
) -> None:
"""Register a list tools handler."""
logger.debug("Registering handler for ListToolsRequest")
async def handler(req: types.ListToolsRequest) -> types.ListToolsResult:
# Context is already set by _handle_request, retrieve it
ctx = request_ctx.get()
result = await func(ctx, req.params)
# Validate tool names (existing behavior)
for tool in result.tools:
validate_and_warn_tool_name(tool.name)
self._tool_cache[tool.name] = tool
return result
self.request_handlers[types.ListToolsRequest] = handler
def _register_call_tool_handler(
self,
func: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.CallToolRequestParams],
Awaitable[types.CallToolResult],
],
) -> None:
"""Register a call tool handler."""
logger.debug("Registering handler for CallToolRequest")
async def handler(req: types.CallToolRequest) -> types.CallToolResult:
ctx = request_ctx.get()
# User handler is responsible for returning CallToolResult
return await func(ctx, req.params)
self.request_handlers[types.CallToolRequest] = handler
def _register_get_prompt_handler(
self,
func: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.GetPromptRequestParams],
Awaitable[types.GetPromptResult],
],
) -> None:
"""Register a get prompt handler."""
logger.debug("Registering handler for GetPromptRequest")
async def handler(req: types.GetPromptRequest) -> types.GetPromptResult:
ctx = request_ctx.get()
return await func(ctx, req.params)
self.request_handlers[types.GetPromptRequest] = handler
def _register_read_resource_handler(
self,
func: Callable[
[RequestContext[ServerSession, LifespanResultT, RequestT], types.ReadResourceRequestParams],
Awaitable[types.ReadResourceResult],
],
) -> None:
"""Register a read resource handler."""
logger.debug("Registering handler for ReadResourceRequest")
async def handler(req: types.ReadResourceRequest) -> types.ReadResourceResult:
ctx = request_ctx.get()
return await func(ctx, req.params)
self.request_handlers[types.ReadResourceRequest] = handlerFile: src/mcp/server/lowlevel/server.py
Keep decorators for backward compatibility but mark as deprecated:
def list_tools(self):
"""Register a list tools handler.
.. deprecated::
Use the `on_list_tools` constructor parameter instead.
"""
warnings.warn(
"The @server.list_tools() decorator is deprecated. "
"Use the on_list_tools constructor parameter instead.",
DeprecationWarning,
stacklevel=2,
)
def decorator(
func: Callable[[], Awaitable[list[types.Tool]]]
| Callable[[types.ListToolsRequest], Awaitable[types.ListToolsResult]],
):
# Keep existing decorator logic for backward compatibility
wrapper = create_call_wrapper(func, types.ListToolsRequest)
async def handler(req: types.ListToolsRequest):
result = await wrapper(req)
if isinstance(result, types.ListToolsResult):
for tool in result.tools:
validate_and_warn_tool_name(tool.name)
self._tool_cache[tool.name] = tool
return result
else:
self._tool_cache.clear()
for tool in result:
validate_and_warn_tool_name(tool.name)
self._tool_cache[tool.name] = tool
return types.ListToolsResult(tools=result)
self.request_handlers[types.ListToolsRequest] = handler
return func
return decoratorFile: tests/server/lowlevel/test_constructor_handlers.py (create new)
import pytest
from mcp.server.lowlevel import Server
from mcp.server.session import ServerSession
from mcp.shared.context import RequestContext
import mcp.types as types
from typing import Any
@pytest.mark.anyio
async def test_constructor_list_tools_handler():
"""Test registering list_tools via constructor."""
async def list_tools(
ctx: RequestContext[ServerSession, Any, Any],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="test-tool", description="A test tool")]
)
server = Server(
name="test-server",
on_list_tools=list_tools,
)
assert types.ListToolsRequest in server.request_handlers
@pytest.mark.anyio
async def test_constructor_call_tool_handler():
"""Test registering call_tool via constructor."""
async def call_tool(
ctx: RequestContext[ServerSession, Any, Any],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
return types.CallToolResult(
content=[types.TextContent(type="text", text=f"Called {params.name}")],
)
server = Server(
name="test-server",
on_call_tool=call_tool,
)
assert types.CallToolRequest in server.request_handlers
@pytest.mark.anyio
async def test_decorator_deprecation_warning():
"""Test that decorators emit deprecation warnings."""
server = Server(name="test-server")
with pytest.warns(DeprecationWarning, match="on_list_tools constructor parameter"):
@server.list_tools()
async def list_tools():
return []Follow the pattern from tests/client/test_client.py:
@pytest.mark.anyio
async def test_constructor_tools_e2e():
"""E2E test for constructor-based tool handlers."""
async def list_tools(
ctx: RequestContext[ServerSession, Any, Any],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="echo",
description="Echo input",
input_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
},
)
]
)
async def call_tool(
ctx: RequestContext[ServerSession, Any, Any],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
if params.name == "echo":
msg = (params.arguments or {}).get("message", "")
return types.CallToolResult(
content=[types.TextContent(type="text", text=msg)],
)
return types.CallToolResult(
content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")],
is_error=True,
)
server = Server(
name="test-server",
on_list_tools=list_tools,
on_call_tool=call_tool,
)
# Use in-memory transport for testing
async with create_client_server_pair(server) as (client, _):
tools = await client.list_tools()
assert len(tools.tools) == 1
assert tools.tools[0].name == "echo"
result = await client.call_tool("echo", {"message": "hello"})
assert result.content[0].text == "hello"File: src/mcp/server/lowlevel/server.py
"""MCP Server Module
This module provides a framework for creating an MCP (Model Context Protocol) server.
Usage:
1. Define handler functions that receive (context, params) and return result objects:
async def list_tools(
ctx: RequestContext[ServerSession, Any, Any],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[
types.Tool(name="my-tool", description="...")
])
async def call_tool(
ctx: RequestContext[ServerSession, Any, Any],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
# Access context for session, lifespan data, etc.
db = ctx.lifespan_context["db"]
return types.CallToolResult(content=[...])
2. Create a Server instance with handlers:
server = Server(
name="your_server_name",
on_list_tools=list_tools,
on_call_tool=call_tool,
)
3. Run the server:
async def main():
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options(),
)
asyncio.run(main())
Note: The decorator-based API is deprecated but still supported for backward compatibility.
"""File: docs/migration.md
Add a new section documenting the change:
## Low-Level Server API Changes
### Constructor-Based Handler Registration
The low-level `Server` class now supports constructor-based handler registration,
which is the recommended approach. The decorator-based API is deprecated.
**Before (Deprecated):**
```python
server = Server("my-server")
@server.list_tools()
async def list_tools():
return [types.Tool(name="tool", description="...")]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
return {"result": "..."}After (Recommended):
async def list_tools(
ctx: RequestContext[ServerSession, Any, Any],
params: types.PaginatedRequestParams | None,
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[
types.Tool(name="tool", description="...")
])
async def call_tool(
ctx: RequestContext[ServerSession, Any, Any],
params: types.CallToolRequestParams,
) -> types.CallToolResult:
return types.CallToolResult(
content=[types.TextContent(type="text", text="result")]
)
server = Server(
"my-server",
on_list_tools=list_tools,
on_call_tool=call_tool,
)Key differences:
- Handlers receive
(context, params)instead of extracted arguments - Handlers return proper result types (
ListToolsResult,CallToolResult, etc.) - Context provides access to session, lifespan data, and request metadata
Migration steps:
- Update handler signatures to accept
(ctx, params) - Update return types to use proper result classes
- Pass handlers to the Server constructor using
on_*parameters - Remove decorator calls
Benefits:
- Context available in all handlers (session, lifespan data, request metadata)
- Type-safe params and return types
- Clearer dependencies at construction time
- Better testability (handlers can be mocked/replaced)
---
## Phase 6: Implementation Checklist
### Files to Modify
- [ ] `src/mcp/server/lowlevel/server.py` - Main server class
- [ ] `docs/migration.md` - Document breaking changes
### Files to Create/Update for Tests
- [ ] `tests/server/lowlevel/test_constructor_handlers.py` - New tests for constructor API
- [ ] Update existing tests in `tests/server/` to use new API where appropriate
### Implementation Order
1. **Add private registration methods** (`_register_*_handler`) that accept the new signature
2. **Update constructor** to accept handler parameters with inline types
3. **Register handlers in constructor** by calling private methods
4. **Deprecate decorator methods** with warnings
5. **Write tests** for new constructor-based API
6. **Update documentation** and migration guide
7. **Run full test suite** to ensure backward compatibility
---
## Phase 7: Backward Compatibility Strategy
### Approach: Deprecation with Migration Period
1. **Keep decorators working** - They should continue to function but emit deprecation warnings
2. **Allow mixed usage** - Users can use constructor params for some handlers and decorators for others (during migration)
3. **Future removal** - Plan to remove decorator methods in a future major version
### Conflict Resolution
If a handler is registered both via constructor and decorator, raise an error:
```python
def _register_list_tools_handler(self, func) -> None:
if types.ListToolsRequest in self.request_handlers:
raise ValueError(
"A list_tools handler is already registered. "
"Cannot register multiple handlers for the same request type."
)
# ... rest of registration ...
| Component | Change Type | Description |
|---|---|---|
Server.__init__ |
Addition | New on_* parameters with inline types for all handlers |
Server._register_*_handler |
Addition | Private methods for handler registration |
Server.list_tools, etc. |
Deprecation | Decorator methods emit warnings |
| Tests | Addition | New tests for constructor-based API |
| Documentation | Update | Migration guide and module docstring |
All handlers follow the pattern: (context, params) -> result
| Handler | Context Type | Params Type | Return Type |
|---|---|---|---|
on_list_prompts |
RequestContext[...] |
PaginatedRequestParams | None |
ListPromptsResult |
on_get_prompt |
RequestContext[...] |
GetPromptRequestParams |
GetPromptResult |
on_list_resources |
RequestContext[...] |
PaginatedRequestParams | None |
ListResourcesResult |
on_list_resource_templates |
RequestContext[...] |
PaginatedRequestParams | None |
ListResourceTemplatesResult |
on_read_resource |
RequestContext[...] |
ReadResourceRequestParams |
ReadResourceResult |
on_subscribe_resource |
RequestContext[...] |
SubscribeRequestParams |
EmptyResult |
on_unsubscribe_resource |
RequestContext[...] |
UnsubscribeRequestParams |
EmptyResult |
on_list_tools |
RequestContext[...] |
PaginatedRequestParams | None |
ListToolsResult |
on_call_tool |
RequestContext[...] |
CallToolRequestParams |
CallToolResult |
on_set_logging_level |
RequestContext[...] |
SetLevelRequestParams |
EmptyResult |
on_completion |
RequestContext[...] |
CompleteRequestParams |
CompleteResult |
on_progress_notification |
RequestContext[...] |
ProgressNotificationParams |
None |
Where RequestContext[...] is RequestContext[ServerSession, LifespanResultT, RequestT].
-
Experimental handlers - Should
server.experimentalhandlers also move to constructor parameters, or stay separate? -
Should we keep the decorator API indefinitely? Or plan a hard removal in v2.0?