From b3e84e939e55ae342f5ca0d713051980be6bf68c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 16:31:14 +0200 Subject: [PATCH 1/3] Return -32602 for resource not found (SEP-2164) Reading a missing resource now returns JSON-RPC error -32602 (invalid params) with the requested URI in error.data, instead of code 0. A new ResourceNotFoundError(ResourceError) carries the not-found signal: ResourceManager.get_resource raises it for an unmatched URI, and _handle_read_resource maps it to -32602 while other ResourceErrors map to -32603 (internal error). Resource lookups now raise typed exceptions instead of ValueError. Implements SEP-2164. Removes the matching conformance baseline entry, which now passes. --- .../expected-failures.2026-07-28.yml | 2 - .../actions/conformance/expected-failures.yml | 2 - docs/migration.md | 6 +++ src/mcp/server/mcpserver/context.py | 4 ++ src/mcp/server/mcpserver/exceptions.py | 9 ++++ .../mcpserver/resources/resource_manager.py | 15 ++++--- .../server/mcpserver/resources/templates.py | 11 ++++- src/mcp/server/mcpserver/server.py | 23 ++++++---- tests/interaction/_requirements.py | 16 ++++--- tests/interaction/mcpserver/test_resources.py | 18 ++++---- .../resources/test_resource_manager.py | 3 +- .../resources/test_resource_template.py | 3 +- tests/server/mcpserver/test_server.py | 42 +++++++++++++++++-- 13 files changed, 112 insertions(+), 42 deletions(-) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 1d010abc0d..02a3ef81af 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -117,7 +117,5 @@ server: # These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but # the expected-failures evaluator counts WARNINGs as failures. Same entries # as the draft suite in expected-failures.yml. - # SEP-2164: server returns -32600 (not -32602) and omits error.data.uri. - - sep-2164-resource-not-found # SEP-2322 SHOULD-level behaviour (re-request missing inputResponses). - input-required-result-missing-input-response diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 9b676ea475..cc6071205b 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -70,8 +70,6 @@ server: - http-header-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. - # SEP-2164: server returns -32600 (not -32602) and omits error.data.uri. - - sep-2164-resource-not-found # SEP-2322 SHOULD-level behaviour (re-request missing inputResponses). - input-required-result-missing-input-response # SEP-2322 negative-case scenarios: input-required-result-validate-input is diff --git a/docs/migration.md b/docs/migration.md index 9fbbbf2ed2..03e55477bd 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -480,6 +480,12 @@ async def my_tool(x: int, ctx: Context) -> str: The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. +### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164) + +Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response. + +The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`). + ### Registering lowlevel handlers from `MCPServer` `MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 92de074d34..e853605496 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -113,6 +113,10 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent Returns: The resource content as either text or bytes + + Raises: + ResourceNotFoundError: If no resource or template matches the URI. + ResourceError: If template creation or resource reading fails. """ assert self._mcp_server is not None, "Context is not available outside of a request" return await self._mcp_server.read_resource(uri, self) diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index dd1b75e829..188bec8a48 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -13,6 +13,15 @@ class ResourceError(MCPServerError): """Error in resource operations.""" +class ResourceNotFoundError(ResourceError): + """Resource does not exist. + + Raise this from a resource template handler to signal that the requested + instance does not exist; clients receive `-32602` (invalid params) per + SEP-2164. + """ + + class ToolError(MCPServerError): """Error in tool operations.""" diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index 766cf51aea..cff41495e0 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -7,6 +7,7 @@ from pydantic import AnyUrl +from mcp.server.mcpserver.exceptions import ResourceNotFoundError from mcp.server.mcpserver.resources.base import Resource from mcp.server.mcpserver.resources.templates import ResourceTemplate from mcp.server.mcpserver.utilities.logging import get_logger @@ -79,7 +80,12 @@ def add_template( return template async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource: - """Get resource by URI, checking concrete resources first, then templates.""" + """Get resource by URI, checking concrete resources first, then templates. + + Raises: + ResourceNotFoundError: If no resource or template matches the URI. + ResourceError: If a matching template fails to create the resource. + """ uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) @@ -90,12 +96,9 @@ async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContext # Then check templates for template in self._templates.values(): if params := template.matches(uri_str): - try: - return await template.create_resource(uri_str, params, context=context) - except Exception as e: # pragma: no cover - raise ValueError(f"Error creating resource from template: {e}") + return await template.create_resource(uri_str, params, context=context) - raise ValueError(f"Unknown resource: {uri}") + raise ResourceNotFoundError(f"Unknown resource: {uri}") def list_resources(self) -> list[Resource]: """List all registered resources.""" diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index f1ee29a37f..1fcb228f54 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -11,12 +11,16 @@ import anyio.to_thread from pydantic import BaseModel, Field, validate_call +from mcp.server.mcpserver.exceptions import ResourceError from mcp.server.mcpserver.resources.types import FunctionResource, Resource from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context from mcp.server.mcpserver.utilities.func_metadata import func_metadata +from mcp.server.mcpserver.utilities.logging import get_logger from mcp.shared._callable_inspection import is_async_callable from mcp.types import Annotations, Icon +logger = get_logger(__name__) + if TYPE_CHECKING: from mcp.server.context import LifespanContextT, RequestT from mcp.server.mcpserver.context import Context @@ -106,7 +110,7 @@ async def create_resource( """Create a resource from the template with the given parameters. Raises: - ValueError: If creating the resource fails. + ResourceError: If creating the resource fails. """ try: # Add context to params if needed @@ -129,5 +133,8 @@ async def create_resource( meta=self.meta, fn=lambda: result, # Capture result in closure ) + except ResourceError: + raise except Exception as e: - raise ValueError(f"Error creating resource from template: {e}") + logger.exception("Error creating resource from template") + raise ResourceError(f"Error creating resource from template: {e}") from e diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index fdb69571d8..22c259b2a1 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -31,7 +31,7 @@ from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan from mcp.server.mcpserver.context import Context -from mcp.server.mcpserver.exceptions import ResourceError +from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError from mcp.server.mcpserver.prompts import Prompt, PromptManager from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager from mcp.server.mcpserver.tools import Tool, ToolManager @@ -44,6 +44,8 @@ from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.types import ( + INTERNAL_ERROR, + INVALID_PARAMS, Annotations, BlobResourceContents, CallToolRequestParams, @@ -341,7 +343,12 @@ async def _handle_read_resource( self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams ) -> ReadResourceResult: context = Context(request_context=ctx, mcp_server=self) - results = await self.read_resource(params.uri, context) + try: + results = await self.read_resource(params.uri, context) + except ResourceNotFoundError as err: + raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}) + except ResourceError as err: + raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)}) contents: list[TextResourceContents | BlobResourceContents] = [] for item in results: if isinstance(item.content, bytes): @@ -442,13 +449,15 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: async def read_resource( self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None ) -> Iterable[ReadResourceContents]: - """Read a resource by URI.""" + """Read a resource by URI. + + Raises: + ResourceNotFoundError: If no resource or template matches the URI. + ResourceError: If template creation or resource reading fails. + """ if context is None: context = Context(mcp_server=self) - try: - resource = await self._resource_manager.get_resource(uri, context) - except ValueError as exc: - raise ResourceError(f"Unknown resource: {uri}") from exc + resource = await self._resource_manager.get_resource(uri, context) try: content = await resource.read() diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 0e7b2d25c0..6b9f745933 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1142,8 +1142,10 @@ def __post_init__(self) -> None: ), "mcpserver:resource:read-throws-surfaced": Requirement( source="sdk", - behavior="A resource function that raises is surfaced to the caller as a JSON-RPC error response.", - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), + behavior=( + "A resource function that raises is surfaced to the caller as a JSON-RPC error response " + "(-32603 Internal error), with the original exception text withheld." + ), ), "mcpserver:resource:static": Requirement( source="sdk", @@ -1161,14 +1163,10 @@ def __post_init__(self) -> None: ), "mcpserver:resource:unknown-uri": Requirement( source=f"{SPEC_BASE_URL}/server/resources#error-handling", - behavior="resources/read for a URI matching no registered resource returns JSON-RPC error -32002.", - divergence=Divergence( - note=( - "The spec reserves -32002 for resource-not-found; MCPServer raises ResourceError, which " - "the low-level server converts to error code 0." - ), + behavior=( + "resources/read for a URI matching no registered resource returns JSON-RPC error -32602 " + "(invalid params) with the requested URI in error.data, per SEP-2164." ), - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), # ═══════════════════════════════════════════════════════════════════════════ # Prompts diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py index 57b0fdc86d..d2be655b6b 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -112,10 +112,7 @@ def user_profile(user_id: str) -> str: @requirement("mcpserver:resource:unknown-uri") async def test_read_unknown_uri_is_error(connect: Connect) -> None: - """Reading a URI that matches no registered resource fails with a JSON-RPC error. - - The spec reserves -32002 for resource-not-found; see the divergence note on the requirement. - """ + """Reading a URI that matches no registered resource fails with -32602 and the URI in data (SEP-2164).""" mcp = MCPServer("library") @mcp.resource("config://app") @@ -127,16 +124,17 @@ def app_config() -> str: with pytest.raises(MCPError) as exc_info: await client.read_resource("config://missing") - assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown resource: config://missing")) + assert exc_info.value.error == snapshot( + ErrorData(code=-32602, message="Unknown resource: config://missing", data={"uri": "config://missing"}) + ) @requirement("mcpserver:resource:read-throws-surfaced") async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(connect: Connect) -> None: """An exception raised by a resource function reaches the caller as a JSON-RPC error. - MCPServer wraps the failure in a generic error that names only the URI, so the original - exception text is not leaked to the client. The wrapped exception becomes error code 0 the - same way every other unhandled server-side exception does. + MCPServer wraps the failure in a generic ResourceError that names only the URI, so the original + exception text is not leaked to the client. The wrapped exception surfaces as -32603 Internal error. """ mcp = MCPServer("library") @@ -148,7 +146,9 @@ def boom() -> str: with pytest.raises(MCPError) as exc_info: await client.read_resource("res://boom") - assert exc_info.value.error == snapshot(ErrorData(code=0, message="Error reading resource res://boom")) + assert exc_info.value.error == snapshot( + ErrorData(code=-32603, message="Error reading resource res://boom", data={"uri": "res://boom"}) + ) @requirement("mcpserver:resource:duplicate-name") diff --git a/tests/server/mcpserver/resources/test_resource_manager.py b/tests/server/mcpserver/resources/test_resource_manager.py index b91c71581c..bbb7de7eb8 100644 --- a/tests/server/mcpserver/resources/test_resource_manager.py +++ b/tests/server/mcpserver/resources/test_resource_manager.py @@ -5,6 +5,7 @@ from pydantic import AnyUrl from mcp.server.mcpserver import Context +from mcp.server.mcpserver.exceptions import ResourceNotFoundError from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate @@ -101,7 +102,7 @@ def greet(name: str) -> str: async def test_get_unknown_resource(): """Test getting a non-existent resource.""" manager = ResourceManager() - with pytest.raises(ValueError, match="Unknown resource"): + with pytest.raises(ResourceNotFoundError, match="Unknown resource"): await manager.get_resource(AnyUrl("unknown://test"), Context()) diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 2a7ba8d503..0e8121b990 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.exceptions import ResourceError from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate from mcp.types import Annotations @@ -87,7 +88,7 @@ def failing_func(x: str) -> str: name="fail", ) - with pytest.raises(ValueError, match="Error creating resource from template"): + with pytest.raises(ResourceError, match="Error creating resource from template"): await template.create_resource("fail://test", {"x": "test"}, Context()) @pytest.mark.anyio diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 6ec060d20b..66941ab7fa 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -12,13 +12,15 @@ from mcp.client import Client from mcp.server.context import ServerRequestContext from mcp.server.mcpserver import Context, MCPServer -from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.types import ( + INTERNAL_ERROR, + INVALID_PARAMS, AudioContent, BlobResourceContents, Completion, @@ -726,13 +728,16 @@ def get_text(): assert result.contents[0].text == "Hello, world!" async def test_read_unknown_resource(self): - """Test that reading an unknown resource raises MCPError.""" + """Test that reading an unknown resource returns -32602 with uri in data (SEP-2164).""" mcp = MCPServer() async with Client(mcp) as client: - with pytest.raises(MCPError, match="Unknown resource: unknown://missing"): + with pytest.raises(MCPError, match="Unknown resource: unknown://missing") as exc_info: await client.read_resource("unknown://missing") + assert exc_info.value.error.code == INVALID_PARAMS + assert exc_info.value.error.data == {"uri": "unknown://missing"} + async def test_read_resource_error(self): """Test that resource read errors are properly wrapped in MCPError.""" mcp = MCPServer() @@ -1515,3 +1520,34 @@ async def test_report_progress_passes_related_request_id(): message="halfway", related_request_id="req-abc-123", ) + + +async def test_read_resource_template_error(): + """Template-creation failure must surface as INTERNAL_ERROR, not INVALID_PARAMS (not-found).""" + mcp = MCPServer() + + @mcp.resource("resource://item/{item_id}") + def get_item(item_id: str) -> str: + raise RuntimeError("backend unavailable") + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Error creating resource from template") as exc_info: + await client.read_resource("resource://item/42") + + assert exc_info.value.error.code == INTERNAL_ERROR + + +async def test_read_resource_template_not_found(): + """A template handler raising ResourceNotFoundError must surface as INVALID_PARAMS per SEP-2164.""" + mcp = MCPServer() + + @mcp.resource("resource://users/{user_id}") + def get_user(user_id: str) -> str: + raise ResourceNotFoundError(f"no user {user_id}") + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="no user 999") as exc_info: + await client.read_resource("resource://users/999") + + assert exc_info.value.error.code == INVALID_PARAMS + assert exc_info.value.error.data == {"uri": "resource://users/999"} From 854a24ca6aaf2b0d8a5fc2003b327c586ba79981 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 16:41:01 +0200 Subject: [PATCH 2/3] Withhold template-creation exception text from the client Mirror the read_resource read-path: log the full traceback but raise a ResourceError naming only the URI, so the original exception is not leaked to the client. Reflow the ResourceNotFoundError docstring to 120. --- src/mcp/server/mcpserver/exceptions.py | 5 ++--- src/mcp/server/mcpserver/resources/templates.py | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 188bec8a48..ac73689e38 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -16,9 +16,8 @@ class ResourceError(MCPServerError): class ResourceNotFoundError(ResourceError): """Resource does not exist. - Raise this from a resource template handler to signal that the requested - instance does not exist; clients receive `-32602` (invalid params) per - SEP-2164. + Raise this from a resource template handler to signal that the requested instance does not exist; + clients receive `-32602` (invalid params) per SEP-2164. """ diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 1fcb228f54..1d0dda2946 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -135,6 +135,7 @@ async def create_resource( ) except ResourceError: raise - except Exception as e: - logger.exception("Error creating resource from template") - raise ResourceError(f"Error creating resource from template: {e}") from e + except Exception as exc: + logger.exception(f"Error creating resource from template {uri}") + # If an exception happens when creating the resource, we should not leak the exception to the client. + raise ResourceError(f"Error creating resource from template {uri}") from exc From 0cd887af041787305c957cfd27d4d2073d8d7af1 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Jun 2026 16:46:49 +0200 Subject: [PATCH 3/3] Link SEP-2164 in docstring and drop redundant comment --- src/mcp/server/mcpserver/exceptions.py | 3 ++- src/mcp/server/mcpserver/resources/templates.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index ac73689e38..8095c451d5 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -17,7 +17,8 @@ class ResourceNotFoundError(ResourceError): """Resource does not exist. Raise this from a resource template handler to signal that the requested instance does not exist; - clients receive `-32602` (invalid params) per SEP-2164. + clients receive `-32602` (invalid params) per + [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). """ diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 1d0dda2946..0c5df425c9 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -137,5 +137,4 @@ async def create_resource( raise except Exception as exc: logger.exception(f"Error creating resource from template {uri}") - # If an exception happens when creating the resource, we should not leak the exception to the client. raise ResourceError(f"Error creating resource from template {uri}") from exc