Skip to content
Merged
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
2 changes: 0 additions & 2 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 0 additions & 2 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent

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)
Expand Down
9 changes: 9 additions & 0 deletions src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
15 changes: 9 additions & 6 deletions src/mcp/server/mcpserver/resources/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

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
Expand Down Expand Up @@ -79,7 +80,12 @@ def add_template(
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})

Expand All @@ -90,12 +96,9 @@ async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContext
# 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."""
Expand Down
13 changes: 10 additions & 3 deletions src/mcp/server/mcpserver/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@
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__)
Comment thread
Kludex marked this conversation as resolved.

if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.context import Context
Expand Down Expand Up @@ -106,7 +110,7 @@ async def create_resource(
"""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
Expand All @@ -129,5 +133,8 @@ async def create_resource(
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
23 changes: 16 additions & 7 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
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
Expand All @@ -44,6 +44,8 @@
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
Annotations,
BlobResourceContents,
CallToolRequestParams,
Expand Down Expand Up @@ -341,7 +343,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 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)})
Comment thread
Kludex marked this conversation as resolved.
contents: list[TextResourceContents | BlobResourceContents] = []
for item in results:
if isinstance(item.content, bytes):
Expand Down Expand Up @@ -442,13 +449,15 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
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()
Expand Down
16 changes: 7 additions & 9 deletions tests/interaction/_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -1142,8 +1142,10 @@ def __post_init__(self) -> None:
),
"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",
Expand All @@ -1161,14 +1163,10 @@ 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."
),
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
Expand Down
18 changes: 9 additions & 9 deletions tests/interaction/mcpserver/test_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,7 @@ def user_profile(user_id: str) -> str:

@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")
Expand All @@ -127,16 +124,17 @@ 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=-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")

Expand All @@ -148,7 +146,9 @@ def boom() -> str:
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")
Expand Down
3 changes: 2 additions & 1 deletion tests/server/mcpserver/resources/test_resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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


Expand Down Expand Up @@ -101,7 +102,7 @@ def greet(name: str) -> str:
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())


Expand Down
3 changes: 2 additions & 1 deletion tests/server/mcpserver/resources/test_resource_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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

Expand Down Expand Up @@ -87,7 +88,7 @@ def failing_func(x: str) -> str:
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
Expand Down
42 changes: 39 additions & 3 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
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,
Expand Down Expand Up @@ -726,13 +728,16 @@ def get_text():
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()
Expand Down Expand Up @@ -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