Skip to content

Commit 2a381da

Browse files
committed
Keep the outer request's input_responses out of nested resource reads
ctx.read_resource passed the handler's own Context into the nested resource template, so on the outer request's retry the template saw input_responses and request_state addressed to the outer handler — with a colliding key a write-once template would silently consume an answer to a different question. The continuation payload belongs to the wire request's target dispatch only, so nested invocations now use Context._nested_invocation(): same request infrastructure, no input_responses/request_state — a nested template always behaves as round one, making ctx.read_resource's raise-on-input-required contract deterministic across rounds. The explicit forwarding path (MCPServer.read_resource(uri, context)) is unchanged: there the caller deliberately re-addresses the payload.
1 parent 2738181 commit 2a381da

2 files changed

Lines changed: 55 additions & 6 deletions

File tree

src/mcp/server/mcpserver/context.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]:
8989
raise ValueError("Context is not available outside of a request")
9090
return self._request_context
9191

92+
def _nested_invocation(self) -> Context[LifespanContextT, RequestT]:
93+
"""A Context for invoking another handler's function from inside this request.
94+
95+
Shares the request infrastructure (session, request metadata, lifespan) but
96+
carries no `input_responses`/`request_state`: those are addressed to the wire
97+
request's own target — their keys are ones that handler minted — so a nested
98+
invocation always starts on round one.
99+
"""
100+
return Context(request_context=self._request_context, mcp_server=self._mcp_server)
101+
92102
async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
93103
"""Report progress for the current operation.
94104
@@ -104,11 +114,14 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
104114
105115
This is a content reader: an `InputRequiredResult` returned by a
106116
resource template function (the 2026-07-28 multi-round-trip flow)
107-
raises here. A handler that wants to receive and forward one as its
108-
own result calls `MCPServer.read_resource(uri, context)` instead —
109-
but not from a tool whose dependencies elicit via `Resolve(...)`:
110-
the resolver owns that tool's `request_state` channel, and a
111-
forwarded result's state would clobber it.
117+
raises here, and the nested template never sees this request's
118+
`input_responses`/`request_state` — those answer the outer handler's
119+
own questions, so the template always behaves as round one. A handler
120+
that wants to receive and forward an `InputRequiredResult` as its own
121+
result calls `MCPServer.read_resource(uri, context)` instead — but
122+
not from a tool whose dependencies elicit via `Resolve(...)`: the
123+
resolver owns that tool's `request_state` channel, and a forwarded
124+
result's state would clobber it.
112125
113126
Args:
114127
uri: Resource URI to read
@@ -122,7 +135,7 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
122135
RuntimeError: If the resource returned an `InputRequiredResult`.
123136
"""
124137
assert self._mcp_server is not None, "Context is not available outside of a request"
125-
result = await self._mcp_server.read_resource(uri, self)
138+
result = await self._mcp_server.read_resource(uri, self._nested_invocation())
126139
if isinstance(result, InputRequiredResult):
127140
raise RuntimeError(
128141
"Resource returned InputRequiredResult; ctx.read_resource() only returns "

tests/server/mcpserver/test_server.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
Icon,
2828
ImageContent,
2929
InputRequiredResult,
30+
InputResponses,
3031
ListPromptsResult,
3132
ListRootsRequest,
3233
Prompt,
@@ -2105,6 +2106,41 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
21052106
assert result is sentinel
21062107

21072108

2109+
async def test_context_read_resource_keeps_outer_input_responses_from_the_nested_template():
2110+
"""ctx.read_resource never participates in the multi-round-trip flow, so the nested
2111+
template must not see the outer request's input_responses/request_state — a colliding
2112+
key would otherwise consume an answer meant for the outer handler's own question."""
2113+
mcp = MCPServer()
2114+
seen_responses: list[InputResponses | None] = []
2115+
seen_state: list[str | None] = []
2116+
2117+
@mcp.resource("ask://{topic}")
2118+
async def ask(topic: str, ctx: Context) -> str:
2119+
seen_responses.append(ctx.input_responses)
2120+
seen_state.append(ctx.request_state)
2121+
return f"{topic} content"
2122+
2123+
@mcp.tool()
2124+
async def outer(ctx: Context) -> str:
2125+
contents = list(await ctx.read_resource("ask://databases"))
2126+
assert isinstance(contents[0].content, str)
2127+
return contents[0].content
2128+
2129+
with anyio.fail_after(5):
2130+
async with Client(mcp, mode="2026-07-28") as client:
2131+
result = await client.session.call_tool(
2132+
"outer",
2133+
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
2134+
request_state="outer-state",
2135+
)
2136+
assert isinstance(result, CallToolResult)
2137+
block = result.content[0]
2138+
assert isinstance(block, TextContent)
2139+
assert block.text == "databases content"
2140+
assert seen_responses == [None]
2141+
assert seen_state == [None]
2142+
2143+
21082144
async def test_prompt_raising_mcp_error_surfaces_code_and_data_to_client():
21092145
"""A handler-raised MCPError keeps its code and data through the prompt pipeline —
21102146
the same parity tools/call has, needed for self-service capability rejection."""

0 commit comments

Comments
 (0)