Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions lib/mcp/stdio_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 16 additions & 3 deletions spec/raix/mcp/stdio_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,22 @@

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
expect(File.exist?(test_server_path)).to be true
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
Expand Down Expand Up @@ -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, {})
Expand Down
37 changes: 37 additions & 0 deletions spec/support/mcp_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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")
Expand Down
Loading