Return -32602 for resource not found (SEP-2164) (#2920)
This commit is contained in:
parent
0fa03ec69f
commit
f253682393
5 changed files with 113 additions and 43 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue