ruby_llm-mcp 0.0.1

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: e0de5a83b2962398c602fe95d3b1c606bf3c0d1b8367209b70f9ef1f41a7f1e2
4
+ data.tar.gz: 65c1a8c158efc387d9bec8ee67b701432cc605fd276647496d0825d0d83cafb3
5
+ SHA512:
6
+ metadata.gz: 416161bdd8d22711c144dd7bf3460e27f01cd57f764ebc5d024748bcdcc4fcf279898da28fefcba1ae31546f8b001d48798c7f9e48a0c1bc7b1ad555e2334fcd
7
+ data.tar.gz: f9a6cbabf0e78aa89268c0cbbdaa2e07e9b33497cf47d1eab7282f60c6bfa161e4d5cbb7a1e6694de93ed18988318c1040ee0e3ffcd363b5d2d761e40158ce1b
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Patrick Vice
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # RubyLLM::MCP
2
+
3
+ Aiming to make using MCP with RubyLLM as easy as possible.
4
+
5
+ This project is a Ruby client for the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), designed to work seamlessly with [RubyLLM](https://github.com/patvice/ruby_llm). This gem enables Ruby applications to connect to MCP servers and use their tools as part of LLM conversations.
6
+
7
+ **Note:** This project is still under development and the API is subject to change. Currently supports the connecting workflow, tool lists and tool execution.
8
+
9
+ ## Features
10
+
11
+ - 🔌 **Multiple Transport Types**: Support for SSE (Server-Sent Events) and stdio transports
12
+ - 🛠️ **Tool Integration**: Automatically converts MCP tools into RubyLLM-compatible tools
13
+ - 🔄 **Real-time Communication**: Efficient bidirectional communication with MCP servers
14
+ - 🎯 **Simple API**: Easy-to-use interface that integrates seamlessly with RubyLLM
15
+
16
+ ## Installation
17
+
18
+ Add this line to your application's Gemfile:
19
+
20
+ ```ruby
21
+ gem 'ruby_llm-mcp'
22
+ ```
23
+
24
+ And then execute:
25
+
26
+ ```bash
27
+ bundle install
28
+ ```
29
+
30
+ Or install it yourself as:
31
+
32
+ ```bash
33
+ gem install ruby_llm-mcp
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ### Basic Setup
39
+
40
+ First, configure your RubyLLM client and create an MCP connection:
41
+
42
+ ```ruby
43
+ require 'ruby_llm/mcp'
44
+
45
+ # Configure RubyLLM
46
+ RubyLLM.configure do |config|
47
+ config.openai_api_key = "your-api-key"
48
+ end
49
+
50
+ # Connect to an MCP server via SSE
51
+ client = RubyLLM::MCP.client(
52
+ name: "my-mcp-server",
53
+ transport_type: "sse",
54
+ config: {
55
+ url: "http://localhost:9292/mcp/sse"
56
+ }
57
+ )
58
+
59
+ # Or connect via stdio
60
+ client = RubyLLM::MCP.client(
61
+ name: "my-mcp-server",
62
+ transport_type: "stdio",
63
+ config: {
64
+ command: "node",
65
+ args: ["path/to/mcp-server.js"],
66
+ env: { "NODE_ENV" => "production" }
67
+ }
68
+ )
69
+ ```
70
+
71
+ ### Using MCP Tools with RubyLLM
72
+
73
+ ```ruby
74
+ # Get available tools from the MCP server
75
+ tools = client.tools
76
+ puts "Available tools:"
77
+ tools.each do |tool|
78
+ puts "- #{tool.name}: #{tool.description}"
79
+ end
80
+
81
+ # Create a chat session with MCP tools
82
+ chat = RubyLLM.chat(model: "gpt-4")
83
+ chat.with_tools(*client.tools)
84
+
85
+ # Ask a question that will use the MCP tools
86
+ response = chat.ask("Can you help me search for recent files in my project?")
87
+ puts response
88
+ ```
89
+
90
+ ### Streaming Responses with Tool Calls
91
+
92
+ ```ruby
93
+ chat = RubyLLM.chat(model: "gpt-4")
94
+ chat.with_tools(*client.tools)
95
+
96
+ chat.ask("Analyze my project structure") do |chunk|
97
+ if chunk.tool_call?
98
+ chunk.tool_calls.each do |key, tool_call|
99
+ puts "\n🔧 Using tool: #{tool_call.name}"
100
+ end
101
+ else
102
+ print chunk.content
103
+ end
104
+ end
105
+ ```
106
+
107
+ ### Manual Tool Execution
108
+
109
+ You can also execute MCP tools directly:
110
+
111
+ ```ruby
112
+ # Execute a specific tool
113
+ result = client.execute_tool(
114
+ name: "search_files",
115
+ parameters: {
116
+ query: "*.rb",
117
+ directory: "/path/to/search"
118
+ }
119
+ )
120
+
121
+ puts result
122
+ ```
123
+
124
+ ## Transport Types
125
+
126
+ ### SSE (Server-Sent Events)
127
+
128
+ Best for web-based MCP servers or when you need HTTP-based communication:
129
+
130
+ ```ruby
131
+ client = RubyLLM::MCP.client(
132
+ name: "web-mcp-server",
133
+ transport_type: "sse",
134
+ config: {
135
+ url: "https://your-mcp-server.com/mcp/sse"
136
+ }
137
+ )
138
+ ```
139
+
140
+ ### Stdio
141
+
142
+ Best for local MCP servers or command-line tools:
143
+
144
+ ```ruby
145
+ client = RubyLLM::MCP.client(
146
+ name: "local-mcp-server",
147
+ transport_type: "stdio",
148
+ config: {
149
+ command: "python",
150
+ args: ["-m", "my_mcp_server"],
151
+ env: { "DEBUG" => "1" }
152
+ }
153
+ )
154
+ ```
155
+
156
+ ## Configuration Options
157
+
158
+ - `name`: A unique identifier for your MCP client
159
+ - `transport_type`: Either `:sse` or `:stdio`
160
+ - `request_timeout`: Timeout for requests in milliseconds (default: 8000)
161
+ - `config`: Transport-specific configuration
162
+ - For SSE: `{ url: "http://..." }`
163
+ - For stdio: `{ command: "...", args: [...], env: {...} }`
164
+
165
+ ## Development
166
+
167
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
168
+
169
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
170
+
171
+ ## Examples
172
+
173
+ Check out the `examples/` directory for more detailed usage examples:
174
+
175
+ - `examples/test_local_mcp.rb` - Complete example with SSE transport
176
+
177
+ ## Contributing
178
+
179
+ We welcome contributions! Bug reports and pull requests are welcome on GitHub at https://github.com/patvice/ruby_llm-mcp.
180
+
181
+ ## License
182
+
183
+ Released under the MIT License.
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Client
6
+ PROTOCOL_VERSION = "2025-03-26"
7
+
8
+ def initialize(name:, transport_type:, request_timeout: 8000, reverse_proxy_url: nil, config: {})
9
+ @name = name
10
+ @config = config
11
+ @transport_type = transport_type.to_sym
12
+
13
+ # TODO: Add streamable HTTP
14
+ case @transport_type
15
+ when :sse
16
+ @transport = RubyLLM::MCP::Transport::SSE.new(config[:url])
17
+ when :stdio
18
+ @transport = RubyLLM::MCP::Transport::Stdio.new(config[:command], args: config[:args], env: config[:env])
19
+ else
20
+ raise "Invalid transport type: #{transport_type}"
21
+ end
22
+
23
+ @request_timeout = request_timeout
24
+ @reverse_proxy_url = reverse_proxy_url
25
+
26
+ initialize_request
27
+ notification_request
28
+ end
29
+
30
+ def request(body, wait_for_response: true)
31
+ @transport.request(body, wait_for_response: wait_for_response)
32
+ end
33
+
34
+ def tools(refresh: false)
35
+ @tools = nil if refresh
36
+ @tools ||= fetch_and_create_tools
37
+ end
38
+
39
+ def execute_tool(name:, parameters:)
40
+ response = execute_tool_request(name: name, parameters: parameters)
41
+ result = response["result"]
42
+ # TODO: handle tool error when "isError" is true in result
43
+ #
44
+ # TODO: Implement "type": "image" and "type": "resource"
45
+ result["content"].map { |content| content["text"] }.join("\n")
46
+ end
47
+
48
+ private
49
+
50
+ def initialize_request
51
+ @initialize_response = RubyLLM::MCP::Requests::Initialization.new(self).call
52
+ end
53
+
54
+ def notification_request
55
+ @notification_response = RubyLLM::MCP::Requests::Notification.new(self).call
56
+ end
57
+
58
+ def tool_list_request
59
+ @tool_request = RubyLLM::MCP::Requests::ToolList.new(self).call
60
+ end
61
+
62
+ def execute_tool_request(name:, parameters:)
63
+ @execute_tool_response = RubyLLM::MCP::Requests::ToolCall.new(self, name: name, parameters: parameters).call
64
+ end
65
+
66
+ def fetch_and_create_tools
67
+ tools_response = tool_list_request
68
+ tools_response = tools_response["result"]["tools"]
69
+
70
+ @tools = tools_response.map do |tool|
71
+ RubyLLM::MCP::Tool.new(self, tool)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Errors
6
+ class TimeoutError < StandardError
7
+ attr_reader :message
8
+
9
+ def initialize(message:)
10
+ @message = message
11
+ super(message)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyLLM
6
+ module MCP
7
+ module Requests
8
+ class Base
9
+ attr_reader :client
10
+
11
+ def initialize(client)
12
+ @client = client
13
+ end
14
+
15
+ def call
16
+ raise "Not implemented"
17
+ end
18
+
19
+ private
20
+
21
+ def validate_response!(response, body)
22
+ # TODO: Implement response validation
23
+ end
24
+
25
+ def raise_error(error)
26
+ raise "MCP Error: code: #{error['code']} message: #{error['message']} data: #{error['data']}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RubyLLM::MCP::Requests::Initialization < RubyLLM::MCP::Requests::Base
4
+ def call
5
+ client.request(initialize_body)
6
+ end
7
+
8
+ private
9
+
10
+ def initialize_body
11
+ {
12
+ jsonrpc: "2.0",
13
+ method: "initialize",
14
+ params: {
15
+ protocolVersion: RubyLLM::MCP::Client::PROTOCOL_VERSION,
16
+ capabilities: {
17
+ tools: {
18
+ listChanged: true
19
+ }
20
+ },
21
+ clientInfo: {
22
+ name: "RubyLLM MCP Client",
23
+ version: RubyLLM::MCP::VERSION
24
+ }
25
+ }
26
+ }
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RubyLLM::MCP::Requests::Notification < RubyLLM::MCP::Requests::Base
4
+ def call
5
+ client.request(notification_body, wait_for_response: false)
6
+ end
7
+
8
+ def notification_body
9
+ {
10
+ jsonrpc: "2.0",
11
+ method: "notifications/initialized"
12
+ }
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class ToolCall
7
+ def initialize(client, name:, parameters: {})
8
+ @client = client
9
+ @name = name
10
+ @parameters = parameters
11
+ end
12
+
13
+ def call
14
+ @client.request(request_body)
15
+ end
16
+
17
+ private
18
+
19
+ def request_body
20
+ {
21
+ jsonrpc: "2.0",
22
+ method: "tools/call",
23
+ params: {
24
+ name: @name,
25
+ arguments: @parameters
26
+ }
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RubyLLM::MCP::Requests::ToolList < RubyLLM::MCP::Requests::Base
4
+ def call
5
+ client.request(tool_list_body)
6
+ end
7
+
8
+ private
9
+
10
+ def tool_list_body
11
+ {
12
+ jsonrpc: "2.0",
13
+ method: "tools/list",
14
+ params: {}
15
+ }
16
+ end
17
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Tool < RubyLLM::Tool
6
+ attr_reader :name, :description, :parameters, :mcp_client, :tool_response
7
+
8
+ # @tool_response = {
9
+ # name: string; // Unique identifier for the tool
10
+ # description?: string; // Human-readable description
11
+ # inputSchema: { // JSON Schema for the tool's parameters
12
+ # type: "object",
13
+ # properties: { ... } // Tool-specific parameters
14
+ # },
15
+ # annotations?: { // Optional hints about tool behavior
16
+ # title?: string; // Human-readable title for the tool
17
+ # readOnlyHint?: boolean; // If true, the tool does not modify its environment
18
+ # destructiveHint?: boolean; // If true, the tool may perform destructive updates
19
+ # idempotentHint?: boolean; // If true, repeated calls with same args have no additional effect
20
+ # openWorldHint?: boolean; // If true, tool interacts with external entities
21
+ # }
22
+ # }
23
+ def initialize(mcp_client, tool_response)
24
+ super()
25
+ @mcp_client = mcp_client
26
+
27
+ @name = tool_response["name"]
28
+ @description = tool_response["description"]
29
+ @parameters = create_parameters(tool_response["inputSchema"])
30
+ end
31
+
32
+ def execute(**params)
33
+ @mcp_client.execute_tool(
34
+ name: @name,
35
+ parameters: params
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def create_parameters(input_schema)
42
+ params = {}
43
+ input_schema["properties"].each_key do |key|
44
+ param = RubyLLM::Parameter.new(
45
+ key,
46
+ type: input_schema["properties"][key]["type"],
47
+ desc: input_schema["properties"][key]["description"],
48
+ required: input_schema["properties"][key]["required"]
49
+ )
50
+
51
+ params[key] = param
52
+ end
53
+
54
+ params
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require "faraday"
6
+ require "timeout"
7
+ require "securerandom"
8
+
9
+ module RubyLLM
10
+ module MCP
11
+ module Transport
12
+ class SSE
13
+ attr_reader :headers, :id
14
+
15
+ def initialize(url, headers: {})
16
+ @event_url = url
17
+ @messages_url = url.gsub("sse", "messages")
18
+ @client_id = SecureRandom.uuid
19
+ @headers = headers.merge({
20
+ "Accept" => "text/event-stream",
21
+ "Cache-Control" => "no-cache",
22
+ "Connection" => "keep-alive",
23
+ "X-CLIENT-ID" => @client_id
24
+ })
25
+
26
+ @id_counter = 0
27
+ @id_mutex = Mutex.new
28
+ @pending_requests = {}
29
+ @pending_mutex = Mutex.new
30
+ @connection_mutex = Mutex.new
31
+ @running = true
32
+ @sse_thread = nil
33
+
34
+ # Start the SSE listener thread
35
+ start_sse_listener
36
+ end
37
+
38
+ def request(body, wait_for_response: true)
39
+ # Generate a unique request ID
40
+ @id_mutex.synchronize { @id_counter += 1 }
41
+ request_id = @id_counter
42
+ body["id"] = request_id
43
+
44
+ # Create a queue for this request's response
45
+ response_queue = Queue.new
46
+ if wait_for_response
47
+ @pending_mutex.synchronize do
48
+ @pending_requests[request_id.to_s] = response_queue
49
+ end
50
+ end
51
+
52
+ # Send the request using Faraday
53
+ begin
54
+ conn = Faraday.new do |f|
55
+ f.options.timeout = 30
56
+ f.options.open_timeout = 5
57
+ end
58
+
59
+ response = conn.post(@messages_url) do |req|
60
+ @headers.each do |key, value|
61
+ req.headers[key] = value
62
+ end
63
+ req.headers["Content-Type"] = "application/json"
64
+ req.body = JSON.generate(body)
65
+ end
66
+
67
+ unless response.status == 200
68
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
69
+ raise "Failed to request #{@messages_url}: #{response.status} - #{response.body}"
70
+ end
71
+ rescue StandardError => e
72
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
73
+ raise e
74
+ end
75
+ return unless wait_for_response
76
+
77
+ begin
78
+ Timeout.timeout(30) do
79
+ response_queue.pop
80
+ end
81
+ rescue Timeout::Error
82
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
83
+ raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
84
+ end
85
+ end
86
+
87
+ def close
88
+ @running = false
89
+ @sse_thread&.join(1) # Give the thread a second to clean up
90
+ @sse_thread = nil
91
+ end
92
+
93
+ private
94
+
95
+ def start_sse_listener
96
+ @connection_mutex.synchronize do
97
+ return if sse_thread_running?
98
+
99
+ @sse_thread = Thread.new do
100
+ listen_for_events while @running
101
+ end
102
+
103
+ @sse_thread.abort_on_exception = true
104
+ sleep 0.1 # Wait for the SSE connection to be established
105
+ end
106
+ end
107
+
108
+ def sse_thread_running?
109
+ @sse_thread && @sse_thread.alive?
110
+ end
111
+
112
+ def listen_for_events
113
+ stream_events_from_server
114
+ rescue Faraday::Error => e
115
+ handle_connection_error("SSE connection failed", e)
116
+ rescue StandardError => e
117
+ handle_connection_error("SSE connection error", e)
118
+ end
119
+
120
+ def stream_events_from_server
121
+ buffer = +""
122
+ create_sse_connection.get(@event_url) do |req|
123
+ setup_request_headers(req)
124
+ setup_streaming_callback(req, buffer)
125
+ end
126
+ end
127
+
128
+ def create_sse_connection
129
+ Faraday.new do |f|
130
+ f.options.timeout = 300 # 5 minutes
131
+ f.response :raise_error # raise errors on non-200 responses
132
+ end
133
+ end
134
+
135
+ def setup_request_headers(request)
136
+ @headers.each do |key, value|
137
+ request.headers[key] = value
138
+ end
139
+ end
140
+
141
+ def setup_streaming_callback(request, buffer)
142
+ request.options.on_data = proc do |chunk, _size, _env|
143
+ buffer << chunk
144
+ process_buffer_events(buffer)
145
+ end
146
+ end
147
+
148
+ def process_buffer_events(buffer)
149
+ while (event = extract_event(buffer))
150
+ event_data, buffer = event
151
+ process_event(event_data) if event_data
152
+ end
153
+ end
154
+
155
+ def handle_connection_error(message, error)
156
+ puts "#{message}: #{error.message}. Reconnecting in 3 seconds..."
157
+ sleep 3
158
+ end
159
+
160
+ def process_event(raw_event)
161
+ return if raw_event[:data].nil?
162
+
163
+ event = begin
164
+ JSON.parse(raw_event[:data])
165
+ rescue StandardError
166
+ nil
167
+ end
168
+ return if event.nil?
169
+
170
+ request_id = event["id"]&.to_s
171
+
172
+ @pending_mutex.synchronize do
173
+ if request_id && @pending_requests.key?(request_id)
174
+ response_queue = @pending_requests.delete(request_id)
175
+ response_queue&.push(event)
176
+ end
177
+ end
178
+ rescue JSON::ParserError => e
179
+ puts "Error parsing event data: #{e.message}"
180
+ end
181
+
182
+ def extract_event(buffer)
183
+ return nil unless buffer.include?("\n\n")
184
+
185
+ raw, rest = buffer.split("\n\n", 2)
186
+ [parse_event(raw), rest]
187
+ end
188
+
189
+ def parse_event(raw)
190
+ event = {}
191
+ raw.each_line do |line|
192
+ case line
193
+ when /^data:\s*(.*)/
194
+ (event[:data] ||= []) << ::Regexp.last_match(1)
195
+ when /^event:\s*(.*)/
196
+ event[:event] = ::Regexp.last_match(1)
197
+ when /^id:\s*(.*)/
198
+ event[:id] = ::Regexp.last_match(1)
199
+ end
200
+ end
201
+ event[:data] = event[:data]&.join("\n")
202
+ event
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "timeout"
6
+ require "securerandom"
7
+
8
+ module RubyLLM
9
+ module MCP
10
+ module Transport
11
+ class Stdio
12
+ attr_reader :command, :stdin, :stdout, :stderr, :id
13
+
14
+ def initialize(command, args: [], env: {})
15
+ @command = command
16
+ @args = args
17
+ @env = env
18
+ @client_id = SecureRandom.uuid
19
+
20
+ # Initialize state variables
21
+ @id_counter = 0
22
+ @id_mutex = Mutex.new
23
+ @pending_requests = {}
24
+ @pending_mutex = Mutex.new
25
+ @running = true
26
+ @reader_thread = nil
27
+
28
+ # Start the process
29
+ start_process
30
+ end
31
+
32
+ def request(body, wait_for_response: true)
33
+ # Generate a unique request ID
34
+ @id_mutex.synchronize { @id_counter += 1 }
35
+ request_id = @id_counter
36
+ body["id"] = request_id
37
+
38
+ # Create a queue for this request's response
39
+ response_queue = Queue.new
40
+ if wait_for_response
41
+ @pending_mutex.synchronize do
42
+ @pending_requests[request_id.to_s] = response_queue
43
+ end
44
+ end
45
+
46
+ # Send the request to the process
47
+ begin
48
+ @stdin.puts(JSON.generate(body))
49
+ @stdin.flush
50
+ rescue IOError, Errno::EPIPE => e
51
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
52
+ restart_process
53
+ raise "Failed to send request: #{e.message}"
54
+ end
55
+
56
+ return unless wait_for_response
57
+
58
+ # Wait for the response with matching ID using a timeout
59
+ begin
60
+ Timeout.timeout(30) do
61
+ response_queue.pop
62
+ end
63
+ rescue Timeout::Error
64
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
65
+ raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
66
+ end
67
+ end
68
+
69
+ def close
70
+ @running = false
71
+
72
+ # Close stdin to signal the process to exit
73
+ begin
74
+ @stdin&.close
75
+ rescue StandardError
76
+ nil
77
+ end
78
+
79
+ # Wait for process to exit
80
+ begin
81
+ @wait_thread&.join(1)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ # Close remaining IO streams
87
+ begin
88
+ @stdout&.close
89
+ rescue StandardError
90
+ nil
91
+ end
92
+ begin
93
+ @stderr&.close
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ # Wait for reader thread to finish
99
+ begin
100
+ @reader_thread&.join(1)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ @stdin = nil
106
+ @stdout = nil
107
+ @stderr = nil
108
+ @wait_thread = nil
109
+ @reader_thread = nil
110
+ end
111
+
112
+ private
113
+
114
+ def start_process
115
+ # Close any existing process
116
+ close if @stdin || @stdout || @stderr || @wait_thread
117
+
118
+ # Start a new process
119
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
120
+
121
+ # Start a thread to read responses
122
+ start_reader_thread
123
+ end
124
+
125
+ def restart_process
126
+ puts "Process connection lost. Restarting..."
127
+ start_process
128
+ end
129
+
130
+ def start_reader_thread
131
+ @reader_thread = Thread.new do
132
+ while @running
133
+ begin
134
+ if @stdout.closed? || @wait_thread.nil? || !@wait_thread.alive?
135
+ sleep 1
136
+ restart_process if @running
137
+ next
138
+ end
139
+
140
+ # Read a line from the process
141
+ line = @stdout.gets
142
+
143
+ # Skip empty lines
144
+ next unless line && !line.strip.empty?
145
+
146
+ # Process the response
147
+ process_response(line.strip)
148
+ rescue IOError, Errno::EPIPE => e
149
+ puts "Reader error: #{e.message}. Restarting in 1 second..."
150
+ sleep 1
151
+ restart_process if @running
152
+ rescue StandardError => e
153
+ puts "Error in reader thread: #{e.message}, #{e.backtrace.join("\n")}"
154
+ sleep 1
155
+ end
156
+ end
157
+ end
158
+
159
+ @reader_thread.abort_on_exception = true
160
+ end
161
+
162
+ def process_response(line)
163
+ # Try to parse the response as JSON
164
+ response = begin
165
+ JSON.parse(line)
166
+ rescue JSON::ParserError => e
167
+ puts "Error parsing response as JSON: #{e.message}"
168
+ puts "Raw response: #{line}"
169
+ return
170
+ end
171
+
172
+ # Extract the request ID
173
+ request_id = response["id"]&.to_s
174
+
175
+ # Find and fulfill the matching request
176
+ @pending_mutex.synchronize do
177
+ if request_id && @pending_requests.key?(request_id)
178
+ response_queue = @pending_requests.delete(request_id)
179
+ response_queue&.push(response)
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Transport
6
+ class Streamable
7
+ def initialize(url, headers: {})
8
+ @url = url
9
+ @headers = headers
10
+ end
11
+
12
+ def request(messages)
13
+ # TODO: Implement streaming
14
+ end
15
+
16
+ def close
17
+ # TODO: Implement closing
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+ require "zeitwerk"
5
+
6
+ loader = Zeitwerk::Loader.for_gem_extension(RubyLLM)
7
+ loader.inflector.inflect("mcp" => "MCP")
8
+ loader.inflector.inflect("sse" => "SSE")
9
+ loader.setup
10
+
11
+ module RubyLLM
12
+ module MCP
13
+ def self.client(*args, **kwargs)
14
+ @client ||= Client.new(*args, **kwargs)
15
+ end
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Patrick Vice
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-05-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.10.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.10.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-multipart
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday-net_http
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: faraday-retry
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '1'
69
+ - !ruby/object:Gem::Dependency
70
+ name: ruby_llm
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: zeitwerk
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2'
97
+ description: |
98
+ A Ruby client for the Model Context Protocol (MCP) that seamlessly integrates with RubyLLM.
99
+ Connect to MCP servers via SSE or stdio transports, automatically convert MCP tools into
100
+ RubyLLM-compatible tools, and enable AI models to interact with external data sources and
101
+ services. Makes using MCP with RubyLLM as easy as possible.
102
+ email:
103
+ - patrickgvice@gmail.com
104
+ executables: []
105
+ extensions: []
106
+ extra_rdoc_files: []
107
+ files:
108
+ - LICENSE
109
+ - README.md
110
+ - lib/ruby_llm/mcp.rb
111
+ - lib/ruby_llm/mcp/client.rb
112
+ - lib/ruby_llm/mcp/errors.rb
113
+ - lib/ruby_llm/mcp/requests/base.rb
114
+ - lib/ruby_llm/mcp/requests/initialization.rb
115
+ - lib/ruby_llm/mcp/requests/notification.rb
116
+ - lib/ruby_llm/mcp/requests/tool_call.rb
117
+ - lib/ruby_llm/mcp/requests/tool_list.rb
118
+ - lib/ruby_llm/mcp/tool.rb
119
+ - lib/ruby_llm/mcp/transport/sse.rb
120
+ - lib/ruby_llm/mcp/transport/stdio.rb
121
+ - lib/ruby_llm/mcp/transport/streamable.rb
122
+ - lib/ruby_llm/mcp/version.rb
123
+ homepage: https://github.com/patvice/ruby_llm-mcp
124
+ licenses:
125
+ - MIT
126
+ metadata:
127
+ homepage_uri: https://github.com/patvice/ruby_llm-mcp
128
+ source_code_uri: https://github.com/patvice/ruby_llm-mcp
129
+ changelog_uri: https://github.com/patvice/ruby_llm-mcp/commits/main
130
+ documentation_uri: https://github.com/patvice/ruby_llm-mcp
131
+ bug_tracker_uri: https://github.com/patvice/ruby_llm-mcp/issues
132
+ rubygems_mfa_required: 'true'
133
+ allowed_push_host: https://rubygems.org
134
+ post_install_message:
135
+ rdoc_options: []
136
+ require_paths:
137
+ - lib
138
+ required_ruby_version: !ruby/object:Gem::Requirement
139
+ requirements:
140
+ - - ">="
141
+ - !ruby/object:Gem::Version
142
+ version: 3.1.0
143
+ required_rubygems_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '0'
148
+ requirements: []
149
+ rubygems_version: 3.5.11
150
+ signing_key:
151
+ specification_version: 4
152
+ summary: A RubyLLM MCP Client
153
+ test_files: []