diff --git a/lib/mcp/stdio_client.rb b/lib/mcp/stdio_client.rb index 586dcac..be36dd5 100644 --- a/lib/mcp/stdio_client.rb +++ b/lib/mcp/stdio_client.rb @@ -2,15 +2,22 @@ require "json" require "securerandom" require "digest" +require "raix/version" module Raix module MCP # Client for communicating with MCP servers via stdio using JSON-RPC. class StdioClient + PROTOCOL_VERSION = "2024-11-05".freeze + JSONRPC_VERSION = "2.0".freeze + # Creates a new client with a bidirectional pipe to the MCP server. def initialize(*args, env) @args = args @io = IO.popen(env, args, "w+") + + # Initialize the MCP session + initialize_mcp_session end # Returns available tools from the server. @@ -64,6 +71,33 @@ def unique_key private + # Initialize the MCP session according to the MCP lifecycle + def initialize_mcp_session + result = call( + "initialize", + protocolVersion: PROTOCOL_VERSION, + capabilities: { + roots: {}, + sampling: {} + }, + clientInfo: { + name: "Raix", + version: Raix::VERSION + } + ) + + # Send initialized notification if the server supports tool list changes + return unless result.dig("capabilities", "tools", "listChanged") + + send_notification("notifications/initialized", {}) + end + + # Sends a notification (no response expected) + def send_notification(method, params = {}) + @io.puts({ method:, params:, jsonrpc: JSONRPC_VERSION }.to_json) + @io.flush + end + # Sends JSON-RPC request and returns the result. def call(method, **params) @io.puts({ id: SecureRandom.uuid, method:, params:, jsonrpc: JSONRPC_VERSION }.to_json) diff --git a/spec/raix/mcp/stdio_client_spec.rb b/spec/raix/mcp/stdio_client_spec.rb index 0be5f48..4f25c91 100644 --- a/spec/raix/mcp/stdio_client_spec.rb +++ b/spec/raix/mcp/stdio_client_spec.rb @@ -4,7 +4,6 @@ RSpec.describe Raix::MCP::StdioClient do let(:test_server_path) { File.join(__dir__, "../../support/mcp_server.rb") } - let(:client) { described_class.new("ruby", test_server_path, {}) } before do # Ensure the test server exists @@ -12,7 +11,15 @@ end after do - client&.close + # Only close if client was created and we're not in a mocked test + if instance_variable_defined?(:@client) + @client&.close + end + end + + # Helper method to create and track the client + def client + @client ||= described_class.new("ruby", test_server_path, {}) end describe "#initialize" do @@ -147,7 +154,13 @@ allow(IO).to receive(:popen).and_return(io_mock) allow(io_mock).to receive(:puts) allow(io_mock).to receive(:flush) - allow(io_mock).to receive(:gets).and_return('{"jsonrpc":"2.0","id":"test","error":{"code":-32601,"message":"Method not found"}}') + + # First return success for initialization, then error for tools/list + allow(io_mock).to receive(:gets).and_return( + '{"jsonrpc":"2.0","id":"test","result":{"capabilities":{}}}', + '{"jsonrpc":"2.0","id":"test","error":{"code":-32601,"message":"Method not found"}}' + ) + allow(io_mock).to receive(:close) test_client = described_class.new("ruby", test_server_path, {}) diff --git a/spec/support/mcp_server.rb b/spec/support/mcp_server.rb index e4967d5..fafd3c6 100644 --- a/spec/support/mcp_server.rb +++ b/spec/support/mcp_server.rb @@ -10,6 +10,7 @@ class TestMCPServer def initialize $stdout.sync = true # Enable auto-flushing for immediate output @tools = build_tools + @initialized = false end def run @@ -49,7 +50,17 @@ def handle_request(request) params = request["params"] || {} id = request["id"] + # Check if server is initialized for non-initialization methods + if !@initialized && method != "initialize" && method != "notifications/initialized" + return create_error_response(id, -32_002, "Server not initialized. Please call 'initialize' first.") + end + case method + when "initialize" + handle_initialize(id, params) + when "notifications/initialized" + # Notifications don't require a response + nil when "tools/list" handle_tools_list(id) when "tools/call" @@ -59,6 +70,32 @@ def handle_request(request) end end + def handle_initialize(id, params) + # Validate protocol version + protocol_version = params["protocolVersion"] + unless protocol_version == "2024-11-05" + return create_error_response(id, -32_602, "Unsupported protocol version: #{protocol_version}") + end + + # Mark server as initialized + @initialized = true + + create_response(id:, result: { + protocolVersion: "2024-11-05", + capabilities: { + tools: { + listChanged: true + }, + prompts: {}, + resources: {} + }, + serverInfo: { + name: "Test MCP Server", + version: "1.0.0" + } + }) + end + def handle_tools_list(id) tools_without_handlers = @tools.values.map do |tool| tool.except("handler")