Keep the live Context when a prompt or template annotates Context[T] by maxisbey · Pull Request #3624 · modelcontextprotocol/python-sdk · GitHub
Skip to content
Merged
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
17 changes: 2 additions & 15 deletions docs/handlers/lifespan.md
2 changes: 0 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1714,8 +1714,6 @@ async def my_tool(ctx: Context) -> str: ...
async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
```

The parametrized `Context[MyLifespanState]` annotation currently works only on `@mcp.tool()` handlers. On `@mcp.prompt()` and templated `@mcp.resource("scheme://{param}")` handlers, annotate the parameter as bare `Context` for now: these handlers are wrapped in `pydantic.validate_call`, which re-validates the injected `Context` into a fresh `Context[MyLifespanState]` detached from the request, so the first access to `ctx.request_id`, `ctx.session`, or `ctx.request_context` raises `ValueError: Context is not available outside of a request` (the client sees an internal server error, or `Error creating resource from template ...`). Bare `Context` still exposes `ctx.request_context.lifespan_context`; only its static type is lost.

### `ServerSession` is now a thin proxy (no longer a `BaseSession`)

`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers — `create_message`, `elicit` / `elicit_form` / `elicit_url`, `send_elicit_complete`, `list_roots`, `send_log_message`, `send_resource_updated`, `send_resource_list_changed` / `send_tool_list_changed` / `send_prompt_list_changed`, `send_ping`, `send_progress_notification`, and the new `report_progress` — plus `check_client_capability` and the read-only `client_params`, `client_capabilities`, `protocol_version`, and `can_send_request` properties. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`.
Expand Down
11 changes: 9 additions & 2 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from typing import TYPE_CHECKING, Any, Generic, cast

from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel
from pydantic import AnyUrl, BaseModel
from typing_extensions import deprecated
from pydantic import AnyUrl, BaseModel, ModelWrapValidatorHandler, model_validator
from typing_extensions import Self, deprecated

from mcp.server.context import LifespanContextT, RequestT, ServerRequestContext
from mcp.server.elicitation import (
Expand Down Expand Up @@ -84,6 +84,13 @@ def __init__(
self._input_params = input_params
self._subscriptions = subscriptions

@model_validator(mode="wrap")
@classmethod
def _keep_instance(cls, value: Any, handler: ModelWrapValidatorHandler[Self]) -> Self:
"""Validate an existing `Context` to itself. `Context[T]` is a separate class at runtime, so
pydantic would otherwise rebuild an instance of plain `Context` without its request state."""
return cast(Self, value) if isinstance(value, Context) else handler(value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Servers whose prompt or template parameter is annotated with their own Context subclass now get a plain Context handed in silently; on the base branch that call failed validation loudly. The isinstance(value, Context) test at src/mcp/server/mcpserver/context.py:92 is true for every subclass annotation, so the wrap validator returns the injected base instance even when cls is the subclass. Overridden methods on the subclass are skipped and added attributes raise AttributeError mid-handler. Fix: short-circuit only when isinstance(value, cls) or cls is a parametrization of Context (compare __pydantic_generic_metadata__['origin']), and let any other subclass fall through to handler(value) so the mismatch still surfaces. [also at: src/mcp/server/mcpserver/context.py:92 - nit: AGENTS.md says any change to an existing v2 API's observable behaviour is an explicit maintainer decision.]

Why this was flagged

A user defines class MyContext(Context) with an overridden info() or an added helper and annotates ctx: MyContext on @ mcp.prompt() or a templated @ mcp.resource(). find_context_parameter at src/mcp/server/mcpserver/utilities/context_injection.py:35 uses issubclass, so the parameter is injected with the plain Context the server builds; validate_call at src/mcp/server/mcpserver/prompts/base.py:146 then validates it against MyContext. On the base branch pydantic rejected a non-instance of a non-generic subclass, so every call raised and the author learned at first call. After this change _keep_instance at src/mcp/server/mcpserver/context.py:92 returns the plain instance because isinstance(value, Context) is true regardless of cls. The override is silently bypassed; an added attribute raises AttributeError, wrapped as ValueError("Error rendering prompt ...") at prompts/base.py:213 with the real cause hidden. Remedy: narrow the short-circuit to isinstance(value, cls) or same generic origin.

Verification: A server author defines class MyContext(Context) and annotates ctx: MyContext on an @ mcp.prompt() handler. src/mcp/server/mcpserver/context.py:92 tests against the base Context, not cls, so validate_call (prompts/base.py:146) returns the plain Context instead of letting pydantic reject it. On base pydantic raised a model_type ValidationError on every call; on head overridden methods are silently bypassed.


@property
def mcp_server(self) -> MCPServer:
"""Access to the MCPServer instance."""
Expand Down
21 changes: 10 additions & 11 deletions tests/docs_src/test_lifespan.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from mcp_types import TextContent, TextResourceContents

from docs_src.lifespan import tutorial001, tutorial002
from mcp import Client, MCPError
from mcp import Client
from mcp.server import MCPServer
from mcp.server.mcpserver import Context

Expand Down Expand Up @@ -74,8 +74,8 @@ def stock_report(ctx: Context) -> str:
assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.")


async def test_parameterized_context_is_tool_only(caplog: pytest.LogCaptureFixture) -> None:
"""`Context[AppContext]` on a resource or prompt fails every call; the server logs the `ValueError`."""
async def test_parameterized_context_reaches_the_lifespan_object_in_resources_and_prompts() -> None:
"""`ctx: Context[AppContext]` gives a resource or prompt the same typed lifespan object a tool gets."""
mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan)

@mcp.resource("books://{genre}/count")
Expand All @@ -89,14 +89,13 @@ def stock_report(ctx: Context[tutorial001.AppContext]) -> str:
return f"Summarise a shelf of {ctx.request_context.lifespan_context.db.query()} books."

async with Client(mcp) as client:
with pytest.raises(MCPError, match="Error creating resource from template"):
await client.read_resource("books://poetry/count")
assert "ValueError: Context is not available outside of a request" in caplog.text

caplog.clear()
with pytest.raises(MCPError):
await client.get_prompt("stock_report")
assert "ValueError: Context is not available outside of a request" in caplog.text
resource = await client.read_resource("books://poetry/count")
assert resource.contents == [
TextResourceContents(uri="books://poetry/count", mime_type="text/plain", text="3 books in 'poetry'.")
]
prompt = await client.get_prompt("stock_report")
(message,) = prompt.messages
assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.")


async def test_default_lifespan_yields_an_empty_dict() -> None:
Expand Down
76 changes: 76 additions & 0 deletions tests/server/mcpserver/test_server.py
Loading