From 3a9655420b2292314f468b4254a29f6b0ff325cf Mon Sep 17 00:00:00 2001 From: Keisuke Umegaki Date: Sat, 18 Apr 2026 11:18:09 -0400 Subject: [PATCH] Add MCP Streamable HTTP specification support for the client Implements the MCP Streamable HTTP specification for the Ruby SDK client: SSE response parsing via event_stream_parser, session management, 202 Accepted handling, DELETE for session termination, and protocol version header. Co-Authored-By: Ates Goral Co-Authored-By: Claude Opus 4.6 Co-Authored-By: Claude Opus 4.7 --- Gemfile | 1 + docs/building-clients.md | 13 +- examples/streamable_http_client.rb | 288 ++++++------ examples/streamable_http_server.rb | 2 +- lib/mcp/client.rb | 114 +++-- lib/mcp/client/http.rb | 117 ++++- .../transports/streamable_http_transport.rb | 4 + test/mcp/client/http_test.rb | 422 ++++++++++++------ test/mcp/client_test.rb | 206 ++++++--- 9 files changed, 732 insertions(+), 435 deletions(-) diff --git a/Gemfile b/Gemfile index 5166516d..8b76222d 100644 --- a/Gemfile +++ b/Gemfile @@ -23,6 +23,7 @@ gem "yard", "~> 0.9" gem "yard-sorbet", "~> 0.9" if RUBY_VERSION >= "3.1" group :test do + gem "event_stream_parser", ">= 1.0" gem "faraday", ">= 2.0" gem "minitest", "~> 5.1", require: false gem "mocha" diff --git a/docs/building-clients.md b/docs/building-clients.md index 45c7e8dc..da461599 100644 --- a/docs/building-clients.md +++ b/docs/building-clients.md @@ -51,17 +51,26 @@ stdio_transport.close ## HTTP Transport -Use `MCP::Client::HTTP` to interact with MCP servers over HTTP. Requires the `faraday` gem: +Use `MCP::Client::HTTP` to interact with MCP servers over [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http). Requires the `faraday` gem, plus `event_stream_parser` if the server uses SSE responses: ```ruby gem 'mcp' gem 'faraday', '>= 2.0' +gem 'event_stream_parser', '>= 1.0' # optional, required only for SSE responses ``` +Call `MCP::Client#connect` explicitly to perform the MCP initialization handshake. The transport tracks the session ID and protocol version from the response and includes them on subsequent requests. Call `MCP::Client#close` to terminate the session via DELETE: + ```ruby http_transport = MCP::Client::HTTP.new(url: "https://api.example.com/mcp") client = MCP::Client.new(transport: http_transport) +client.connect(client_info: { name: "my-client", version: "1.0" }) + +client.session_id # => "abc123..." +client.protocol_version # => "2025-11-25" +client.connected? # => true + tools = client.tools tools.each do |tool| puts "Tool: #{tool.name} - #{tool.description}" @@ -71,6 +80,8 @@ response = client.call_tool( tool: tools.first, arguments: { message: "Hello, world!" } ) + +client.close ``` ### Authorization diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index de83b231..c208b391 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -1,49 +1,30 @@ # frozen_string_literal: true +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "mcp/client" +require "mcp/client/http" +require "mcp/client/tool" require "net/http" require "uri" require "json" require "logger" +require "event_stream_parser" -# Logger for client operations -logger = Logger.new($stdout) -logger.formatter = proc do |severity, datetime, _progname, msg| - "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" -end - -# Server configuration -SERVER_URL = "http://localhost:9393/mcp" -PROTOCOL_VERSION = "2024-11-05" +SERVER_URL = "http://localhost:9393" -# Helper method to make JSON-RPC requests -def make_request(session_id, method, params = {}, id = nil) - uri = URI(SERVER_URL) - http = Net::HTTP.new(uri.host, uri.port) - - request = Net::HTTP::Post.new(uri) - request["Content-Type"] = "application/json" - request["Mcp-Session-Id"] = session_id if session_id - - body = { - jsonrpc: "2.0", - method: method, - params: params, - id: id || SecureRandom.uuid, - } - - request.body = body.to_json - response = http.request(request) - - { - status: response.code, - headers: response.to_hash, - body: JSON.parse(response.body), - } -rescue => e - { error: e.message } +# Logger for client operations +def create_logger + logger = Logger.new($stdout) + logger.formatter = proc do |severity, datetime, _progname, msg| + "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" + end + logger end -# Connect to SSE stream +# Connect to SSE stream for real-time notifications +# The SDK doesn't support HTTP GET for SSE streaming yet, so we use raw Net::HTTP +# See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server def connect_sse(session_id, logger) uri = URI(SERVER_URL) @@ -59,17 +40,13 @@ def connect_sse(session_id, logger) if response.code == "200" logger.info("SSE stream connected successfully") + parser = EventStreamParser::Parser.new response.read_body do |chunk| - chunk.split("\n").each do |line| - if line.start_with?("data: ") - data = line[6..-1] - begin - logger.info("SSE data: #{data}") - rescue JSON::ParserError - logger.debug("Non-JSON SSE data: #{data}") - end - elsif line.start_with?(": ") - logger.debug("SSE keepalive received: #{line}") + parser.feed(chunk) do |type, data, _id| + if type.empty? + logger.info("SSE event: #{data}") + else + logger.info("SSE event (#{type}): #{data}") end end end @@ -79,129 +56,128 @@ def connect_sse(session_id, logger) end end rescue Interrupt - logger.info("SSE connection interrupted by user") + logger.info("SSE connection interrupted") rescue => e logger.error("SSE connection error: #{e.message}") end -# Main client flow def main - logger = Logger.new($stdout) - logger.formatter = proc do |severity, datetime, _progname, msg| - "[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n" - end - - puts "=== MCP SSE Test Client ===" - - # Step 1: Initialize session - logger.info("Initializing session...") - - init_response = make_request( - nil, - "initialize", - { - protocolVersion: PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { - name: "sse-test-client", - version: "1.0", - }, - }, - "init-1", - ) - - if init_response[:error] - logger.error("Failed to initialize: #{init_response[:error]}") - exit(1) - end - - session_id = init_response[:headers]["mcp-session-id"]&.first - - if session_id.nil? - logger.error("No session ID received") - exit(1) - end - - if init_response[:body].dig("result", "capabilities", "logging") - make_request(session_id, "logging/setLevel", { level: "info" }) - end - - logger.info("Session initialized: #{session_id}") - logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}") - - # Step 2: Start SSE connection in a separate thread - sse_thread = Thread.new { connect_sse(session_id, logger) } - - # Give SSE time to connect - sleep(1) - - # Step 3: Interactive menu - loop do - puts <<~MESSAGE.chomp - - === Available Actions === - 1. Send custom notification - 2. Test echo - 3. List tools - 0. Exit - - Choose an action:#{" "} + logger = create_logger + + puts <<~MESSAGE + MCP Streamable HTTP Client + Make sure the server is running (ruby examples/streamable_http_server.rb) + #{"=" * 60} + MESSAGE + + # Initialize SDK client + transport = MCP::Client::HTTP.new(url: SERVER_URL) + client = MCP::Client.new(transport: transport) + + begin + # Initialize session using SDK + puts "=== Initializing session ===" + init_response = client.connect( + client_info: { name: "streamable-http-client", version: "1.0" }, + ) + puts <<~MESSAGE + ID: #{client.session_id} + Version: #{client.protocol_version} + Server: #{init_response.dig("result", "serverInfo")} MESSAGE - choice = gets.chomp - - case choice - when "1" - print("Enter notification message: ") - message = gets.chomp - print("Enter delay in seconds (0 for immediate): ") - delay = gets.chomp.to_f - - response = make_request( - session_id, - "tools/call", - { - name: "notification_tool", - arguments: { - message: message, - delay: delay, - }, - }, - ) - if response[:body]["accepted"] - logger.info("Notification sent successfully") + # Get available tools BEFORE establishing SSE connection + # (Once SSE is active, server sends responses via SSE stream, not POST response) + puts "=== Listing tools ===" + tools = client.tools + tools.each { |t| puts " - #{t.name}: #{t.description}" } + + echo_tool = tools.find { |t| t.name == "echo" } + notification_tool = tools.find { |t| t.name == "notification_tool" } + + # Start SSE connection in a separate thread (uses raw HTTP) + # Note: After this, server responses will be sent via SSE, not POST + sse_thread = Thread.new { connect_sse(client.session_id, logger) } + + # Give SSE time to connect + sleep(1) + + # Interactive menu + loop do + puts <<~MENU.chomp + + === Available Actions === + 1. Send notification (triggers SSE event) + 2. Echo message + 3. List tools + 0. Exit + + Choose an action:#{" "} + MENU + + choice = gets.chomp + + case choice + when "1" + if notification_tool + print("Enter notification message: ") + message = gets.chomp + print("Enter delay in seconds (0 for immediate): ") + delay = gets.chomp.to_f + + puts "=== Calling tool: notification_tool ===" + response = client.call_tool( + tool: notification_tool, + arguments: { message: message, delay: delay }, + ) + puts "Response: #{JSON.pretty_generate(response)}" + else + puts "notification_tool not available" + end + when "2" + if echo_tool + print("Enter message to echo: ") + message = gets.chomp + + puts "=== Calling tool: echo ===" + response = client.call_tool(tool: echo_tool, arguments: { message: message }) + puts "Response: #{JSON.pretty_generate(response)}" + else + puts "echo tool not available" + end + when "3" + puts "=== Listing tools ===" + puts "(Note: Response will appear in SSE stream when active)" + client.tools.each do |tool| + puts " - #{tool.name}: #{tool.description}" + end + when "0" + logger.info("Exiting...") + break else - logger.error("Error: #{response[:body]["error"]}") + puts "Invalid choice" end - when "2" - print("Enter message to echo: ") - message = gets.chomp - make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } }) - when "3" - make_request(session_id, "tools/list") - when "0" - logger.info("Exiting...") - break - else - puts "Invalid choice" end + rescue MCP::Client::SessionExpiredError => e + logger.error("Session expired: #{e.message}") + rescue MCP::Client::RequestHandlerError => e + logger.error("Request error: #{e.message}") + rescue Interrupt + logger.info("Client interrupted") + rescue => e + logger.error("Error: #{e.message}") + logger.error(e.backtrace.first(5).join("\n")) + ensure + # Clean up SSE thread + sse_thread.kill if sse_thread&.alive? + + # Close session using SDK + puts "=== Closing session ===" + client.close + puts "Session closed" end - - # Clean up - sse_thread.kill if sse_thread.alive? - - # Close session - logger.info("Closing session...") - make_request(session_id, "close") - logger.info("Session closed") -rescue Interrupt - logger.info("Client interrupted by user") -rescue => e - logger.error("Client error: #{e.message}") - logger.error(e.backtrace.join("\n")) end -# Run the client if __FILE__ == $PROGRAM_NAME main end diff --git a/examples/streamable_http_server.rb b/examples/streamable_http_server.rb index afe48c49..2f70b66c 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -31,7 +31,7 @@ class << self def call(message:, delay: 0) sleep(delay) if delay > 0 - logger&.info("Returning notification message: #{message}") + logger.info("Returning notification message: #{message}") MCP::Tool::Response.new([{ type: "text", diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index d37b24a8..5f0ee5fc 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -6,6 +6,9 @@ module MCP class Client + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + LATEST_PROTOCOL_VERSION = "2025-11-25" + class ServerError < StandardError attr_reader :code, :data @@ -27,32 +30,51 @@ def initialize(message, request, error_type: :internal_error, original_error: ni end end - # Initializes a new MCP::Client instance. - # - # @param transport [Object] The transport object to use for communication with the server. - # The transport should be a duck type that responds to `send_request`. See the README for more details. - # - # @example - # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") - # client = MCP::Client.new(transport: transport) + # Raised when the server responds 404 to a request containing an expired + # session ID. Inherits from `RequestHandlerError` for backward compatibility + # with callers that rescue the generic error. + class SessionExpiredError < RequestHandlerError + def initialize(message, request) + super(message, request, error_type: :not_found) + end + end + + # @param transport [#send_request] Transport responding to `send_request(request:)` + # and returning a `Hash` (a JSON-RPC response). `MCP::Client::HTTP` and + # `MCP::Client::Stdio` both satisfy this contract. def initialize(transport:) @transport = transport end - # The user may want to access additional transport-specific methods/attributes - # So keeping it public attr_reader :transport - # Returns the list of tools available from the server. - # Each call will make a new request – the result is not cached. - # - # @return [Array] An array of available tools. - # - # @example - # tools = client.tools - # tools.each do |tool| - # puts tool.name - # end + # Session ID is exposed for transports that track one (HTTP); `nil` for + # transports that don't (Stdio). + def session_id + @transport.session_id if @transport.respond_to?(:session_id) + end + + def protocol_version + @transport.protocol_version if @transport.respond_to?(:protocol_version) + end + + def connected? + !protocol_version.nil? + end + + # Performs the MCP initialization handshake. HTTP users should call this + # explicitly; Stdio initializes lazily on the first request. + def connect(client_info:, protocol_version: LATEST_PROTOCOL_VERSION, capabilities: {}) + request( + method: "initialize", + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + ) + end + def tools response = request(method: "tools/list") @@ -65,56 +87,30 @@ def tools end || [] end - # Returns the list of resources available from the server. - # Each call will make a new request – the result is not cached. - # - # @return [Array] An array of available resources. def resources response = request(method: "resources/list") response.dig("result", "resources") || [] end - # Returns the list of resource templates available from the server. - # Each call will make a new request – the result is not cached. - # - # @return [Array] An array of available resource templates. def resource_templates response = request(method: "resources/templates/list") response.dig("result", "resourceTemplates") || [] end - # Returns the list of prompts available from the server. - # Each call will make a new request – the result is not cached. - # - # @return [Array] An array of available prompts. def prompts response = request(method: "prompts/list") response.dig("result", "prompts") || [] end - # Calls a tool via the transport layer and returns the full response from the server. - # # @param name [String] The name of the tool to call. # @param tool [MCP::Client::Tool] The tool to be called. # @param arguments [Object, nil] The arguments to pass to the tool. - # @param progress_token [String, Integer, nil] A token to request progress notifications from the server during tool execution. + # @param progress_token [String, Integer, nil] A token to request progress + # notifications from the server during tool execution. # @return [Hash] The full JSON-RPC response from the transport. - # - # @example Call by name - # response = client.call_tool(name: "my_tool", arguments: { foo: "bar" }) - # content = response.dig("result", "content") - # - # @example Call with a tool object - # tool = client.tools.first - # response = client.call_tool(tool: tool, arguments: { foo: "bar" }) - # structured_content = response.dig("result", "structuredContent") - # - # @note - # The exact requirements for `arguments` are determined by the transport layer in use. - # Consult the documentation for your transport (e.g., MCP::Client::HTTP) for details. def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil) tool_name = name || tool&.name raise ArgumentError, "Either `name:` or `tool:` must be provided." unless tool_name @@ -127,33 +123,18 @@ def call_tool(name: nil, tool: nil, arguments: nil, progress_token: nil) request(method: "tools/call", params: params) end - # Reads a resource from the server by URI and returns the contents. - # - # @param uri [String] The URI of the resource to read. - # @return [Array] An array of resource contents (text or blob). def read_resource(uri:) response = request(method: "resources/read", params: { uri: uri }) response.dig("result", "contents") || [] end - # Gets a prompt from the server by name and returns its details. - # - # @param name [String] The name of the prompt to get. - # @return [Hash] A hash containing the prompt details. def get_prompt(name:) response = request(method: "prompts/get", params: { name: name }) response.fetch("result", {}) end - # Requests completion suggestions from the server for a prompt argument or resource template URI. - # - # @param ref [Hash] The reference, e.g. `{ type: "ref/prompt", name: "my_prompt" }` - # or `{ type: "ref/resource", uri: "file:///{path}" }`. - # @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`. - # @param context [Hash, nil] Optional context with previously resolved arguments. - # @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`. def complete(ref:, argument:, context: nil) params = { ref: ref, argument: argument } params[:context] = context if context @@ -163,6 +144,13 @@ def complete(ref:, argument:, context: nil) response.dig("result", "completion") || { "values" => [], "hasMore" => false } end + # Closes the connection. For HTTP transport, terminates the session via + # DELETE (see the spec's session-termination section). No-op for transports + # that don't track session state. + def close + @transport.close if @transport.respond_to?(:close) + end + private def request(method:, params: nil) diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 1637b0ee..307afcd0 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -2,24 +2,37 @@ module MCP class Client + # TODO: HTTP GET for SSE streaming is not yet implemented. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server + # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery class HTTP ACCEPT_HEADER = "application/json, text/event-stream" + SESSION_ID_HEADER = "Mcp-Session-Id" - attr_reader :url + attr_reader :url, :session_id, :protocol_version def initialize(url:, headers: {}, &block) @url = url @headers = headers @faraday_customizer = block + @session_id = nil + @protocol_version = nil end + # Sends a JSON-RPC request and returns the parsed response body. + # Tracks the session ID and protocol version returned by `initialize` + # and automatically includes them on subsequent requests. def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] - response = client.post("", request) - validate_response_content_type!(response, method, params) - response.body + response = client.post("", request, session_headers) + body = parse_response_body(response, method, params) + + capture_session_info(method, response, body) + + body rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", @@ -41,12 +54,14 @@ def send_request(request:) error_type: :forbidden, original_error: e, ) - rescue Faraday::ResourceNotFound => e - raise RequestHandlerError.new( + rescue Faraday::ResourceNotFound + # The server MAY terminate the session at any time, after which it MUST + # respond with HTTP 404 to requests containing that session ID. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + clear_session + raise SessionExpiredError.new( "The #{method} request is not found", { method: method, params: params }, - error_type: :not_found, - original_error: e, ) rescue Faraday::UnprocessableEntityError => e raise RequestHandlerError.new( @@ -55,7 +70,7 @@ def send_request(request:) error_type: :unprocessable_entity, original_error: e, ) - rescue Faraday::Error => e # Catch-all + rescue Faraday::Error => e raise RequestHandlerError.new( "Internal error handling #{method} request", { method: method, params: params }, @@ -64,6 +79,22 @@ def send_request(request:) ) end + # Terminates the session via DELETE. Silently succeeds if the server + # rejects the request; session state is cleared regardless. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-termination + def close + return unless @session_id + + begin + client.delete("", nil, session_headers) + rescue Faraday::Error + # Server may respond 405 Method Not Allowed if it doesn't support DELETE; + # that's fine, we still clear local state. + end + + clear_session + end + private attr_reader :headers @@ -84,6 +115,25 @@ def client end end + def session_headers + h = {} + h[SESSION_ID_HEADER] = @session_id if @session_id + h + end + + def capture_session_info(method, response, body) + return unless method.to_s == "initialize" + + # Faraday normalizes header names to lowercase. + @session_id ||= response.headers[SESSION_ID_HEADER.downcase] + @protocol_version ||= body.is_a?(Hash) ? body.dig("result", "protocolVersion") : nil + end + + def clear_session + @session_id = nil + @protocol_version = nil + end + def require_faraday! require "faraday" rescue LoadError @@ -92,14 +142,55 @@ def require_faraday! "See https://rubygems.org/gems/faraday for more details." end - def validate_response_content_type!(response, method, params) + def require_event_stream_parser! + require "event_stream_parser" + rescue LoadError + raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \ + "Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'" \ + "See https://rubygems.org/gems/event_stream_parser for more details." + end + + def parse_response_body(response, method, params) content_type = response.headers["Content-Type"] - return if content_type&.include?("application/json") + + if content_type&.include?("text/event-stream") + parse_sse_response(response.body, method, params) + elsif content_type&.include?("application/json") + response.body + elsif response.status == 202 + # Server accepted the request and will deliver the response via an SSE stream. + { "accepted" => true } + else + raise RequestHandlerError.new( + "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.", + { method: method, params: params }, + error_type: :unsupported_media_type, + ) + end + end + + def parse_sse_response(body, method, params) + require_event_stream_parser! + + json_rpc_response = nil + parser = EventStreamParser::Parser.new + parser.feed(body.to_s) do |_type, data, _id| + next if data.empty? + + begin + parsed = JSON.parse(data) + json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error")) + rescue JSON::ParserError + next + end + end + + return json_rpc_response if json_rpc_response raise RequestHandlerError.new( - "Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.", + "No valid JSON-RPC response found in SSE stream", { method: method, params: params }, - error_type: :unsupported_media_type, + error_type: :parse_error, ) end end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 5c6c068e..b43efad7 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -15,6 +15,10 @@ module MCP class Server module Transports + # TODO: Resumability and redelivery with Last-Event-ID is not yet implemented. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery + # TODO: The security considerations from the spec are not yet enforced. + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning class StreamableHTTPTransport < Transport SSE_HEADERS = { "Content-Type" => "text/event-stream", diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 081f0e6a..77c3d8ce 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -1,44 +1,53 @@ # frozen_string_literal: true require "test_helper" +require "event_stream_parser" require "faraday" require "webmock/minitest" require "mcp/client/http" -require "mcp/client/tool" require "mcp/client" module MCP class Client class HTTPTest < Minitest::Test def test_raises_load_error_when_faraday_not_available - client = HTTP.new(url: url) + transport = HTTP.new(url: url) - # simulate Faraday not being available HTTP.any_instance.stubs(:require).with("faraday").raises(LoadError, "cannot load such file -- faraday") error = assert_raises(LoadError) do - # This should immediately try to instantiate the client and fail - client.send_request(request: {}) + transport.send_request(request: { method: "tools/list" }) end assert_includes(error.message, "The 'faraday' gem is required to use the MCP client HTTP transport") assert_includes(error.message, "Add it to your Gemfile: gem 'faraday', '>= 2.0'") end - def test_headers_are_added_to_the_request - headers = { "Authorization" => "Bearer token" } - client = HTTP.new(url: url, headers: headers) + def test_raises_load_error_when_event_stream_parser_not_available + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "data: {}\n\n", + ) - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + HTTP.any_instance.stubs(:require).with("faraday").returns(true) + HTTP.any_instance.stubs(:require).with("event_stream_parser") + .raises(LoadError, "cannot load such file -- event_stream_parser") + + error = assert_raises(LoadError) do + transport.send_request(request: { method: "tools/list" }) + end + + assert_includes(error.message, "The 'event_stream_parser' gem is required to parse SSE responses") + end + + def test_send_request_with_default_headers + request = { jsonrpc: "2.0", id: "test_id", method: "tools/list" } stub_request(:post, url) .with( headers: { - "Authorization" => "Bearer token", "Content-Type" => "application/json", "Accept" => "application/json, text/event-stream", }, @@ -50,21 +59,20 @@ def test_headers_are_added_to_the_request body: { result: { tools: [] } }.to_json, ) - # The test passes if the request is made with the correct headers - # If headers are wrong, the stub_request won't match and will raise - client.send_request(request: request) + response = transport.send_request(request: request) + + assert_equal({ "result" => { "tools" => [] } }, response) end - def test_accept_header_is_included_in_requests - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_send_request_with_custom_transport_headers + custom_transport = HTTP.new(url: url, headers: { "Authorization" => "Bearer token" }) + request = { jsonrpc: "2.0", id: "test_id", method: "tools/list" } stub_request(:post, url) .with( headers: { + "Authorization" => "Bearer token", + "Content-Type" => "application/json", "Accept" => "application/json, text/event-stream", }, ) @@ -74,23 +82,45 @@ def test_accept_header_is_included_in_requests body: { result: { tools: [] } }.to_json, ) - client.send_request(request: request) + custom_transport.send_request(request: request) end - def test_custom_accept_header_overrides_default - custom_accept = "application/json" - custom_client = HTTP.new(url: url, headers: { "Accept" => custom_accept }) + def test_send_request_captures_session_id_and_protocol_version_on_initialize + request = { jsonrpc: "2.0", id: "test_id", method: "initialize" } - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2024-11-05" } }.to_json, + ) + + transport.send_request(request: request) + + assert_equal("session-abc", transport.session_id) + assert_equal("2024-11-05", transport.protocol_version) + end + + def test_send_request_includes_session_headers_after_initialize + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2024-11-05" } }.to_json, + ) + + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) stub_request(:post, url) .with( headers: { - "Accept" => custom_accept, + "Mcp-Session-Id" => "session-abc", }, ) .to_return( @@ -99,147 +129,152 @@ def test_custom_accept_header_overrides_default body: { result: { tools: [] } }.to_json, ) - custom_client.send_request(request: request) + transport.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) end - def test_send_request_returns_faraday_response - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_session_id_not_overwritten_by_subsequent_responses + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "original-session", + }, + body: { result: { protocolVersion: "2024-11-05" } }.to_json, + ) + + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("original-session", transport.session_id) stub_request(:post, url) - .with(body: request.to_json) .to_return( status: 200, - headers: { "Content-Type" => "application/json" }, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "different-session", + }, body: { result: { tools: [] } }.to_json, ) - response = client.send_request(request: request) - assert_instance_of(Hash, response) - assert_equal({ "result" => { "tools" => [] } }, response) - end + transport.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) - def test_send_request_raises_bad_request_error - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + assert_equal("original-session", transport.session_id) + end + def test_send_request_works_without_session_id_for_stateless_servers stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 400) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { protocolVersion: "2024-11-05" } }.to_json, + ) + + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_nil(transport.session_id) + assert_equal("2024-11-05", transport.protocol_version) + end + + def test_send_request_raises_bad_request_error + stub_request(:post, url).to_return(status: 400) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.send_request(request: { method: "tools/list" }) end - assert_equal("The tools/list request is invalid", error.message) + assert_includes(error.message, "request is invalid") assert_equal(:bad_request, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) end def test_send_request_raises_unauthorized_error - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } - - stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 401) + stub_request(:post, url).to_return(status: 401) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.send_request(request: { method: "tools/list" }) end - assert_equal("You are unauthorized to make tools/list requests", error.message) + assert_includes(error.message, "unauthorized") assert_equal(:unauthorized, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) end def test_send_request_raises_forbidden_error - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } - - stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 403) + stub_request(:post, url).to_return(status: 403) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.send_request(request: { method: "tools/list" }) end - assert_equal("You are forbidden to make tools/list requests", error.message) + assert_includes(error.message, "forbidden") assert_equal(:forbidden, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_send_request_raises_not_found_error - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_send_request_raises_session_expired_error_on_404 + stub_request(:post, url).to_return(status: 404) - stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 404) + error = assert_raises(SessionExpiredError) do + transport.send_request(request: { method: "tools/list" }) + end + + assert_includes(error.message, "not found") + assert_equal(:not_found, error.error_type) + end + + def test_session_expired_error_is_a_request_handler_error + stub_request(:post, url).to_return(status: 404) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.send_request(request: { method: "tools/list" }) end - assert_equal("The tools/list request is not found", error.message) assert_equal(:not_found, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_send_request_raises_unprocessable_entity_error - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } - + def test_send_request_clears_session_state_on_404 stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 422) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-abc", + }, + body: { result: { protocolVersion: "2024-11-05" } }.to_json, + ) + + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + assert_equal("session-abc", transport.session_id) + + stub_request(:post, url).to_return(status: 404) + + assert_raises(SessionExpiredError) do + transport.send_request(request: { jsonrpc: "2.0", id: "2", method: "tools/list" }) + end + + assert_nil(transport.session_id) + assert_nil(transport.protocol_version) + end + + def test_send_request_raises_unprocessable_entity_error + stub_request(:post, url).to_return(status: 422) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.send_request(request: { method: "tools/list" }) end - assert_equal("The tools/list request is unprocessable", error.message) + assert_includes(error.message, "unprocessable") assert_equal(:unprocessable_entity, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) end - def test_send_request_raises_internal_error - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } - - stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 500) + def test_send_request_raises_internal_error_on_500 + stub_request(:post, url).to_return(status: 500) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.send_request(request: { method: "tools/list" }) end - assert_equal("Internal error handling tools/list request", error.message) + assert_includes(error.message, "Internal error") assert_equal(:internal_error, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) end def test_block_customizes_faraday_connection @@ -267,31 +302,148 @@ def test_block_customizes_faraday_connection custom_client.send_request(request: request) end - def test_send_request_raises_error_for_non_json_response - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_send_request_raises_error_for_unsupported_content_type + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "text/html" }, + body: "", + ) + + error = assert_raises(RequestHandlerError) do + transport.send_request(request: { method: "tools/list" }) + end + + assert_includes(error.message, "Unsupported Content-Type") + assert_equal(:unsupported_media_type, error.error_type) + end + + def test_send_request_parses_sse_response + sse_body = <<~SSE + : comment + data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}} + + data: {"jsonrpc":"2.0","id":"test_id","result":{"tools":[{"name":"echo"}]}} + + SSE stub_request(:post, url) - .with(body: request.to_json) .to_return( status: 200, headers: { "Content-Type" => "text/event-stream" }, - body: "data: {}\n\n", + body: sse_body, + ) + + response = transport.send_request(request: { method: "tools/list" }) + + assert_equal({ "tools" => [{ "name" => "echo" }] }, response["result"]) + end + + def test_send_request_parses_sse_error_response + sse_body = <<~SSE + data: {"jsonrpc":"2.0","id":"test_id","error":{"code":-32600,"message":"Invalid request"}} + + SSE + + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = transport.send_request(request: { method: "tools/list" }) + + assert_equal(-32600, response.dig("error", "code")) + assert_equal("Invalid request", response.dig("error", "message")) + end + + def test_send_request_raises_error_for_sse_without_response + sse_body = <<~SSE + : just a comment + data: {"jsonrpc":"2.0","method":"notifications/progress","params":{}} + + SSE + + stub_request(:post, url) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, ) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.send_request(request: { method: "tools/list" }) end - assert_equal( - 'Unsupported Content-Type: "text/event-stream". This client only supports JSON responses.', - error.message, - ) - assert_equal(:unsupported_media_type, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) + assert_includes(error.message, "No valid JSON-RPC response found in SSE stream") + assert_equal(:parse_error, error.error_type) + end + + def test_send_request_returns_accepted_for_202_with_no_content_type + stub_request(:post, url) + .to_return(status: 202, body: "") + + response = transport.send_request(request: { method: "notifications/initialized" }) + + assert_equal({ "accepted" => true }, response) + end + + def test_close_sends_delete_with_session_headers + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-to-close", + }, + body: { result: { protocolVersion: "2024-11-05" } }.to_json, + ) + + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + delete_stub = stub_request(:delete, url) + .with( + headers: { + "Mcp-Session-Id" => "session-to-close", + }, + ) + .to_return(status: 200) + + transport.close + + assert_requested(delete_stub) + assert_nil(transport.session_id) + assert_nil(transport.protocol_version) + end + + def test_close_handles_errors_gracefully + stub_request(:post, url) + .to_return( + status: 200, + headers: { + "Content-Type" => "application/json", + "Mcp-Session-Id" => "session-to-close", + }, + body: { result: { protocolVersion: "2024-11-05" } }.to_json, + ) + + transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" }) + + stub_request(:delete, url).to_return(status: 405) + + transport.close + + assert_nil(transport.session_id) + assert_nil(transport.protocol_version) + end + + def test_close_does_nothing_without_session + # No DELETE should be sent when there's no session + transport.close + + assert_not_requested(:delete, url) + assert_nil(transport.session_id) end private @@ -304,8 +456,8 @@ def url "http://example.com" end - def client - @client ||= HTTP.new(url: url) + def transport + @transport ||= HTTP.new(url: url) end end end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb index 0252e0e0..a4034c51 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -7,7 +7,7 @@ module MCP class ClientTest < Minitest::Test def test_tools_sends_request_to_transport_and_returns_tools_array transport = mock - mock_response = { + response_body = { "result" => { "tools" => [ { "name" => "tool1", "description" => "tool1", "inputSchema" => {} }, @@ -16,10 +16,9 @@ def test_tools_sends_request_to_transport_and_returns_tools_array }, } - # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0" - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) tools = client.tools @@ -31,12 +30,11 @@ def test_tools_sends_request_to_transport_and_returns_tools_array def test_tools_returns_empty_array_when_no_tools transport = mock - mock_response = { "result" => { "tools" => [] } } + response_body = { "result" => { "tools" => [] } } - # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "tools/list" && args.dig(:request, :jsonrpc) == "2.0" - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) tools = client.tools @@ -48,17 +46,16 @@ def test_call_tool_sends_request_to_transport_and_returns_content transport = mock tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) arguments = { foo: "bar" } - mock_response = { + response_body = { "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, } - # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "tools/call" && args.dig(:request, :jsonrpc) == "2.0" && args.dig(:request, :params, :name) == "tool1" && args.dig(:request, :params, :arguments) == arguments - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) result = client.call_tool(tool: tool, arguments: arguments) @@ -70,14 +67,14 @@ def test_call_tool_sends_request_to_transport_and_returns_content def test_call_tool_by_name transport = mock arguments = { foo: "bar" } - mock_response = { + response_body = { "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, } transport.expects(:send_request).with do |args| args.dig(:request, :params, :name) == "tool1" && args.dig(:request, :params, :arguments) == arguments - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) result = client.call_tool(name: "tool1", arguments: arguments) @@ -95,7 +92,7 @@ def test_call_tool_raises_when_no_name_or_tool def test_resources_sends_request_to_transport_and_returns_resources_array transport = mock - mock_response = { + response_body = { "result" => { "resources" => [ { "name" => "resource1", "uri" => "file:///path/to/resource1", "description" => "First resource" }, @@ -104,10 +101,9 @@ def test_resources_sends_request_to_transport_and_returns_resources_array }, } - # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "resources/list" && args.dig(:request, :jsonrpc) == "2.0" - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) resources = client.resources @@ -121,9 +117,9 @@ def test_resources_sends_request_to_transport_and_returns_resources_array def test_resources_returns_empty_array_when_no_resources transport = mock - mock_response = { "result" => { "resources" => [] } } + response_body = { "result" => { "resources" => [] } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) resources = client.resources @@ -134,7 +130,7 @@ def test_resources_returns_empty_array_when_no_resources def test_read_resource_sends_request_to_transport_and_returns_contents transport = mock uri = "file:///path/to/resource.txt" - mock_response = { + response_body = { "result" => { "contents" => [ { "uri" => uri, "mimeType" => "text/plain", "text" => "Hello, world!" }, @@ -142,12 +138,11 @@ def test_read_resource_sends_request_to_transport_and_returns_contents }, } - # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "resources/read" && args.dig(:request, :jsonrpc) == "2.0" && args.dig(:request, :params, :uri) == uri - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) contents = client.read_resource(uri: uri) @@ -161,9 +156,9 @@ def test_read_resource_sends_request_to_transport_and_returns_contents def test_read_resource_returns_empty_array_when_no_contents transport = mock uri = "file:///path/to/nonexistent.txt" - mock_response = { "result" => {} } + response_body = { "result" => {} } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) contents = client.read_resource(uri: uri) @@ -173,7 +168,7 @@ def test_read_resource_returns_empty_array_when_no_contents def test_resource_templates_sends_request_to_transport_and_returns_resource_templates_array transport = mock - mock_response = { + response_body = { "result" => { "resourceTemplates" => [ { "name" => "template1", "uriTemplate" => "file:///path/{filename}", "description" => "First template" }, @@ -184,7 +179,7 @@ def test_resource_templates_sends_request_to_transport_and_returns_resource_temp transport.expects(:send_request).with do |args| args.dig(:request, :method) == "resources/templates/list" && args.dig(:request, :jsonrpc) == "2.0" - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) resource_templates = client.resource_templates @@ -198,9 +193,9 @@ def test_resource_templates_sends_request_to_transport_and_returns_resource_temp def test_resource_templates_returns_empty_array_when_no_resource_templates transport = mock - mock_response = { "result" => { "resourceTemplates" => [] } } + response_body = { "result" => { "resourceTemplates" => [] } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) resource_templates = client.resource_templates @@ -210,7 +205,7 @@ def test_resource_templates_returns_empty_array_when_no_resource_templates def test_prompts_sends_request_to_transport_and_returns_prompts_array transport = mock - mock_response = { + response_body = { "result" => { "prompts" => [ { @@ -239,10 +234,9 @@ def test_prompts_sends_request_to_transport_and_returns_prompts_array }, } - # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "prompts/list" && args.dig(:request, :jsonrpc) == "2.0" - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) prompts = client.prompts @@ -263,9 +257,9 @@ def test_prompts_sends_request_to_transport_and_returns_prompts_array def test_prompts_returns_empty_array_when_no_prompts transport = mock - mock_response = { "result" => { "prompts" => [] } } + response_body = { "result" => { "prompts" => [] } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) prompts = client.prompts @@ -276,7 +270,7 @@ def test_prompts_returns_empty_array_when_no_prompts def test_get_prompt_sends_request_to_transport_and_returns_contents transport = mock name = "first_prompt" - mock_response = { + response_body = { "result" => { "description" => "First prompt", "messages" => [ @@ -291,12 +285,11 @@ def test_get_prompt_sends_request_to_transport_and_returns_contents }, } - # Only checking for the essential parts of the request transport.expects(:send_request).with do |args| args.dig(:request, :method) == "prompts/get" && args.dig(:request, :jsonrpc) == "2.0" && args.dig(:request, :params, :name) == name - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) contents = client.get_prompt(name: name) @@ -309,9 +302,9 @@ def test_get_prompt_sends_request_to_transport_and_returns_contents def test_get_prompt_returns_empty_hash_when_no_contents transport = mock name = "nonexistent_prompt" - mock_response = { "result" => {} } + response_body = { "result" => {} } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) contents = client.get_prompt(name: name) @@ -322,9 +315,9 @@ def test_get_prompt_returns_empty_hash_when_no_contents def test_get_prompt_returns_empty_hash transport = mock name = "nonexistent_prompt" - mock_response = {} + response_body = {} - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) contents = client.get_prompt(name: name) @@ -337,7 +330,7 @@ def test_call_tool_includes_meta_progress_token_when_provided tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) arguments = { foo: "bar" } progress_token = "my-progress-token" - mock_response = { + response_body = { "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, } @@ -346,7 +339,7 @@ def test_call_tool_includes_meta_progress_token_when_provided args.dig(:request, :params, :_meta, :progressToken) == progress_token && args.dig(:request, :params, :name) == "tool1" && args.dig(:request, :params, :arguments) == arguments - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) client.call_tool(tool: tool, arguments: arguments, progress_token: progress_token) @@ -356,7 +349,7 @@ def test_call_tool_omits_meta_when_no_progress_token transport = mock tool = MCP::Client::Tool.new(name: "tool1", description: "tool1", input_schema: {}) arguments = { foo: "bar" } - mock_response = { + response_body = { "result" => { "content" => [{ "type": "text", "text": "Hello, world!" }] }, } @@ -364,7 +357,7 @@ def test_call_tool_omits_meta_when_no_progress_token args.dig(:request, :method) == "tools/call" && args.dig(:request, :params, :name) == "tool1" && args.dig(:request, :params).key?(:_meta) == false - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) client.call_tool(tool: tool, arguments: arguments) @@ -372,9 +365,9 @@ def test_call_tool_omits_meta_when_no_progress_token def test_tools_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + response_body = { "error" => { "code" => -32_601, "message" => "Method not found" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) error = assert_raises(Client::ServerError) { client.tools } @@ -384,9 +377,9 @@ def test_tools_raises_server_error_on_error_response def test_resources_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } } + response_body = { "error" => { "code" => -32_602, "message" => "Invalid params" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) error = assert_raises(Client::ServerError) { client.resources } @@ -395,9 +388,9 @@ def test_resources_raises_server_error_on_error_response def test_read_resource_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_602, "message" => "Resource not found" } } + response_body = { "error" => { "code" => -32_602, "message" => "Resource not found" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) assert_raises(Client::ServerError) { client.read_resource(uri: "file:///missing") } @@ -405,9 +398,9 @@ def test_read_resource_raises_server_error_on_error_response def test_get_prompt_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_602, "message" => "Prompt not found" } } + response_body = { "error" => { "code" => -32_602, "message" => "Prompt not found" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) assert_raises(Client::ServerError) { client.get_prompt(name: "missing") } @@ -415,9 +408,9 @@ def test_get_prompt_raises_server_error_on_error_response def test_prompts_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + response_body = { "error" => { "code" => -32_601, "message" => "Method not found" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) assert_raises(Client::ServerError) { client.prompts } @@ -425,9 +418,9 @@ def test_prompts_raises_server_error_on_error_response def test_resource_templates_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_601, "message" => "Method not found" } } + response_body = { "error" => { "code" => -32_601, "message" => "Method not found" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) assert_raises(Client::ServerError) { client.resource_templates } @@ -435,9 +428,9 @@ def test_resource_templates_raises_server_error_on_error_response def test_call_tool_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_602, "message" => "Tool not found" } } + response_body = { "error" => { "code" => -32_602, "message" => "Tool not found" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) error = assert_raises(Client::ServerError) { client.call_tool(name: "missing") } @@ -446,11 +439,11 @@ def test_call_tool_raises_server_error_on_error_response def test_server_error_includes_data_field transport = mock - mock_response = { + response_body = { "error" => { "code" => -32_603, "message" => "Internal error", "data" => "extra details" }, } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) error = assert_raises(Client::ServerError) { client.tools } @@ -459,18 +452,20 @@ def test_server_error_includes_data_field def test_complete_raises_server_error_on_error_response transport = mock - mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } } + response_body = { "error" => { "code" => -32_602, "message" => "Invalid params" } } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) - error = assert_raises(Client::ServerError) { client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) } + error = assert_raises(Client::ServerError) do + client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) + end assert_equal(-32_602, error.code) end def test_complete_sends_request_and_returns_completion_result transport = mock - mock_response = { + response_body = { "result" => { "completion" => { "values" => ["python", "pytorch"], @@ -485,7 +480,7 @@ def test_complete_sends_request_and_returns_completion_result args.dig(:request, :params, :ref) == { type: "ref/prompt", name: "code_review" } && args.dig(:request, :params, :argument) == { name: "language", value: "py" } && !args.dig(:request, :params).key?(:context) - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) result = client.complete( @@ -499,7 +494,7 @@ def test_complete_sends_request_and_returns_completion_result def test_complete_includes_context_when_provided transport = mock - mock_response = { + response_body = { "result" => { "completion" => { "values" => ["flask"], @@ -510,7 +505,7 @@ def test_complete_includes_context_when_provided transport.expects(:send_request).with do |args| args.dig(:request, :params, :context) == { arguments: { language: "python" } } - end.returns(mock_response).once + end.returns(response_body).once client = Client.new(transport: transport) result = client.complete( @@ -524,9 +519,9 @@ def test_complete_includes_context_when_provided def test_complete_returns_default_when_result_is_missing transport = mock - mock_response = { "result" => {} } + response_body = { "result" => {} } - transport.expects(:send_request).returns(mock_response).once + transport.expects(:send_request).returns(response_body).once client = Client.new(transport: transport) result = client.complete( @@ -537,5 +532,84 @@ def test_complete_returns_default_when_result_is_missing assert_equal([], result["values"]) refute(result["hasMore"]) end + + def test_connected_returns_false_when_transport_has_no_protocol_version + transport = mock + transport.stubs(:respond_to?).with(:session_id).returns(true) + transport.stubs(:respond_to?).with(:protocol_version).returns(true) + transport.stubs(:session_id).returns(nil) + transport.stubs(:protocol_version).returns(nil) + + client = Client.new(transport: transport) + + refute(client.connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_session_id_and_protocol_version_delegate_to_transport + transport = mock + transport.stubs(:respond_to?).with(:session_id).returns(true) + transport.stubs(:respond_to?).with(:protocol_version).returns(true) + transport.stubs(:session_id).returns("session-123") + transport.stubs(:protocol_version).returns("2024-11-05") + + client = Client.new(transport: transport) + + assert(client.connected?) + assert_equal("session-123", client.session_id) + assert_equal("2024-11-05", client.protocol_version) + end + + def test_session_id_nil_when_transport_does_not_respond + transport = mock + transport.stubs(:respond_to?).with(:session_id).returns(false) + transport.stubs(:respond_to?).with(:protocol_version).returns(false) + + client = Client.new(transport: transport) + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + refute(client.connected?) + end + + def test_connect_sends_initialize_request + transport = mock + response_body = { + "result" => { + "protocolVersion" => "2024-11-05", + "serverInfo" => { "name" => "test-server", "version" => "1.0" }, + "capabilities" => {}, + }, + } + + transport.expects(:send_request).with do |args| + args.dig(:request, :method) == "initialize" && + args.dig(:request, :params, :clientInfo, :name) == "test-client" + end.returns(response_body).once + + client = Client.new(transport: transport) + result = client.connect(client_info: { name: "test-client", version: "1.0" }) + + assert_equal("test-server", result.dig("result", "serverInfo", "name")) + end + + def test_close_delegates_to_transport_when_supported + transport = mock + transport.stubs(:respond_to?).with(:close).returns(true) + transport.expects(:close).once + + client = Client.new(transport: transport) + client.close + end + + def test_close_noop_when_transport_does_not_respond + transport = mock + transport.stubs(:respond_to?).with(:close).returns(false) + transport.expects(:close).never + + client = Client.new(transport: transport) + client.close + end end end