ruby-mcp-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0fd821eaf2b01a8628e7d54f2b39826b3d980d9e0d183ac0d81d5fb2ae135a36
4
+ data.tar.gz: 0d2afee24c1ebbb49f597056b9257546256e3152a4d789b7bc2789b3afc40a4f
5
+ SHA512:
6
+ metadata.gz: b21d59cb475b4202442658dd81b3ee394f317b767fb0a1bacba3c575dc96ac4004a0849a4fc59b2d7481c75ae55181cca21f11a5ebbe2d15179eeb2a30aba242
7
+ data.tar.gz: 6a06a38dd0066331efcd1ec3a1eb5d6178dd12091a7f217199a314c891aef4b0100d3d7da9d12e3f69ac01da42d3eee60135f68ac43bf79cebf14f9fd43308e4
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Szymon Kurcab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # ruby-mcp-client
2
+
3
+ This gem provides a Ruby client for the Model Context Protocol (MCP),
4
+ enabling integration with external tools and services via a standardized protocol.
5
+
6
+ ## Installation
7
+
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem 'ruby-mcp-client'
12
+ ```
13
+
14
+ And then execute:
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ Or install it yourself as:
21
+
22
+ ```bash
23
+ gem install ruby-mcp-client
24
+ ```
25
+
26
+ ## Overview
27
+
28
+ MCP enables AI assistants and other services to discover and invoke external tools
29
+ via different transport mechanisms:
30
+
31
+ - **Standard I/O**: Local processes implementing the MCP protocol
32
+ - **Server-Sent Events (SSE)**: Remote MCP servers over HTTP
33
+
34
+ The core client resides in `MCPClient::Client` and provides helper methods for integrating
35
+ with popular AI services with built-in conversions:
36
+
37
+ - `to_openai_tools()` - Formats tools for OpenAI API
38
+ - `to_anthropic_tools()` - Formats tools for Anthropic Claude API
39
+
40
+ ## Usage
41
+
42
+ ### Basic Client Usage
43
+
44
+ ```ruby
45
+ require 'mcp_client'
46
+
47
+ client = MCPClient.create_client(
48
+ mcp_server_configs: [
49
+ # Local stdio server
50
+ MCPClient.stdio_config(command: 'npx -y @modelcontextprotocol/server-filesystem /home/user'),
51
+ # Remote HTTP SSE server
52
+ MCPClient.sse_config(
53
+ base_url: 'https://api.example.com/sse',
54
+ headers: { 'Authorization' => 'Bearer YOUR_TOKEN' },
55
+ read_timeout: 30 # Optional timeout in seconds (default: 30)
56
+ )
57
+ ]
58
+ )
59
+
60
+ # List available tools
61
+ tools = client.list_tools
62
+
63
+ # Call a specific tool by name
64
+ result = client.call_tool('example_tool', { param1: 'value1', param2: 42 })
65
+
66
+ # Format tools for specific AI services
67
+ openai_tools = client.to_openai_tools
68
+ anthropic_tools = client.to_anthropic_tools
69
+
70
+ # Clean up connections
71
+ client.cleanup
72
+ ```
73
+
74
+ ### Integration Examples
75
+
76
+ The repository includes examples for integrating with popular AI APIs:
77
+
78
+ #### OpenAI Integration
79
+
80
+ Ruby-MCP-Client works with both official and community OpenAI gems:
81
+
82
+ ```ruby
83
+ # Using the openai/openai-ruby gem (official)
84
+ require 'mcp_client'
85
+ require 'openai'
86
+
87
+ # Create MCP client
88
+ mcp_client = MCPClient.create_client(
89
+ mcp_server_configs: [
90
+ MCPClient.stdio_config(
91
+ command: %W[npx -y @modelcontextprotocol/server-filesystem #{Dir.pwd}]
92
+ )
93
+ ]
94
+ )
95
+
96
+ # Convert tools to OpenAI format
97
+ tools = mcp_client.to_openai_tools
98
+
99
+ # Use with OpenAI client
100
+ client = OpenAI::Client.new(api_key: ENV['OPENAI_API_KEY'])
101
+ response = client.chat.completions.create(
102
+ model: 'gpt-4',
103
+ messages: [
104
+ { role: 'user', content: 'List files in current directory' }
105
+ ],
106
+ tools: tools
107
+ )
108
+
109
+ # Process tool calls and results
110
+ # See examples directory for complete implementation
111
+ ```
112
+
113
+ ```ruby
114
+ # Using the alexrudall/ruby-openai gem (community)
115
+ require 'mcp_client'
116
+ require 'openai'
117
+
118
+ # Create MCP client
119
+ mcp_client = MCPClient.create_client(
120
+ mcp_server_configs: [
121
+ MCPClient.stdio_config(command: 'npx @playwright/mcp@latest')
122
+ ]
123
+ )
124
+
125
+ # Convert tools to OpenAI format
126
+ tools = mcp_client.to_openai_tools
127
+
128
+ # Use with Ruby-OpenAI client
129
+ client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
130
+ # See examples directory for complete implementation
131
+ ```
132
+
133
+ #### Anthropic Integration
134
+
135
+ ```ruby
136
+ require 'mcp_client'
137
+ require 'anthropic'
138
+
139
+ # Create MCP client
140
+ mcp_client = MCPClient.create_client(
141
+ mcp_server_configs: [
142
+ MCPClient.stdio_config(
143
+ command: %W[npx -y @modelcontextprotocol/server-filesystem #{Dir.pwd}]
144
+ )
145
+ ]
146
+ )
147
+
148
+ # Convert tools to Anthropic format
149
+ claude_tools = mcp_client.to_anthropic_tools
150
+
151
+ # Use with Anthropic client
152
+ client = Anthropic::Client.new(access_token: ENV['ANTHROPIC_API_KEY'])
153
+ # See examples directory for complete implementation
154
+ ```
155
+
156
+ Complete examples can be found in the `examples/` directory:
157
+ - `ruby_openai_mcp.rb` - Integration with alexrudall/ruby-openai gem
158
+ - `openai_ruby_mcp.rb` - Integration with official openai/openai-ruby gem
159
+ - `ruby_anthropic_mcp.rb` - Integration with alexrudall/ruby-anthropic gem
160
+
161
+ ## MCP Server Compatibility
162
+
163
+ This client works with any MCP-compatible server, including:
164
+
165
+ - [@modelcontextprotocol/server-filesystem](https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem) - File system access
166
+ - [@playwright/mcp](https://www.npmjs.com/package/@playwright/mcp) - Browser automation
167
+ - Custom servers implementing the MCP protocol
168
+
169
+ ### Server-Sent Events (SSE) Implementation
170
+
171
+ The SSE client implementation provides these key features:
172
+
173
+ - **Robust connection handling**: Properly manages HTTP/HTTPS connections with configurable timeouts
174
+ - **Thread safety**: All operations are thread-safe using monitors and synchronized access
175
+ - **Reliable error handling**: Comprehensive error handling for network issues, timeouts, and malformed responses
176
+ - **JSON-RPC over SSE**: Full implementation of JSON-RPC 2.0 over SSE transport
177
+
178
+ ## Requirements
179
+
180
+ - Ruby >= 2.7.0
181
+ - No runtime dependencies
182
+
183
+ ## Implementing an MCP Server
184
+
185
+ To implement a compatible MCP server you must:
186
+
187
+ - Listen on your chosen transport (JSON-RPC stdio, or HTTP SSE)
188
+ - Respond to `list_tools` requests with a JSON list of tools
189
+ - Respond to `call_tool` requests by executing the specified tool
190
+ - Return results (or errors) in JSON format
191
+
192
+ ## Tool Schema
193
+
194
+ Each tool is defined by a name, description, and a JSON Schema for its parameters:
195
+
196
+ ```json
197
+ {
198
+ "name": "example_tool",
199
+ "description": "Does something useful",
200
+ "schema": {
201
+ "type": "object",
202
+ "properties": {
203
+ "param1": { "type": "string" },
204
+ "param2": { "type": "number" }
205
+ },
206
+ "required": ["param1"]
207
+ }
208
+ }
209
+ ```
210
+
211
+ ## License
212
+
213
+ This gem is available as open source under the [MIT License](LICENSE).
214
+
215
+ ## Contributing
216
+
217
+ Bug reports and pull requests are welcome on GitHub at
218
+ https://github.com/simonx1/ruby-mcp-client.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # MCP Client for integrating with the Model Context Protocol
5
+ # This is the main entry point for using MCP tools
6
+ class Client
7
+ attr_reader :servers, :tool_cache
8
+
9
+ def initialize(mcp_server_configs: [])
10
+ @servers = mcp_server_configs.map { |config| MCPClient::ServerFactory.create(config) }
11
+ @tool_cache = {}
12
+ end
13
+
14
+ # Lists all available tools from all connected MCP servers
15
+ # @param cache [Boolean] whether to use cached tools or fetch fresh
16
+ # @return [Array<MCPClient::Tool>] list of available tools
17
+ def list_tools(cache: true)
18
+ return @tool_cache.values if cache && !@tool_cache.empty?
19
+
20
+ tools = []
21
+ servers.each do |server|
22
+ server.list_tools.each do |tool|
23
+ @tool_cache[tool.name] = tool
24
+ tools << tool
25
+ end
26
+ end
27
+
28
+ tools
29
+ end
30
+
31
+ # Calls a specific tool by name with the given parameters
32
+ # @param tool_name [String] the name of the tool to call
33
+ # @param parameters [Hash] the parameters to pass to the tool
34
+ # @return [Object] the result of the tool invocation
35
+ def call_tool(tool_name, parameters)
36
+ tools = list_tools
37
+ tool = tools.find { |t| t.name == tool_name }
38
+
39
+ raise MCPClient::Errors::ToolNotFound, "Tool '#{tool_name}' not found" unless tool
40
+
41
+ # Find the server that owns this tool
42
+ server = find_server_for_tool(tool)
43
+ raise MCPClient::Errors::ServerNotFound, "No server found for tool '#{tool_name}'" unless server
44
+
45
+ server.call_tool(tool_name, parameters)
46
+ end
47
+
48
+ # Convert MCP tools to OpenAI function specifications
49
+ # @return [Array<Hash>] OpenAI function specifications
50
+ def to_openai_tools
51
+ list_tools.map(&:to_openai_tool)
52
+ end
53
+
54
+ # Convert MCP tools to Anthropic Claude tool specifications
55
+ # @return [Array<Hash>] Anthropic Claude tool specifications
56
+ def to_anthropic_tools
57
+ list_tools.map(&:to_anthropic_tool)
58
+ end
59
+
60
+ # Clean up all server connections
61
+ def cleanup
62
+ servers.each(&:cleanup)
63
+ end
64
+
65
+ private
66
+
67
+ def find_server_for_tool(tool)
68
+ servers.find do |server|
69
+ server.list_tools.any? { |t| t.name == tool.name }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Collection of error classes used by the MCP client
5
+ module Errors
6
+ # Base error class for all MCP-related errors
7
+ class MCPError < StandardError; end
8
+
9
+ # Raised when a tool is not found
10
+ class ToolNotFound < MCPError; end
11
+
12
+ # Raised when a server is not found
13
+ class ServerNotFound < MCPError; end
14
+
15
+ # Raised when there's an error calling a tool
16
+ class ToolCallError < MCPError; end
17
+
18
+ # Raised when there's a connection error with an MCP server
19
+ class ConnectionError < MCPError; end
20
+
21
+ # Raised when the MCP server returns an error response
22
+ class ServerError < MCPError; end
23
+
24
+ # Raised when there's an error in the MCP server transport
25
+ class TransportError < MCPError; end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Base class for MCP servers - serves as the interface for different server implementations
5
+ class ServerBase
6
+ # Initialize a connection to the MCP server
7
+ # @return [Boolean] true if connection successful
8
+ def connect
9
+ raise NotImplementedError, 'Subclasses must implement connect'
10
+ end
11
+
12
+ # List all tools available from the MCP server
13
+ # @return [Array<MCPClient::Tool>] list of available tools
14
+ def list_tools
15
+ raise NotImplementedError, 'Subclasses must implement list_tools'
16
+ end
17
+
18
+ # Call a tool with the given parameters
19
+ # @param tool_name [String] the name of the tool to call
20
+ # @param parameters [Hash] the parameters to pass to the tool
21
+ # @return [Object] the result of the tool invocation
22
+ def call_tool(tool_name, parameters)
23
+ raise NotImplementedError, 'Subclasses must implement call_tool'
24
+ end
25
+
26
+ # Clean up the server connection
27
+ def cleanup
28
+ raise NotImplementedError, 'Subclasses must implement cleanup'
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Factory for creating MCP server instances based on configuration
5
+ class ServerFactory
6
+ # Create a server instance based on configuration
7
+ # @param config [Hash] server configuration
8
+ # @return [MCPClient::ServerBase] server instance
9
+ def self.create(config)
10
+ case config[:type]
11
+ when 'stdio'
12
+ MCPClient::ServerStdio.new(command: config[:command])
13
+ when 'sse'
14
+ MCPClient::ServerSSE.new(
15
+ base_url: config[:base_url],
16
+ headers: config[:headers] || {},
17
+ read_timeout: config[:read_timeout] || 30
18
+ )
19
+ else
20
+ raise ArgumentError, "Unknown server type: #{config[:type]}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'openssl'
7
+ require 'monitor'
8
+
9
+ module MCPClient
10
+ # Implementation of MCP server that communicates via Server-Sent Events (SSE)
11
+ # Useful for communicating with remote MCP servers over HTTP
12
+ class ServerSSE < ServerBase
13
+ attr_reader :base_url, :tools, :session_id, :http_client
14
+
15
+ # @param base_url [String] The base URL of the MCP server
16
+ # @param headers [Hash] Additional headers to include in requests
17
+ # @param read_timeout [Integer] Read timeout in seconds (default: 30)
18
+ def initialize(base_url:, headers: {}, read_timeout: 30)
19
+ super()
20
+ @base_url = base_url.end_with?('/') ? base_url : "#{base_url}/"
21
+ @headers = headers.merge({
22
+ 'Accept' => 'text/event-stream',
23
+ 'Cache-Control' => 'no-cache',
24
+ 'Connection' => 'keep-alive'
25
+ })
26
+ @http_client = nil
27
+ @tools = nil
28
+ @read_timeout = read_timeout
29
+ @session_id = nil
30
+ @tools_data = nil
31
+ @request_id = 0
32
+ @sse_results = {}
33
+ @mutex = Monitor.new
34
+ @buffer = ''
35
+ @sse_connected = false
36
+ @connection_established = false
37
+ @connection_cv = @mutex.new_cond
38
+ end
39
+
40
+ # List all tools available from the MCP server
41
+ # @return [Array<MCPClient::Tool>] list of available tools
42
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
43
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
44
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
45
+ def list_tools
46
+ @mutex.synchronize do
47
+ return @tools if @tools
48
+ end
49
+
50
+ connect
51
+
52
+ begin
53
+ tools_data = request_tools_list
54
+ @mutex.synchronize do
55
+ @tools = tools_data.map do |tool_data|
56
+ MCPClient::Tool.from_json(tool_data)
57
+ end
58
+ end
59
+
60
+ @mutex.synchronize { @tools }
61
+ rescue MCPClient::Errors::TransportError
62
+ # Re-raise TransportError directly
63
+ raise
64
+ rescue JSON::ParserError => e
65
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
66
+ rescue StandardError => e
67
+ raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
68
+ end
69
+ end
70
+
71
+ # Call a tool with the given parameters
72
+ # @param tool_name [String] the name of the tool to call
73
+ # @param parameters [Hash] the parameters to pass to the tool
74
+ # @return [Object] the result of the tool invocation
75
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
76
+ # @raise [MCPClient::Errors::TransportError] if response isn't valid JSON
77
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
78
+ def call_tool(tool_name, parameters)
79
+ connect
80
+
81
+ begin
82
+ request_id = @mutex.synchronize { @request_id += 1 }
83
+
84
+ json_rpc_request = {
85
+ jsonrpc: '2.0',
86
+ id: request_id,
87
+ method: 'tools/call',
88
+ params: {
89
+ name: tool_name,
90
+ arguments: parameters
91
+ }
92
+ }
93
+
94
+ send_jsonrpc_request(json_rpc_request)
95
+ rescue MCPClient::Errors::TransportError
96
+ # Re-raise TransportError directly
97
+ raise
98
+ rescue JSON::ParserError => e
99
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
100
+ rescue StandardError => e
101
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
102
+ end
103
+ end
104
+
105
+ # Connect to the MCP server over HTTP/HTTPS with SSE
106
+ # @return [Boolean] true if connection was successful
107
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
108
+ def connect
109
+ @mutex.synchronize do
110
+ return true if @connection_established
111
+
112
+ uri = URI.parse(@base_url)
113
+ @http_client = Net::HTTP.new(uri.host, uri.port)
114
+
115
+ if uri.scheme == 'https'
116
+ @http_client.use_ssl = true
117
+ @http_client.verify_mode = OpenSSL::SSL::VERIFY_PEER
118
+ end
119
+
120
+ @http_client.open_timeout = 10
121
+ @http_client.read_timeout = @read_timeout
122
+ @http_client.keep_alive_timeout = 60
123
+
124
+ @http_client.start
125
+ start_sse_thread
126
+
127
+ timeout = 10
128
+ success = @connection_cv.wait(timeout) { @connection_established }
129
+
130
+ unless success
131
+ cleanup
132
+ raise MCPClient::Errors::ConnectionError, 'Timed out waiting for SSE connection to be established'
133
+ end
134
+
135
+ @connection_established
136
+ end
137
+ rescue StandardError => e
138
+ cleanup
139
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server at #{@base_url}: #{e.message}"
140
+ end
141
+
142
+ # Clean up the server connection
143
+ # Properly closes HTTP connections and clears cached tools
144
+ def cleanup
145
+ @mutex.synchronize do
146
+ begin
147
+ @sse_thread&.kill
148
+ rescue StandardError
149
+ nil
150
+ end
151
+ @sse_thread = nil
152
+
153
+ if @http_client
154
+ @http_client.finish if @http_client.started?
155
+ @http_client = nil
156
+ end
157
+
158
+ @tools = nil
159
+ @session_id = nil
160
+ @connection_established = false
161
+ @sse_connected = false
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ # Start the SSE thread to listen for events
168
+ def start_sse_thread
169
+ return if @sse_thread&.alive?
170
+
171
+ @sse_thread = Thread.new do
172
+ sse_http = nil
173
+ begin
174
+ uri = URI.parse(@base_url)
175
+ sse_http = Net::HTTP.new(uri.host, uri.port)
176
+
177
+ if uri.scheme == 'https'
178
+ sse_http.use_ssl = true
179
+ sse_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
180
+ end
181
+
182
+ sse_http.open_timeout = 10
183
+ sse_http.read_timeout = @read_timeout
184
+ sse_http.keep_alive_timeout = 60
185
+
186
+ sse_http.start do |http|
187
+ request = Net::HTTP::Get.new(uri)
188
+ @headers.each { |k, v| request[k] = v }
189
+
190
+ http.request(request) do |response|
191
+ unless response.is_a?(Net::HTTPSuccess) && response['content-type']&.start_with?('text/event-stream')
192
+ @mutex.synchronize do
193
+ @connection_established = false
194
+ @connection_cv.broadcast
195
+ end
196
+ raise MCPClient::Errors::ServerError, 'Server response not OK or not text/event-stream'
197
+ end
198
+
199
+ @mutex.synchronize do
200
+ @sse_connected = true
201
+ end
202
+
203
+ response.read_body do |chunk|
204
+ process_sse_chunk(chunk.dup)
205
+ end
206
+ end
207
+ end
208
+ rescue StandardError
209
+ nil
210
+ ensure
211
+ sse_http&.finish if sse_http&.started?
212
+ @mutex.synchronize do
213
+ @sse_connected = false
214
+ end
215
+ end
216
+ end
217
+ end
218
+
219
+ # Process an SSE chunk from the server
220
+ # @param chunk [String] the chunk to process
221
+ def process_sse_chunk(chunk)
222
+ local_buffer = nil
223
+
224
+ @mutex.synchronize do
225
+ @buffer += chunk
226
+
227
+ while (event_end = @buffer.index("\n\n"))
228
+ event_data = @buffer.slice!(0, event_end + 2)
229
+ local_buffer = event_data
230
+ end
231
+ end
232
+
233
+ parse_and_handle_sse_event(local_buffer) if local_buffer
234
+ end
235
+
236
+ # Parse and handle an SSE event
237
+ # @param event_data [String] the event data to parse
238
+ def parse_and_handle_sse_event(event_data)
239
+ event = parse_sse_event(event_data)
240
+ return if event.nil?
241
+
242
+ case event[:event]
243
+ when 'endpoint'
244
+ if event[:data].include?('sessionId=')
245
+ session_id = event[:data].split('sessionId=').last
246
+
247
+ @mutex.synchronize do
248
+ @session_id = session_id
249
+ @connection_established = true
250
+ @connection_cv.broadcast
251
+ end
252
+ end
253
+ when 'message'
254
+ begin
255
+ data = JSON.parse(event[:data])
256
+
257
+ @mutex.synchronize do
258
+ @tools_data = data['result']['tools'] if data['result'] && data['result']['tools']
259
+
260
+ if data['id']
261
+ if data['error']
262
+ @sse_results[data['id']] = {
263
+ 'isError' => true,
264
+ 'content' => [{ 'type' => 'text', 'text' => data['error'].to_json }]
265
+ }
266
+ elsif data['result']
267
+ @sse_results[data['id']] = data['result']
268
+ end
269
+ end
270
+ end
271
+ rescue JSON::ParserError
272
+ nil
273
+ end
274
+ end
275
+ end
276
+
277
+ # Parse an SSE event
278
+ # @param event_data [String] the event data to parse
279
+ # @return [Hash, nil] the parsed event, or nil if the event is invalid
280
+ def parse_sse_event(event_data)
281
+ event = { event: 'message', data: '', id: nil }
282
+ data_lines = []
283
+
284
+ event_data.each_line do |line|
285
+ line = line.chomp
286
+ next if line.empty?
287
+
288
+ if line.start_with?('event:')
289
+ event[:event] = line[6..].strip
290
+ elsif line.start_with?('data:')
291
+ data_lines << line[5..].strip
292
+ elsif line.start_with?('id:')
293
+ event[:id] = line[3..].strip
294
+ end
295
+ end
296
+
297
+ event[:data] = data_lines.join("\n")
298
+ event[:data].empty? ? nil : event
299
+ end
300
+
301
+ # Request the tools list using JSON-RPC
302
+ # @return [Array<Hash>] the tools data
303
+ def request_tools_list
304
+ @mutex.synchronize do
305
+ return @tools_data if @tools_data
306
+ end
307
+
308
+ request_id = @mutex.synchronize { @request_id += 1 }
309
+
310
+ json_rpc_request = {
311
+ jsonrpc: '2.0',
312
+ id: request_id,
313
+ method: 'tools/list',
314
+ params: {}
315
+ }
316
+
317
+ result = send_jsonrpc_request(json_rpc_request)
318
+
319
+ if result && result['tools']
320
+ @mutex.synchronize do
321
+ @tools_data = result['tools']
322
+ end
323
+ return @mutex.synchronize { @tools_data.dup }
324
+ elsif result
325
+ @mutex.synchronize do
326
+ @tools_data = result
327
+ end
328
+ return @mutex.synchronize { @tools_data.dup }
329
+ end
330
+
331
+ raise MCPClient::Errors::ToolCallError, 'Failed to get tools list from JSON-RPC request'
332
+ end
333
+
334
+ # Send a JSON-RPC request to the server and wait for result
335
+ # @param request [Hash] the JSON-RPC request
336
+ # @return [Hash] the result of the request
337
+ def send_jsonrpc_request(request)
338
+ uri = URI.parse(@base_url)
339
+ rpc_http = Net::HTTP.new(uri.host, uri.port)
340
+
341
+ if uri.scheme == 'https'
342
+ rpc_http.use_ssl = true
343
+ rpc_http.verify_mode = OpenSSL::SSL::VERIFY_PEER
344
+ end
345
+
346
+ rpc_http.open_timeout = 10
347
+ rpc_http.read_timeout = @read_timeout
348
+ rpc_http.keep_alive_timeout = 60
349
+
350
+ begin
351
+ rpc_http.start do |http|
352
+ session_id = @mutex.synchronize { @session_id }
353
+
354
+ url = if session_id
355
+ "#{@base_url.sub(%r{/sse/?$}, '')}/messages?sessionId=#{session_id}"
356
+ else
357
+ "#{@base_url.sub(%r{/sse/?$}, '')}/messages"
358
+ end
359
+
360
+ uri = URI.parse(url)
361
+ http_request = Net::HTTP::Post.new(uri)
362
+ http_request.content_type = 'application/json'
363
+ http_request.body = request.to_json
364
+
365
+ headers = @mutex.synchronize { @headers.dup }
366
+ headers.except('Accept', 'Cache-Control')
367
+ .each { |k, v| http_request[k] = v }
368
+
369
+ response = http.request(http_request)
370
+
371
+ unless response.is_a?(Net::HTTPSuccess)
372
+ raise MCPClient::Errors::ServerError, "Server returned error: #{response.code} #{response.message}"
373
+ end
374
+
375
+ if response.code == '202'
376
+ request_id = request[:id]
377
+
378
+ start_time = Time.now
379
+ timeout = 10
380
+ result = nil
381
+
382
+ loop do
383
+ @mutex.synchronize do
384
+ if @sse_results[request_id]
385
+ result = @sse_results[request_id]
386
+ @sse_results.delete(request_id)
387
+ end
388
+ end
389
+
390
+ break if result || (Time.now - start_time > timeout)
391
+
392
+ sleep 0.1
393
+ end
394
+
395
+ return result if result
396
+
397
+ raise MCPClient::Errors::ToolCallError, "Timeout waiting for SSE result for request #{request_id}"
398
+
399
+ else
400
+ begin
401
+ data = JSON.parse(response.body)
402
+ return data['result']
403
+ rescue JSON::ParserError => e
404
+ raise MCPClient::Errors::TransportError, "Invalid JSON response from server: #{e.message}"
405
+ end
406
+ end
407
+ end
408
+ ensure
409
+ rpc_http.finish if rpc_http.started?
410
+ end
411
+ end
412
+ end
413
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require_relative 'version'
6
+
7
+ module MCPClient
8
+ # JSON-RPC implementation of MCP server over stdio.
9
+ class ServerStdio < ServerBase
10
+ attr_reader :command
11
+
12
+ # Timeout in seconds for responses
13
+ READ_TIMEOUT = 15
14
+
15
+ # @param command [String, Array] the stdio command to launch the MCP JSON-RPC server
16
+ def initialize(command:)
17
+ super()
18
+ @command = command.is_a?(Array) ? command.join(' ') : command
19
+ @mutex = Mutex.new
20
+ @cond = ConditionVariable.new
21
+ @next_id = 1
22
+ @pending = {}
23
+ @initialized = false
24
+ end
25
+
26
+ # Connect to the MCP server by launching the command process via stdout/stdin
27
+ # @return [Boolean] true if connection was successful
28
+ # @raise [MCPClient::Errors::ConnectionError] if connection fails
29
+ def connect
30
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@command)
31
+ true
32
+ rescue StandardError => e
33
+ raise MCPClient::Errors::ConnectionError, "Failed to connect to MCP server: #{e.message}"
34
+ end
35
+
36
+ # Spawn a reader thread to collect JSON-RPC responses
37
+ def start_reader
38
+ @reader_thread = Thread.new do
39
+ @stdout.each_line do |line|
40
+ handle_line(line)
41
+ end
42
+ rescue StandardError
43
+ # Reader thread aborted unexpectedly
44
+ end
45
+ end
46
+
47
+ # Handle a line of output from the stdio server
48
+ # Parses JSON-RPC messages and adds them to pending responses
49
+ # @param line [String] line of output to parse
50
+ def handle_line(line)
51
+ msg = JSON.parse(line)
52
+ id = msg['id']
53
+ return unless id
54
+
55
+ @mutex.synchronize do
56
+ @pending[id] = msg
57
+ @cond.broadcast
58
+ end
59
+ rescue JSON::ParserError
60
+ # Skip non-JSONRPC lines in the output stream
61
+ end
62
+
63
+ # List all tools available from the MCP server
64
+ # @return [Array<MCPClient::Tool>] list of available tools
65
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
66
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool listing
67
+ def list_tools
68
+ ensure_initialized
69
+ req_id = next_id
70
+ # JSON-RPC method for listing tools
71
+ req = { 'jsonrpc' => '2.0', 'id' => req_id, 'method' => 'tools/list', 'params' => {} }
72
+ send_request(req)
73
+ res = wait_response(req_id)
74
+ if (err = res['error'])
75
+ raise MCPClient::Errors::ServerError, err['message']
76
+ end
77
+
78
+ (res.dig('result', 'tools') || []).map { |td| MCPClient::Tool.from_json(td) }
79
+ rescue StandardError => e
80
+ raise MCPClient::Errors::ToolCallError, "Error listing tools: #{e.message}"
81
+ end
82
+
83
+ # Call a tool with the given parameters
84
+ # @param tool_name [String] the name of the tool to call
85
+ # @param parameters [Hash] the parameters to pass to the tool
86
+ # @return [Object] the result of the tool invocation
87
+ # @raise [MCPClient::Errors::ServerError] if server returns an error
88
+ # @raise [MCPClient::Errors::ToolCallError] for other errors during tool execution
89
+ def call_tool(tool_name, parameters)
90
+ ensure_initialized
91
+ req_id = next_id
92
+ # JSON-RPC method for calling a tool
93
+ req = {
94
+ 'jsonrpc' => '2.0',
95
+ 'id' => req_id,
96
+ 'method' => 'tools/call',
97
+ 'params' => { 'name' => tool_name, 'arguments' => parameters }
98
+ }
99
+ send_request(req)
100
+ res = wait_response(req_id)
101
+ if (err = res['error'])
102
+ raise MCPClient::Errors::ServerError, err['message']
103
+ end
104
+
105
+ res['result']
106
+ rescue StandardError => e
107
+ raise MCPClient::Errors::ToolCallError, "Error calling tool '#{tool_name}': #{e.message}"
108
+ end
109
+
110
+ # Clean up the server connection
111
+ # Closes all stdio handles and terminates any running processes and threads
112
+ def cleanup
113
+ return unless @stdin
114
+
115
+ @stdin.close unless @stdin.closed?
116
+ @stdout.close unless @stdout.closed?
117
+ @stderr.close unless @stderr.closed?
118
+ if @wait_thread&.alive?
119
+ Process.kill('TERM', @wait_thread.pid)
120
+ @wait_thread.join(1)
121
+ end
122
+ @reader_thread&.kill
123
+ rescue StandardError
124
+ # Clean up resources during unexpected termination
125
+ ensure
126
+ @stdin = @stdout = @stderr = @wait_thread = @reader_thread = nil
127
+ end
128
+
129
+ private
130
+
131
+ # Ensure the server process is started and initialized (handshake)
132
+ def ensure_initialized
133
+ return if @initialized
134
+
135
+ connect
136
+ start_reader
137
+ perform_initialize
138
+
139
+ @initialized = true
140
+ end
141
+
142
+ # Handshake: send initialize request and initialized notification
143
+ def perform_initialize
144
+ # Initialize request
145
+ init_id = next_id
146
+ init_req = {
147
+ 'jsonrpc' => '2.0',
148
+ 'id' => init_id,
149
+ 'method' => 'initialize',
150
+ 'params' => {
151
+ 'protocolVersion' => '2024-11-05',
152
+ 'capabilities' => {},
153
+ 'clientInfo' => { 'name' => 'ruby-mcp-client', 'version' => MCPClient::VERSION }
154
+ }
155
+ }
156
+ send_request(init_req)
157
+ res = wait_response(init_id)
158
+ if (err = res['error'])
159
+ raise MCPClient::Errors::ConnectionError, "Initialize failed: #{err['message']}"
160
+ end
161
+
162
+ # Send initialized notification
163
+ notif = { 'jsonrpc' => '2.0', 'method' => 'notifications/initialized', 'params' => {} }
164
+ @stdin.puts(notif.to_json)
165
+ end
166
+
167
+ def next_id
168
+ @mutex.synchronize do
169
+ id = @next_id
170
+ @next_id += 1
171
+ id
172
+ end
173
+ end
174
+
175
+ def send_request(req)
176
+ @stdin.puts(req.to_json)
177
+ rescue StandardError => e
178
+ raise MCPClient::Errors::TransportError, "Failed to send JSONRPC request: #{e.message}"
179
+ end
180
+
181
+ def wait_response(id)
182
+ deadline = Time.now + READ_TIMEOUT
183
+ @mutex.synchronize do
184
+ until @pending.key?(id)
185
+ remaining = deadline - Time.now
186
+ break if remaining <= 0
187
+
188
+ @cond.wait(@mutex, remaining)
189
+ end
190
+ msg = @pending[id]
191
+ @pending[id] = nil
192
+ raise MCPClient::Errors::TransportError, "Timeout waiting for JSONRPC response id=#{id}" unless msg
193
+
194
+ msg
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Representation of an MCP tool
5
+ class Tool
6
+ attr_reader :name, :description, :schema
7
+
8
+ def initialize(name:, description:, schema:)
9
+ @name = name
10
+ @description = description
11
+ @schema = schema
12
+ end
13
+
14
+ # Create a Tool instance from JSON data
15
+ # @param data [Hash] JSON data from MCP server
16
+ # @return [MCPClient::Tool] tool instance
17
+ def self.from_json(data)
18
+ # Some servers (Playwright MCP CLI) use 'inputSchema' instead of 'schema'
19
+ schema = data['inputSchema'] || data['schema']
20
+ new(
21
+ name: data['name'],
22
+ description: data['description'],
23
+ schema: schema
24
+ )
25
+ end
26
+
27
+ # Convert tool to OpenAI function specification format
28
+ # @return [Hash] OpenAI function specification
29
+ def to_openai_tool
30
+ {
31
+ type: 'function',
32
+ function: {
33
+ name: @name,
34
+ description: @description,
35
+ parameters: @schema
36
+ }
37
+ }
38
+ end
39
+
40
+ # Convert tool to Anthropic Claude tool specification format
41
+ # @return [Hash] Anthropic Claude tool specification
42
+ def to_anthropic_tool
43
+ {
44
+ name: @name,
45
+ description: @description,
46
+ input_schema: @schema
47
+ }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCPClient
4
+ # Current version of the MCP client gem
5
+ VERSION = '0.1.0'
6
+ end
data/lib/mcp_client.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load all MCPClient components
4
+ require_relative 'mcp_client/errors'
5
+ require_relative 'mcp_client/tool'
6
+ require_relative 'mcp_client/server_base'
7
+ require_relative 'mcp_client/server_stdio'
8
+ require_relative 'mcp_client/server_sse'
9
+ require_relative 'mcp_client/server_factory'
10
+ require_relative 'mcp_client/client'
11
+ require_relative 'mcp_client/version'
12
+
13
+ # Model Context Protocol (MCP) Client module
14
+ # Provides a standardized way for agents to communicate with external tools and services
15
+ # through a protocol-based approach
16
+ module MCPClient
17
+ # Create a new MCPClient client
18
+ # @param mcp_server_configs [Array<Hash>] configurations for MCP servers
19
+ # @return [MCPClient::Client] new client instance
20
+ def self.create_client(mcp_server_configs: [])
21
+ MCPClient::Client.new(mcp_server_configs: mcp_server_configs)
22
+ end
23
+
24
+ # Create a standard server configuration for stdio
25
+ # @param command [String, Array<String>] command to execute
26
+ # @return [Hash] server configuration
27
+ def self.stdio_config(command:)
28
+ {
29
+ type: 'stdio',
30
+ command: command
31
+ }
32
+ end
33
+
34
+ # Create a standard server configuration for SSE
35
+ # @param base_url [String] base URL for the server
36
+ # @param headers [Hash] HTTP headers to include in requests
37
+ # @param read_timeout [Integer] read timeout in seconds (default: 30)
38
+ # @return [Hash] server configuration
39
+ def self.sse_config(base_url:, headers: {}, read_timeout: 30)
40
+ {
41
+ type: 'sse',
42
+ base_url: base_url,
43
+ headers: headers,
44
+ read_timeout: read_timeout
45
+ }
46
+ end
47
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-mcp-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Szymon Kurcab
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rdoc
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.5'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.12'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.62'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.62'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 0.9.34
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 0.9.34
69
+ description: Ruby client library for integrating with Model Context Protocol (MCP)
70
+ servers to access and invoke tools from AI assistants
71
+ email:
72
+ - szymon.kurcab@gmail.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE
78
+ - README.md
79
+ - lib/mcp_client.rb
80
+ - lib/mcp_client/client.rb
81
+ - lib/mcp_client/errors.rb
82
+ - lib/mcp_client/server_base.rb
83
+ - lib/mcp_client/server_factory.rb
84
+ - lib/mcp_client/server_sse.rb
85
+ - lib/mcp_client/server_stdio.rb
86
+ - lib/mcp_client/tool.rb
87
+ - lib/mcp_client/version.rb
88
+ homepage: https://github.com/simonx1/ruby-mcp-client
89
+ licenses:
90
+ - MIT
91
+ metadata:
92
+ rubygems_mfa_required: 'true'
93
+ post_install_message:
94
+ rdoc_options: []
95
+ require_paths:
96
+ - lib
97
+ required_ruby_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 2.7.0
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '0'
107
+ requirements: []
108
+ rubygems_version: 3.5.16
109
+ signing_key:
110
+ specification_version: 4
111
+ summary: A Ruby client for the Model Context Protocol (MCP)
112
+ test_files: []