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
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it neve

`genre` is the only argument the model can pass. The lifespan is your server's business.

`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **[The Context](context.md)**.
`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too. Everything `ctx` carries is in **[The Context](context.md)**.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This wording now implies that every @mcp.resource() function can accept ctx, but static resources still reject Context parameters at decoration time; only resource templates support injection. Qualify the resource reference as a template and retain the static-resource limitation in this lifespan guide, including the recap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/handlers/lifespan.md, line 44:

<comment>This wording now implies that every `@mcp.resource()` function can accept `ctx`, but static resources still reject `Context` parameters at decoration time; only resource templates support injection. Qualify the resource reference as a template and retain the static-resource limitation in this lifespan guide, including the recap.</comment>

<file context>
@@ -41,7 +41,7 @@ Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it neve
 `genre` is the only argument the model can pass. The lifespan is your server's business.
 
-`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **[The Context](context.md)**.
+`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too. Everything `ctx` carries is in **[The Context](context.md)**.
 
 ### It really is typed
</file context>

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.

🟣 pre-existing, not blocking: Readers following docs/handlers/lifespan.md:44 and adding ctx to a static @ mcp.resource("config://app") get a ValueError at decoration time, because the sentence now promises @ mcp.resource() functions can take ctx with no qualifier. The PR rewrote this exact line and dropped the trailing clause, but src/mcp/server/mcpserver/server.py:915-922 still refuses a Context parameter on any resource whose URI has no template variables. Fix: say templated resources (@ mcp.resource("scheme://{param}")) and prompts can take ctx, and note that static resources cannot.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

The diff changes docs/handlers/lifespan.md:44 from a sentence with a qualifying clause to the bare statement '@ mcp.resource() and @ mcp.prompt() functions can take a ctx parameter too.' A reader writes @ mcp.resource("config://app") def cfg(ctx: Context[AppContext]). At decoration, find_context_parameter at src/mcp/server/mcpserver/server.py:865 finds ctx, uri_params is empty, and server.py:915-922 raises ValueError("Resource 'config://app' has no URI template variables, but the handler declares a Context parameter. Context injection for static resources is not supported..."). The PR description itself lists 'Static resources still take no Context parameter' under Not included, so the author knew the limitation while rewriting the sentence. The dismissal said the claim is pre-existing, which is true of the first half of the sentence, but the PR removed the only hedge and the page is the one AGENTS.md:144 says must be kept accurate for user-visible behaviour. Remedy: qualify the sentence with 'templated' and mention the static-resource refusal.

Verification: A reader puts ctx: Context on a @ mcp.resource("config://app") function whose URI has no template variables. src/mcp/server/mcpserver/server.py:915-922 raises ValueError, and the rewritten docs/handlers/lifespan.md:44 says unqualified "@ mcp.resource() and @ mcp.prompt() functions can take a ctx parameter too." The base line made the same unqualified promise, so merging makes nothing worse than the base.


### It really is typed

