Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2a5ebec
refactor: replace lowlevel Server decorators with on_* constructor kw…
maxisbey Feb 3, 2026
2b9e8c7
fix: remove unnecessary request_ctx contextvar from notification hand…
maxisbey Feb 3, 2026
a7779e1
fix: address PR review comments on migration docs and type hints
maxisbey Feb 6, 2026
b2dc1af
fix: collapse single-arg Server() to one line in migration example
maxisbey Feb 6, 2026
e7a6e5f
refactor: replace _create_handler_kwargs with private methods on MCPS…
maxisbey Feb 9, 2026
e0fc054
refactor: use dict instead of list of tuples for handler maps
maxisbey Feb 9, 2026
56e4ada
docs: document keyword-only Server constructor params in migration guide
maxisbey Feb 9, 2026
5e4274a
refactor: inline _register_default_task_handlers into enable_tasks
maxisbey Feb 9, 2026
032ebb2
feat: add on_* handler kwargs to enable_tasks for custom task handlers
maxisbey Feb 9, 2026
9b77527
refactor: drop explicit Any from ServerRequestContext, rely on Reques…
maxisbey Feb 10, 2026
b466beb
refactor: make ExperimentalHandlers generic on LifespanResultT
maxisbey Feb 10, 2026
edf4833
refactor: type MCPServer handler signatures instead of Any
maxisbey Feb 10, 2026
7801dc3
refactor: type notify as ClientNotification, remove getattr
maxisbey Feb 10, 2026
5fd17d1
fix: resolve pyright errors in ExperimentalHandlers.enable_tasks
maxisbey Feb 10, 2026
178d41f
fix: advertise subscribe capability when handler is registered
maxisbey Feb 10, 2026
ca8fd2e
refactor: make on_ping non-optional with default handler per MCP spec
maxisbey Feb 10, 2026
f4c256f
fix: update tests to use new Server constructor kwargs pattern
maxisbey Feb 11, 2026
b3f817f
fix: migrate experimental task server tests to new handler pattern
maxisbey Feb 11, 2026
c72c2dc
fix: migrate tests to new Server constructor kwargs pattern
maxisbey Feb 11, 2026
793b144
fix: skip partial task capability tests
maxisbey Feb 11, 2026
8da8b7b
fix: migrate examples and snippets to new Server constructor kwargs p…
maxisbey Feb 11, 2026
92f0de5
fix: update README.v2.md prose to match new low-level Server API
maxisbey Feb 11, 2026
f61ca1f
fix: improve test coverage across lowlevel server and experimental tasks
maxisbey Feb 11, 2026
c1a7020
fix: cover experimental property cache-hit branch and fix completion …
maxisbey Feb 11, 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
314 changes: 149 additions & 165 deletions README.v2.md

Large diffs are not rendered by default.

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
262 changes: 251 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,246 @@ 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`: automatic return value wrapping removed

The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`.

**`call_tool()` — structured output wrapping removed:**

The old decorator accepted several return types and auto-wrapped them into `CallToolResult`:

```python
# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent
@server.call_tool()
async def handle(name: str, arguments: dict) -> dict:
return {"temperature": 22.5, "city": "London"}

# Before (v1) — returning a list auto-wrapped into CallToolResult.content
@server.call_tool()
async def handle(name: str, arguments: dict) -> list[TextContent]:
return [TextContent(type="text", text="Done")]
```

```python
# After (v2) — construct the full result yourself
import json

async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
data = {"temperature": 22.5, "city": "London"}
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(data, indent=2))],
structured_content=data,
)
```

Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). Use `params.arguments or {}` to preserve the old behavior.

**`read_resource()` — content type wrapping removed:**

The old decorator auto-wrapped `str` into `TextResourceContents` and `bytes` into `BlobResourceContents` (with base64 encoding), and applied a default mime type of `text/plain`:

```python
# Before (v1) — str/bytes auto-wrapped with mime type defaulting
@server.read_resource()
async def handle(uri: str) -> str:
return "file contents"

@server.read_resource()
async def handle(uri: str) -> bytes:
return b"\x89PNG..."
```

```python
# After (v2) — construct TextResourceContents or BlobResourceContents yourself
import base64

async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
# Text content
return ReadResourceResult(
contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")]
)

async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult:
# Binary content — you must base64-encode it yourself
return ReadResourceResult(
contents=[BlobResourceContents(
uri=str(params.uri),
blob=base64.b64encode(b"\x89PNG...").decode("utf-8"),
mime_type="image/png",
)]
)
```

**`list_tools()`, `list_resources()`, `list_prompts()` — list wrapping removed:**

The old decorators accepted bare lists and wrapped them into the result type:

```python
# Before (v1)
@server.list_tools()
async def handle() -> list[Tool]:
return [Tool(name="my_tool", ...)]

# After (v2)
async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="my_tool", ...)])
```

**Using `MCPServer` instead:**

If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types.

### 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 +746,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
32 changes: 19 additions & 13 deletions examples/servers/everything-server/mcp_everything_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging

import click
from mcp.server import ServerRequestContext
from mcp.server.mcpserver import Context, MCPServer
from mcp.server.mcpserver.prompts.base import UserMessage
from mcp.server.session import ServerSession
Expand All @@ -20,13 +21,17 @@
CompletionArgument,
CompletionContext,
EmbeddedResource,
EmptyResult,
ImageContent,
JSONRPCMessage,
PromptReference,
ResourceTemplateReference,
SamplingMessage,
SetLevelRequestParams,
SubscribeRequestParams,
TextContent,
TextResourceContents,
UnsubscribeRequestParams,
)
from pydantic import BaseModel, Field

Expand Down Expand Up @@ -393,28 +398,29 @@ def test_prompt_with_image() -> list[UserMessage]:
# Custom request handlers
# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource,
# and set_logging_level to avoid accessing protected _lowlevel_server attribute.
@mcp._lowlevel_server.set_logging_level() # pyright: ignore[reportPrivateUsage]
async def handle_set_logging_level(level: str) -> None:
async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
"""Handle logging level changes"""
logger.info(f"Log level set to: {level}")
# In a real implementation, you would adjust the logging level here
# For conformance testing, we just acknowledge the request
logger.info(f"Log level set to: {params.level}")
return EmptyResult()


async def handle_subscribe(uri: str) -> None:
async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult:
"""Handle resource subscription"""
resource_subscriptions.add(str(uri))
logger.info(f"Subscribed to resource: {uri}")
resource_subscriptions.add(str(params.uri))
logger.info(f"Subscribed to resource: {params.uri}")
return EmptyResult()


async def handle_unsubscribe(uri: str) -> None:
async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeRequestParams) -> EmptyResult:
"""Handle resource unsubscription"""
resource_subscriptions.discard(str(uri))
logger.info(f"Unsubscribed from resource: {uri}")
resource_subscriptions.discard(str(params.uri))
logger.info(f"Unsubscribed from resource: {params.uri}")
return EmptyResult()


mcp._lowlevel_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server.unsubscribe_resource()(handle_unsubscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage]
mcp._lowlevel_server._add_request_handler("resources/unsubscribe", handle_unsubscribe) # pyright: ignore[reportPrivateUsage]


@mcp.completion()
Expand Down
Loading