Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6b6a4e0
refactor: replace lowlevel Server decorators with on_* constructor kw…
maxisbey Feb 3, 2026
c5a5fb2
fix: remove unnecessary request_ctx contextvar from notification hand…
maxisbey Feb 3, 2026
03aa9c1
fix: address PR review comments on migration docs and type hints
maxisbey Feb 6, 2026
67172b6
fix: collapse single-arg Server() to one line in migration example
maxisbey Feb 6, 2026
7e9e53f
refactor: replace _create_handler_kwargs with private methods on MCPS…
maxisbey Feb 9, 2026
bfe9537
refactor: use dict instead of list of tuples for handler maps
maxisbey Feb 9, 2026
f41ec17
docs: document keyword-only Server constructor params in migration guide
maxisbey Feb 9, 2026
9f0ae6c
refactor: inline _register_default_task_handlers into enable_tasks
maxisbey Feb 9, 2026
4150b2d
feat: add on_* handler kwargs to enable_tasks for custom task handlers
maxisbey Feb 9, 2026
3fdee2b
refactor: drop explicit Any from ServerRequestContext, rely on Reques…
maxisbey Feb 10, 2026
9835917
refactor: make ExperimentalHandlers generic on LifespanResultT
maxisbey Feb 10, 2026
7fb52b4
refactor: type MCPServer handler signatures instead of Any
maxisbey Feb 10, 2026
addc451
refactor: type notify as ClientNotification, remove getattr
maxisbey Feb 10, 2026
eb011d5
fix: resolve pyright errors in ExperimentalHandlers.enable_tasks
maxisbey Feb 10, 2026
fa9c59b
fix: advertise subscribe capability when handler is registered
maxisbey Feb 10, 2026
e9df629
refactor: make on_ping non-optional with default handler per MCP spec
maxisbey Feb 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions docs/experimental/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,9 @@ Tasks are useful for:
Experimental features are accessed via the `.experimental` property:

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

# Client-side
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})
Expand Down
173 changes: 162 additions & 11 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,6 @@ The nested `RequestParams.Meta` Pydantic model class has been replaced with a to
- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict)
- Attribute access (`meta.progress_token`) → Dictionary access (`meta.get("progress_token")`)
- `progress_token` field changed from `ProgressToken | None = None` to `NotRequired[ProgressToken]`
`

**In request context handlers:**

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

# After (v2)
@server.call_tool()
async def handle_tool(name: str, arguments: dict) -> list[TextContent]:
ctx = server.request_context
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
if ctx.meta and "progress_token" in ctx.meta:
await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100)
...

server = Server("my-server", on_call_tool=handle_call_tool)
```

### `RequestContext` and `ProgressContext` type parameters simplified
Expand Down Expand Up @@ -471,6 +471,157 @@ await client.read_resource("test://resource")
await client.read_resource(str(my_any_url))
```

### Lowlevel `Server`: constructor parameters are now keyword-only

All parameters after `name` are now keyword-only. If you were passing `version` or other parameters positionally, use keyword arguments instead:

```python
# Before (v1)
server = Server("my-server", "1.0")

# After (v2)
server = Server("my-server", version="1.0")
```

### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params

The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor.

**Before (v1):**

```python
from mcp.server.lowlevel.server import Server

server = Server("my-server")

@server.list_tools()
async def handle_list_tools():
return [types.Tool(name="my_tool", description="A tool", inputSchema={})]

@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
return [types.TextContent(type="text", text=f"Called {name}")]
```

**After (v2):**

```python
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)

async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", inputSchema={})])


async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text=f"Called {params.name}")],
is_error=False,
)

server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool)
```

**Key differences:**

- 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.
- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`).
- 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.

**Notification handlers:**

```python
from mcp.server import Server, ServerRequestContext
from mcp.types import ProgressNotificationParams


async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None:
print(f"Progress: {params.progress}/{params.total}")

server = Server("my-server", on_progress=handle_progress)
```

### Lowlevel `Server`: `request_context` property removed

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.

**Before (v1):**

