Skip to content

Commit 2738181

Browse files
committed
Let handler-raised MCPError keep its code and data in the prompt and resource pipelines
tools/call already re-raises MCPError so a handler can answer with a typed protocol error (e.g. a missing-client-capability rejection with data.requiredCapabilities); the prompt and resource pipelines wrapped it into generic internal errors, destroying the code and data. Add the same carve-out at the five wrap sites (Prompt.render, MCPServer.get_prompt, ResourceTemplate.create_resource, FunctionResource.read, MCPServer.read_resource) — this matters now that prompt and template functions participate in the multi-round-trip flow and must be able to reject a request themselves. Also document that a tool whose dependencies elicit owns its request_state channel, so handlers should not forward another result's InputRequiredResult from such a tool.
1 parent f689d8f commit 2738181

7 files changed

Lines changed: 83 additions & 3 deletions

File tree

docs/migration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ resource functions never return one). `Prompt.render()` and
3636
`ctx.read_resource()` inside a handler is unchanged: it still returns content,
3737
and raises `RuntimeError` if the resource requests input. A handler that wants
3838
to receive the `InputRequiredResult` and forward it as its own result calls
39-
`MCPServer.read_resource(uri, context)` directly.
39+
`MCPServer.read_resource(uri, context)` directly — but not from a tool whose
40+
dependencies elicit via `Resolve(...)`: the resolver owns that tool's
41+
`request_state` channel, and a forwarded result's state would clobber it.
4042

4143
### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error
4244

src/mcp/server/mcpserver/context.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
105105
This is a content reader: an `InputRequiredResult` returned by a
106106
resource template function (the 2026-07-28 multi-round-trip flow)
107107
raises here. A handler that wants to receive and forward one as its
108-
own result calls `MCPServer.read_resource(uri, context)` instead.
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.
109112
110113
Args:
111114
uri: Resource URI to read

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1515
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1616
from mcp.shared._callable_inspection import is_async_callable
17+
from mcp.shared.exceptions import MCPError
1718

1819
if TYPE_CHECKING:
1920
from mcp.server.context import LifespanContextT, RequestT
@@ -193,5 +194,7 @@ async def render(
193194
raise ValueError(f"Could not convert prompt result to message: {msg}")
194195

195196
return messages
197+
except MCPError:
198+
raise
196199
except Exception as e:
197200
raise ValueError(f"Error rendering prompt {self.name}: {e}")

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1818
from mcp.server.mcpserver.utilities.logging import get_logger
1919
from mcp.shared._callable_inspection import is_async_callable
20+
from mcp.shared.exceptions import MCPError
2021
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
2122
from mcp.shared.uri_template import UriTemplate
2223

@@ -243,7 +244,7 @@ async def create_resource(
243244
meta=self.meta,
244245
fn=lambda: result, # Capture result in closure
245246
)
246-
except ResourceError:
247+
except (ResourceError, MCPError):
247248
raise
248249
except Exception as exc:
249250
logger.exception(f"Error creating resource from template {uri}")

src/mcp/server/mcpserver/resources/types.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from mcp.server.mcpserver.resources.base import Resource
1919
from mcp.shared._callable_inspection import is_async_callable
20+
from mcp.shared.exceptions import MCPError
2021

2122

2223
class TextResource(Resource):
@@ -79,6 +80,8 @@ async def read(self) -> str | bytes:
7980
return result
8081
else:
8182
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
83+
except MCPError:
84+
raise
8285
except Exception as e:
8386
raise ValueError(f"Error reading resource {self.uri}: {e}")
8487

src/mcp/server/mcpserver/server.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,8 @@ async def read_resource(
519519
try:
520520
content = await resource.read()
521521
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
522+
except MCPError:
523+
raise
522524
except Exception as exc:
523525
logger.exception(f"Error getting resource {uri}")
524526
# If an exception happens when reading the resource, we should not leak the exception to the client.
@@ -1232,6 +1234,8 @@ async def get_prompt(
12321234
description=prompt.description,
12331235
messages=pydantic_core.to_jsonable_python(rendered),
12341236
)
1237+
except MCPError:
1238+
raise
12351239
except Exception as e:
12361240
logger.exception(f"Error getting prompt {name}")
12371241
raise ValueError(str(e)) from e

tests/server/mcpserver/test_server.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from mcp_types import (
1111
INTERNAL_ERROR,
1212
INVALID_PARAMS,
13+
MISSING_REQUIRED_CLIENT_CAPABILITY,
1314
AudioContent,
1415
BlobResourceContents,
1516
CallToolResult,
@@ -2104,6 +2105,69 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
21042105
assert result is sentinel
21052106

21062107

2108+
async def test_prompt_raising_mcp_error_surfaces_code_and_data_to_client():
2109+
"""A handler-raised MCPError keeps its code and data through the prompt pipeline —
2110+
the same parity tools/call has, needed for self-service capability rejection."""
2111+
mcp = MCPServer()
2112+
2113+
@mcp.prompt()
2114+
async def briefing(ctx: Context) -> str:
2115+
raise MCPError(
2116+
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
2117+
message="needs elicitation",
2118+
data={"requiredCapabilities": ["elicitation"]},
2119+
)
2120+
2121+
async with Client(mcp) as client:
2122+
with pytest.raises(MCPError) as exc:
2123+
await client.get_prompt("briefing")
2124+
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
2125+
assert exc.value.error.message == "needs elicitation"
2126+
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
2127+
2128+
2129+
async def test_resource_template_raising_mcp_error_surfaces_code_and_data_to_client():
2130+
"""A handler-raised MCPError keeps its code and data through the resource template
2131+
pipeline instead of being wrapped into a generic ResourceError."""
2132+
mcp = MCPServer()
2133+
2134+
@mcp.resource("ask://{topic}")
2135+
async def ask(topic: str, ctx: Context) -> str:
2136+
raise MCPError(
2137+
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
2138+
message="needs elicitation",
2139+
data={"requiredCapabilities": ["elicitation"]},
2140+
)
2141+
2142+
async with Client(mcp) as client:
2143+
with pytest.raises(MCPError) as exc:
2144+
await client.read_resource("ask://databases")
2145+
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
2146+
assert exc.value.error.message == "needs elicitation"
2147+
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
2148+
2149+
2150+
async def test_static_resource_raising_mcp_error_surfaces_code_and_data_to_client():
2151+
"""A handler-raised MCPError keeps its code and data through the static resource
2152+
read path too — parity with the template path above."""
2153+
mcp = MCPServer()
2154+
2155+
@mcp.resource("static://thing")
2156+
def thing() -> str:
2157+
raise MCPError(
2158+
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
2159+
message="needs elicitation",
2160+
data={"requiredCapabilities": ["elicitation"]},
2161+
)
2162+
2163+
async with Client(mcp) as client:
2164+
with pytest.raises(MCPError) as exc:
2165+
await client.read_resource("static://thing")
2166+
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
2167+
assert exc.value.error.message == "needs elicitation"
2168+
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
2169+
2170+
21072171
async def test_context_exposes_client_capabilities_from_connection():
21082172
mcp = MCPServer()
21092173
seen: list[ClientCapabilities | None] = []

0 commit comments

Comments
 (0)