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
commit f253682393
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 113 additions and 43 deletions

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

View file

@ -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")

View file

@ -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())

View file

@ -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

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"}