From d096b8a7a93fc5f63be2557538742ad184fcc09e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Wed, 10 Jun 2026 16:26:16 +0900 Subject: [PATCH] Standardize Resource Not Found Errors on -32602 with URI Data per SEP-2164 ## Motivation and Context SEP-2164 (modelcontextprotocol/modelcontextprotocol#2164, merged for the 2026-07-28 spec release) standardizes the resource-not-found error on the JSON-RPC Invalid Params code (-32602), dropping the legacy -32002 code, and both reference SDK implementations attach the requested URI as structured error data (typescript-sdk#2267 throws `InvalidParams` with `data: { uri }`; python-sdk#2344 adds a `ResourceNotFoundError` that maps to `code=-32602, data={"uri": ...}`). The Ruby server already used -32602 (via `error_type: :invalid_params`) for its built-in not-found path (`completion/complete` with an unknown `ref/resource` URI), but the response carried no structured data and the generic "Invalid params" message, and there was no public error class for `resources_read_handler` blocks to raise. The Ruby client never special-cased -32002, so nothing needed removal there. - New public `MCP::Server::ResourceNotFoundError` (modeled on the `URLElicitationRequiredError` precedent): code -32602, message `"Resource not found: "`, and `data: { uri: }`. Custom `resources_read_handler` blocks raise it for unknown URIs; the README documents the pattern. - The `completion/complete` `ref/resource` not-found path now raises this class, so its error response gains `data: { uri: }` and a descriptive message instead of the generic "Invalid params" text. - The default `resources/read` behavior (returning empty contents for unknown URIs when no handler is registered) is intentionally unchanged; raising there would alter existing wire behavior. Resolves #379. ## How Has This Been Tested? New tests in `test/mcp/server_test.rb`: - `resources/read` with a handler raising `ResourceNotFoundError` responds with code -32602, message `"Resource not found: file:///missing.txt"`, and `data: { uri: "file:///missing.txt" }` - `completion/complete` with an unknown `ref/resource` URI carries the same code, message, and structured data New test in `test/mcp/client_test.rb` asserts `Client#read_resource` surfaces the -32602 code and the `data` hash unmodified through `Client::ServerError` (regression: no special-casing swallows it). The pre-existing completion error-code tests pass unchanged. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes None at the JSON-RPC code level (-32602 before and after). The `completion/complete` resource-not-found error response changes shape within the error object: `message` becomes `"Resource not found: "` instead of `"Invalid params"`, and `data` becomes `{ uri: }` instead of the message string. Both members are advisory per JSON-RPC 2.0. --- README.md | 13 +++++++++++++ lib/mcp/server.rb | 28 ++++++++++++++++++++++++++- test/mcp/client_test.rb | 21 ++++++++++++++++++++ test/mcp/server_test.rb | 43 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 30104889..eb10dc58 100644 --- a/README.md +++ b/README.md @@ -921,6 +921,19 @@ end otherwise `resources/read` requests will be a no-op. +For unknown URIs, raise `MCP::Server::ResourceNotFoundError` from the handler. +Per SEP-2164, the server then responds with the standard JSON-RPC Invalid Params error (`-32602`) +carrying the requested URI in the error `data` member: + +```ruby +server.resources_read_handler do |params| + resource = lookup(params[:uri]) + raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless resource + + [{ uri: params[:uri], mimeType: resource.mime_type, text: resource.body }] +end +``` + ### Resource Templates The `MCP::ResourceTemplate` class provides a way to register resource templates with the server. diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index c1b8be14..f93bea85 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -58,6 +58,32 @@ def initialize(elicitations) end end + # Raised when a requested resource URI does not exist. Per SEP-2164, + # resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) + # with the requested URI in the error `data` member. Raise this from + # a `resources_read_handler` block for unknown URIs: + # + # server.resources_read_handler do |params| + # raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless known?(params[:uri]) + # do_something(params[:uri]) + # end + # + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164 + class ResourceNotFoundError < RequestHandlerError + def initialize(uri, request = nil) + # The explicit `error_code` keeps the descriptive message in the JSON-RPC + # error response; `error_type: :invalid_params` alone would replace it + # with the generic "Invalid params" string. + super( + "Resource not found: #{uri}", + request, + error_type: :invalid_params, + error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS, + error_data: { uri: uri }, + ) + end + end + class MethodAlreadyDefinedError < StandardError attr_reader :method_name @@ -846,7 +872,7 @@ def validate_completion_params!(params) uri = ref[:uri] found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri } unless found - raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params) + raise ResourceNotFoundError.new(uri, params) end else raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params) diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 3c690815..cdf9d87a 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -610,6 +610,27 @@ def test_read_resource_raises_server_error_on_error_response assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") } end + def test_read_resource_surfaces_resource_not_found_code_and_data + # Per SEP-2164, servers report unknown resource URIs with -32602 and + # the URI in the error `data`; the client must expose both unmodified. + transport = mock + mock_response = { + "error" => { + "code" => -32_602, + "message" => "Resource not found: file:///missing.txt", + "data" => { "uri" => "file:///missing.txt" }, + }, + } + + transport.expects(:send_request).returns(mock_response).once + + client = Client.new(transport: transport) + error = assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing.txt") } + + assert_equal(-32_602, error.code) + assert_equal({ "uri" => "file:///missing.txt" }, error.data) + end + def test_get_prompt_raises_server_error_on_error_response transport = mock mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } } diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 35a72a7a..b40f604e 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -724,6 +724,25 @@ class Example < Tool ) end + test "#handle resources/read returns -32602 with the uri in error data when the handler raises ResourceNotFoundError" do + # Per SEP-2164, resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602) + # and carry the requested URI in `data`. + @server.resources_read_handler do |request| + raise Server::ResourceNotFoundError.new(request[:uri], request) + end + + response = @server.handle({ + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "file:///missing.txt" }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: file:///missing.txt", response[:error][:message]) + assert_equal({ uri: "file:///missing.txt" }, response[:error][:data]) + end + test "#handle resources/templates/list returns a list of resource templates" do request = { jsonrpc: "2.0", @@ -2206,6 +2225,30 @@ class Example < Tool assert_equal(-32_602, response[:error][:code]) end + test "#handle completion/complete resource-not-found error carries the uri in error data" do + server = Server.new( + name: "test_server", + capabilities: { completions: {} }, + ) + + server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 }) + server.handle({ jsonrpc: "2.0", method: "notifications/initialized" }) + + response = server.handle({ + jsonrpc: "2.0", + id: 2, + method: "completion/complete", + params: { + ref: { type: "ref/resource", uri: "unknown://template" }, + argument: { name: "arg", value: "val" }, + }, + }) + + assert_equal(-32602, response[:error][:code]) + assert_equal("Resource not found: unknown://template", response[:error][:message]) + assert_equal({ uri: "unknown://template" }, response[:error][:data]) + end + test "#handle completion/complete returns error for invalid ref type" do server = Server.new( name: "test_server",