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 +7 -0
- data/LICENSE +21 -0
- data/README.md +183 -0
- data/lib/ruby_llm/mcp/client.rb +76 -0
- data/lib/ruby_llm/mcp/errors.rb +16 -0
- data/lib/ruby_llm/mcp/requests/base.rb +31 -0
- data/lib/ruby_llm/mcp/requests/initialization.rb +28 -0
- data/lib/ruby_llm/mcp/requests/notification.rb +14 -0
- data/lib/ruby_llm/mcp/requests/tool_call.rb +32 -0
- data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -0
- data/lib/ruby_llm/mcp/tool.rb +58 -0
- data/lib/ruby_llm/mcp/transport/sse.rb +207 -0
- data/lib/ruby_llm/mcp/transport/stdio.rb +186 -0
- data/lib/ruby_llm/mcp/transport/streamable.rb +22 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +17 -0
- metadata +153 -0
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,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
|
data/lib/ruby_llm/mcp.rb
ADDED
@@ -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: []
|