Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/mcp/server/mcpserver/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .base import Resource
from .resource_manager import ResourceManager
from .resource_manager import ResourceManager, UnknownResourceError
from .templates import ResourceTemplate
from .types import (
BinaryResource,
Expand All @@ -20,4 +20,5 @@
"DirectoryResource",
"ResourceTemplate",
"ResourceManager",
"UnknownResourceError",
]
8 changes: 6 additions & 2 deletions src/mcp/server/mcpserver/resources/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
logger = get_logger(__name__)


class UnknownResourceError(ValueError):
"""Raised when no registered resource or resource template matches a URI."""


class ResourceManager:
"""Manages MCPServer resources."""

Expand Down Expand Up @@ -92,10 +96,10 @@ async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContext
if params := template.matches(uri_str):
try:
return await template.create_resource(uri_str, params, context=context)
except Exception as e: # pragma: no cover
except Exception as e:
raise ValueError(f"Error creating resource from template: {e}")

raise ValueError(f"Unknown resource: {uri}")
raise UnknownResourceError(f"Unknown resource: {uri}")

def list_resources(self) -> list[Resource]:
"""List all registered resources."""
Expand Down
15 changes: 12 additions & 3 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import ResourceError
from mcp.server.mcpserver.prompts import Prompt, PromptManager
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager, UnknownResourceError
from mcp.server.mcpserver.tools import Tool, ToolManager
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger
Expand All @@ -44,6 +44,7 @@
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from mcp.types import (
RESOURCE_NOT_FOUND,
Annotations,
BlobResourceContents,
CallToolRequestParams,
Expand Down Expand Up @@ -341,7 +342,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 ResourceError as exc:
if isinstance(exc.__cause__, UnknownResourceError):
raise MCPError(RESOURCE_NOT_FOUND, str(exc)) from exc
raise
contents: list[TextResourceContents | BlobResourceContents] = []
for item in results:
if isinstance(item.content, bytes):
Expand Down Expand Up @@ -447,8 +453,11 @@ async def read_resource(
context = Context(mcp_server=self)
try:
resource = await self._resource_manager.get_resource(uri, context)
except ValueError as exc:
except UnknownResourceError as exc:
raise ResourceError(f"Unknown resource: {uri}") from exc
except ValueError as exc:
logger.exception(f"Error getting resource {uri}")
raise ResourceError(f"Error reading resource {uri}") from exc

try:
content = await resource.read()
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@
METHOD_NOT_FOUND,
PARSE_ERROR,
REQUEST_TIMEOUT,
RESOURCE_NOT_FOUND,
URL_ELICITATION_REQUIRED,
ErrorData,
JSONRPCError,
Expand Down Expand Up @@ -320,6 +321,7 @@
"METHOD_NOT_FOUND",
"PARSE_ERROR",
"REQUEST_TIMEOUT",
"RESOURCE_NOT_FOUND",
"URL_ELICITATION_REQUIRED",
"ErrorData",
"JSONRPCError",
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/types/jsonrpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ class JSONRPCResponse(BaseModel):


# MCP-specific error codes in the range [-32000, -32099]
RESOURCE_NOT_FOUND = -32002
"""Error code indicating that a requested resource URI does not exist."""

URL_ELICITATION_REQUIRED = -32042
"""Error code indicating that a URL mode elicitation is required before the request can be processed."""

Expand Down
6 changes: 0 additions & 6 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -901,12 +901,6 @@ 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."
),
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Prompts
Expand Down
23 changes: 21 additions & 2 deletions tests/interaction/mcpserver/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
from mcp.types import (
RESOURCE_NOT_FOUND,
ErrorData,
ListResourcesResult,
ListResourceTemplatesResult,
Expand Down Expand Up @@ -114,7 +115,7 @@ def user_profile(user_id: str) -> str:
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.
The spec reserves -32002 for resource-not-found.
"""
mcp = MCPServer("library")

Expand All @@ -127,7 +128,9 @@ 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=RESOURCE_NOT_FOUND, message="Unknown resource: config://missing")
)


@requirement("mcpserver:resource:read-throws-surfaced")
Expand All @@ -151,6 +154,22 @@ def boom() -> str:
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Error reading resource res://boom"))


@requirement("mcpserver:resource:read-throws-surfaced")
async def test_templated_resource_function_that_raises_is_not_reported_as_missing(connect: Connect) -> None:
"""A matching resource template that raises is a read failure, not a missing resource."""
mcp = MCPServer("library")

@mcp.resource("users://{user_id}/profile")
def user_profile(user_id: str) -> str:
raise RuntimeError(f"profile unavailable for {user_id}")

async with connect(mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.read_resource("users://42/profile")

assert exc_info.value.error == snapshot(ErrorData(code=0, message="Error reading resource users://42/profile"))


@requirement("mcpserver:resource:duplicate-name")
async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(connect: Connect) -> None:
"""Registering a second static resource at an already-used URI keeps the first registration.
Expand Down
Loading