Return -32602 for resource not found (SEP-2164) (#2920)
This commit is contained in:
committed by
GitHub
parent
0fa03ec69f
commit
f253682393
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user