ruby_llm-mcp 0.0.2 → 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 +4 -4
- data/README.md +30 -4
- data/lib/ruby_llm/mcp/capabilities.rb +29 -0
- data/lib/ruby_llm/mcp/client.rb +53 -9
- data/lib/ruby_llm/mcp/errors.rb +7 -1
- data/lib/ruby_llm/mcp/requests/completion.rb +50 -0
- data/lib/ruby_llm/mcp/requests/initialization.rb +3 -7
- data/lib/ruby_llm/mcp/requests/notification.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resource_list.rb +21 -0
- data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +21 -0
- data/lib/ruby_llm/mcp/resource.rb +70 -0
- data/lib/ruby_llm/mcp/transport/sse.rb +47 -15
- data/lib/ruby_llm/mcp/transport/stdio.rb +7 -7
- data/lib/ruby_llm/mcp/transport/streamable.rb +274 -4
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +2 -2
- metadata +11 -6
- data/lib/ruby_llm/overrides.rb +0 -21
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b6cf22dd6e681b51686e73f0149f91b0fd3a256ccbba1e3dd20461d0a92b370d
|
4
|
+
data.tar.gz: 8ed938f20b001c4879a409ab77944e6d8b5e53aa0062d6e499d3739355c5054d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 817fa242ef9d4cd526ea8905093d6c548f36ffeb228ee3b0acfbb0fd80303ec69557493e9b0ee0b2419c0d5adddf79c2bda02ba577e7e90a0503fd9482444bc5
|
7
|
+
data.tar.gz: '09326061dfcdc66d963ef55740ad4d0d82c260ed1da6021229d6441c9c8f1c17af744fd90d9e865e9ddc209589f663c8c5a94aea020ca7bc189c41b668d58433'
|
data/README.md
CHANGED
@@ -8,7 +8,7 @@ This project is a Ruby client for the [Model Context Protocol (MCP)](https://mod
|
|
8
8
|
|
9
9
|
## Features
|
10
10
|
|
11
|
-
- 🔌 **Multiple Transport Types**: Support for SSE (Server-Sent Events) and stdio transports
|
11
|
+
- 🔌 **Multiple Transport Types**: Support for SSE (Server-Sent Events), Streamable HTTP, and stdio transports
|
12
12
|
- 🛠️ **Tool Integration**: Automatically converts MCP tools into RubyLLM-compatible tools
|
13
13
|
- 🔄 **Real-time Communication**: Efficient bidirectional communication with MCP servers
|
14
14
|
- 🎯 **Simple API**: Easy-to-use interface that integrates seamlessly with RubyLLM
|
@@ -66,6 +66,16 @@ client = RubyLLM::MCP.client(
|
|
66
66
|
env: { "NODE_ENV" => "production" }
|
67
67
|
}
|
68
68
|
)
|
69
|
+
|
70
|
+
# Or connect via streamable HTTP
|
71
|
+
client = RubyLLM::MCP.client(
|
72
|
+
name: "my-mcp-server",
|
73
|
+
transport_type: :streamable",
|
74
|
+
config: {
|
75
|
+
url: "http://localhost:8080/mcp",
|
76
|
+
headers: { "Authorization" => "Bearer your-token" }
|
77
|
+
}
|
78
|
+
)
|
69
79
|
```
|
70
80
|
|
71
81
|
### Using MCP Tools with RubyLLM
|
@@ -138,13 +148,28 @@ Best for web-based MCP servers or when you need HTTP-based communication:
|
|
138
148
|
```ruby
|
139
149
|
client = RubyLLM::MCP.client(
|
140
150
|
name: "web-mcp-server",
|
141
|
-
transport_type:
|
151
|
+
transport_type: :sse,
|
142
152
|
config: {
|
143
153
|
url: "https://your-mcp-server.com/mcp/sse"
|
144
154
|
}
|
145
155
|
)
|
146
156
|
```
|
147
157
|
|
158
|
+
### Streamable HTTP
|
159
|
+
|
160
|
+
Best for HTTP-based MCP servers that support streaming responses:
|
161
|
+
|
162
|
+
```ruby
|
163
|
+
client = RubyLLM::MCP.client(
|
164
|
+
name: "streaming-mcp-server",
|
165
|
+
transport_type: :streamable,
|
166
|
+
config: {
|
167
|
+
url: "https://your-mcp-server.com/mcp",
|
168
|
+
headers: { "Authorization" => "Bearer your-token" }
|
169
|
+
}
|
170
|
+
)
|
171
|
+
```
|
172
|
+
|
148
173
|
### Stdio
|
149
174
|
|
150
175
|
Best for local MCP servers or command-line tools:
|
@@ -152,7 +177,7 @@ Best for local MCP servers or command-line tools:
|
|
152
177
|
```ruby
|
153
178
|
client = RubyLLM::MCP.client(
|
154
179
|
name: "local-mcp-server",
|
155
|
-
transport_type:
|
180
|
+
transport_type: :stdio,
|
156
181
|
config: {
|
157
182
|
command: "python",
|
158
183
|
args: ["-m", "my_mcp_server"],
|
@@ -164,10 +189,11 @@ client = RubyLLM::MCP.client(
|
|
164
189
|
## Configuration Options
|
165
190
|
|
166
191
|
- `name`: A unique identifier for your MCP client
|
167
|
-
- `transport_type`: Either `:sse
|
192
|
+
- `transport_type`: Either `:sse`, `:streamable`, or `:stdio`
|
168
193
|
- `request_timeout`: Timeout for requests in milliseconds (default: 8000)
|
169
194
|
- `config`: Transport-specific configuration
|
170
195
|
- For SSE: `{ url: "http://..." }`
|
196
|
+
- For Streamable: `{ url: "http://...", headers: {...} }`
|
171
197
|
- For stdio: `{ command: "...", args: [...], env: {...} }`
|
172
198
|
|
173
199
|
## Development
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
class Capabilities
|
6
|
+
attr_reader :capabilities
|
7
|
+
|
8
|
+
def initialize(capabilities)
|
9
|
+
@capabilities = capabilities
|
10
|
+
end
|
11
|
+
|
12
|
+
def resources_list_changed?
|
13
|
+
@capabilities.dig("resources", "listChanged") || false
|
14
|
+
end
|
15
|
+
|
16
|
+
def resource_subscribe?
|
17
|
+
@capabilities.dig("resources", "subscribe") || false
|
18
|
+
end
|
19
|
+
|
20
|
+
def tools_list_changed?
|
21
|
+
@capabilities.dig("tools", "listChanged") || false
|
22
|
+
end
|
23
|
+
|
24
|
+
def completion?
|
25
|
+
@capabilities["completion"].present?
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/ruby_llm/mcp/client.rb
CHANGED
@@ -4,22 +4,30 @@ module RubyLLM
|
|
4
4
|
module MCP
|
5
5
|
class Client
|
6
6
|
PROTOCOL_VERSION = "2025-03-26"
|
7
|
-
|
7
|
+
PV_2024_11_05 = "2024-11-05"
|
8
|
+
|
9
|
+
attr_reader :name, :config, :transport_type, :transport, :request_timeout, :reverse_proxy_url, :protocol_version,
|
10
|
+
:capabilities
|
8
11
|
|
9
12
|
def initialize(name:, transport_type:, request_timeout: 8000, reverse_proxy_url: nil, config: {})
|
10
13
|
@name = name
|
11
14
|
@config = config
|
15
|
+
@protocol_version = PROTOCOL_VERSION
|
16
|
+
@headers = config[:headers] || {}
|
17
|
+
|
12
18
|
@transport_type = transport_type.to_sym
|
13
19
|
|
14
|
-
# TODO: Add streamable HTTP
|
15
20
|
case @transport_type
|
16
21
|
when :sse
|
17
|
-
@transport = RubyLLM::MCP::Transport::SSE.new(@config[:url])
|
22
|
+
@transport = RubyLLM::MCP::Transport::SSE.new(@config[:url], headers: @headers)
|
18
23
|
when :stdio
|
19
24
|
@transport = RubyLLM::MCP::Transport::Stdio.new(@config[:command], args: @config[:args], env: @config[:env])
|
25
|
+
when :streamable
|
26
|
+
@transport = RubyLLM::MCP::Transport::Streamable.new(@config[:url], headers: @headers)
|
20
27
|
else
|
21
28
|
raise "Invalid transport type: #{transport_type}"
|
22
29
|
end
|
30
|
+
@capabilities = nil
|
23
31
|
|
24
32
|
@request_timeout = request_timeout
|
25
33
|
@reverse_proxy_url = reverse_proxy_url
|
@@ -28,8 +36,8 @@ module RubyLLM
|
|
28
36
|
notification_request
|
29
37
|
end
|
30
38
|
|
31
|
-
def request(body,
|
32
|
-
@transport.request(body,
|
39
|
+
def request(body, **options)
|
40
|
+
@transport.request(body, **options)
|
33
41
|
end
|
34
42
|
|
35
43
|
def tools(refresh: false)
|
@@ -37,6 +45,16 @@ module RubyLLM
|
|
37
45
|
@tools ||= fetch_and_create_tools
|
38
46
|
end
|
39
47
|
|
48
|
+
def resources(refresh: false)
|
49
|
+
@resources = nil if refresh
|
50
|
+
@resources ||= fetch_and_create_resources
|
51
|
+
end
|
52
|
+
|
53
|
+
def resource_templates(refresh: false)
|
54
|
+
@resource_templates = nil if refresh
|
55
|
+
@resource_templates ||= fetch_and_create_resources(set_as_template: true)
|
56
|
+
end
|
57
|
+
|
40
58
|
def execute_tool(name:, parameters:)
|
41
59
|
response = execute_tool_request(name: name, parameters: parameters)
|
42
60
|
result = response["result"]
|
@@ -46,22 +64,39 @@ module RubyLLM
|
|
46
64
|
result["content"].map { |content| content["text"] }.join("\n")
|
47
65
|
end
|
48
66
|
|
67
|
+
def resource_read_request(**args)
|
68
|
+
RubyLLM::MCP::Requests::ResourceRead.new(self, **args).call
|
69
|
+
end
|
70
|
+
|
71
|
+
def completion(**args)
|
72
|
+
RubyLLM::MCP::Requests::Completion.new(self, **args).call
|
73
|
+
end
|
74
|
+
|
49
75
|
private
|
50
76
|
|
51
77
|
def initialize_request
|
52
78
|
@initialize_response = RubyLLM::MCP::Requests::Initialization.new(self).call
|
79
|
+
@capabilities = RubyLLM::MCP::Capabilities.new(@initialize_response["result"]["capabilities"])
|
53
80
|
end
|
54
81
|
|
55
82
|
def notification_request
|
56
|
-
|
83
|
+
RubyLLM::MCP::Requests::Notification.new(self).call
|
57
84
|
end
|
58
85
|
|
59
86
|
def tool_list_request
|
60
|
-
|
87
|
+
RubyLLM::MCP::Requests::ToolList.new(self).call
|
88
|
+
end
|
89
|
+
|
90
|
+
def execute_tool_request(**args)
|
91
|
+
RubyLLM::MCP::Requests::ToolCall.new(self, **args).call
|
92
|
+
end
|
93
|
+
|
94
|
+
def resources_list_request
|
95
|
+
RubyLLM::MCP::Requests::ResourceList.new(self).call
|
61
96
|
end
|
62
97
|
|
63
|
-
def
|
64
|
-
|
98
|
+
def resource_template_list_request
|
99
|
+
RubyLLM::MCP::Requests::ResourceTemplateList.new(self).call
|
65
100
|
end
|
66
101
|
|
67
102
|
def fetch_and_create_tools
|
@@ -72,6 +107,15 @@ module RubyLLM
|
|
72
107
|
RubyLLM::MCP::Tool.new(self, tool)
|
73
108
|
end
|
74
109
|
end
|
110
|
+
|
111
|
+
def fetch_and_create_resources(set_as_template: false)
|
112
|
+
resources_response = resources_list_request
|
113
|
+
resources_response = resources_response["result"]["resources"]
|
114
|
+
|
115
|
+
@resources = resources_response.map do |resource|
|
116
|
+
RubyLLM::MCP::Resource.new(self, resource, template: set_as_template)
|
117
|
+
end
|
118
|
+
end
|
75
119
|
end
|
76
120
|
end
|
77
121
|
end
|
data/lib/ruby_llm/mcp/errors.rb
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
module RubyLLM
|
4
4
|
module MCP
|
5
5
|
module Errors
|
6
|
-
class
|
6
|
+
class BaseError < StandardError
|
7
7
|
attr_reader :message
|
8
8
|
|
9
9
|
def initialize(message:)
|
@@ -11,6 +11,12 @@ module RubyLLM
|
|
11
11
|
super(message)
|
12
12
|
end
|
13
13
|
end
|
14
|
+
|
15
|
+
class InvalidProtocolVersionError < BaseError; end
|
16
|
+
|
17
|
+
class SessionExpiredError < BaseError; end
|
18
|
+
|
19
|
+
class TimeoutError < BaseError; end
|
14
20
|
end
|
15
21
|
end
|
16
22
|
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class Completion
|
7
|
+
def initialize(client, type:, name:, argument:, value:)
|
8
|
+
@client = client
|
9
|
+
@type = type
|
10
|
+
@name = name
|
11
|
+
@argument = argument
|
12
|
+
@value = value
|
13
|
+
end
|
14
|
+
|
15
|
+
def call
|
16
|
+
@client.request(request_body)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def request_body
|
22
|
+
{
|
23
|
+
jsonrpc: "2.0",
|
24
|
+
id: 1,
|
25
|
+
method: "completion/complete",
|
26
|
+
params: {
|
27
|
+
ref: {
|
28
|
+
type: ref_type,
|
29
|
+
name: @name
|
30
|
+
},
|
31
|
+
argument: {
|
32
|
+
name: @argument,
|
33
|
+
value: @value
|
34
|
+
}
|
35
|
+
}
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
def ref_type
|
40
|
+
case @type
|
41
|
+
when :prompt
|
42
|
+
"ref/prompt"
|
43
|
+
when :resource
|
44
|
+
"ref/resource"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -12,14 +12,10 @@ class RubyLLM::MCP::Requests::Initialization < RubyLLM::MCP::Requests::Base
|
|
12
12
|
jsonrpc: "2.0",
|
13
13
|
method: "initialize",
|
14
14
|
params: {
|
15
|
-
protocolVersion:
|
16
|
-
capabilities: {
|
17
|
-
tools: {
|
18
|
-
listChanged: true
|
19
|
-
}
|
20
|
-
},
|
15
|
+
protocolVersion: @client.protocol_version,
|
16
|
+
capabilities: {},
|
21
17
|
clientInfo: {
|
22
|
-
name: "RubyLLM
|
18
|
+
name: "RubyLLM-MCP Client",
|
23
19
|
version: RubyLLM::MCP::VERSION
|
24
20
|
}
|
25
21
|
}
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceList < Base
|
7
|
+
def call
|
8
|
+
client.request(resource_list_body)
|
9
|
+
end
|
10
|
+
|
11
|
+
def resource_list_body
|
12
|
+
{
|
13
|
+
jsonrpc: "2.0",
|
14
|
+
method: "resources/list",
|
15
|
+
params: {}
|
16
|
+
}
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceRead
|
7
|
+
attr_reader :client, :uri
|
8
|
+
|
9
|
+
def initialize(client, uri:)
|
10
|
+
@client = client
|
11
|
+
@uri = uri
|
12
|
+
end
|
13
|
+
|
14
|
+
def call
|
15
|
+
client.request(reading_resource_body(uri))
|
16
|
+
end
|
17
|
+
|
18
|
+
def reading_resource_body(uri)
|
19
|
+
{
|
20
|
+
jsonrpc: "2.0",
|
21
|
+
method: "resources/read",
|
22
|
+
params: {
|
23
|
+
uri: uri
|
24
|
+
}
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
module Requests
|
6
|
+
class ResourceTemplateList < Base
|
7
|
+
def call
|
8
|
+
client.request(resource_template_list_body)
|
9
|
+
end
|
10
|
+
|
11
|
+
def resource_template_list_body
|
12
|
+
{
|
13
|
+
jsonrpc: "2.0",
|
14
|
+
method: "resources/templates/list",
|
15
|
+
params: {}
|
16
|
+
}
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyLLM
|
4
|
+
module MCP
|
5
|
+
class Resource
|
6
|
+
attr_reader :uri, :name, :description, :mime_type, :mcp_client, :template
|
7
|
+
|
8
|
+
def initialize(mcp_client, resource, template: false)
|
9
|
+
@mcp_client = mcp_client
|
10
|
+
@uri = resource["uri"]
|
11
|
+
@name = resource["name"]
|
12
|
+
@description = resource["description"]
|
13
|
+
@mime_type = resource["mimeType"]
|
14
|
+
@template = template
|
15
|
+
end
|
16
|
+
|
17
|
+
def content(parsable_information: {})
|
18
|
+
response = if template?
|
19
|
+
templated_uri = apply_template(@uri, parsable_information)
|
20
|
+
|
21
|
+
read_response(uri: templated_uri)
|
22
|
+
else
|
23
|
+
read_response
|
24
|
+
end
|
25
|
+
|
26
|
+
response.dig("result", "contents", 0, "text") ||
|
27
|
+
response.dig("result", "contents", 0, "blob")
|
28
|
+
end
|
29
|
+
|
30
|
+
def arguments_search(argument, value)
|
31
|
+
if template? && @mcp_client.capabilities.completion?
|
32
|
+
response = @mcp_client.completion(type: :resource, name: @name, argument: argument, value: value)
|
33
|
+
response = response.dig("result", "completion")
|
34
|
+
|
35
|
+
Struct.new(:arg_values, :total, :has_more)
|
36
|
+
.new(response["values"], response["total"], response["hasMore"])
|
37
|
+
else
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def template?
|
43
|
+
@template
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def read_response(uri: @uri)
|
49
|
+
parsed = URI.parse(uri)
|
50
|
+
case parsed.scheme
|
51
|
+
when "http", "https"
|
52
|
+
fetch_uri_content(uri)
|
53
|
+
else # file:// or git://
|
54
|
+
@read_response ||= @mcp_client.resource_read_request(uri: uri)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def fetch_uri_content(uri)
|
59
|
+
response = Faraday.get(uri)
|
60
|
+
{ "result" => { "contents" => [{ "text" => response.body }] } }
|
61
|
+
end
|
62
|
+
|
63
|
+
def apply_template(uri, parsable_information)
|
64
|
+
uri.gsub(/\{(\w+)\}/) do
|
65
|
+
parsable_information[::Regexp.last_match(1).to_sym] || "{#{::Regexp.last_match(1)}}"
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -14,7 +14,12 @@ module RubyLLM
|
|
14
14
|
|
15
15
|
def initialize(url, headers: {})
|
16
16
|
@event_url = url
|
17
|
-
@messages_url =
|
17
|
+
@messages_url = nil
|
18
|
+
|
19
|
+
uri = URI.parse(url)
|
20
|
+
@root_url = "#{uri.scheme}://#{uri.host}"
|
21
|
+
@root_url += ":#{uri.port}" if uri.port != uri.default_port
|
22
|
+
|
18
23
|
@client_id = SecureRandom.uuid
|
19
24
|
@headers = headers.merge({
|
20
25
|
"Accept" => "text/event-stream",
|
@@ -35,11 +40,14 @@ module RubyLLM
|
|
35
40
|
start_sse_listener
|
36
41
|
end
|
37
42
|
|
38
|
-
|
43
|
+
# rubocop:disable Metrics/MethodLength
|
44
|
+
def request(body, add_id: true, wait_for_response: true)
|
39
45
|
# Generate a unique request ID
|
40
|
-
|
41
|
-
|
42
|
-
|
46
|
+
if add_id
|
47
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
48
|
+
request_id = @id_counter
|
49
|
+
body["id"] = request_id
|
50
|
+
end
|
43
51
|
|
44
52
|
# Create a queue for this request's response
|
45
53
|
response_queue = Queue.new
|
@@ -83,6 +91,7 @@ module RubyLLM
|
|
83
91
|
raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
|
84
92
|
end
|
85
93
|
end
|
94
|
+
# rubocop:enable Metrics/MethodLength
|
86
95
|
|
87
96
|
def close
|
88
97
|
@running = false
|
@@ -96,17 +105,35 @@ module RubyLLM
|
|
96
105
|
@connection_mutex.synchronize do
|
97
106
|
return if sse_thread_running?
|
98
107
|
|
108
|
+
response_queue = Queue.new
|
109
|
+
@pending_mutex.synchronize do
|
110
|
+
@pending_requests["endpoint"] = response_queue
|
111
|
+
end
|
112
|
+
|
99
113
|
@sse_thread = Thread.new do
|
100
114
|
listen_for_events while @running
|
101
115
|
end
|
102
|
-
|
103
116
|
@sse_thread.abort_on_exception = true
|
104
|
-
|
117
|
+
|
118
|
+
endpoint = response_queue.pop
|
119
|
+
set_message_endpoint(endpoint)
|
120
|
+
|
121
|
+
@pending_mutex.synchronize { @pending_requests.delete("endpoint") }
|
105
122
|
end
|
106
123
|
end
|
107
124
|
|
125
|
+
def set_message_endpoint(endpoint)
|
126
|
+
uri = URI.parse(endpoint)
|
127
|
+
|
128
|
+
@messages_url = if uri.host.nil?
|
129
|
+
"#{@root_url}#{endpoint}"
|
130
|
+
else
|
131
|
+
endpoint
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
108
135
|
def sse_thread_running?
|
109
|
-
@sse_thread
|
136
|
+
@sse_thread&.alive?
|
110
137
|
end
|
111
138
|
|
112
139
|
def listen_for_events
|
@@ -160,14 +187,19 @@ module RubyLLM
|
|
160
187
|
def process_event(raw_event)
|
161
188
|
return if raw_event[:data].nil?
|
162
189
|
|
163
|
-
event
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
190
|
+
if raw_event[:event] == "endpoint"
|
191
|
+
request_id = "endpoint"
|
192
|
+
event = raw_event[:data]
|
193
|
+
else
|
194
|
+
event = begin
|
195
|
+
JSON.parse(raw_event[:data])
|
196
|
+
rescue StandardError
|
197
|
+
nil
|
198
|
+
end
|
199
|
+
return if event.nil?
|
169
200
|
|
170
|
-
|
201
|
+
request_id = event["id"]&.to_s
|
202
|
+
end
|
171
203
|
|
172
204
|
@pending_mutex.synchronize do
|
173
205
|
if request_id && @pending_requests.key?(request_id)
|
@@ -27,10 +27,12 @@ module RubyLLM
|
|
27
27
|
start_process
|
28
28
|
end
|
29
29
|
|
30
|
-
def request(body, wait_for_response: true)
|
31
|
-
|
32
|
-
|
33
|
-
|
30
|
+
def request(body, add_id: true, wait_for_response: true)
|
31
|
+
if add_id
|
32
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
33
|
+
request_id = @id_counter
|
34
|
+
body["id"] = request_id
|
35
|
+
end
|
34
36
|
|
35
37
|
response_queue = Queue.new
|
36
38
|
if wait_for_response
|
@@ -150,9 +152,7 @@ module RubyLLM
|
|
150
152
|
response = begin
|
151
153
|
JSON.parse(line)
|
152
154
|
rescue JSON::ParserError => e
|
153
|
-
|
154
|
-
puts "Raw response: #{line}"
|
155
|
-
return
|
155
|
+
raise "Error parsing response as JSON: #{e.message}\nRaw response: #{line}"
|
156
156
|
end
|
157
157
|
request_id = response["id"]&.to_s
|
158
158
|
|
@@ -1,20 +1,290 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "json"
|
4
|
+
require "uri"
|
5
|
+
require "faraday"
|
6
|
+
require "timeout"
|
7
|
+
require "securerandom"
|
8
|
+
|
3
9
|
module RubyLLM
|
4
10
|
module MCP
|
5
11
|
module Transport
|
6
12
|
class Streamable
|
13
|
+
attr_reader :headers, :id, :session_id
|
14
|
+
|
7
15
|
def initialize(url, headers: {})
|
8
16
|
@url = url
|
9
|
-
@
|
17
|
+
@client_id = SecureRandom.uuid
|
18
|
+
@session_id = nil
|
19
|
+
@base_headers = headers.merge({
|
20
|
+
"Content-Type" => "application/json",
|
21
|
+
"Accept" => "application/json, text/event-stream",
|
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
|
+
@running = true
|
31
|
+
@sse_streams = {}
|
32
|
+
@sse_mutex = Mutex.new
|
33
|
+
|
34
|
+
# Initialize HTTP connection
|
35
|
+
@connection = create_connection
|
10
36
|
end
|
11
37
|
|
12
|
-
def request(
|
13
|
-
#
|
38
|
+
def request(body, add_id: true, wait_for_response: true)
|
39
|
+
# Generate a unique request ID for requests
|
40
|
+
if add_id && body.is_a?(Hash) && !body.key?("id")
|
41
|
+
@id_mutex.synchronize { @id_counter += 1 }
|
42
|
+
body["id"] = @id_counter
|
43
|
+
end
|
44
|
+
|
45
|
+
request_id = body.is_a?(Hash) ? body["id"] : nil
|
46
|
+
is_initialization = body.is_a?(Hash) && body["method"] == "initialize"
|
47
|
+
|
48
|
+
# Create a queue for this request's response if needed
|
49
|
+
response_queue = setup_response_queue(request_id, wait_for_response)
|
50
|
+
|
51
|
+
# Send the HTTP request
|
52
|
+
response = send_http_request(body, request_id, is_initialization: is_initialization)
|
53
|
+
|
54
|
+
# Handle different response types based on content
|
55
|
+
handle_response(response, request_id, response_queue, wait_for_response)
|
14
56
|
end
|
15
57
|
|
16
58
|
def close
|
17
|
-
|
59
|
+
@running = false
|
60
|
+
@sse_mutex.synchronize do
|
61
|
+
@sse_streams.each_value(&:close)
|
62
|
+
@sse_streams.clear
|
63
|
+
end
|
64
|
+
@connection&.close if @connection.respond_to?(:close)
|
65
|
+
@connection = nil
|
66
|
+
end
|
67
|
+
|
68
|
+
def terminate_session
|
69
|
+
return unless @session_id
|
70
|
+
|
71
|
+
begin
|
72
|
+
response = @connection.delete do |req|
|
73
|
+
build_headers.each { |key, value| req.headers[key] = value }
|
74
|
+
end
|
75
|
+
@session_id = nil if response.status == 200
|
76
|
+
rescue StandardError => e
|
77
|
+
# Server may not support session termination (405), which is allowed
|
78
|
+
puts "Warning: Failed to terminate session: #{e.message}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def create_connection
|
85
|
+
Faraday.new(url: @url) do |f|
|
86
|
+
f.options.timeout = 300
|
87
|
+
f.options.open_timeout = 10
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def build_headers
|
92
|
+
headers = @base_headers.dup
|
93
|
+
headers["Mcp-Session-Id"] = @session_id if @session_id
|
94
|
+
headers
|
95
|
+
end
|
96
|
+
|
97
|
+
def build_initialization_headers
|
98
|
+
@base_headers.dup
|
99
|
+
end
|
100
|
+
|
101
|
+
def setup_response_queue(request_id, wait_for_response)
|
102
|
+
response_queue = Queue.new
|
103
|
+
if wait_for_response && request_id
|
104
|
+
@pending_mutex.synchronize do
|
105
|
+
@pending_requests[request_id.to_s] = response_queue
|
106
|
+
end
|
107
|
+
end
|
108
|
+
response_queue
|
109
|
+
end
|
110
|
+
|
111
|
+
def send_http_request(body, request_id, is_initialization: false)
|
112
|
+
@connection.post do |req|
|
113
|
+
headers = is_initialization ? build_initialization_headers : build_headers
|
114
|
+
headers.each { |key, value| req.headers[key] = value }
|
115
|
+
req.body = JSON.generate(body)
|
116
|
+
end
|
117
|
+
rescue StandardError => e
|
118
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
119
|
+
raise e
|
120
|
+
end
|
121
|
+
|
122
|
+
def handle_response(response, request_id, response_queue, wait_for_response)
|
123
|
+
case response.status
|
124
|
+
when 200
|
125
|
+
handle_200_response(response, request_id, response_queue, wait_for_response)
|
126
|
+
when 202
|
127
|
+
# Accepted - for notifications/responses only, no body expected
|
128
|
+
nil
|
129
|
+
when 400..499
|
130
|
+
handle_client_error(response)
|
131
|
+
when 404
|
132
|
+
handle_session_expired
|
133
|
+
else
|
134
|
+
raise "HTTP request failed: #{response.status} - #{response.body}"
|
135
|
+
end
|
136
|
+
rescue StandardError => e
|
137
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
138
|
+
raise e
|
139
|
+
end
|
140
|
+
|
141
|
+
def handle_200_response(response, request_id, response_queue, wait_for_response)
|
142
|
+
content_type = response.headers["content-type"]
|
143
|
+
|
144
|
+
if content_type&.include?("text/event-stream")
|
145
|
+
handle_sse_response(response, request_id, response_queue, wait_for_response)
|
146
|
+
elsif content_type&.include?("application/json")
|
147
|
+
handle_json_response(response, request_id, response_queue, wait_for_response)
|
148
|
+
else
|
149
|
+
raise "Unexpected content type: #{content_type}"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def handle_sse_response(response, request_id, response_queue, wait_for_response)
|
154
|
+
# Extract session ID from initial response if present
|
155
|
+
extract_session_id(response)
|
156
|
+
|
157
|
+
if wait_for_response && request_id
|
158
|
+
# Process SSE stream for this specific request
|
159
|
+
process_sse_for_request(response.body, request_id.to_s, response_queue)
|
160
|
+
# Wait for the response with timeout
|
161
|
+
wait_for_response_with_timeout(request_id.to_s, response_queue)
|
162
|
+
else
|
163
|
+
# Process general SSE stream
|
164
|
+
process_sse_stream(response.body)
|
165
|
+
nil
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def handle_json_response(response, request_id, response_queue, wait_for_response)
|
170
|
+
# Extract session ID from response if present
|
171
|
+
extract_session_id(response)
|
172
|
+
|
173
|
+
begin
|
174
|
+
json_response = JSON.parse(response.body)
|
175
|
+
|
176
|
+
if wait_for_response && request_id && response_queue
|
177
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
178
|
+
return json_response
|
179
|
+
end
|
180
|
+
|
181
|
+
json_response
|
182
|
+
rescue JSON::ParserError => e
|
183
|
+
raise "Invalid JSON response: #{e.message}"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
def extract_session_id(response)
|
188
|
+
session_id = response.headers["Mcp-Session-Id"]
|
189
|
+
@session_id = session_id if session_id
|
190
|
+
end
|
191
|
+
|
192
|
+
def handle_client_error(response)
|
193
|
+
begin
|
194
|
+
error_body = JSON.parse(response.body)
|
195
|
+
if error_body.is_a?(Hash) && error_body["error"]
|
196
|
+
error_message = error_body["error"]["message"] || error_body["error"]["code"]
|
197
|
+
|
198
|
+
if error_message.to_s.downcase.include?("session")
|
199
|
+
raise "Server error: #{error_message} (Current session ID: #{@session_id || 'none'})"
|
200
|
+
end
|
201
|
+
|
202
|
+
raise "Server error: #{error_message}"
|
203
|
+
|
204
|
+
end
|
205
|
+
rescue JSON::ParserError
|
206
|
+
# Fall through to generic error
|
207
|
+
end
|
208
|
+
|
209
|
+
raise "HTTP client error: #{response.status} - #{response.body}"
|
210
|
+
end
|
211
|
+
|
212
|
+
def handle_session_expired
|
213
|
+
@session_id = nil
|
214
|
+
raise RubyLLM::MCP::Errors::SessionExpiredError.new(
|
215
|
+
message: "Session expired, re-initialization required"
|
216
|
+
)
|
217
|
+
end
|
218
|
+
|
219
|
+
def process_sse_for_request(sse_body, request_id, response_queue)
|
220
|
+
Thread.new do
|
221
|
+
process_sse_events(sse_body) do |event_data|
|
222
|
+
if event_data.is_a?(Hash) && event_data["id"]&.to_s == request_id
|
223
|
+
response_queue.push(event_data)
|
224
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id) }
|
225
|
+
break # Found our response, stop processing
|
226
|
+
end
|
227
|
+
end
|
228
|
+
rescue StandardError => e
|
229
|
+
puts "Error processing SSE stream: #{e.message}"
|
230
|
+
response_queue.push({ "error" => { "message" => e.message } })
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
def process_sse_stream(sse_body)
|
235
|
+
Thread.new do
|
236
|
+
process_sse_events(sse_body) do |event_data|
|
237
|
+
# Handle server-initiated requests/notifications
|
238
|
+
handle_server_message(event_data) if event_data.is_a?(Hash)
|
239
|
+
end
|
240
|
+
rescue StandardError => e
|
241
|
+
puts "Error processing SSE stream: #{e.message}"
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def process_sse_events(sse_body)
|
246
|
+
event_buffer = ""
|
247
|
+
event_id = nil
|
248
|
+
|
249
|
+
sse_body.each_line do |line|
|
250
|
+
line = line.strip
|
251
|
+
|
252
|
+
if line.empty?
|
253
|
+
# End of event, process accumulated data
|
254
|
+
unless event_buffer.empty?
|
255
|
+
begin
|
256
|
+
event_data = JSON.parse(event_buffer)
|
257
|
+
yield event_data
|
258
|
+
rescue JSON::ParserError
|
259
|
+
puts "Warning: Failed to parse SSE event data: #{event_buffer}"
|
260
|
+
end
|
261
|
+
event_buffer = ""
|
262
|
+
end
|
263
|
+
elsif line.start_with?("id:")
|
264
|
+
event_id = line[3..].strip
|
265
|
+
elsif line.start_with?("data:")
|
266
|
+
data = line[5..].strip
|
267
|
+
event_buffer += data
|
268
|
+
elsif line.start_with?("event:")
|
269
|
+
# Event type - could be used for different message types
|
270
|
+
# For now, we treat all as data events
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
def handle_server_message(message)
|
276
|
+
# Handle server-initiated requests and notifications
|
277
|
+
# This would typically be passed to a message handler
|
278
|
+
puts "Received server message: #{message.inspect}"
|
279
|
+
end
|
280
|
+
|
281
|
+
def wait_for_response_with_timeout(request_id, response_queue)
|
282
|
+
Timeout.timeout(30) do
|
283
|
+
response_queue.pop
|
284
|
+
end
|
285
|
+
rescue Timeout::Error
|
286
|
+
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
287
|
+
raise RubyLLM::MCP::Errors::TimeoutError.new(message: "Request timed out after 30 seconds")
|
18
288
|
end
|
19
289
|
end
|
20
290
|
end
|
data/lib/ruby_llm/mcp/version.rb
CHANGED
data/lib/ruby_llm/mcp.rb
CHANGED
@@ -17,8 +17,8 @@ module RubyLLM
|
|
17
17
|
end
|
18
18
|
|
19
19
|
def support_complex_parameters!
|
20
|
-
require_relative "providers/open_ai/complex_parameter_support"
|
21
|
-
require_relative "providers/anthropic/complex_parameter_support"
|
20
|
+
require_relative "mcp/providers/open_ai/complex_parameter_support"
|
21
|
+
require_relative "mcp/providers/anthropic/complex_parameter_support"
|
22
22
|
end
|
23
23
|
end
|
24
24
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby_llm-mcp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Patrick Vice
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: faraday
|
@@ -72,14 +72,14 @@ dependencies:
|
|
72
72
|
requirements:
|
73
73
|
- - "~>"
|
74
74
|
- !ruby/object:Gem::Version
|
75
|
-
version: '1.
|
75
|
+
version: '1.3'
|
76
76
|
type: :runtime
|
77
77
|
prerelease: false
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
79
79
|
requirements:
|
80
80
|
- - "~>"
|
81
81
|
- !ruby/object:Gem::Version
|
82
|
-
version: '1.
|
82
|
+
version: '1.3'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
84
|
name: zeitwerk
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -108,22 +108,27 @@ files:
|
|
108
108
|
- LICENSE
|
109
109
|
- README.md
|
110
110
|
- lib/ruby_llm/mcp.rb
|
111
|
+
- lib/ruby_llm/mcp/capabilities.rb
|
111
112
|
- lib/ruby_llm/mcp/client.rb
|
112
113
|
- lib/ruby_llm/mcp/errors.rb
|
113
114
|
- lib/ruby_llm/mcp/parameter.rb
|
114
115
|
- lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb
|
115
116
|
- lib/ruby_llm/mcp/providers/open_ai/complex_parameter_support.rb
|
116
117
|
- lib/ruby_llm/mcp/requests/base.rb
|
118
|
+
- lib/ruby_llm/mcp/requests/completion.rb
|
117
119
|
- lib/ruby_llm/mcp/requests/initialization.rb
|
118
120
|
- lib/ruby_llm/mcp/requests/notification.rb
|
121
|
+
- lib/ruby_llm/mcp/requests/resource_list.rb
|
122
|
+
- lib/ruby_llm/mcp/requests/resource_read.rb
|
123
|
+
- lib/ruby_llm/mcp/requests/resource_template_list.rb
|
119
124
|
- lib/ruby_llm/mcp/requests/tool_call.rb
|
120
125
|
- lib/ruby_llm/mcp/requests/tool_list.rb
|
126
|
+
- lib/ruby_llm/mcp/resource.rb
|
121
127
|
- lib/ruby_llm/mcp/tool.rb
|
122
128
|
- lib/ruby_llm/mcp/transport/sse.rb
|
123
129
|
- lib/ruby_llm/mcp/transport/stdio.rb
|
124
130
|
- lib/ruby_llm/mcp/transport/streamable.rb
|
125
131
|
- lib/ruby_llm/mcp/version.rb
|
126
|
-
- lib/ruby_llm/overrides.rb
|
127
132
|
homepage: https://github.com/patvice/ruby_llm-mcp
|
128
133
|
licenses:
|
129
134
|
- MIT
|
@@ -150,7 +155,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
150
155
|
- !ruby/object:Gem::Version
|
151
156
|
version: '0'
|
152
157
|
requirements: []
|
153
|
-
rubygems_version: 3.
|
158
|
+
rubygems_version: 3.3.7
|
154
159
|
signing_key:
|
155
160
|
specification_version: 4
|
156
161
|
summary: A RubyLLM MCP Client
|
data/lib/ruby_llm/overrides.rb
DELETED
@@ -1,21 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module RubyLLM
|
4
|
-
module ToolsComplexParametersSupport
|
5
|
-
def param_schema(param)
|
6
|
-
format = {
|
7
|
-
type: param.type,
|
8
|
-
description: param.description
|
9
|
-
}.compact
|
10
|
-
|
11
|
-
if param.type == "array"
|
12
|
-
format[:items] = param.items
|
13
|
-
elsif param.type == "object"
|
14
|
-
format[:properties] = param.properties
|
15
|
-
end
|
16
|
-
format
|
17
|
-
end
|
18
|
-
end
|
19
|
-
end
|
20
|
-
|
21
|
-
RubyLLM::Providers::OpenAI.extend(RubyLLM::ToolsComplexParametersSupport)
|