diff --git a/.cursor/rules/release-changelogs.mdc b/.cursor/rules/release-changelogs.mdc index f7309a93..a5508dd7 100644 --- a/.cursor/rules/release-changelogs.mdc +++ b/.cursor/rules/release-changelogs.mdc @@ -1,23 +1,20 @@ --- description: writing changelog markdown when cutting a new release of the gem -globs: +globs: alwaysApply: false --- -- output the changelog as markdown when asked. +- output the changelog as markdown when asked. - git tags are used to mark the commit that cut a new release of the gem -- the gem version is located in [version.rb](mdc:lib/model_context_protocol/version.rb) +- the gem version is located in [version.rb](mdc:lib/mcp/version.rb) - use the git history, especially merge commits from PRs to construct the changelog -- when necessary, look at the diff of files changed to determine whether a PR should be listed in +- when necessary, look at the diff of files changed to determine whether a PR should be listed in - ## Added; adds new functionality - ## Changed; alters functionality; especially backward compatible changes - - ## Fixed; bugfixes that are forward compatible + - ## Fixed; bugfixes that are forward compatible use the following format for changelogs: -https://cloudsmith.io/~shopify/repos/gems/packages/detail/ruby/mcp-ruby/{gem version}/ - -# Changelog - +``` ## Added - New functionality added that was not present before @@ -25,8 +22,9 @@ https://cloudsmith.io/~shopify/repos/gems/packages/detail/ruby/mcp-ruby/{gem ver - Alterations to functionality that may indicate breaking changes ## Fixed -- Bug fixes +- Bug fixes #### Full change list: -- [Name of the PR #123](mdc:https:/github.com/Shopify/mcp-ruby/pull/123) @github-author-username -- [Name of the PR #456](mdc:https:/github.com/Shopify/mcp-ruby/pull/456) @another-github-author +- [Name of the PR #123](https:/github.com/modelcontextprotocol/ruby-sdk/pull/123) @github-author-username +- [Name of the PR #456](https:/github.com/modelcontextprotocol/ruby-sdk/pull/456) @another-github-author +``` diff --git a/README.md b/README.md index 00acd408..02cd8306 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,29 @@ A Ruby gem for implementing Model Context Protocol servers +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'mcp' +``` + +And then execute: + +```bash +$ bundle install +``` + +Or install it yourself as: + +```bash +$ gem install mcp +``` + ## MCP Server -The `ModelContextProtocol::Server` class is the core component that handles JSON-RPC requests and responses. +The `MCP::Server` class is the core component that handles JSON-RPC requests and responses. It implements the Model Context Protocol specification, handling model context requests and responses. ### Key Features @@ -67,8 +87,8 @@ If you want to build a local command-line application, you can use the stdio tra ```ruby #!/usr/bin/env ruby -require "model_context_protocol" -require "model_context_protocol/transports/stdio" +require "mcp" +require "mcp/transports/stdio" # Create a simple tool class ExampleTool < ModelContextProtocol::Tool diff --git a/examples/http_client_example.rb b/examples/http_client_example.rb new file mode 100755 index 00000000..37bd8f80 --- /dev/null +++ b/examples/http_client_example.rb @@ -0,0 +1,159 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/mcp" + +# Example of using the MCP HTTP client +def main + # Create an HTTP transport pointing to your MCP server + transport = MCP::Client::Transports::HTTP.new( + url: "http://localhost:3000/mcp", + timeout: 30, + headers: { + "Authorization" => "Bearer your-token-here", # Optional authentication + }, + ) + + # Create the client with the transport + client = MCP::Client.new(transport: transport) + + begin + puts "Pinging..." + client.ping(request_id: "ping-001") + + # Initialize the session + puts "Initializing session..." + result = client.initialize_session( + protocol_version: "2025-03-26", + capabilities: {}, + client_info: { + name: "example_client", + version: "1.0.0", + }, + request_id: "init-001", # Custom request ID + ) + + puts "Connected to server: #{client.server_info[:name]} v#{client.server_info[:version]}" + puts "Protocol version: #{result[:protocolVersion]}" + puts "Server capabilities: #{client.capabilities.keys.join(", ")}" + + # List available tools + if client.capabilities[:tools] + puts "Listing tools..." + tools_result = client.list_tools(request_id: "tools-list-001") + tools = tools_result[:tools] || [] + if tools.any? + tools.each do |tool| + puts "- #{tool[:name]}: #{tool[:description]}" + end + else + puts "No tools available" + end + puts + + # Call a tool if available + if tools.any? + tool_name = tools.first[:name] + puts "Calling tool: #{tool_name}" + begin + result = client.call_tool( + name: tool_name, + arguments: {}, + request_id: "tool-call-001", # Custom request ID + ) + puts "Tool result: #{result}" + rescue MCP::Client::ClientError => e + puts "Tool call failed: #{e.message}" + end + puts + end + end + + # List available prompts (using auto-incrementing ID) + if client.capabilities[:prompts] + puts "Listing prompts..." + prompts_result = client.list_prompts # No custom ID - will auto-increment + prompts = prompts_result[:prompts] || [] + if prompts.any? + prompts.each do |prompt| + puts "- #{prompt[:name]}: #{prompt[:description]}" + end + else + puts "No prompts available" + end + puts + + # Get a prompt if available + if prompts.any? + prompt_name = prompts.first[:name] + puts "Getting prompt: #{prompt_name}" + begin + result = client.get_prompt( + name: prompt_name, + arguments: {}, + request_id: "prompt-get-001", # Custom request ID + ) + puts "Prompt result: #{result[:description]}" + puts "Messages: #{result[:messages].length} message(s)" + rescue MCP::Client::ClientError => e + puts "Prompt get failed: #{e.message}" + end + puts + end + end + + # List available resources + if client.capabilities[:resources] + puts "Listing resources..." + resources_result = client.list_resources # Auto-incrementing ID + resources = resources_result[:resources] || [] + if resources.any? + resources.each do |resource| + puts "- #{resource[:uri]}: #{resource[:name]} (#{resource[:mimeType]})" + end + else + puts "No resources available" + end + puts + + # Read a resource if available + if resources.any? + resource_uri = resources.first[:uri] + puts "Reading resource: #{resource_uri}" + begin + result = client.read_resource( + uri: resource_uri, + request_id: "resource-read-001", # Custom request ID + ) + contents = result[:contents] || [] + contents.each do |content| + puts "Content type: #{content[:mimeType]}" + if content[:text] + puts "Text content: #{content[:text][0..100]}#{"..." if content[:text].length > 100}" + elsif content[:blob] + puts "Binary content: #{content[:blob].length} bytes" + end + end + rescue MCP::Client::ClientError => e + puts "Resource read failed: #{e.message}" + end + puts + end + end + rescue MCP::Client::ClientError => e + puts "Client error: #{e.message}" + exit(1) + rescue MCP::Client::Transports::HTTP::HTTPError => e + puts "HTTP error: #{e.message}" + puts "Status code: #{e.status_code}" if e.status_code + exit(1) + rescue => e + puts "Unexpected error: #{e.message}" + puts e.backtrace.join("\n") + exit(1) + end +end + +if __FILE__ == $0 + main +end diff --git a/examples/stdio_client.rb b/examples/stdio_client.rb new file mode 100755 index 00000000..80f1f088 --- /dev/null +++ b/examples/stdio_client.rb @@ -0,0 +1,62 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "mcp" +require "mcp/client/transports/stdio" + +# Create a client with stdio transport +transport = MCP::Client::Transports::Stdio.new(timeout: 10) +client = MCP::Client.new(transport: transport) + +begin + # Initialize the session + result = client.initialize_session( + protocol_version: "2025-03-26", + capabilities: {}, + client_info: { + name: "example_stdio_client", + version: "1.0.0", + }, + ) + + puts "Connected to server: #{result[:serverInfo][:name]} v#{result[:serverInfo][:version]}" + puts "Server capabilities: #{result[:capabilities].keys.join(", ")}" + + # Test ping + ping_result = client.ping + puts "Ping successful: #{ping_result}" + + # List available tools + tools_result = client.list_tools + puts "Available tools:" + tools_result[:tools].each do |tool| + puts " - #{tool[:name]}: #{tool[:description]}" + end + + # Call a tool if available + if tools_result[:tools].any? + tool_name = tools_result[:tools].first[:name] + puts "\nCalling tool: #{tool_name}" + + # Example arguments - adjust based on the actual tool + tool_result = client.call_tool( + name: tool_name, + arguments: tool_name == "echo" ? { message: "Hello from stdio client!" } : {}, + ) + + puts "Tool result:" + tool_result[:content].each do |content| + puts " #{content[:text]}" if content[:type] == "text" + end + end +rescue MCP::Client::ClientError => e + puts "Client error: #{e.message}" + puts "Error type: #{e.error_type}" +rescue MCP::Client::Transports::Stdio::StdioError => e + puts "Stdio transport error: #{e.message}" + puts "Original error: #{e.original_error}" if e.original_error +rescue StandardError => e + puts "Unexpected error: #{e.message}" + puts e.backtrace.join("\n") +end diff --git a/examples/stdio_server.rb b/examples/stdio_server.rb index 631822ed..177b3721 100755 --- a/examples/stdio_server.rb +++ b/examples/stdio_server.rb @@ -2,8 +2,8 @@ # frozen_string_literal: true $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) -require "model_context_protocol" -require "model_context_protocol/transports/stdio" +require "mcp" +require "mcp/transports/stdio" # Create a simple tool class ExampleTool < MCP::Tool diff --git a/lib/mcp-ruby.rb b/lib/mcp-ruby.rb deleted file mode 100644 index ef77ab61..00000000 --- a/lib/mcp-ruby.rb +++ /dev/null @@ -1,3 +0,0 @@ -# frozen_string_literal: true - -require_relative "model_context_protocol" diff --git a/lib/mcp.rb b/lib/mcp.rb new file mode 100644 index 00000000..676f7d0a --- /dev/null +++ b/lib/mcp.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative "mcp/server" +require_relative "mcp/client" +require_relative "mcp/client/transports/http" +require_relative "mcp/string_utils" +require_relative "mcp/tool" +require_relative "mcp/tool/input_schema" +require_relative "mcp/tool/annotations" +require_relative "mcp/tool/response" +require_relative "mcp/content" +require_relative "mcp/resource" +require_relative "mcp/resource/contents" +require_relative "mcp/resource/embedded" +require_relative "mcp/resource_template" +require_relative "mcp/prompt" +require_relative "mcp/prompt/argument" +require_relative "mcp/prompt/message" +require_relative "mcp/prompt/result" +require_relative "mcp/version" +require_relative "mcp/configuration" +require_relative "mcp/methods" + +module MCP + class << self + def configure + yield(configuration) + end + + def configuration + @configuration ||= Configuration.new + end + end + + class Annotations + attr_reader :audience, :priority + + def initialize(audience: nil, priority: nil) + @audience = audience + @priority = priority + end + end +end + +ModelContextProtocol = MCP diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb new file mode 100644 index 00000000..32be6066 --- /dev/null +++ b/lib/mcp/client.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require "json" +require_relative "methods" + +module MCP + class Client + class ClientError < StandardError + attr_reader :error_type, :original_error + + def initialize(message, error_type: :client_error, original_error: nil) + super(message) + @error_type = error_type + @original_error = original_error + end + end + + attr_reader :transport, :server_info, :capabilities + + def initialize(transport:) + @transport = transport + @request_id = 0 + @server_info = nil + @capabilities = nil + end + + def initialize_session(protocol_version: "2025-03-26", capabilities: {}, client_info: {}, request_id: nil) + params = { + protocolVersion: protocol_version, + capabilities: capabilities, + clientInfo: client_info, + } + + request = build_request(Methods::INITIALIZE, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new( + "Failed to initialize: #{response[:error][:message]}", + error_type: :initialization_error, + ) + end + + result = response[:result] + @server_info = result[:serverInfo] + @capabilities = result[:capabilities] + + result + end + + def ping(request_id: nil) + request = build_request(Methods::PING, nil, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Ping failed: #{response[:error][:message]}") + end + + response[:result] + end + + def list_tools(cursor: nil, request_id: nil) + params = cursor ? { cursor: cursor } : nil + request = build_request(Methods::TOOLS_LIST, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to list tools: #{response[:error][:message]}") + end + + response[:result] + end + + def call_tool(name:, arguments: {}, request_id: nil) + params = { name: name } + params[:arguments] = arguments unless arguments.empty? + + request = build_request(Methods::TOOLS_CALL, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to call tool '#{name}': #{response[:error][:message]}") + end + + response[:result] + end + + def list_prompts(cursor: nil, request_id: nil) + params = cursor ? { cursor: cursor } : nil + request = build_request(Methods::PROMPTS_LIST, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to list prompts: #{response[:error][:message]}") + end + + response[:result] + end + + def get_prompt(name:, arguments: {}, request_id: nil) + params = { name: name } + params[:arguments] = arguments unless arguments.empty? + + request = build_request(Methods::PROMPTS_GET, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to get prompt '#{name}': #{response[:error][:message]}") + end + + response[:result] + end + + def list_resources(cursor: nil, request_id: nil) + params = cursor ? { cursor: cursor } : nil + request = build_request(Methods::RESOURCES_LIST, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to list resources: #{response[:error][:message]}") + end + + response[:result] + end + + def read_resource(uri:, request_id: nil) + request = build_request(Methods::RESOURCES_READ, { uri: uri }, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to read resource '#{uri}': #{response[:error][:message]}") + end + + response[:result] + end + + def list_resource_templates(cursor: nil, request_id: nil) + params = cursor ? { cursor: cursor } : nil + request = build_request(Methods::RESOURCES_TEMPLATES_LIST, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to list resource templates: #{response[:error][:message]}") + end + + response[:result] + end + + def subscribe_resource(uri:, request_id: nil) + request = build_request(Methods::RESOURCES_SUBSCRIBE, { uri: uri }, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to subscribe to resource '#{uri}': #{response[:error][:message]}") + end + + response[:result] + end + + def unsubscribe_resource(uri:, request_id: nil) + request = build_request(Methods::RESOURCES_UNSUBSCRIBE, { uri: uri }, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to unsubscribe from resource '#{uri}': #{response[:error][:message]}") + end + + response[:result] + end + + def set_logging_level(level:, request_id: nil) + request = build_request(Methods::LOGGING_SET_LEVEL, { level: level }, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to set logging level: #{response[:error][:message]}") + end + + response[:result] + end + + def complete(ref:, argument:, request_id: nil) + params = { + ref: ref, + argument: argument, + } + + request = build_request(Methods::COMPLETION_COMPLETE, params, request_id) + response = @transport.send_request(request) + + if response[:error] + raise ClientError.new("Failed to get completions: #{response[:error][:message]}") + end + + response[:result] + end + + private + + def build_request(method, params = nil, request_id = nil) + request = { + jsonrpc: "2.0", + method: method, + id: request_id.nil? ? next_request_id : request_id, + } + + request[:params] = params if params + request + end + + def next_request_id + @request_id += 1 + end + end +end diff --git a/lib/mcp/client/transports/http.rb b/lib/mcp/client/transports/http.rb new file mode 100644 index 00000000..832d20a8 --- /dev/null +++ b/lib/mcp/client/transports/http.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "faraday" +require "json" + +module MCP + class Client + module Transports + class HTTP + class HTTPError < StandardError + attr_reader :status_code, :response_body + + def initialize(message, status_code: nil, response_body: nil) + super(message) + @status_code = status_code + @response_body = response_body + end + end + + attr_reader :url, :timeout, :headers + + def initialize(url:, timeout: 30, headers: {}) + @url = url + @timeout = timeout + @headers = default_headers.merge(headers) + @connection = build_connection + end + + def send_request(request) + json_request = JSON.generate(request) + + begin + response = @connection.post do |req| + req.body = json_request + req.headers.update(@headers) + end + + handle_response(response) + rescue Faraday::TimeoutError => e + raise HTTPError.new("Request timeout: #{e.message}") + rescue Faraday::ConnectionFailed => e + raise HTTPError.new("Connection failed: #{e.message}") + rescue Faraday::Error => e + raise HTTPError.new("HTTP error: #{e.message}") + end + end + + private + + def build_connection + Faraday.new(url: @url) do |conn| + conn.options.timeout = @timeout + conn.options.open_timeout = @timeout + conn.adapter(Faraday.default_adapter) + end + end + + def default_headers + { + "Content-Type" => "application/json", + "Accept" => "application/json", + } + end + + def handle_response(response) + unless response.success? + raise HTTPError.new( + "HTTP #{response.status}: #{response.reason_phrase}", + status_code: response.status, + response_body: response.body, + ) + end + + begin + JSON.parse(response.body, symbolize_names: true) + rescue JSON::ParserError => e + raise HTTPError.new("Invalid JSON response: #{e.message}", response_body: response.body) + end + end + end + end + end +end diff --git a/lib/mcp/client/transports/stdio.rb b/lib/mcp/client/transports/stdio.rb new file mode 100644 index 00000000..73e47b67 --- /dev/null +++ b/lib/mcp/client/transports/stdio.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "json" +require "stringio" + +module MCP + class Client + module Transports + class Stdio + class StdioError < StandardError + attr_reader :original_error + + def initialize(message, original_error: nil) + super(message) + @original_error = original_error + end + end + + attr_reader :timeout + + def initialize(timeout: 30) + @timeout = timeout + @stdin = $stdin + @stdout = $stdout + @stdin.set_encoding("UTF-8") + @stdout.set_encoding("UTF-8") + end + + def send_request(request) + json_request = JSON.generate(request) + + begin + # Send request to stdout + @stdout.puts(json_request) + @stdout.flush + + # Read response from stdin with timeout + response_line = read_with_timeout(@timeout) + + unless response_line + raise StdioError.new("No response received within #{@timeout} seconds") + end + + # Parse the JSON response + JSON.parse(response_line.strip, symbolize_names: true) + rescue JSON::ParserError => e + raise StdioError.new("Invalid JSON response: #{e.message}", original_error: e) + rescue Errno::EPIPE => e + raise StdioError.new("Broken pipe: #{e.message}", original_error: e) + rescue IOError => e + raise StdioError.new("IO error: #{e.message}", original_error: e) + rescue StandardError => e + raise StdioError.new("Stdio transport error: #{e.message}", original_error: e) + end + end + + private + + def read_with_timeout(timeout) + # Handle StringIO for testing + if @stdin.is_a?(StringIO) + @stdin.gets + elsif IO.select([@stdin], nil, nil, timeout) + @stdin.gets + else + nil + end + end + end + end + end +end diff --git a/lib/model_context_protocol/configuration.rb b/lib/mcp/configuration.rb similarity index 98% rename from lib/model_context_protocol/configuration.rb rename to lib/mcp/configuration.rb index b6dab82f..39eeebef 100644 --- a/lib/model_context_protocol/configuration.rb +++ b/lib/mcp/configuration.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP class Configuration DEFAULT_PROTOCOL_VERSION = "2024-11-05" diff --git a/lib/model_context_protocol/content.rb b/lib/mcp/content.rb similarity index 95% rename from lib/model_context_protocol/content.rb rename to lib/mcp/content.rb index 6a11e7a3..65fc11f5 100644 --- a/lib/model_context_protocol/content.rb +++ b/lib/mcp/content.rb @@ -1,7 +1,7 @@ # typed: true # frozen_string_literal: true -module ModelContextProtocol +module MCP module Content class Text attr_reader :text, :annotations diff --git a/lib/model_context_protocol/instrumentation.rb b/lib/mcp/instrumentation.rb similarity index 95% rename from lib/model_context_protocol/instrumentation.rb rename to lib/mcp/instrumentation.rb index 73d1209a..5632fdda 100644 --- a/lib/model_context_protocol/instrumentation.rb +++ b/lib/mcp/instrumentation.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP module Instrumentation def instrument_call(method, &block) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) diff --git a/lib/model_context_protocol/methods.rb b/lib/mcp/methods.rb similarity index 98% rename from lib/model_context_protocol/methods.rb rename to lib/mcp/methods.rb index 721d11ff..a0a2c303 100644 --- a/lib/model_context_protocol/methods.rb +++ b/lib/mcp/methods.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP module Methods INITIALIZE = "initialize" PING = "ping" diff --git a/lib/model_context_protocol/prompt.rb b/lib/mcp/prompt.rb similarity index 95% rename from lib/model_context_protocol/prompt.rb rename to lib/mcp/prompt.rb index d7cd4b97..7624ef55 100644 --- a/lib/model_context_protocol/prompt.rb +++ b/lib/mcp/prompt.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class Prompt class << self NOT_SET = Object.new @@ -67,7 +67,7 @@ def validate_arguments!(args) missing = required_args - args.keys return if missing.empty? - raise ModelContextProtocol::Server::RequestHandlerError.new( + raise MCP::Server::RequestHandlerError.new( "Missing required arguments: #{missing.join(", ")}", nil, error_type: :missing_required_arguments ) end diff --git a/lib/model_context_protocol/prompt/argument.rb b/lib/mcp/prompt/argument.rb similarity index 93% rename from lib/model_context_protocol/prompt/argument.rb rename to lib/mcp/prompt/argument.rb index 99402845..2a22fd4a 100644 --- a/lib/model_context_protocol/prompt/argument.rb +++ b/lib/mcp/prompt/argument.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class Prompt class Argument attr_reader :name, :description, :required, :arguments diff --git a/lib/model_context_protocol/prompt/message.rb b/lib/mcp/prompt/message.rb similarity index 91% rename from lib/model_context_protocol/prompt/message.rb rename to lib/mcp/prompt/message.rb index e7bf602c..b5c94f9a 100644 --- a/lib/model_context_protocol/prompt/message.rb +++ b/lib/mcp/prompt/message.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class Prompt class Message attr_reader :role, :content diff --git a/lib/model_context_protocol/prompt/result.rb b/lib/mcp/prompt/result.rb similarity index 92% rename from lib/model_context_protocol/prompt/result.rb rename to lib/mcp/prompt/result.rb index 685bbc85..693885f7 100644 --- a/lib/model_context_protocol/prompt/result.rb +++ b/lib/mcp/prompt/result.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class Prompt class Result attr_reader :description, :messages diff --git a/lib/model_context_protocol/resource.rb b/lib/mcp/resource.rb similarity index 93% rename from lib/model_context_protocol/resource.rb rename to lib/mcp/resource.rb index 800834f6..5b985a93 100644 --- a/lib/model_context_protocol/resource.rb +++ b/lib/mcp/resource.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class Resource attr_reader :uri, :name, :description, :mime_type diff --git a/lib/model_context_protocol/resource/contents.rb b/lib/mcp/resource/contents.rb similarity index 96% rename from lib/model_context_protocol/resource/contents.rb rename to lib/mcp/resource/contents.rb index 34282f3c..0bebb396 100644 --- a/lib/model_context_protocol/resource/contents.rb +++ b/lib/mcp/resource/contents.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class Resource class Contents attr_reader :uri, :mime_type diff --git a/lib/model_context_protocol/resource/embedded.rb b/lib/mcp/resource/embedded.rb similarity index 92% rename from lib/model_context_protocol/resource/embedded.rb rename to lib/mcp/resource/embedded.rb index 9449d8ba..4cc9132b 100644 --- a/lib/model_context_protocol/resource/embedded.rb +++ b/lib/mcp/resource/embedded.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class Resource class Embedded attr_reader :resource, :annotations diff --git a/lib/model_context_protocol/resource_template.rb b/lib/mcp/resource_template.rb similarity index 94% rename from lib/model_context_protocol/resource_template.rb rename to lib/mcp/resource_template.rb index f82242af..e2cc6f2e 100644 --- a/lib/model_context_protocol/resource_template.rb +++ b/lib/mcp/resource_template.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP class ResourceTemplate attr_reader :uri_template, :name, :description, :mime_type diff --git a/lib/model_context_protocol/server.rb b/lib/mcp/server.rb similarity index 98% rename from lib/model_context_protocol/server.rb rename to lib/mcp/server.rb index f0653fcc..2a5fb56c 100644 --- a/lib/model_context_protocol/server.rb +++ b/lib/mcp/server.rb @@ -4,7 +4,7 @@ require_relative "instrumentation" require_relative "methods" -module ModelContextProtocol +module MCP class Server DEFAULT_VERSION = "0.1.0" @@ -44,7 +44,7 @@ def initialize( @resource_templates = resource_templates @resource_index = index_resources_by_uri(resources) @server_context = server_context - @configuration = ModelContextProtocol.configuration.merge(configuration) + @configuration = MCP.configuration.merge(configuration) @handlers = { Methods::RESOURCES_LIST => method(:list_resources), diff --git a/lib/model_context_protocol/string_utils.rb b/lib/mcp/string_utils.rb similarity index 94% rename from lib/model_context_protocol/string_utils.rb rename to lib/mcp/string_utils.rb index 08bd5a80..3f0dcdb9 100644 --- a/lib/model_context_protocol/string_utils.rb +++ b/lib/mcp/string_utils.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -module ModelContextProtocol +module MCP module StringUtils extend self diff --git a/lib/model_context_protocol/tool.rb b/lib/mcp/tool.rb similarity index 98% rename from lib/model_context_protocol/tool.rb rename to lib/mcp/tool.rb index 388cf887..48378e43 100644 --- a/lib/model_context_protocol/tool.rb +++ b/lib/mcp/tool.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP class Tool class << self NOT_SET = Object.new diff --git a/lib/model_context_protocol/tool/annotations.rb b/lib/mcp/tool/annotations.rb similarity index 96% rename from lib/model_context_protocol/tool/annotations.rb rename to lib/mcp/tool/annotations.rb index 2266c9f0..6344334d 100644 --- a/lib/model_context_protocol/tool/annotations.rb +++ b/lib/mcp/tool/annotations.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP class Tool class Annotations attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint diff --git a/lib/model_context_protocol/tool/input_schema.rb b/lib/mcp/tool/input_schema.rb similarity index 95% rename from lib/model_context_protocol/tool/input_schema.rb rename to lib/mcp/tool/input_schema.rb index 4718205e..4683b7e4 100644 --- a/lib/model_context_protocol/tool/input_schema.rb +++ b/lib/mcp/tool/input_schema.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP class Tool class InputSchema attr_reader :properties, :required diff --git a/lib/model_context_protocol/tool/response.rb b/lib/mcp/tool/response.rb similarity index 91% rename from lib/model_context_protocol/tool/response.rb rename to lib/mcp/tool/response.rb index 92077938..abf6ff48 100644 --- a/lib/model_context_protocol/tool/response.rb +++ b/lib/mcp/tool/response.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP class Tool class Response attr_reader :content, :is_error diff --git a/lib/model_context_protocol/transport.rb b/lib/mcp/transport.rb similarity index 96% rename from lib/model_context_protocol/transport.rb rename to lib/mcp/transport.rb index fc6aca53..7e4b63e1 100644 --- a/lib/model_context_protocol/transport.rb +++ b/lib/mcp/transport.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP class Transport def initialize(server) @server = server diff --git a/lib/model_context_protocol/transports/stdio.rb b/lib/mcp/transports/stdio.rb similarity index 96% rename from lib/model_context_protocol/transports/stdio.rb rename to lib/mcp/transports/stdio.rb index f72508b4..b76b9619 100644 --- a/lib/model_context_protocol/transports/stdio.rb +++ b/lib/mcp/transports/stdio.rb @@ -3,7 +3,7 @@ require_relative "../transport" require "json" -module ModelContextProtocol +module MCP module Transports class StdioTransport < Transport def initialize(server) diff --git a/lib/model_context_protocol/version.rb b/lib/mcp/version.rb similarity index 66% rename from lib/model_context_protocol/version.rb rename to lib/mcp/version.rb index c2e23239..1297fbf9 100644 --- a/lib/model_context_protocol/version.rb +++ b/lib/mcp/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -module ModelContextProtocol +module MCP VERSION = "0.7.0" end diff --git a/lib/model_context_protocol.rb b/lib/model_context_protocol.rb deleted file mode 100644 index c3866c7d..00000000 --- a/lib/model_context_protocol.rb +++ /dev/null @@ -1,44 +0,0 @@ -# typed: strict -# frozen_string_literal: true - -require_relative "model_context_protocol/server" -require_relative "model_context_protocol/string_utils" -require_relative "model_context_protocol/tool" -require_relative "model_context_protocol/tool/input_schema" -require_relative "model_context_protocol/tool/annotations" -require_relative "model_context_protocol/tool/response" -require_relative "model_context_protocol/content" -require_relative "model_context_protocol/resource" -require_relative "model_context_protocol/resource/contents" -require_relative "model_context_protocol/resource/embedded" -require_relative "model_context_protocol/resource_template" -require_relative "model_context_protocol/prompt" -require_relative "model_context_protocol/prompt/argument" -require_relative "model_context_protocol/prompt/message" -require_relative "model_context_protocol/prompt/result" -require_relative "model_context_protocol/version" -require_relative "model_context_protocol/configuration" -require_relative "model_context_protocol/methods" - -module ModelContextProtocol - class << self - def configure - yield(configuration) - end - - def configuration - @configuration ||= Configuration.new - end - end - - class Annotations - attr_reader :audience, :priority - - def initialize(audience: nil, priority: nil) - @audience = audience - @priority = priority - end - end -end - -MCP = ModelContextProtocol diff --git a/model_context_protocol.gemspec b/mcp.gemspec similarity index 84% rename from model_context_protocol.gemspec rename to mcp.gemspec index 0f8278d1..c6ffdf5c 100644 --- a/model_context_protocol.gemspec +++ b/mcp.gemspec @@ -1,10 +1,10 @@ # frozen_string_literal: true -require_relative "lib/model_context_protocol/version" +require_relative "lib/mcp/version" Gem::Specification.new do |spec| - spec.name = "model_context_protocol" - spec.version = ModelContextProtocol::VERSION + spec.name = "mcp" + spec.version = MCP::VERSION spec.authors = ["Model Context Protocol"] spec.email = ["mcp-support@anthropic.com"] @@ -27,6 +27,8 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_dependency("faraday", "~> 2.0") spec.add_dependency("json_rpc_handler", "~> 0.1") spec.add_development_dependency("activesupport") + spec.add_development_dependency("webmock") end diff --git a/test/mcp/client/transports/http_test.rb b/test/mcp/client/transports/http_test.rb new file mode 100644 index 00000000..7ceffe57 --- /dev/null +++ b/test/mcp/client/transports/http_test.rb @@ -0,0 +1,443 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "webmock/minitest" + +module MCP + class Client + module Transports + class HTTPTest < ActiveSupport::TestCase + setup do + @url = "http://localhost:3000/mcp" + @transport = HTTP.new(url: @url) + end + + test "initializes with default values" do + transport = HTTP.new(url: @url) + + assert_equal @url, transport.url + assert_equal 30, transport.timeout + assert_equal "application/json", transport.headers["Content-Type"] + assert_equal "application/json", transport.headers["Accept"] + end + + test "initializes with custom timeout and headers" do + custom_headers = { "Authorization" => "Bearer token123", "X-Custom" => "value" } + transport = HTTP.new(url: @url, timeout: 60, headers: custom_headers) + + assert_equal @url, transport.url + assert_equal 60, transport.timeout + assert_equal "Bearer token123", transport.headers["Authorization"] + assert_equal "value", transport.headers["X-Custom"] + # Should still have default headers + assert_equal "application/json", transport.headers["Content-Type"] + assert_equal "application/json", transport.headers["Accept"] + end + + test "custom headers override default headers" do + custom_headers = { "Content-Type" => "application/vnd.api+json" } + transport = HTTP.new(url: @url, headers: custom_headers) + + assert_equal "application/vnd.api+json", transport.headers["Content-Type"] + assert_equal "application/json", transport.headers["Accept"] + end + + test "send_request makes POST request with correct headers and body" do + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + } + + response_body = { + jsonrpc: "2.0", + id: 1, + result: {}, + } + + stub_request(:post, @url) + .with( + body: JSON.generate(request), + headers: { + "Content-Type" => "application/json", + "Accept" => "application/json", + }, + ) + .to_return( + status: 200, + body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" }, + ) + + result = @transport.send_request(request) + + assert_equal response_body, result + end + + test "send_request includes custom headers in request" do + custom_headers = { "Authorization" => "Bearer token123" } + transport = HTTP.new(url: @url, headers: custom_headers) + + request = { jsonrpc: "2.0", method: "ping", id: 1 } + + stub_request(:post, @url) + .with( + headers: { + "Content-Type" => "application/json", + "Accept" => "application/json", + "Authorization" => "Bearer token123", + }, + ) + .to_return(status: 200, body: '{"jsonrpc":"2.0","id":1,"result":{}}') + + transport.send_request(request) + + # WebMock will verify the headers were sent correctly + end + + test "send_request parses JSON response with symbolized names" do + request = { jsonrpc: "2.0", method: "ping", id: 1 } + response_body = { + "jsonrpc" => "2.0", + "id" => 1, + "result" => { + "serverInfo" => { "name" => "test_server" }, + "capabilities" => { "tools" => {} }, + }, + } + + stub_request(:post, @url) + .to_return( + status: 200, + body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" }, + ) + + result = @transport.send_request(request) + + # Should have symbolized keys + assert_equal "2.0", result[:jsonrpc] + assert_equal 1, result[:id] + assert_equal "test_server", result[:result][:serverInfo][:name] + assert_equal({}, result[:result][:capabilities][:tools]) + end + + test "send_request raises HTTPError on connection failure" do + stub_request(:post, @url).to_raise(Faraday::ConnectionFailed.new("Connection refused")) + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + assert_includes error.message, "Connection failed" + assert_includes error.message, "Connection refused" + assert_nil error.status_code + assert_nil error.response_body + end + + test "send_request raises HTTPError on timeout" do + stub_request(:post, @url).to_timeout + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + # WebMock's timeout simulation is caught as a connection failure + assert_includes error.message, "Connection failed" + assert_includes error.message, "execution expired" + assert_nil error.status_code + assert_nil error.response_body + end + + test "send_request raises HTTPError on Faraday timeout error" do + stub_request(:post, @url).to_raise(Faraday::TimeoutError.new("Request timed out")) + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + assert_includes error.message, "Request timeout" + assert_includes error.message, "Request timed out" + assert_nil error.status_code + assert_nil error.response_body + end + + test "send_request raises HTTPError on other Faraday errors" do + stub_request(:post, @url).to_raise(Faraday::SSLError.new("SSL verification failed")) + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + assert_includes error.message, "HTTP error" + assert_includes error.message, "SSL verification failed" + end + + test "send_request raises HTTPError on non-success HTTP status" do + stub_request(:post, @url) + .to_return( + status: 500, + body: "Internal Server Error", + headers: { "Content-Type" => "text/plain" }, + ) + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + assert_includes error.message, "HTTP 500" + assert_equal 500, error.status_code + assert_equal "Internal Server Error", error.response_body + end + + test "send_request raises HTTPError on 404 status" do + stub_request(:post, @url) + .to_return( + status: 404, + body: "Not Found", + headers: { "Content-Type" => "text/plain" }, + ) + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + assert_includes error.message, "HTTP 404" + assert_equal 404, error.status_code + assert_equal "Not Found", error.response_body + end + + test "send_request raises HTTPError on invalid JSON response" do + stub_request(:post, @url) + .to_return( + status: 200, + body: "invalid json response", + headers: { "Content-Type" => "application/json" }, + ) + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + assert_includes error.message, "Invalid JSON response" + assert_nil error.status_code + assert_equal "invalid json response", error.response_body + end + + test "send_request raises HTTPError on empty response body" do + stub_request(:post, @url) + .to_return( + status: 200, + body: "", + headers: { "Content-Type" => "application/json" }, + ) + + error = assert_raises(HTTP::HTTPError) do + @transport.send_request({ jsonrpc: "2.0", method: "ping", id: 1 }) + end + + assert_includes error.message, "Invalid JSON response" + assert_equal "", error.response_body + end + + test "send_request handles complex request objects" do + complex_request = { + jsonrpc: "2.0", + method: "tools/call", + id: "complex-123", + params: { + name: "test_tool", + arguments: { + message: "Hello, world!", + options: { + format: "json", + nested: { + array: [1, 2, 3], + boolean: true, + null_value: nil, + }, + }, + }, + }, + } + + response_body = { + jsonrpc: "2.0", + id: "complex-123", + result: { + content: [ + { + type: "text", + text: "Tool executed successfully", + }, + ], + isError: false, + }, + } + + stub_request(:post, @url) + .with(body: JSON.generate(complex_request)) + .to_return( + status: 200, + body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" }, + ) + + result = @transport.send_request(complex_request) + + assert_equal "complex-123", result[:id] + assert_equal "Tool executed successfully", result[:result][:content][0][:text] + assert_equal false, result[:result][:isError] + end + + test "send_request handles unicode characters" do + request = { + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "unicode_test", + arguments: { + message: "Hello 世界! 🌍 Café naïve résumé", + }, + }, + } + + response_body = { + jsonrpc: "2.0", + id: 1, + result: { + content: [{ type: "text", text: "Processed: Hello 世界! 🌍 Café naïve résumé" }], + }, + } + + stub_request(:post, @url) + .with(body: JSON.generate(request)) + .to_return( + status: 200, + body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json; charset=utf-8" }, + ) + + result = @transport.send_request(request) + + assert_equal "Processed: Hello 世界! 🌍 Café naïve résumé", result[:result][:content][0][:text] + end + + test "HTTPError stores all error information" do + error = HTTP::HTTPError.new( + "Test error message", + status_code: 422, + response_body: '{"error": "validation failed"}', + ) + + assert_equal "Test error message", error.message + assert_equal 422, error.status_code + assert_equal '{"error": "validation failed"}', error.response_body + end + + test "HTTPError works with minimal information" do + error = HTTP::HTTPError.new("Simple error") + + assert_equal "Simple error", error.message + assert_nil error.status_code + assert_nil error.response_body + end + + test "build_connection configures Faraday correctly" do + transport = HTTP.new(url: @url, timeout: 45) + + # Access the private connection to verify configuration + connection = transport.instance_variable_get(:@connection) + + assert_equal @url, connection.url_prefix.to_s + assert_equal 45, connection.options.timeout + assert_equal 45, connection.options.open_timeout + end + + test "default_headers returns correct headers" do + transport = HTTP.new(url: @url) + + # Access private method for testing + default_headers = transport.send(:default_headers) + + expected_headers = { + "Content-Type" => "application/json", + "Accept" => "application/json", + } + + assert_equal expected_headers, default_headers + end + + test "handles response with different content types" do + request = { jsonrpc: "2.0", method: "ping", id: 1 } + response_body = { jsonrpc: "2.0", id: 1, result: {} } + + # Test with different content type headers + ["application/json", "application/json; charset=utf-8", "application/vnd.api+json"].each do |content_type| + stub_request(:post, @url) + .to_return( + status: 200, + body: JSON.generate(response_body), + headers: { "Content-Type" => content_type }, + ) + + result = @transport.send_request(request) + assert_equal response_body, result + + # Clear stubs for next iteration + WebMock.reset! + end + end + + test "handles large response bodies" do + request = { jsonrpc: "2.0", method: "ping", id: 1 } + + # Create a large response + large_data = "x" * 10000 # 10KB of data + response_body = { + jsonrpc: "2.0", + id: 1, + result: { + data: large_data, + size: large_data.length, + }, + } + + stub_request(:post, @url) + .to_return( + status: 200, + body: JSON.generate(response_body), + headers: { "Content-Type" => "application/json" }, + ) + + result = @transport.send_request(request) + + assert_equal large_data, result[:result][:data] + assert_equal 10000, result[:result][:size] + end + + test "preserves request ID types" do + # Test with string ID + string_request = { jsonrpc: "2.0", method: "ping", id: "string-id-123" } + stub_request(:post, @url) + .to_return(status: 200, body: '{"jsonrpc":"2.0","id":"string-id-123","result":{}}') + + result = @transport.send_request(string_request) + assert_equal "string-id-123", result[:id] + + WebMock.reset! + + # Test with numeric ID + numeric_request = { jsonrpc: "2.0", method: "ping", id: 42 } + stub_request(:post, @url) + .to_return(status: 200, body: '{"jsonrpc":"2.0","id":42,"result":{}}') + + result = @transport.send_request(numeric_request) + assert_equal 42, result[:id] + end + end + end + end +end diff --git a/test/mcp/client/transports/stdio_test.rb b/test/mcp/client/transports/stdio_test.rb new file mode 100644 index 00000000..46b40ee0 --- /dev/null +++ b/test/mcp/client/transports/stdio_test.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/transports/stdio" +require "json" + +module MCP + class Client + module Transports + class StdioTest < ActiveSupport::TestCase + setup do + @transport = Stdio.new(timeout: 1) + end + + test "initializes with default timeout" do + transport = Stdio.new + assert_equal 30, transport.timeout + end + + test "initializes with custom timeout" do + transport = Stdio.new(timeout: 10) + assert_equal 10, transport.timeout + end + + test "sends request and receives response successfully" do + request = { + jsonrpc: "2.0", + method: "ping", + id: 1, + } + + response = { + jsonrpc: "2.0", + id: 1, + result: {}, + } + + # Mock stdin and stdout + mock_stdin = StringIO.new(JSON.generate(response) + "\n") + mock_stdout = StringIO.new + + @transport.instance_variable_set(:@stdin, mock_stdin) + @transport.instance_variable_set(:@stdout, mock_stdout) + + result = @transport.send_request(request) + + # Verify request was sent to stdout + assert_equal JSON.generate(request) + "\n", mock_stdout.string + + # Verify response was parsed correctly + assert_equal response, result + end + + test "raises error on timeout" do + request = { jsonrpc: "2.0", method: "ping", id: 1 } + + # Mock stdin that never returns data + mock_stdin = StringIO.new("") + mock_stdout = StringIO.new + + @transport.instance_variable_set(:@stdin, mock_stdin) + @transport.instance_variable_set(:@stdout, mock_stdout) + + error = assert_raises(Stdio::StdioError) do + @transport.send_request(request) + end + assert_match(/No response received within 1 seconds/, error.message) + end + + test "raises error on invalid JSON response" do + request = { jsonrpc: "2.0", method: "ping", id: 1 } + + # Mock stdin with invalid JSON + mock_stdin = StringIO.new("invalid json\n") + mock_stdout = StringIO.new + + @transport.instance_variable_set(:@stdin, mock_stdin) + @transport.instance_variable_set(:@stdout, mock_stdout) + + error = assert_raises(Stdio::StdioError) do + @transport.send_request(request) + end + assert_match(/Invalid JSON response/, error.message) + assert_instance_of JSON::ParserError, error.original_error + end + + test "raises error on broken pipe" do + request = { jsonrpc: "2.0", method: "ping", id: 1 } + + mock_stdout = StringIO.new + @transport.instance_variable_set(:@stdout, mock_stdout) + + # Mock puts to raise EPIPE + mock_stdout.define_singleton_method(:puts) do |*args| + raise Errno::EPIPE.new("Broken pipe") + end + + error = assert_raises(Stdio::StdioError) do + @transport.send_request(request) + end + assert_match(/Broken pipe/, error.message) + assert_instance_of Errno::EPIPE, error.original_error + end + + test "raises error on IO error" do + request = { jsonrpc: "2.0", method: "ping", id: 1 } + + mock_stdout = StringIO.new + @transport.instance_variable_set(:@stdout, mock_stdout) + + # Mock puts to raise IOError + mock_stdout.define_singleton_method(:puts) do |*args| + raise IOError.new("IO error") + end + + error = assert_raises(Stdio::StdioError) do + @transport.send_request(request) + end + assert_match(/IO error/, error.message) + assert_instance_of IOError, error.original_error + end + end + end + end +end diff --git a/test/mcp/client_test.rb b/test/mcp/client_test.rb new file mode 100644 index 00000000..3cbd499d --- /dev/null +++ b/test/mcp/client_test.rb @@ -0,0 +1,719 @@ +# frozen_string_literal: true + +require "test_helper" +require "json" +require "webmock/minitest" + +module MCP + class ClientTest < ActiveSupport::TestCase + setup do + @server_url = "http://localhost:3000/mcp" + @http_transport = Client::Transports::HTTP.new(url: @server_url) + @client = Client.new(transport: @http_transport) + end + + test "initialize_session sends initialize request and stores server info" do + server_response = { + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: "2025-03-26", + capabilities: { tools: {}, prompts: {}, resources: {} }, + serverInfo: { name: "test_server", version: "1.0.0" }, + }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "initialize", + id: 1, + params: hash_including( + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test_client", version: "1.0.0" }, + ), + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.initialize_session(client_info: { name: "test_client", version: "1.0.0" }) + + assert_equal "2025-03-26", result[:protocolVersion] + assert_equal "test_server", @client.server_info[:name] + assert_equal "1.0.0", @client.server_info[:version] + assert_not_nil @client.capabilities + end + + test "ping sends ping request and returns empty result" do + server_response = { + jsonrpc: "2.0", + id: 1, + result: {}, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "ping", + id: 1, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.ping + assert_equal({}, result) + end + + test "list_tools sends tools/list request and returns tools array" do + tools = [ + { name: "test_tool", description: "Test tool", inputSchema: {} }, + ] + + server_response = { + jsonrpc: "2.0", + id: 1, + result: { tools: tools }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "tools/list", + id: 1, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.list_tools + assert_equal({ tools: tools }, result) + end + + test "call_tool sends tools/call request with name and arguments" do + tool_response = { content: "Tool executed successfully", isError: false } + + server_response = { + jsonrpc: "2.0", + id: 1, + result: tool_response, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { + name: "test_tool", + arguments: { message: "hello" }, + }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.call_tool(name: "test_tool", arguments: { message: "hello" }) + assert_equal tool_response, result + end + + test "list_prompts sends prompts/list request and returns prompts array" do + prompts = [ + { name: "test_prompt", description: "Test prompt", arguments: [] }, + ] + + server_response = { + jsonrpc: "2.0", + id: 1, + result: { prompts: prompts }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "prompts/list", + id: 1, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.list_prompts + assert_equal({ prompts: prompts }, result) + end + + test "get_prompt sends prompts/get request with name and arguments" do + prompt_result = { + description: "Test prompt result", + messages: [{ role: "user", content: { text: "Hello, world!", type: "text" } }], + } + + server_response = { + jsonrpc: "2.0", + id: 1, + result: prompt_result, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "prompts/get", + id: 1, + params: { + name: "test_prompt", + arguments: { name: "World" }, + }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.get_prompt(name: "test_prompt", arguments: { name: "World" }) + assert_equal prompt_result, result + end + + test "list_resources sends resources/list request and returns resources array" do + resources = [ + { uri: "test_resource", name: "Test resource", description: "Test resource" }, + ] + + server_response = { + jsonrpc: "2.0", + id: 1, + result: { resources: resources }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "resources/list", + id: 1, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.list_resources + assert_equal({ resources: resources }, result) + end + + test "read_resource sends resources/read request with uri" do + contents = [ + { uri: "test_resource", mimeType: "text/plain", text: "Resource content" }, + ] + + server_response = { + jsonrpc: "2.0", + id: 1, + result: { contents: contents }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "resources/read", + id: 1, + params: { uri: "test_resource" }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.read_resource(uri: "test_resource") + assert_equal({ contents: contents }, result) + end + + test "raises ClientError when server returns error response" do + error_response = { + jsonrpc: "2.0", + id: 1, + error: { + code: -32601, + message: "Method not found", + data: "unknown_method", + }, + } + + stub_request(:post, @server_url) + .to_return( + status: 200, + body: JSON.generate(error_response), + headers: { "Content-Type" => "application/json" }, + ) + + error = assert_raises(Client::ClientError) do + @client.ping + end + + assert_includes error.message, "Method not found" + end + + test "HTTP transport raises HTTPError on connection failure" do + stub_request(:post, @server_url).to_raise(Faraday::ConnectionFailed.new("Connection refused")) + + error = assert_raises(Client::Transports::HTTP::HTTPError) do + @client.ping + end + + assert_includes error.message, "Connection failed" + end + + test "HTTP transport raises HTTPError on timeout" do + stub_request(:post, @server_url).to_timeout + + error = assert_raises(Client::Transports::HTTP::HTTPError) do + @client.ping + end + + assert_includes error.message, "Connection failed" + end + + test "HTTP transport raises HTTPError on non-200 status" do + stub_request(:post, @server_url) + .to_return(status: 500, body: "Internal Server Error") + + error = assert_raises(Client::Transports::HTTP::HTTPError) do + @client.ping + end + + assert_includes error.message, "HTTP 500" + assert_equal 500, error.status_code + end + + test "HTTP transport raises HTTPError on invalid JSON response" do + stub_request(:post, @server_url) + .to_return( + status: 200, + body: "invalid json", + headers: { "Content-Type" => "application/json" }, + ) + + error = assert_raises(Client::Transports::HTTP::HTTPError) do + @client.ping + end + + assert_includes error.message, "Invalid JSON response" + end + + test "HTTP transport can be configured with custom headers and timeout" do + custom_headers = { "Authorization" => "Bearer token123" } + custom_timeout = 60 + + transport = Client::Transports::HTTP.new( + url: @server_url, + timeout: custom_timeout, + headers: custom_headers, + ) + + assert_equal @server_url, transport.url + assert_equal custom_timeout, transport.timeout + assert_includes transport.headers, "Authorization" + assert_equal "Bearer token123", transport.headers["Authorization"] + end + + test "client methods accept custom request_id parameter" do + custom_id = "custom-123" + + server_response = { + jsonrpc: "2.0", + id: custom_id, + result: {}, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "ping", + id: custom_id, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.ping(request_id: custom_id) + assert_equal({}, result) + end + + test "client methods use auto-incrementing IDs when request_id is not provided" do + server_response = { + jsonrpc: "2.0", + id: 1, + result: {}, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "ping", + id: 1, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.ping + assert_equal({}, result) + end + + test "initialize_session accepts custom request_id" do + custom_id = "init-456" + + server_response = { + jsonrpc: "2.0", + id: custom_id, + result: { + protocolVersion: "2025-03-26", + capabilities: { tools: {}, prompts: {}, resources: {} }, + serverInfo: { name: "test_server", version: "1.0.0" }, + }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "initialize", + id: custom_id, + params: hash_including( + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test_client", version: "1.0.0" }, + ), + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.initialize_session( + client_info: { name: "test_client", version: "1.0.0" }, + request_id: custom_id, + ) + + assert_equal "2025-03-26", result[:protocolVersion] + end + + test "call_tool accepts custom request_id" do + custom_id = "tool-789" + tool_response = { content: "Tool executed successfully", isError: false } + + server_response = { + jsonrpc: "2.0", + id: custom_id, + result: tool_response, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "tools/call", + id: custom_id, + params: { + name: "test_tool", + arguments: { message: "hello" }, + }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.call_tool( + name: "test_tool", + arguments: { message: "hello" }, + request_id: custom_id, + ) + assert_equal tool_response, result + end + + test "list_tools accepts custom request_id" do + custom_id = "list-tools-123" + tools = [{ name: "test_tool", description: "Test tool", inputSchema: {} }] + + server_response = { + jsonrpc: "2.0", + id: custom_id, + result: { tools: tools }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "tools/list", + id: custom_id, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.list_tools(request_id: custom_id) + assert_equal({ tools: tools }, result) + end + + test "get_prompt accepts custom request_id" do + custom_id = "prompt-456" + prompt_result = { + description: "Test prompt result", + messages: [{ role: "user", content: { text: "Hello, world!", type: "text" } }], + } + + server_response = { + jsonrpc: "2.0", + id: custom_id, + result: prompt_result, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "prompts/get", + id: custom_id, + params: { + name: "test_prompt", + arguments: { name: "World" }, + }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.get_prompt( + name: "test_prompt", + arguments: { name: "World" }, + request_id: custom_id, + ) + assert_equal prompt_result, result + end + + test "read_resource accepts custom request_id" do + custom_id = "resource-789" + contents = [{ uri: "test_resource", mimeType: "text/plain", text: "Resource content" }] + + server_response = { + jsonrpc: "2.0", + id: custom_id, + result: { contents: contents }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "resources/read", + id: custom_id, + params: { uri: "test_resource" }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.read_resource(uri: "test_resource", request_id: custom_id) + assert_equal({ contents: contents }, result) + end + + test "list_tools supports pagination with cursor" do + tools = [{ name: "test_tool", description: "Test tool", inputSchema: {} }] + cursor = "next-page-token" + + server_response = { + jsonrpc: "2.0", + id: 1, + result: { tools: tools, nextCursor: "next-token" }, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "tools/list", + id: 1, + params: { cursor: cursor }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.list_tools(cursor: cursor) + assert_equal({ tools: tools, nextCursor: "next-token" }, result) + end + + test "subscribe_resource sends resources/subscribe request" do + server_response = { + jsonrpc: "2.0", + id: 1, + result: {}, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "resources/subscribe", + id: 1, + params: { uri: "test_resource" }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.subscribe_resource(uri: "test_resource") + assert_equal({}, result) + end + + test "unsubscribe_resource sends resources/unsubscribe request" do + server_response = { + jsonrpc: "2.0", + id: 1, + result: {}, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "resources/unsubscribe", + id: 1, + params: { uri: "test_resource" }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.unsubscribe_resource(uri: "test_resource") + assert_equal({}, result) + end + + test "set_logging_level sends logging/setLevel request" do + server_response = { + jsonrpc: "2.0", + id: 1, + result: {}, + } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "logging/setLevel", + id: 1, + params: { level: "info" }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.set_logging_level(level: "info") + assert_equal({}, result) + end + + test "complete sends completion/complete request" do + completion_result = { + completion: { + values: ["option1", "option2"], + total: 2, + hasMore: false, + }, + } + + server_response = { + jsonrpc: "2.0", + id: 1, + result: completion_result, + } + + ref = { type: "ref/prompt", name: "test_prompt" } + argument = { name: "arg1", value: "partial" } + + stub_request(:post, @server_url) + .with( + body: hash_including( + jsonrpc: "2.0", + method: "completion/complete", + id: 1, + params: { + ref: ref, + argument: argument, + }, + ), + ) + .to_return( + status: 200, + body: JSON.generate(server_response), + headers: { "Content-Type" => "application/json" }, + ) + + result = @client.complete(ref: ref, argument: argument) + assert_equal(completion_result, result) + end + end +end diff --git a/test/model_context_protocol/configuration_test.rb b/test/model_context_protocol/configuration_test.rb index e71efd44..71a1c278 100644 --- a/test/model_context_protocol/configuration_test.rb +++ b/test/model_context_protocol/configuration_test.rb @@ -2,7 +2,7 @@ require "test_helper" -module ModelContextProtocol +module MCP class ConfigurationTest < ActiveSupport::TestCase test "initializes with a default no-op exception reporter" do config = Configuration.new diff --git a/test/model_context_protocol/instrumentation_test.rb b/test/model_context_protocol/instrumentation_test.rb index 7a4f9337..e1ecf519 100644 --- a/test/model_context_protocol/instrumentation_test.rb +++ b/test/model_context_protocol/instrumentation_test.rb @@ -2,14 +2,14 @@ require "test_helper" -module ModelContextProtocol +module MCP class InstrumentationTest < ActiveSupport::TestCase class Subject include Instrumentation attr_reader :instrumentation_data_received, :configuration def initialize - @configuration = ModelContextProtocol::Configuration.new + @configuration = MCP::Configuration.new @configuration.instrumentation_callback = ->(data) { @instrumentation_data_received = data } end diff --git a/test/model_context_protocol/methods_test.rb b/test/model_context_protocol/methods_test.rb index e8ff5222..02d754c7 100644 --- a/test/model_context_protocol/methods_test.rb +++ b/test/model_context_protocol/methods_test.rb @@ -3,7 +3,7 @@ require "test_helper" -module ModelContextProtocol +module MCP class MethodsTest < ActiveSupport::TestCase test "ensure_capability! for tools/list method raises an error if tools capability is not present" do error = assert_raises(Methods::MissingRequiredCapabilityError) do diff --git a/test/model_context_protocol/prompt_test.rb b/test/model_context_protocol/prompt_test.rb index 151875cf..b93395fd 100644 --- a/test/model_context_protocol/prompt_test.rb +++ b/test/model_context_protocol/prompt_test.rb @@ -3,7 +3,7 @@ require "test_helper" -module ModelContextProtocol +module MCP class PromptTest < ActiveSupport::TestCase class TestPrompt < Prompt description "Test prompt" diff --git a/test/model_context_protocol/server_test.rb b/test/model_context_protocol/server_test.rb index ae527e27..5ff19600 100644 --- a/test/model_context_protocol/server_test.rb +++ b/test/model_context_protocol/server_test.rb @@ -4,7 +4,7 @@ require "json" require "debug" -module ModelContextProtocol +module MCP class ServerTest < ActiveSupport::TestCase include InstrumentationTestHelper setup do @@ -46,7 +46,7 @@ class ServerTest < ActiveSupport::TestCase ) @server_name = "test_server" - configuration = ModelContextProtocol::Configuration.new + configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback @server = Server.new( @@ -60,7 +60,7 @@ class ServerTest < ActiveSupport::TestCase ) end - # https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/utilities/ping/#behavior-requirements + # https://spec.MCP.io/specification/2025-03-26/basic/utilities/ping/#behavior-requirements test "#handle ping request returns empty response" do request = { jsonrpc: "2.0", @@ -617,14 +617,14 @@ class ServerTest < ActiveSupport::TestCase test "the global configuration is used if no configuration is passed to the server" do server = Server.new(name: "test_server") - assert_equal ModelContextProtocol.configuration.instrumentation_callback, + assert_equal MCP.configuration.instrumentation_callback, server.configuration.instrumentation_callback - assert_equal ModelContextProtocol.configuration.exception_reporter, + assert_equal MCP.configuration.exception_reporter, server.configuration.exception_reporter end test "the server configuration takes precedence over the global configuration" do - configuration = ModelContextProtocol::Configuration.new + configuration = MCP::Configuration.new local_callback = ->(data) { puts "Local callback #{data.inspect}" } local_exception_reporter = ->(exception, server_context) { puts "Local exception reporter #{exception.inspect} #{server_context.inspect}" diff --git a/test/model_context_protocol/string_utils_test.rb b/test/model_context_protocol/string_utils_test.rb index b43d1ee0..6613e891 100644 --- a/test/model_context_protocol/string_utils_test.rb +++ b/test/model_context_protocol/string_utils_test.rb @@ -2,7 +2,7 @@ require "test_helper" -module ModelContextProtocol +module MCP class StringUtilsTest < Minitest::Test def test_handle_from_class_name_returns_the_class_name_without_the_module_for_a_class_without_a_module assert_equal("test", StringUtils.handle_from_class_name("Test")) diff --git a/test/model_context_protocol/tool/input_schema_test.rb b/test/model_context_protocol/tool/input_schema_test.rb index c28bbcf2..1ded7afe 100644 --- a/test/model_context_protocol/tool/input_schema_test.rb +++ b/test/model_context_protocol/tool/input_schema_test.rb @@ -2,7 +2,7 @@ require "test_helper" -module ModelContextProtocol +module MCP class Tool class InputSchemaTest < ActiveSupport::TestCase test "required arguments are converted to symbols" do diff --git a/test/model_context_protocol/tool_test.rb b/test/model_context_protocol/tool_test.rb index b1378322..28eb4d8c 100644 --- a/test/model_context_protocol/tool_test.rb +++ b/test/model_context_protocol/tool_test.rb @@ -2,7 +2,7 @@ require "test_helper" -module ModelContextProtocol +module MCP class ToolTest < ActiveSupport::TestCase class TestTool < Tool tool_name "test_tool" diff --git a/test/model_context_protocol/transports/stdio_transport_test.rb b/test/model_context_protocol/transports/stdio_transport_test.rb index 498b0ffa..42c76cfe 100644 --- a/test/model_context_protocol/transports/stdio_transport_test.rb +++ b/test/model_context_protocol/transports/stdio_transport_test.rb @@ -1,16 +1,16 @@ # frozen_string_literal: true require "test_helper" -require "model_context_protocol/transports/stdio" +require "mcp/transports/stdio" require "json" -module ModelContextProtocol +module MCP module Transports class StdioTransportTest < ActiveSupport::TestCase include InstrumentationTestHelper setup do - configuration = ModelContextProtocol::Configuration.new + configuration = MCP::Configuration.new configuration.instrumentation_callback = instrumentation_helper.callback @server = Server.new(name: "test_server", configuration: configuration) @transport = StdioTransport.new(@server) diff --git a/test/test_helper.rb b/test/test_helper.rb index e6784706..7db4ce1d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,7 +3,7 @@ ENV["RAILS_ENV"] ||= "test" require "bundler/setup" -require "model_context_protocol" +require "mcp" require "minitest/autorun" require "minitest/reporters"