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",