Skip to content

Commit b3e84e9

Browse files
committed
Return -32602 for resource not found (SEP-2164)
Reading a missing resource now returns JSON-RPC error -32602 (invalid params) with the requested URI in error.data, instead of code 0. A new ResourceNotFoundError(ResourceError) carries the not-found signal: ResourceManager.get_resource raises it for an unmatched URI, and _handle_read_resource maps it to -32602 while other ResourceErrors map to -32603 (internal error). Resource lookups now raise typed exceptions instead of ValueError. Implements SEP-2164. Removes the matching conformance baseline entry, which now passes.
1 parent 0fa03ec commit b3e84e9

13 files changed

Lines changed: 112 additions & 42 deletions

File tree

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,5 @@ server:
117117
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
118118
# the expected-failures evaluator counts WARNINGs as failures. Same entries
119119
# as the draft suite in expected-failures.yml.
120-
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
121-
- sep-2164-resource-not-found
122120
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
123121
- input-required-result-missing-input-response

.github/actions/conformance/expected-failures.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@ server:
7070
- http-header-validation
7171
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
7272
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
73-
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
74-
- sep-2164-resource-not-found
7573
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
7674
- input-required-result-missing-input-response
7775
# SEP-2322 negative-case scenarios: input-required-result-validate-input is

docs/migration.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,12 @@ async def my_tool(x: int, ctx: Context) -> str:
480480

481481
The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument.
482482

483+
### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164)
484+
485+
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.
486+
487+
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`).
488+
483489
### Registering lowlevel handlers from `MCPServer`
484490

485491
`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:

src/mcp/server/mcpserver/context.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
113113
114114
Returns:
115115
The resource content as either text or bytes
116+
117+
Raises:
118+
ResourceNotFoundError: If no resource or template matches the URI.
119+
ResourceError: If template creation or resource reading fails.
116120
"""
117121
assert self._mcp_server is not None, "Context is not available outside of a request"
118122
return await self._mcp_server.read_resource(uri, self)

src/mcp/server/mcpserver/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ class ResourceError(MCPServerError):
1313
"""Error in resource operations."""
1414

1515

16+
class ResourceNotFoundError(ResourceError):
17+
"""Resource does not exist.
18+
19+
Raise this from a resource template handler to signal that the requested
20+
instance does not exist; clients receive `-32602` (invalid params) per
21+
SEP-2164.
22+
"""
23+
24+
1625
class ToolError(MCPServerError):
1726
"""Error in tool operations."""
1827

src/mcp/server/mcpserver/resources/resource_manager.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from pydantic import AnyUrl
99

10+
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
1011
from mcp.server.mcpserver.resources.base import Resource
1112
from mcp.server.mcpserver.resources.templates import ResourceTemplate
1213
from mcp.server.mcpserver.utilities.logging import get_logger
@@ -79,7 +80,12 @@ def add_template(
7980
return template
8081

8182
async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource:
82-
"""Get resource by URI, checking concrete resources first, then templates."""
83+
"""Get resource by URI, checking concrete resources first, then templates.
84+
85+
Raises:
86+
ResourceNotFoundError: If no resource or template matches the URI.
87+
ResourceError: If a matching template fails to create the resource.
88+
"""
8389
uri_str = str(uri)
8490
logger.debug("Getting resource", extra={"uri": uri_str})
8591

@@ -90,12 +96,9 @@ async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContext
9096
# Then check templates
9197
for template in self._templates.values():
9298
if params := template.matches(uri_str):
93-
try:
94-
return await template.create_resource(uri_str, params, context=context)
95-
except Exception as e: # pragma: no cover
96-
raise ValueError(f"Error creating resource from template: {e}")
99+
return await template.create_resource(uri_str, params, context=context)
97100

98-
raise ValueError(f"Unknown resource: {uri}")
101+
raise ResourceNotFoundError(f"Unknown resource: {uri}")
99102

100103
def list_resources(self) -> list[Resource]:
101104
"""List all registered resources."""

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@
1111
import anyio.to_thread
1212
from pydantic import BaseModel, Field, validate_call
1313

14+
from mcp.server.mcpserver.exceptions import ResourceError
1415
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
1516
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1617
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
18+
from mcp.server.mcpserver.utilities.logging import get_logger
1719
from mcp.shared._callable_inspection import is_async_callable
1820
from mcp.types import Annotations, Icon
1921

22+
logger = get_logger(__name__)
23+
2024
if TYPE_CHECKING:
2125
from mcp.server.context import LifespanContextT, RequestT
2226
from mcp.server.mcpserver.context import Context
@@ -106,7 +110,7 @@ async def create_resource(
106110
"""Create a resource from the template with the given parameters.
107111
108112
Raises:
109-
ValueError: If creating the resource fails.
113+
ResourceError: If creating the resource fails.
110114
"""
111115
try:
112116
# Add context to params if needed
@@ -129,5 +133,8 @@ async def create_resource(
129133
meta=self.meta,
130134
fn=lambda: result, # Capture result in closure
131135
)
136+
except ResourceError:
137+
raise
132138
except Exception as e:
133-
raise ValueError(f"Error creating resource from template: {e}")
139+
logger.exception("Error creating resource from template")
140+
raise ResourceError(f"Error creating resource from template: {e}") from e

src/mcp/server/mcpserver/server.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from mcp.server.lowlevel.server import LifespanResultT, Server
3232
from mcp.server.lowlevel.server import lifespan as default_lifespan
3333
from mcp.server.mcpserver.context import Context
34-
from mcp.server.mcpserver.exceptions import ResourceError
34+
from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError
3535
from mcp.server.mcpserver.prompts import Prompt, PromptManager
3636
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
3737
from mcp.server.mcpserver.tools import Tool, ToolManager
@@ -44,6 +44,8 @@
4444
from mcp.server.transport_security import TransportSecuritySettings
4545
from mcp.shared.exceptions import MCPError
4646
from mcp.types import (
47+
INTERNAL_ERROR,
48+
INVALID_PARAMS,
4749
Annotations,
4850
BlobResourceContents,
4951
CallToolRequestParams,
@@ -341,7 +343,12 @@ async def _handle_read_resource(
341343
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
342344
) -> ReadResourceResult:
343345
context = Context(request_context=ctx, mcp_server=self)
344-
results = await self.read_resource(params.uri, context)
346+
try:
347+
results = await self.read_resource(params.uri, context)
348+
except ResourceNotFoundError as err:
349+
raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)})
350+
except ResourceError as err:
351+
raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)})
345352
contents: list[TextResourceContents | BlobResourceContents] = []
346353
for item in results:
347354
if isinstance(item.content, bytes):
@@ -442,13 +449,15 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]:
442449
async def read_resource(
443450
self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None
444451
) -> Iterable[ReadResourceContents]:
445-
"""Read a resource by URI."""
452+
"""Read a resource by URI.
453+
454+
Raises:
455+
ResourceNotFoundError: If no resource or template matches the URI.
456+
ResourceError: If template creation or resource reading fails.
457+
"""
446458
if context is None:
447459
context = Context(mcp_server=self)
448-
try:
449-
resource = await self._resource_manager.get_resource(uri, context)
450-
except ValueError as exc:
451-
raise ResourceError(f"Unknown resource: {uri}") from exc
460+
resource = await self._resource_manager.get_resource(uri, context)
452461

453462
try:
454463
content = await resource.read()

tests/interaction/_requirements.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,8 +1142,10 @@ def __post_init__(self) -> None:
11421142
),
11431143
"mcpserver:resource:read-throws-surfaced": Requirement(
11441144
source="sdk",
1145-
behavior="A resource function that raises is surfaced to the caller as a JSON-RPC error response.",
1146-
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
1145+
behavior=(
1146+
"A resource function that raises is surfaced to the caller as a JSON-RPC error response "
1147+
"(-32603 Internal error), with the original exception text withheld."
1148+
),
11471149
),
11481150
"mcpserver:resource:static": Requirement(
11491151
source="sdk",
@@ -1161,14 +1163,10 @@ def __post_init__(self) -> None:
11611163
),
11621164
"mcpserver:resource:unknown-uri": Requirement(
11631165
source=f"{SPEC_BASE_URL}/server/resources#error-handling",
1164-
behavior="resources/read for a URI matching no registered resource returns JSON-RPC error -32002.",
1165-
divergence=Divergence(
1166-
note=(
1167-
"The spec reserves -32002 for resource-not-found; MCPServer raises ResourceError, which "
1168-
"the low-level server converts to error code 0."
1169-
),
1166+
behavior=(
1167+
"resources/read for a URI matching no registered resource returns JSON-RPC error -32602 "
1168+
"(invalid params) with the requested URI in error.data, per SEP-2164."
11701169
),
1171-
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
11721170
),
11731171
# ═══════════════════════════════════════════════════════════════════════════
11741172
# Prompts

tests/interaction/mcpserver/test_resources.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,7 @@ def user_profile(user_id: str) -> str:
112112

113113
@requirement("mcpserver:resource:unknown-uri")
114114
async def test_read_unknown_uri_is_error(connect: Connect) -> None:
115-
"""Reading a URI that matches no registered resource fails with a JSON-RPC error.
116-
117-
The spec reserves -32002 for resource-not-found; see the divergence note on the requirement.
118-
"""
115+
"""Reading a URI that matches no registered resource fails with -32602 and the URI in data (SEP-2164)."""
119116
mcp = MCPServer("library")
120117

121118
@mcp.resource("config://app")
@@ -127,16 +124,17 @@ def app_config() -> str:
127124
with pytest.raises(MCPError) as exc_info:
128125
await client.read_resource("config://missing")
129126

130-
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown resource: config://missing"))
127+
assert exc_info.value.error == snapshot(
128+
ErrorData(code=-32602, message="Unknown resource: config://missing", data={"uri": "config://missing"})
129+
)
131130

132131

133132
@requirement("mcpserver:resource:read-throws-surfaced")
134133
async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(connect: Connect) -> None:
135134
"""An exception raised by a resource function reaches the caller as a JSON-RPC error.
136135
137-
MCPServer wraps the failure in a generic error that names only the URI, so the original
138-
exception text is not leaked to the client. The wrapped exception becomes error code 0 the
139-
same way every other unhandled server-side exception does.
136+
MCPServer wraps the failure in a generic ResourceError that names only the URI, so the original
137+
exception text is not leaked to the client. The wrapped exception surfaces as -32603 Internal error.
140138
"""
141139
mcp = MCPServer("library")
142140

@@ -148,7 +146,9 @@ def boom() -> str:
148146
with pytest.raises(MCPError) as exc_info:
149147
await client.read_resource("res://boom")
150148

151-
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Error reading resource res://boom"))
149+
assert exc_info.value.error == snapshot(
150+
ErrorData(code=-32603, message="Error reading resource res://boom", data={"uri": "res://boom"})
151+
)
152152

153153

154154
@requirement("mcpserver:resource:duplicate-name")

0 commit comments

Comments
 (0)