Expand All @@ -51,19 +51,6 @@ That one type parameter is why `ctx.request_context.lifespan_context` **is** an

Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any]`: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help.

!!! warning
`Context[AppContext]` is a **tool-only** spelling. Put it on an `@mcp.resource()` or
`@mcp.prompt()` function and every call to that handler fails. The client gets an error back,
and the server log shows why:

```text
Context is not available outside of a request
```

In resources and prompts, write the bare `ctx: Context`. The object your lifespan yielded is
still `ctx.request_context.lifespan_context` at runtime; you give up the type parameter, not
the object.

!!! tip
There is always a lifespan. If you don't pass one, the SDK's default yields an empty `dict`,
so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a
Expand Down Expand Up @@ -96,7 +83,7 @@ Strip the server down to the lifecycle: give `Database` a `connected` flag, flip
* Code before the `yield` is startup. The `finally` after it is shutdown.
* It runs once, around the whole life of the server, not per request.
* Whatever you `yield` is `ctx.request_context.lifespan_context` in every tool, resource, and prompt.
* `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`.
* `ctx: Context[AppContext]` makes that access fully typed.
* No `lifespan=` means an empty `dict`, never `None`.

A handler that stops mid-call to ask the user for something only they know is **[Elicitation](elicitation.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
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import base64
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from pathlib import Path
from types import SimpleNamespace
from typing import Annotated, Any
Expand Down Expand Up @@ -1337,6 +1340,45 @@ def prompt_no_context(text: str) -> str:
assert content.text == "Prompt 'test' works"


async def test_parameterized_context_carries_the_request_into_templates_and_prompts():

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.

🟡 nit (optional): AGENTS.md requires type hints for all code: the two new async test functions test_parameterized_context_carries_the_request_into_templates_and_prompts and test_prompt_with_parameterized_context_reads_input_responses_on_retry declare no return annotation, while the third new test in the same diff (test_context_request_context_outside_request_raises() -> None) does. Fix: add -> None to every new test_* function definition, which covers the 2 sites listed. Same instruction at 2 sites (tests/server/mcpserver/test_server.py:1343, tests/server/mcpserver/test_server.py:2196).

Why this was flagged

Nothing fails at runtime; pytest runs the tests regardless. The instruction guards consistent typing so pyright checks test bodies fully; an unannotated async def is inferred as returning Coroutine[Any, Any, None], so nothing is lost today. 41 pre-existing tests in this file already omit the annotation, which is precedent the instruction says not to follow. Cosmetic; the author decides.

Verification: AGENTS.md at the base commit, under "## Code Quality", says verbatim "- Type hints required for all code". The diff adds async def test_parameterized_context_carries_the_request_into_templates_and_prompts(): (tests/server/mcpserver/test_server.py:1343) and async def test_prompt_with_parameterized_context_reads_input_responses_on_retry(): (test_server.py:2196) with no return annotation, while the third new test in the same diff, test_context_request_context_outside_request_raises() -> None (test_server.py:3188), is annotated.

"""`ctx: Context[AppState]` on a resource template or a sync or async prompt is the request's own
context, as it is on a tool: lifespan state and the negotiated protocol version are readable."""

@dataclass
class AppState:
greeting: str

@asynccontextmanager
async def lifespan(_: MCPServer[AppState]) -> AsyncIterator[AppState]:
yield AppState(greeting="Hello")

mcp = MCPServer(lifespan=lifespan)

@mcp.resource("greeting://{name}")
def greeting(name: str, ctx: Context[AppState]) -> str:
return f"{ctx.request_context.lifespan_context.greeting}, {name} ({ctx.protocol_version})"

@mcp.prompt()
def greet_sync(name: str, ctx: Context[AppState]) -> str:
return f"{ctx.request_context.lifespan_context.greeting}, {name} ({ctx.protocol_version})"

@mcp.prompt()
async def greet_async(name: str, ctx: Context[AppState]) -> str:
return f"{ctx.request_context.lifespan_context.greeting}, {name} ({ctx.protocol_version})"

async with Client(mcp, mode="2026-07-28") as client:
resource = await client.read_resource("greeting://Alice")
sync_prompt = await client.get_prompt("greet_sync", {"name": "Alice"})
async_prompt = await client.get_prompt("greet_async", {"name": "Alice"})

assert resource.contents == [
TextResourceContents(uri="greeting://Alice", mime_type="text/plain", text="Hello, Alice (2026-07-28)")
]
expected = [PromptMessage(role="user", content=TextContent(type="text", text="Hello, Alice (2026-07-28)"))]
assert sync_prompt.messages == expected
assert async_prompt.messages == expected


class TestServerPrompts:
"""Test prompt functionality in MCPServer server."""

Expand Down Expand Up @@ -2151,6 +2193,35 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
assert block.text == "Brief Alice (state=r1)"


async def test_prompt_with_parameterized_context_reads_input_responses_on_retry():
"""A prompt annotated `ctx: Context[T]` sees the retry's input_responses and request_state, so the
multi-round-trip flow completes instead of asking the same question again."""
mcp = MCPServer()

@mcp.prompt()
async def briefing(ctx: Context[dict[str, Any]]) -> list[UserMessage] | InputRequiredResult:
responses = ctx.input_responses
if responses and "who" in responses:
who = responses["who"]
assert isinstance(who, ElicitResult) and who.content is not None
return [UserMessage(content=f"Brief {who.content['name']} (state={ctx.request_state})")]
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="r1")

with anyio.fail_after(5):
async with Client(mcp, mode="2026-07-28") as client:
r1 = await client.session.get_prompt("briefing", allow_input_required=True)
assert isinstance(r1, InputRequiredResult)

r2 = await client.session.get_prompt(
"briefing",
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
request_state=r1.request_state,
allow_input_required=True,
)
assert isinstance(r2, GetPromptResult)
assert r2.messages == [PromptMessage(role="user", content=TextContent(type="text", text="Brief Alice (state=r1)"))]


async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error():
"""Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so
the runner rejects the frame with -32603 — the same posture the tools path has."""
Expand Down Expand Up @@ -3114,6 +3185,11 @@ def test_context_mcp_server_outside_request_raises() -> None:
_ = Context().mcp_server


def test_context_request_context_outside_request_raises() -> None:
with pytest.raises(ValueError, match="outside of a request"):
_ = Context().request_context
Comment on lines +3188 to +3190

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.

🟡 nit (optional): the new test_context_request_context_outside_request_raises pins the ValueError by match="outside of a request" and has no docstring, which the repo's test bar asks new tests to avoid even in legacy files. Fix: assert the raise without matching message text (or snapshot the SDK-authored message) and add a 1-2 sentence docstring stating what is pinned, so the test does not break on a reworded error.

Why this was flagged

The added unit test at tests/server/mcpserver/test_server.py:3188-3190 uses pytest.raises(ValueError, match="outside of a request") and carries no docstring. AGENTS.md routes test review to .claude/skills/test-quality/SKILL.md, which says never match= on message text and that every test has a 1-2 sentence provenance docstring; AGENTS.md also says not to copy legacy patterns from the surrounding file. The neighbouring test_context_mcp_server_outside_request_raises does the same, but it predates the bar. Nothing fails at runtime; the cost is a test coupled to error wording that a maintainer rewording the message in context.py:98 would have to touch.

Verification: nit. Trigger: any future rewording of the SDK-authored "Context is not available outside of a request" message (src/mcp/server/mcpserver/context.py:98/105/133) breaks this new test, and the test carries no provenance docstring. The diff adds tests/server/mcpserver/test_server.py:3188-3190 with match= on SDK-authored message text and no docstring.



async def test_context_notify_outside_a_request_raises() -> None:
with pytest.raises(ValueError, match="outside of a request"):
await Context().notify_tools_changed()
Expand Down
Loading