From 09637b633d68978448f0ab22863a8d2b8e4fc651 Mon Sep 17 00:00:00 2001 From: Keisuke Umegaki Date: Fri, 9 Jan 2026 15:22:43 +0900 Subject: [PATCH] Add MCP Streamable HTTP specification support for the client --- examples/streamable_http_client.rb | 279 +++++++------- examples/streamable_http_server.rb | 2 +- lib/mcp/client.rb | 138 +++++-- lib/mcp/client/http.rb | 110 +++++- .../transports/streamable_http_transport.rb | 4 + test/mcp/client/http_test.rb | 303 ++++++++------- test/mcp/client_test.rb | 348 +++++++++++++++--- 7 files changed, 784 insertions(+), 400 deletions(-) diff --git a/examples/streamable_http_client.rb b/examples/streamable_http_client.rb index b3a7477c..171e21e9 100644 --- a/examples/streamable_http_client.rb +++ b/examples/streamable_http_client.rb @@ -1,49 +1,27 @@ # frozen_string_literal: true +require "mcp" +require "mcp/client" +require "mcp/client/http" +require "mcp/client/tool" require "net/http" require "uri" require "json" require "logger" -# 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_URL = "http://localhost:9393" -# Server configuration -SERVER_URL = "http://localhost:9393/mcp" -PROTOCOL_VERSION = "2024-11-05" - -# 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 +# Note: The SDK doesn't support SSE streaming yet, so we use raw Net::HTTP def connect_sse(session_id, logger) uri = URI(SERVER_URL) @@ -51,7 +29,7 @@ def connect_sse(session_id, logger) Net::HTTP.start(uri.host, uri.port) do |http| request = Net::HTTP::Get.new(uri) - request["Mcp-Session-Id"] = session_id + request["MCP-Session-Id"] = session_id request["Accept"] = "text/event-stream" request["Cache-Control"] = "no-cache" @@ -62,14 +40,10 @@ def connect_sse(session_id, logger) 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 + data = line[6..] + logger.info("SSE event: #{data}") elsif line.start_with?(": ") - logger.debug("SSE keepalive received: #{line}") + logger.debug("SSE keepalive: #{line}") end end end @@ -79,125 +53,126 @@ 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 - - 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:#{" "} - 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") + logger = create_logger + + puts <<~MESSAGE + MCP Streamable HTTP Client (SDK + SSE) + 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 "Session ID: #{client.session_id}" + puts "Protocol Version: #{client.protocol_version}" + puts "Server Info: #{init_response.dig("result", "serverInfo")}" + + # 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 || "Test" + print("Enter delay in seconds (0 for immediate): ") + delay = (gets&.chomp || "0").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 || "Hello" + + 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", nil + 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 82566fc6..945a9cdc 100644 --- a/examples/streamable_http_server.rb +++ b/examples/streamable_http_server.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +# Usage: bundle exec ruby examples/streamable_http_server.rb require "mcp" require "rackup" require "json" diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index 7ecf39ae..34ab76d2 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -2,21 +2,62 @@ module MCP class Client + # https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http + LATEST_PROTOCOL_VERSION = "2025-11-25" + SESSION_ID_HEADER = "MCP-Session-Id" + PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" + # 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. + # The transport should be a duck type that responds to `post`. See the README for more details. # # @example # transport = MCP::Client::HTTP.new(url: "http://localhost:3000") # client = MCP::Client.new(transport: transport) def initialize(transport:) @transport = transport + @session_id = nil + @protocol_version = nil + end + + attr_reader :transport, :session_id, :protocol_version + + def connected? + !@protocol_version.nil? end - # The user may want to access additional transport-specific methods/attributes - # So keeping it public - attr_reader :transport + # Opens a connection to the MCP server by performing the initialization handshake. + # + # @param client_info [Hash] Information about the client (name, version) + # @param protocol_version [String] The protocol version to request + # @param capabilities [Hash] Client capabilities to advertise + # @return [Hash] The server's initialization response + # + # @example + # client.connect( + # client_info: { name: "my-client", version: "1.0.0" }, + # ) + def connect(client_info:, protocol_version: LATEST_PROTOCOL_VERSION, capabilities: {}) + request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: request_id, + method: "initialize", + params: { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + }, + } + + response = transport.post(body: request) + + # Faraday normalizes headers to lowercase + @session_id = response.headers["mcp-session-id"] + @protocol_version = response.body.dig("result", "protocolVersion") || protocol_version + + response.body + end # Returns the list of tools available from the server. # Each call will make a new request – the result is not cached. @@ -29,11 +70,7 @@ def initialize(transport:) # puts tool.name # end def tools - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "tools/list", - }) + response = send_request(method: "tools/list") response.dig("result", "tools")&.map do |tool| Tool.new( @@ -49,11 +86,7 @@ def tools # # @return [Array] An array of available resources. def resources - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "resources/list", - }) + response = send_request(method: "resources/list") response.dig("result", "resources") || [] end @@ -63,11 +96,7 @@ def resources # # @return [Array] An array of available prompts. def prompts - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, - method: "prompts/list", - }) + response = send_request(method: "prompts/list") response.dig("result", "prompts") || [] end @@ -82,17 +111,11 @@ def prompts # 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(tool:, arguments: nil) - transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, + send_request( method: "tools/call", params: { name: tool.name, arguments: arguments }, - }) + ) end # Reads a resource from the server by URI and returns the contents. @@ -100,12 +123,10 @@ def call_tool(tool:, arguments: nil) # @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 = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, + response = send_request( method: "resources/read", params: { uri: uri }, - }) + ) response.dig("result", "contents") || [] end @@ -115,18 +136,56 @@ def read_resource(uri:) # @param name [String] The name of the prompt to get. # @return [Hash] A hash containing the prompt details. def get_prompt(name:) - response = transport.send_request(request: { - jsonrpc: JsonRpcHandler::Version::V2_0, - id: request_id, + response = send_request( method: "prompts/get", params: { name: name }, - }) + ) response.fetch("result", {}) end + # Closes the connection with the MCP server. + # For HTTP transport, this sends a DELETE request to terminate the session. + # Session state is cleared regardless of whether the DELETE succeeds. + def close + return unless @session_id + + begin + transport.delete(headers: session_headers) if transport.respond_to?(:delete) + rescue StandardError + # Server may return 405 if it doesn't support session termination + end + + @session_id = nil + @protocol_version = nil + end + private + def send_request(method:, params: nil) + request = { + jsonrpc: JsonRpcHandler::Version::V2_0, + id: request_id, + method: method, + } + request[:params] = params if params + + response = transport.post(body: request, headers: session_headers) + + response.body + rescue SessionExpiredError + @session_id = nil + @protocol_version = nil + raise + end + + def session_headers + headers = {} + headers[SESSION_ID_HEADER] = @session_id if @session_id + headers[PROTOCOL_VERSION_HEADER] = @protocol_version if @protocol_version + headers + end + def request_id SecureRandom.uuid end @@ -141,5 +200,14 @@ def initialize(message, request, error_type: :internal_error, original_error: ni @original_error = original_error end end + + class SessionExpiredError < StandardError + attr_reader :request + + def initialize(message, request) + super(message) + @request = request + end + end end end diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 24c7831a..a0f0ebd6 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -2,9 +2,14 @@ module MCP class Client + # TODO: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http (GET for SSE) + # TODO: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery + class HTTP ACCEPT_HEADER = "application/json, text/event-stream" + Response = Struct.new(:body, :headers, keyword_init: true) + attr_reader :url def initialize(url:, headers: {}) @@ -12,13 +17,39 @@ def initialize(url:, headers: {}) @headers = headers end + # Sends a JSON-RPC request and returns only the response body. + # + # Use this method when: + # - You only need the response body (not headers) + # - You're using the transport directly without MCP::Client + # - You don't need session management + # + # @param request [Hash] The JSON-RPC request to send + # @return [Hash] The parsed response body def send_request(request:) - method = request[:method] || request["method"] - params = request[:params] || request["params"] + post(body: request).body + rescue SessionExpiredError => e + # Preserve original error type for backward compatibility + raise RequestHandlerError.new( + "The #{request[:method] || request["method"]} request is not found", + e.request, + error_type: :not_found, + ) + end - response = client.post("", request) - validate_response_content_type!(response, method, params) - response.body + # Sends a POST request and returns both body and headers. + # Used internally by MCP::Client for session management. + # @param body [Hash] The JSON-RPC request body + # @param headers [Hash] Additional headers to include + # @return [Response] A struct containing body and headers + def post(body:, headers: {}) + method = body[:method] || body["method"] + params = body[:params] || body["params"] + + response = client(headers).post("", body) + parsed_body = parse_response_body(response, method, params) + + Response.new(body: parsed_body, headers: response.headers.to_h) rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", @@ -40,12 +71,13 @@ def send_request(request:) error_type: :forbidden, original_error: e, ) - rescue Faraday::ResourceNotFound => e - raise RequestHandlerError.new( - "The #{method} request is not found", + rescue Faraday::ResourceNotFound + # The server MAY terminate the session at any time, + # after which it MUST respond to requests containing that session ID with HTTP 404 Not Found. + # See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management + raise SessionExpiredError.new( + "Session expired or not found (HTTP 404)", { method: method, params: params }, - error_type: :not_found, - original_error: e, ) rescue Faraday::UnprocessableEntityError => e raise RequestHandlerError.new( @@ -63,21 +95,27 @@ def send_request(request:) ) end + def delete(headers: {}) + client(headers).delete("") + nil + rescue Faraday::Error + nil + end + private attr_reader :headers - def client + def client(request_headers = {}) require_faraday! - @client ||= Faraday.new(url) do |faraday| + Faraday.new(url) do |faraday| faraday.request(:json) faraday.response(:json) faraday.response(:raise_error) faraday.headers["Accept"] = ACCEPT_HEADER - headers.each do |key, value| - faraday.headers[key] = value - end + headers.each { |key, value| faraday.headers[key] = value } + request_headers.each { |key, value| faraday.headers[key] = value } end end @@ -89,14 +127,48 @@ def require_faraday! "See https://rubygems.org/gems/faraday for more details." end - def validate_response_content_type!(response, method, params) + 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 + 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) + json_rpc_response = nil + + body.to_s.each_line do |line| + line = line.strip + next if line.empty? + next if line.start_with?(":") + next unless line.start_with?("data:") + + data = line.sub(/^data:\s*/, "") + 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 350af7cb..5de52ae4 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -7,6 +7,8 @@ module MCP class Server module Transports + # TODO: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery + class StreamableHTTPTransport < Transport def initialize(server, stateless: false) super(server) @@ -21,6 +23,8 @@ def initialize(server, stateless: false) REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze def handle_request(request) + # TODO: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning + case request.env["REQUEST_METHOD"] when "POST" handle_post(request) diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index fffda7f6..b8c0830d 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -4,45 +4,34 @@ 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.post(body: {}) 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) - - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_post_with_default_headers + body = { 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", }, - body: request.to_json, + body: body.to_json, ) .to_return( status: 200, @@ -50,21 +39,21 @@ 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.post(body: body) + + assert_instance_of(HTTP::Response, response) + assert_equal({ "result" => { "tools" => [] } }, response.body) end - def test_accept_header_is_included_in_requests - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_post_with_custom_transport_headers + custom_transport = HTTP.new(url: url, headers: { "Authorization" => "Bearer token" }) + body = { 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 +63,17 @@ def test_accept_header_is_included_in_requests body: { result: { tools: [] } }.to_json, ) - client.send_request(request: request) + custom_transport.post(body: body) end - def test_custom_accept_header_overrides_default - custom_accept = "application/json" - custom_client = HTTP.new(url: url, headers: { "Accept" => custom_accept }) - - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_post_with_request_specific_headers + body = { jsonrpc: "2.0", id: "test_id", method: "tools/list" } stub_request(:post, url) .with( headers: { - "Accept" => custom_accept, + "MCP-Session-Id" => "session-123", + "MCP-Protocol-Version" => "2024-11-05", }, ) .to_return( @@ -99,174 +82,212 @@ def test_custom_accept_header_overrides_default body: { result: { tools: [] } }.to_json, ) - custom_client.send_request(request: request) + transport.post( + body: body, + headers: { + "MCP-Session-Id" => "session-123", + "MCP-Protocol-Version" => "2024-11-05", + }, + ) end - def test_send_request_returns_faraday_response - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_post_returns_response_with_headers + body = { jsonrpc: "2.0", id: "test_id", method: "initialize" } stub_request(:post, url) - .with(body: request.to_json) .to_return( status: 200, - headers: { "Content-Type" => "application/json" }, - body: { result: { tools: [] } }.to_json, + headers: { + "Content-Type" => "application/json", + "MCP-Session-Id" => "session-abc", + }, + body: { result: {} }.to_json, ) - response = client.send_request(request: request) - assert_instance_of(Hash, response) - assert_equal({ "result" => { "tools" => [] } }, response) + response = transport.post(body: body) + + # Faraday normalizes header keys to lowercase + assert_equal("session-abc", response.headers["mcp-session-id"]) end - def test_send_request_raises_bad_request_error - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } - - stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 400) + def test_post_raises_bad_request_error + stub_request(:post, url).to_return(status: 400) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.post(body: {}) 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) + def test_post_raises_unauthorized_error + stub_request(:post, url).to_return(status: 401) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.post(body: {}) 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) + def test_post_raises_forbidden_error + stub_request(:post, url).to_return(status: 403) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.post(body: {}) 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_post_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.post(body: {}) + end + + assert_includes(error.message, "Session expired") + end + + def test_send_request_raises_request_handler_error_on_404_for_backward_compatibility + 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_includes(error.message, "not found") 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", - } - - stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 422) + def test_post_raises_unprocessable_entity_error + stub_request(:post, url).to_return(status: 422) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.post(body: {}) 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", - } + def test_post_raises_internal_error_on_500 + stub_request(:post, url).to_return(status: 500) + + error = assert_raises(RequestHandlerError) do + transport.post(body: {}) + end + + assert_includes(error.message, "Internal error") + assert_equal(:internal_error, error.error_type) + end + def test_post_raises_error_for_unsupported_content_type stub_request(:post, url) - .with(body: request.to_json) - .to_return(status: 500) + .to_return( + status: 200, + headers: { "Content-Type" => "text/html" }, + body: "", + ) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.post(body: {}) end - assert_equal("Internal error handling tools/list request", error.message) - assert_equal(:internal_error, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) + assert_includes(error.message, "Unsupported Content-Type") + assert_equal(:unsupported_media_type, error.error_type) + end + + def test_post_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) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: sse_body, + ) + + response = transport.post(body: {}) + + assert_equal({ "tools" => [{ "name" => "echo" }] }, response.body["result"]) + end + + def test_post_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.post(body: {}) + + assert_equal(-32600, response.body.dig("error", "code")) + assert_equal("Invalid request", response.body.dig("error", "message")) end - def test_send_request_raises_error_for_non_json_response - request = { - jsonrpc: "2.0", - id: "test_id", - method: "tools/list", - } + def test_post_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) - .with(body: request.to_json) .to_return( status: 200, headers: { "Content-Type" => "text/event-stream" }, - body: "data: {}\n\n", + body: sse_body, ) error = assert_raises(RequestHandlerError) do - client.send_request(request: request) + transport.post(body: {}) end - assert_equal( - 'Unsupported Content-Type: "text/event-stream". This client only supports JSON responses.', - error.message, + assert_includes(error.message, "No valid JSON-RPC response found in SSE stream") + assert_equal(:parse_error, error.error_type) + end + + def test_delete_sends_request_with_headers + stub_request(:delete, url) + .with( + headers: { + "MCP-Session-Id" => "session-123", + "MCP-Protocol-Version" => "2024-11-05", + }, + ) + .to_return(status: 200) + + transport.delete( + headers: { + "MCP-Session-Id" => "session-123", + "MCP-Protocol-Version" => "2024-11-05", + }, ) - assert_equal(:unsupported_media_type, error.error_type) - assert_equal({ method: "tools/list", params: nil }, error.request) + end + + def test_delete_handles_errors_gracefully + stub_request(:delete, url).to_return(status: 500) + + # Should not raise, just returns nil + result = transport.delete(headers: {}) + assert_nil(result) end private @@ -279,8 +300,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 168ed944..cb91ccd6 100644 --- a/test/mcp/client_test.rb +++ b/test/mcp/client_test.rb @@ -5,9 +5,14 @@ module MCP class ClientTest < Minitest::Test + # Helper to create a mock response struct like HTTP::Response + def mock_response(body:, headers: {}) + Struct.new(:body, :headers, keyword_init: true).new(body: body, headers: headers) + end + 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 +21,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 + transport.expects(:post).with do |args| + args.dig(:body, :method) == "tools/list" && args.dig(:body, :jsonrpc) == "2.0" + end.returns(mock_response(body: response_body)).once client = Client.new(transport: transport) tools = client.tools @@ -31,12 +35,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 + transport.expects(:post).with do |args| + args.dig(:body, :method) == "tools/list" && args.dig(:body, :jsonrpc) == "2.0" + end.returns(mock_response(body: response_body)).once client = Client.new(transport: transport) tools = client.tools @@ -48,17 +51,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 + transport.expects(:post).with do |args| + args.dig(:body, :method) == "tools/call" && + args.dig(:body, :jsonrpc) == "2.0" && + args.dig(:body, :params, :name) == "tool1" && + args.dig(:body, :params, :arguments) == arguments + end.returns(mock_response(body: response_body)).once client = Client.new(transport: transport) result = client.call_tool(tool: tool, arguments: arguments) @@ -69,7 +71,7 @@ def test_call_tool_sends_request_to_transport_and_returns_content 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" }, @@ -78,10 +80,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 + transport.expects(:post).with do |args| + args.dig(:body, :method) == "resources/list" && args.dig(:body, :jsonrpc) == "2.0" + end.returns(mock_response(body: response_body)).once client = Client.new(transport: transport) resources = client.resources @@ -95,9 +96,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(:post).returns(mock_response(body: response_body)).once client = Client.new(transport: transport) resources = client.resources @@ -108,7 +109,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!" }, @@ -116,12 +117,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 + transport.expects(:post).with do |args| + args.dig(:body, :method) == "resources/read" && + args.dig(:body, :jsonrpc) == "2.0" && + args.dig(:body, :params, :uri) == uri + end.returns(mock_response(body: response_body)).once client = Client.new(transport: transport) contents = client.read_resource(uri: uri) @@ -135,9 +135,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(:post).returns(mock_response(body: response_body)).once client = Client.new(transport: transport) contents = client.read_resource(uri: uri) @@ -147,7 +147,7 @@ def test_read_resource_returns_empty_array_when_no_contents def test_prompts_sends_request_to_transport_and_returns_prompts_array transport = mock - mock_response = { + response_body = { "result" => { "prompts" => [ { @@ -176,10 +176,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 + transport.expects(:post).with do |args| + args.dig(:body, :method) == "prompts/list" && args.dig(:body, :jsonrpc) == "2.0" + end.returns(mock_response(body: response_body)).once client = Client.new(transport: transport) prompts = client.prompts @@ -200,9 +199,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(:post).returns(mock_response(body: response_body)).once client = Client.new(transport: transport) prompts = client.prompts @@ -213,7 +212,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" => [ @@ -228,12 +227,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 + transport.expects(:post).with do |args| + args.dig(:body, :method) == "prompts/get" && + args.dig(:body, :jsonrpc) == "2.0" && + args.dig(:body, :params, :name) == name + end.returns(mock_response(body: response_body)).once client = Client.new(transport: transport) contents = client.get_prompt(name: name) @@ -246,9 +244,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(:post).returns(mock_response(body: response_body)).once client = Client.new(transport: transport) contents = client.get_prompt(name: name) @@ -259,14 +257,260 @@ 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(:post).returns(mock_response(body: response_body)).once client = Client.new(transport: transport) contents = client.get_prompt(name: name) assert_empty(contents) end + + def test_connected_returns_false_before_connect + transport = mock + client = Client.new(transport: transport) + + refute(client.connected?) + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_connect_extracts_session_id_and_protocol_version + transport = mock + response_body = { + "result" => { + "protocolVersion" => "2024-11-05", + "serverInfo" => { "name" => "test-server", "version" => "1.0" }, + "capabilities" => {}, + }, + } + + transport.expects(:post).with do |args| + args.dig(:body, :method) == "initialize" && + args.dig(:body, :params, :clientInfo, :name) == "test-client" + end.returns(mock_response(body: response_body, headers: { "mcp-session-id" => "session-123" })).once + + client = Client.new(transport: transport) + result = client.connect(client_info: { name: "test-client", version: "1.0" }) + + assert(client.connected?) + assert_equal("session-123", client.session_id) + assert_equal("2024-11-05", client.protocol_version) + assert_equal("test-server", result.dig("result", "serverInfo", "name")) + end + + def test_connect_uses_server_protocol_version + transport = mock + response_body = { + "result" => { + "protocolVersion" => "2025-03-26", + "serverInfo" => {}, + "capabilities" => {}, + }, + } + + transport.expects(:post).returns(mock_response(body: response_body, headers: {})).once + + client = Client.new(transport: transport) + client.connect( + client_info: { name: "test-client", version: "1.0" }, + protocol_version: "2024-11-05", + ) + + assert_equal("2025-03-26", client.protocol_version) + end + + def test_send_request_includes_session_headers_after_initialization + transport = mock + + init_response = mock_response( + body: { "result" => { "protocolVersion" => "2024-11-05" } }, + headers: { "mcp-session-id" => "session-abc" }, + ) + transport.expects(:post).returns(init_response).once + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + tools_response = mock_response(body: { "result" => { "tools" => [] } }) + transport.expects(:post).with do |args| + args[:headers][Client::SESSION_ID_HEADER] == "session-abc" && + args[:headers][Client::PROTOCOL_VERSION_HEADER] == "2024-11-05" + end.returns(tools_response).once + + client.tools + end + + def test_session_expired_clears_session_state + transport = mock + + init_response = mock_response( + body: { "result" => { "protocolVersion" => "2024-11-05" } }, + headers: { "mcp-session-id" => "session-xyz" }, + ) + transport.expects(:post).returns(init_response).once + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + assert_equal("session-xyz", client.session_id) + + transport.expects(:post).raises(Client::SessionExpiredError.new("Session expired", {})) + + assert_raises(Client::SessionExpiredError) do + client.tools + end + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_close_sends_delete_request_with_session_headers + transport = mock + + init_response = mock_response( + body: { "result" => { "protocolVersion" => "2024-11-05" } }, + headers: { "mcp-session-id" => "session-to-close" }, + ) + transport.expects(:post).returns(init_response).once + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + transport.expects(:delete).with do |args| + args[:headers][Client::SESSION_ID_HEADER] == "session-to-close" && + args[:headers][Client::PROTOCOL_VERSION_HEADER] == "2024-11-05" + end.once + + client.close + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_close_does_nothing_without_session + transport = mock + client = Client.new(transport: transport) + + # delete should not be called + transport.expects(:delete).never + + client.close + + assert_nil(client.session_id) + end + + def test_close_skips_delete_when_transport_lacks_method + transport = mock + + init_response = mock_response( + body: { "result" => { "protocolVersion" => "2024-11-05" } }, + headers: { "mcp-session-id" => "session-123" }, + ) + transport.expects(:post).returns(init_response).once + transport.stubs(:respond_to?).with(:delete).returns(false) + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + # Should not raise, just clear state + client.close + + assert_nil(client.session_id) + end + + def test_close_rescues_errors_from_non_conforming_transports + transport = mock + + init_response = mock_response( + body: { "result" => { "protocolVersion" => "2024-11-05" } }, + headers: { "mcp-session-id" => "session-123" }, + ) + transport.expects(:post).returns(init_response).once + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + # Server returns 405 Method Not Allowed (doesn't support session termination) + transport.expects(:delete).raises(Faraday::ClientError.new(nil, { status: 405 })) + + # Should not raise, just clear state + client.close + + assert_nil(client.session_id) + assert_nil(client.protocol_version) + end + + def test_session_id_not_overwritten_by_subsequent_responses + transport = mock + + init_response = mock_response( + body: { "result" => { "protocolVersion" => "2024-11-05" } }, + headers: { "mcp-session-id" => "original-session" }, + ) + transport.expects(:post).returns(init_response).once + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + assert_equal("original-session", client.session_id) + + # Subsequent response has different session ID (shouldn't happen per spec) + tools_response = mock_response( + body: { "result" => { "tools" => [] } }, + headers: { "mcp-session-id" => "different-session" }, + ) + transport.expects(:post).returns(tools_response).once + + client.tools + + # Original session ID should be preserved + assert_equal("original-session", client.session_id) + end + + def test_connect_works_without_session_id_for_stateless_servers + transport = mock + response_body = { + "result" => { + "protocolVersion" => "2024-11-05", + "serverInfo" => { "name" => "stateless-server", "version" => "1.0" }, + "capabilities" => {}, + }, + } + + # Stateless server doesn't return session ID + transport.expects(:post).returns(mock_response(body: response_body, headers: {})).once + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + # Client is still connected even without session ID + assert(client.connected?) + assert_nil(client.session_id) + assert_equal("2024-11-05", client.protocol_version) + end + + def test_send_request_works_without_session_id_for_stateless_servers + transport = mock + + init_response = mock_response( + body: { "result" => { "protocolVersion" => "2024-11-05" } }, + headers: {}, + ) + transport.expects(:post).returns(init_response).once + + client = Client.new(transport: transport) + client.connect(client_info: { name: "test", version: "1.0" }) + + tools_response = mock_response(body: { "result" => { "tools" => [] } }) + transport.expects(:post).with do |args| + # Session ID header should not be present for stateless servers + !args[:headers].key?(Client::SESSION_ID_HEADER) && + args[:headers][Client::PROTOCOL_VERSION_HEADER] == "2024-11-05" + end.returns(tools_response).once + + client.tools + end end end