Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
refactor: replace lowlevel Server decorators with on_* constructor kwargs#1985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
2a5ebec2b9e8c7a7779e1b2dc1afe7a6e5fe0fc05456e4ada5e4274a032ebb29b77527b466bebedf48337801dc35fd17d1178d41fca8fd2ef4c256fb3f817fc72c2dc793b1448da8b7b92f0de5f61ca1fc1a7020868165a903866cFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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:** | ||
| @@ -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 | ||
| @@ -471,12 +471,292 @@ 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`: type parameter reduced from 2 to 1 | ||
| The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed — handlers now receive typed params directly rather than a generic request type. | ||
| ```python | ||
| # Before (v1) | ||
| from typing import Any | ||
| from mcp.server.lowlevel.server import Server | ||
| server: Server[dict[str, Any], Any] = Server(...) | ||
| # After (v2) | ||
| from typing import Any | ||
| from mcp.server import Server | ||
| server: Server[dict[str, Any]] = Server(...) | ||
| ``` | ||
| ### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed | ||
| The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done exclusively through constructor `on_*` keyword arguments. There is no public API to register handlers after construction. | ||
| ```python | ||
| # Before (v1) — direct dict access | ||
| from mcp.types import ListToolsRequest | ||
| if ListToolsRequest in server.request_handlers: | ||
| ... | ||
| # After (v2) — no public access to handler dicts | ||
| # Use the on_* constructor params to register handlers | ||
| server = Server("my-server", on_list_tools=handle_list_tools) | ||
| ``` | ||
| ### 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={})] | ||
maxisbey marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| @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", input_schema={})]) | ||
| 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 `ServerRequestContext` 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 is now an internal implementation detail and should not be relied upon. | ||
| **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() | ||
| @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 --> | ||
| ## Bug Fixes | ||
| ### Lowlevel `Server`: `subscribe` capability now correctly reported | ||
| Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. | ||
| ### Extra fields no longer allowed on top-level MCP types | ||
| MCP protocol types no longer accept arbitrary extra fields at the top level. This matches the MCP specification which only allows extra fields within `_meta` objects, not on the types themselves. | ||
| @@ -506,16 +786,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, | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.