```python
from mcp.server.lowlevel.server import request_ctx

@server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
ctx = server.request_context # or request_ctx.get()
await ctx.session.send_log_message(level="info", data="Processing...")
return [types.TextContent(type="text", text="Done")]
```

**After (v2):**

```python
from mcp.server import ServerRequestContext
from mcp.types import CallToolRequestParams, CallToolResult, TextContent


async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
await ctx.session.send_log_message(level="info", data="Processing...")
return CallToolResult(
content=[TextContent(type="text", text="Done")],
is_error=False,
)
```

### `RequestContext`: request-specific fields are now optional

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`.

```python
from mcp.server import ServerRequestContext

# request_id, meta, etc. are available in request handlers
# but None in notification handlers
```

### Experimental: task handler decorators removed

The experimental decorator methods on `ExperimentalHandlers` (`@server.experimental.list_tasks()`, `@server.experimental.get_task()`, etc.) have been removed.

Default task handlers are still registered automatically via `server.experimental.enable_tasks()`. Custom handlers can be passed as `on_*` kwargs to override specific defaults.

**Before (v1):**

```python
server = Server("my-server")
server.experimental.enable_tasks(task_store)

@server.experimental.get_task()
async def custom_get_task(request: GetTaskRequest) -> GetTaskResult:
...
```

**After (v2):**

```python
from mcp.server import Server, ServerRequestContext
from mcp.types import GetTaskRequestParams, GetTaskResult


async def custom_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult:
...


server = Server("my-server")
server.experimental.enable_tasks(on_get_task=custom_get_task)
```

## Deprecations

<!-- Add deprecations below -->
Expand Down Expand Up @@ -506,16 +657,16 @@ params = CallToolRequestParams(
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.

```python
from mcp.server.lowlevel.server import Server
from mcp.server import Server, ServerRequestContext
from mcp.types import ListToolsResult, PaginatedRequestParams

server = Server("my-server")

# Register handlers...
@server.list_tools()
async def list_tools():
return [...]
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[...])


server = Server("my-server", on_list_tools=handle_list_tools)

# Create a Starlette app for streamable HTTP
app = server.streamable_http_app(
streamable_http_path="/mcp",
json_response=False,
Expand Down
3 changes: 2 additions & 1 deletion src/mcp/server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .context import ServerRequestContext
from .lowlevel import NotificationOptions, Server
from .mcpserver import MCPServer
from .models import InitializationOptions

__all__ = ["Server", "MCPServer", "NotificationOptions", "InitializationOptions"]
__all__ = ["Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"]
5 changes: 1 addition & 4 deletions src/mcp/server/experimental/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,7 @@ async def run_task(
RuntimeError: If task support is not enabled or task_metadata is missing

Example:
@server.call_tool()
async def handle_tool(name: str, args: dict):
ctx = server.request_context

async def handle_tool(ctx: RequestContext, params: CallToolRequestParams) -> CallToolResult:
async def work(task: ServerTaskContext) -> CallToolResult:
result = await task.elicit(
message="Are you sure?",
Expand Down
19 changes: 8 additions & 11 deletions src/mcp/server/experimental/task_result_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,14 @@ class TaskResultHandler:
5. Returns the final result

Usage:
# Create handler with store and queue
handler = TaskResultHandler(task_store, message_queue)

# Register it with the server
@server.experimental.get_task_result()
async def handle_task_result(req: GetTaskPayloadRequest) -> GetTaskPayloadResult:
ctx = server.request_context
return await handler.handle(req, ctx.session, ctx.request_id)

# Or use the convenience method
handler.register(server)
async def handle_task_result(
ctx: ServerRequestContext, params: GetTaskPayloadRequestParams
) -> GetTaskPayloadResult:
...

server.experimental.enable_tasks(
on_task_result=handle_task_result,
)
"""

def __init__(
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/server/lowlevel/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .server import NotificationOptions, Server

__all__ = ["Server", "NotificationOptions"]
__all__ = ["NotificationOptions", "Server"]
Loading
Loading