Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
178 changes: 167 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: RequestContext, 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)
...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing to actually pass it to the Server object.

```

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

### 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.lowlevel import Server
from mcp.shared.context import RequestContext
Comment on lines +496 to +497
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from mcp.server.lowlevel import Server
from mcp.shared.context import RequestContext
from mcp.server import Server, ServerRequestContext

from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)

async def handle_list_tools(
ctx: RequestContext, params: PaginatedRequestParams | None
) -> ListToolsResult:
return ListToolsResult(tools=[
Tool(name="my_tool", description="A tool", inputSchema={})
])
Comment on lines +510 to +512
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ruff in this code please


async def handle_call_tool(
ctx: RequestContext, 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.lowlevel import Server
from mcp.shared.context import RequestContext
from mcp.types import ProgressNotificationParams

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

server = Server(
"my-server",
on_progress=handle_progress,
)
Comment on lines +547 to +550
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
server = Server(
"my-server",
on_progress=handle_progress,
)
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.shared.context import RequestContext
from mcp.types import CallToolRequestParams, CallToolResult, TextContent

async def handle_call_tool(
ctx: RequestContext, params: CallToolRequestParams
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about this... It's also an option to rename the shared one to be BaseRequestContext, and then have 2 RequestContext that are imported from different modules... 😅

Suggested change
ctx: RequestContext, params: CallToolRequestParams
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.shared.context import RequestContext

# 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()`.

**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.lowlevel import Server
from mcp.types import GetTaskRequestParams, GetTaskResult

server = Server("my-server")
server.experimental.enable_tasks(task_store)
# Default handlers are registered automatically.
# Custom task handlers are not yet supported via the constructor.
Comment on lines +596 to +622
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this server.experimental.enable_tasks is what we want. It's 2 layers of attributes, and it feels a bit weird - but I think it's not the focus here, and I'm happy to ignore it. But I want to revisit it.

```

## Deprecations

<!-- Add deprecations below -->
Expand Down Expand Up @@ -505,16 +657,20 @@ 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.lowlevel import Server
from mcp.shared.context import RequestContext
from mcp.types import ListToolsResult, PaginatedRequestParams

server = Server("my-server")
async def handle_list_tools(
ctx: RequestContext, params: PaginatedRequestParams | None
) -> ListToolsResult:
return ListToolsResult(tools=[...])

# Register handlers...
@server.list_tools()
async def list_tools():
return [...]
server = Server(
"my-server",
on_list_tools=handle_list_tools,
)
Comment on lines +669 to +672
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
server = Server(
"my-server",
on_list_tools=handle_list_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
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
14 changes: 6 additions & 8 deletions src/mcp/server/experimental/task_result_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,12 @@ class TaskResultHandler:
# 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)
# Register as a handler with the lowlevel server
async def handle_task_result(ctx, params):
return await handler.handle(
GetTaskPayloadRequest(params=params), ctx.session, ctx.request_id
)
server = Server(on_call_tool=..., on_list_tools=...)
Comment on lines +50 to +55
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use type hints, and if possible fill the handlers properly.

"""

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