fix(server): return -32602 for resource not found (SEP-2164)#2344
fix(server): return -32602 for resource not found (SEP-2164)#2344
Conversation
The SDK previously returned error code 0 for resource-not-found, making it impossible for clients to distinguish this from other errors. Per SEP-2164, servers should return -32602 (Invalid Params) with the uri in the error data field. Adds ResourceNotFoundError subclass of ResourceError so existing code catching ResourceError continues to work. The internal handler converts it to MCPError with INVALID_PARAMS before it reaches the JSON-RPC layer. Spec: modelcontextprotocol/modelcontextprotocol#2164
There was a problem hiding this comment.
LGTM — small, spec-driven change with test coverage; the inline nit about template-failure misclassification is pre-existing behavior and can be a follow-up.
Extended reasoning...
Overview
This PR implements SEP-2164 by changing the resource-not-found error code from 0 to -32602 (INVALID_PARAMS) and adding the requested URI to the error's data field. It touches three files: a new ResourceNotFoundError(ResourceError) subclass in exceptions.py, two small edits in server.py (read_resource now raises the subclass, _handle_read_resource maps it to the spec-mandated MCPError), and an updated test asserting the new code/data.
Security risks
None. This is purely error-code/shape plumbing on the server response path — no auth, no input parsing changes, no new external surface.
Level of scrutiny
Low-to-moderate. It's a wire-protocol change, but it brings the SDK into spec compliance, the exception subclass preserves backward compatibility for anyone catching ResourceError, and the diff is ~15 lines of straightforward logic plus a test. The behaviour is verified end-to-end via Client in test_read_unknown_resource.
Other factors
The one inline nit (template-creation ValueErrors now also surface as -32602) is a fair observation, but the misleading "Unknown resource" message on that path is pre-existing — this PR only changes the numeric code attached to it. Distinguishing the two ValueError cases in ResourceManager.get_resource would be a nice follow-up but shouldn't block this spec-compliance fix. No prior reviewer comments, no CODEOWNER constraints visible, and the change matches the linked SEP exactly.
ResourceManager.get_resource() and ResourceTemplate.create_resource() now raise ResourceNotFoundError and ResourceError respectively instead of generic ValueError. This lets read_resource() drop its broad except-ValueError translation, so a template whose user function throws is no longer reported to clients as -32602 (resource not found). It now propagates as ResourceError -> -32603 INTERNAL_ERROR, matching static-resource read failures.
ResourceTemplate.create_resource() now re-raises ResourceError (and its ResourceNotFoundError subclass) instead of wrapping them, so a template handler can raise ResourceNotFoundError to produce -32602 INVALID_PARAMS on the wire. Other exceptions are still wrapped as ResourceError. Also exports ResourceError and ResourceNotFoundError from mcp.server.mcpserver, and documents the ValueError->ResourceError change in docs/migration.md.
| except ResourceError as err: | ||
| raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)}) |
There was a problem hiding this comment.
🟡 Minor: template-function exception text now reaches the client (ResourceError(f"Error creating resource from template: {e}") → message=str(err)), whereas the resource.read() path three lines below explicitly sanitizes per the inline comment "we should not leak the exception to the client". Both paths execute user-supplied resource functions, so it's worth deciding deliberately whether to align them — e.g. drop the {e} interpolation in templates.py:133 and chain via from e so the inner text is logged server-side only, or accept the leak and relax the static-resource path for consistency.
Extended reasoning...
What changed
Before this PR, a template-function failure was (accidentally) sanitized: templates.py raised ValueError("Error creating resource from template: <inner>"), which server.py:443-445's except ValueError: raise ResourceError(f"Unknown resource: {uri}") caught and replaced with a message that discarded the inner text. After this PR, templates.py:133 raises ResourceError(f"Error creating resource from template: {e}"), which propagates unchanged through resource_manager.get_resource() and MCPServer.read_resource() (the try/except around get_resource was removed), and _handle_read_resource() sends it verbatim via MCPError(code=INTERNAL_ERROR, message=str(err), ...).
Step-by-step trace
@mcp.resource("db://{id}") def get(id): raise RuntimeError("connection string postgres://user:pw@host invalid")- Client sends
resources/readwithuri="db://42". ResourceTemplate.create_resource()callsself.fn(id="42")→RuntimeError(...)→ caught byexcept Exception as eat templates.py:132 → re-raised asResourceError("Error creating resource from template: connection string postgres://user:pw@host invalid").resource_manager.get_resource()(line 96) no longer wraps — propagates as-is.MCPServer.read_resource()(line 446) no longer has a try/except aroundget_resource— propagates as-is._handle_read_resource()matchesexcept ResourceError as err→MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": ...}).- Client receives
{code: -32603, message: "Error creating resource from template: connection string postgres://user:pw@host invalid"}.
Previously step 5 would have caught ValueError and replaced the message with "Unknown resource: db://42", so the client never saw the inner text.
The inconsistency
Within the same read_resource() function, the resource.read() path (server.py:448-454) wraps user-function failures as ResourceError(f"Error reading resource {uri}") with an explicit comment: "If an exception happens when reading the resource, we should not leak the exception to the client." That path covers static FunctionResource instances, where the user's function executes inside resource.read(). For templated resources, the user's function executes inside create_resource() instead — and that path now reaches the client unsanitized. So whether a user's exception text leaks depends purely on whether their @mcp.resource URI has {params} in it, which seems unintentional.
Addressing the counter-argument
It's fair to note that (a) the previous sanitization was an accidental side-effect of the very bug this PR fixes, not a designed feature; (b) _handle_call_tool() already returns str(e) to clients for tool exceptions, so the framework doesn't uniformly hide user exception text; and (c) the new test_read_resource_template_error asserts the "Error creating resource from template" prefix reaches the client, suggesting some intent. Those are all valid — which is why this is a nit, not a blocker. But the test uses a substring match and doesn't assert whether ": backend unavailable" is appended, so it doesn't pin down the policy either way. And the resource.read() comment is in the same function being edited here, so the two paths diverging is at minimum worth a conscious decision rather than falling out of the refactor.
Suggested fix
If sanitization is preferred (matching the existing comment): in templates.py:133, raise ResourceError("Error creating resource from template") from e — the inner exception is still chained for server-side logging via logger.exception, but str(err) no longer includes it. Alternatively, if exposing user-handler exception text is the intended policy (as with tools), consider dropping the sanitization comment/wrapper on the resource.read() path so both behave the same.
| raise | ||
| except Exception as e: | ||
| raise ValueError(f"Error creating resource from template: {e}") | ||
| raise ResourceError(f"Error creating resource from template: {e}") |
There was a problem hiding this comment.
We keep leaking info when adding the exception in the message itself. We should drop it out.
This comment is not related to this PR.
| from .utilities.types import Audio, Image | ||
|
|
||
| __all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"] | ||
| __all__ = ["MCPServer", "Context", "Image", "Audio", "Icon", "ResourceError", "ResourceNotFoundError"] |
There was a problem hiding this comment.
Are those errors supposed to be user facing? I don't think that's the case in the MCPServer.
There was a problem hiding this comment.
Reverted the export. mcpserver/__init__.py is back to its original __all__. Template handlers import from mcp.server.mcpserver.exceptions (same place ToolError lives).
| 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)}) |
There was a problem hiding this comment.
Don't we have something like MCPError.from something?
There was a problem hiding this comment.
only for inbound JSONRPCError and ErrorData, not from an exception.
…er exports Per review, MCPServer's top-level __all__ doesn't expose exception types. Template handlers that want to signal SEP-2164 not-found should import ResourceNotFoundError from mcp.server.mcpserver.exceptions (alongside ToolError). The template pass-through and behaviour are unchanged. Also clarifies the ResourceNotFoundError docstring to describe the template-handler contract.
| except ResourceNotFoundError as err: | ||
| raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}) |
There was a problem hiding this comment.
🟡 Edge case: if a template handler composes via await ctx.read_resource("dep://missing") and that inner lookup raises ResourceNotFoundError, the new except ResourceError: raise pass-through (templates.py:130) plus the removed wrap around get_resource lets it reach _handle_read_resource() unchanged — which then attaches data={"uri": str(params.uri)}, i.e. the outer requested URI. The client gets the SEP-2164 -32602 not-found signal with data.uri pointing at a resource that actually exists and matched. Niche (only affects template handlers calling ctx.read_resource()), but since data.uri is the spec-defined machine-readable field it might be worth deriving data.uri from the exception (or having handlers catch/re-wrap nested reads).
Extended reasoning...
What the bug is
When an async resource-template handler internally calls await ctx.read_resource(<inner-uri>) and that inner URI does not exist, the resulting ResourceNotFoundError("Unknown resource: <inner-uri>") now propagates unchanged all the way to _handle_read_resource(), which builds the SEP-2164 error envelope using data={"uri": str(params.uri)} — the outer URI the client originally requested. So the client is told (via the spec-defined machine-readable data.uri field) that the outer URI doesn't exist, when in fact the outer template matched fine and only an internal dependency is missing. The human-readable message text still names the inner URI, but data.uri does not.
Code path that triggers it
Context.read_resource() (context.py:117) is a thin wrapper that calls MCPServer.read_resource() directly with no exception handling. After this PR, MCPServer.read_resource() (server.py:446) no longer wraps get_resource() in a try/except, so an inner ResourceNotFoundError from ResourceManager.get_resource() (resource_manager.py:98) propagates straight out of the nested ctx.read_resource() call and into the handler body.
When that exception exits the handler's await result in ResourceTemplate.create_resource(), it hits the new except ResourceError: raise at templates.py:130 — which re-raises it unchanged (since ResourceNotFoundError <: ResourceError). It then propagates through the outer get_resource() (wrap removed) and outer read_resource() (wrap removed) to _handle_read_resource() at server.py:339, which catches ResourceNotFoundError and raises MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}).
Why nothing prevents it
Before this PR, the inner failure was double-wrapped (templates.py except Exception → ValueError, then server.py except ValueError → ResourceError("Unknown resource: <outer>")) and surfaced as code 0 with no machine-actionable data.uri. The PR's three changes — removing the server.py wrap around get_resource, adding the except ResourceError: raise pass-through in templates.py, and attaching data={"uri": params.uri} — combine to create this leak. Each change is individually correct for its intended purpose (letting template handlers raise ResourceNotFoundError directly), but together they don't distinguish a ResourceNotFoundError raised by the handler about the requested instance from one that bubbled up from a nested read of a different URI.
Step-by-step proof
- Register
@mcp.resource("composite://{id}")with an async handler:async def get(id: str, ctx: Context): base = await ctx.read_resource("dep://missing"); return ... - Client sends
resources/readwithuri="composite://42". - Outer
get_resource("composite://42")matches the template;create_resource()calls the handler. - Handler awaits
ctx.read_resource("dep://missing")→MCPServer.read_resource("dep://missing")→get_resource("dep://missing")→ raisesResourceNotFoundError("Unknown resource: dep://missing")(no wrap). - Exception exits handler body → templates.py:130
except ResourceError: raisepasses it through. - Propagates through outer
get_resource/read_resource(no wrap). _handle_read_resource()catches it →MCPError(code=-32602, message="Unknown resource: dep://missing", data={"uri": "composite://42"}).
Client receives {code: -32602, data: {uri: "composite://42"}} — per SEP-2164, the signal that composite://42 does not exist — when it actually does.
Impact and suggested fix
Niche: only affects async template handlers that compose resources via ctx.read_resource(), which is a supported but uncommon pattern (Context injection into templates is tested in test_resource_with_context). Handler authors can work around it by catching and re-wrapping the inner exception themselves. It's also debatable whether data.uri should echo back the requested URI (reasonable as a request-correlation field) vs. identify the actually-missing URI (the SEP-2164 reading). Options if you want to tighten this: derive data.uri from the caught exception (e.g. attach a .uri attribute to ResourceNotFoundError and prefer it over params.uri when present), or note in ResourceNotFoundError's docstring that handlers composing via ctx.read_resource() should catch and re-raise with the outer URI. Filed as a nit — not blocking.
Summary
Implements SEP-2164 — standardizes the resource-not-found error code to
-32602(Invalid Params).Problem
The Python SDK currently returns error code
0for resource-not-found, making it impossible for clients to reliably distinguish this from other errors. The spec now requires-32602with the URI in thedatafield.Changes
ResourceNotFoundError(ResourceError)subclass inmcpserver/exceptions.pyMCPServer.read_resource()now raisesResourceNotFoundError(instead of genericResourceError) when the resource doesn't exist_handle_read_resource()catchesResourceNotFoundErrorand converts it toMCPError(code=INVALID_PARAMS, ..., data={"uri": ...})The subclass approach keeps backward compatibility — existing code catching
ResourceErrorcontinues to work unchanged.Wire format (before → after)
{ "jsonrpc": "2.0", "id": 2, "error": { - "code": 0, + "code": -32602, "message": "Unknown resource: file:///nonexistent.txt", - "data": null + "data": {"uri": "file:///nonexistent.txt"} } }Spec: modelcontextprotocol/modelcontextprotocol#2164