Return -32602 for resource not found (SEP-2164) (#2920)

This commit is contained in:
Marcelo Trylesinski
2026-06-20 15:55:23 +01:00
committed by GitHub
parent 0fa03ec69f
commit f253682393
13 changed files with 113 additions and 43 deletions
@@ -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
@@ -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
+6
View File
@@ -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:
+4
View File
@@ -113,6 +113,10 @@ class Context(BaseModel, Generic[LifespanContextT, RequestT]):
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)
+9
View File
@@ -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](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164).
"""
class ToolError(MCPServerError):
"""Error in tool operations."""
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
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 @@ class ResourceManager:
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 @@ class ResourceManager:
# 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."""
@@ -11,12 +11,16 @@ from urllib.parse import unquote
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 @@ class ResourceTemplate(BaseModel):
"""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 @@ class ResourceTemplate(BaseModel):
meta=self.meta,
fn=lambda: result, # Capture result in closure
)
except Exception as e:
raise ValueError(f"Error creating resource from template: {e}")
except ResourceError:
raise
except Exception as exc:
logger.exception(f"Error creating resource from template {uri}")
raise ResourceError(f"Error creating resource from template {uri}") from exc
+16 -7
View File
@@ -31,7 +31,7 @@ from mcp.server.lowlevel.helper_types import ReadResourceContents
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.streamable_http_manager import StreamableHTTPSessionManager
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 @@ class MCPServer(Generic[LifespanResultT]):
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 @@ class MCPServer(Generic[LifespanResultT]):
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()
+7 -9
View File
@@ -1142,8 +1142,10 @@ REQUIREMENTS: dict[str, Requirement] = {
),
"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 @@ REQUIREMENTS: dict[str, Requirement] = {
),
"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
@@ -112,10 +112,7 @@ async def test_read_templated_resource(connect: Connect) -> None:
@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 @@ async def test_read_unknown_uri_is_error(connect: Connect) -> None:
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 @@ async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(conn
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")
@@ -5,6 +5,7 @@ import pytest
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 @@ async def test_get_resource_from_template():
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())
@@ -6,6 +6,7 @@ import pytest
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 @@ class TestResourceTemplate:
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
+39 -3
View File
@@ -12,13 +12,15 @@ from starlette.routing import Mount, Route
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 @@ class TestServerResources:
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"}