ruby_llm-mcp 0.3.1 → 0.4.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 +121 -2
- data/lib/ruby_llm/mcp/capabilities.rb +22 -2
- data/lib/ruby_llm/mcp/client.rb +106 -18
- data/lib/ruby_llm/mcp/configuration.rb +66 -0
- data/lib/ruby_llm/mcp/coordinator.rb +197 -33
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +37 -4
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/parameter.rb +2 -0
- data/lib/ruby_llm/mcp/progress.rb +33 -0
- data/lib/ruby_llm/mcp/prompt.rb +12 -5
- data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +5 -2
- data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +6 -3
- data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +6 -3
- data/lib/ruby_llm/mcp/requests/base.rb +3 -3
- data/lib/ruby_llm/mcp/requests/cancelled_notification.rb +32 -0
- data/lib/ruby_llm/mcp/requests/completion_prompt.rb +3 -3
- data/lib/ruby_llm/mcp/requests/completion_resource.rb +3 -3
- data/lib/ruby_llm/mcp/requests/initialization.rb +24 -18
- data/lib/ruby_llm/mcp/requests/initialize_notification.rb +15 -9
- data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
- data/lib/ruby_llm/mcp/requests/meta.rb +30 -0
- data/lib/ruby_llm/mcp/requests/ping.rb +20 -0
- data/lib/ruby_llm/mcp/requests/ping_response.rb +28 -0
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +3 -3
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resource_list.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resource_read.rb +4 -4
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +1 -1
- data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
- data/lib/ruby_llm/mcp/requests/tool_call.rb +6 -3
- data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -11
- data/lib/ruby_llm/mcp/resource.rb +26 -5
- data/lib/ruby_llm/mcp/resource_template.rb +11 -6
- data/lib/ruby_llm/mcp/result.rb +90 -0
- data/lib/ruby_llm/mcp/tool.rb +28 -3
- data/lib/ruby_llm/mcp/transport/sse.rb +81 -75
- data/lib/ruby_llm/mcp/transport/stdio.rb +33 -17
- data/lib/ruby_llm/mcp/transport/streamable_http.rb +647 -0
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +18 -0
- data/lib/tasks/release.rake +23 -0
- metadata +20 -50
- data/lib/ruby_llm/mcp/transport/streamable.rb +0 -299
metadata
CHANGED
@@ -1,71 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ruby_llm-mcp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.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-07-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: httpx
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
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
|
-
- - ">="
|
17
|
+
- - "~>"
|
60
18
|
- !ruby/object:Gem::Version
|
61
|
-
version: '1'
|
19
|
+
version: '1.4'
|
62
20
|
type: :runtime
|
63
21
|
prerelease: false
|
64
22
|
version_requirements: !ruby/object:Gem::Requirement
|
65
23
|
requirements:
|
66
|
-
- - "
|
24
|
+
- - "~>"
|
67
25
|
- !ruby/object:Gem::Version
|
68
|
-
version: '1'
|
26
|
+
version: '1.4'
|
69
27
|
- !ruby/object:Gem::Dependency
|
70
28
|
name: ruby_llm
|
71
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -113,33 +71,45 @@ files:
|
|
113
71
|
- lib/ruby_llm/mcp/capabilities.rb
|
114
72
|
- lib/ruby_llm/mcp/client.rb
|
115
73
|
- lib/ruby_llm/mcp/completion.rb
|
74
|
+
- lib/ruby_llm/mcp/configuration.rb
|
116
75
|
- lib/ruby_llm/mcp/content.rb
|
117
76
|
- lib/ruby_llm/mcp/coordinator.rb
|
77
|
+
- lib/ruby_llm/mcp/error.rb
|
118
78
|
- lib/ruby_llm/mcp/errors.rb
|
79
|
+
- lib/ruby_llm/mcp/logging.rb
|
119
80
|
- lib/ruby_llm/mcp/parameter.rb
|
81
|
+
- lib/ruby_llm/mcp/progress.rb
|
120
82
|
- lib/ruby_llm/mcp/prompt.rb
|
121
83
|
- lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb
|
122
84
|
- lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb
|
123
85
|
- lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb
|
124
86
|
- lib/ruby_llm/mcp/requests/base.rb
|
87
|
+
- lib/ruby_llm/mcp/requests/cancelled_notification.rb
|
125
88
|
- lib/ruby_llm/mcp/requests/completion_prompt.rb
|
126
89
|
- lib/ruby_llm/mcp/requests/completion_resource.rb
|
127
90
|
- lib/ruby_llm/mcp/requests/initialization.rb
|
128
91
|
- lib/ruby_llm/mcp/requests/initialize_notification.rb
|
92
|
+
- lib/ruby_llm/mcp/requests/logging_set_level.rb
|
93
|
+
- lib/ruby_llm/mcp/requests/meta.rb
|
94
|
+
- lib/ruby_llm/mcp/requests/ping.rb
|
95
|
+
- lib/ruby_llm/mcp/requests/ping_response.rb
|
129
96
|
- lib/ruby_llm/mcp/requests/prompt_call.rb
|
130
97
|
- lib/ruby_llm/mcp/requests/prompt_list.rb
|
131
98
|
- lib/ruby_llm/mcp/requests/resource_list.rb
|
132
99
|
- lib/ruby_llm/mcp/requests/resource_read.rb
|
133
100
|
- lib/ruby_llm/mcp/requests/resource_template_list.rb
|
101
|
+
- lib/ruby_llm/mcp/requests/resources_subscribe.rb
|
134
102
|
- lib/ruby_llm/mcp/requests/tool_call.rb
|
135
103
|
- lib/ruby_llm/mcp/requests/tool_list.rb
|
136
104
|
- lib/ruby_llm/mcp/resource.rb
|
137
105
|
- lib/ruby_llm/mcp/resource_template.rb
|
106
|
+
- lib/ruby_llm/mcp/result.rb
|
138
107
|
- lib/ruby_llm/mcp/tool.rb
|
139
108
|
- lib/ruby_llm/mcp/transport/sse.rb
|
140
109
|
- lib/ruby_llm/mcp/transport/stdio.rb
|
141
|
-
- lib/ruby_llm/mcp/transport/
|
110
|
+
- lib/ruby_llm/mcp/transport/streamable_http.rb
|
142
111
|
- lib/ruby_llm/mcp/version.rb
|
112
|
+
- lib/tasks/release.rake
|
143
113
|
homepage: https://github.com/patvice/ruby_llm-mcp
|
144
114
|
licenses:
|
145
115
|
- MIT
|
@@ -1,299 +0,0 @@
|
|
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 Streamable
|
13
|
-
attr_reader :headers, :id, :session_id
|
14
|
-
|
15
|
-
def initialize(url, request_timeout:, headers: {})
|
16
|
-
@url = url
|
17
|
-
@request_timeout = request_timeout
|
18
|
-
@client_id = SecureRandom.uuid
|
19
|
-
@session_id = nil
|
20
|
-
@base_headers = headers.merge({
|
21
|
-
"Content-Type" => "application/json",
|
22
|
-
"Accept" => "application/json, text/event-stream",
|
23
|
-
"Connection" => "keep-alive",
|
24
|
-
"X-CLIENT-ID" => @client_id
|
25
|
-
})
|
26
|
-
|
27
|
-
@id_counter = 0
|
28
|
-
@id_mutex = Mutex.new
|
29
|
-
@pending_requests = {}
|
30
|
-
@pending_mutex = Mutex.new
|
31
|
-
@running = true
|
32
|
-
@sse_streams = {}
|
33
|
-
@sse_mutex = Mutex.new
|
34
|
-
|
35
|
-
# Initialize HTTP connection
|
36
|
-
@connection = create_connection
|
37
|
-
end
|
38
|
-
|
39
|
-
def request(body, add_id: true, wait_for_response: true)
|
40
|
-
# Generate a unique request ID for requests
|
41
|
-
if add_id && body.is_a?(Hash) && !body.key?("id")
|
42
|
-
@id_mutex.synchronize { @id_counter += 1 }
|
43
|
-
body["id"] = @id_counter
|
44
|
-
end
|
45
|
-
|
46
|
-
request_id = body.is_a?(Hash) ? body["id"] : nil
|
47
|
-
is_initialization = body.is_a?(Hash) && body["method"] == "initialize"
|
48
|
-
|
49
|
-
# Create a queue for this request's response if needed
|
50
|
-
response_queue = setup_response_queue(request_id, wait_for_response)
|
51
|
-
|
52
|
-
# Send the HTTP request
|
53
|
-
response = send_http_request(body, request_id, is_initialization: is_initialization)
|
54
|
-
|
55
|
-
# Handle different response types based on content
|
56
|
-
handle_response(response, request_id, response_queue, wait_for_response)
|
57
|
-
end
|
58
|
-
|
59
|
-
def alive?
|
60
|
-
@running
|
61
|
-
end
|
62
|
-
|
63
|
-
def close
|
64
|
-
@running = false
|
65
|
-
@sse_mutex.synchronize do
|
66
|
-
@sse_streams.each_value(&:close)
|
67
|
-
@sse_streams.clear
|
68
|
-
end
|
69
|
-
@connection&.close if @connection.respond_to?(:close)
|
70
|
-
@connection = nil
|
71
|
-
end
|
72
|
-
|
73
|
-
def terminate_session
|
74
|
-
return unless @session_id
|
75
|
-
|
76
|
-
begin
|
77
|
-
response = @connection.delete do |req|
|
78
|
-
build_headers.each { |key, value| req.headers[key] = value }
|
79
|
-
end
|
80
|
-
@session_id = nil if response.status == 200
|
81
|
-
rescue StandardError => e
|
82
|
-
# Server may not support session termination (405), which is allowed
|
83
|
-
puts "Warning: Failed to terminate session: #{e.message}"
|
84
|
-
end
|
85
|
-
end
|
86
|
-
|
87
|
-
private
|
88
|
-
|
89
|
-
def create_connection
|
90
|
-
Faraday.new(url: @url) do |f|
|
91
|
-
f.options.timeout = @request_timeout / 1000
|
92
|
-
f.options.open_timeout = 10
|
93
|
-
end
|
94
|
-
end
|
95
|
-
|
96
|
-
def build_headers
|
97
|
-
headers = @base_headers.dup
|
98
|
-
headers["Mcp-Session-Id"] = @session_id if @session_id
|
99
|
-
headers
|
100
|
-
end
|
101
|
-
|
102
|
-
def build_initialization_headers
|
103
|
-
@base_headers.dup
|
104
|
-
end
|
105
|
-
|
106
|
-
def setup_response_queue(request_id, wait_for_response)
|
107
|
-
response_queue = Queue.new
|
108
|
-
if wait_for_response && request_id
|
109
|
-
@pending_mutex.synchronize do
|
110
|
-
@pending_requests[request_id.to_s] = response_queue
|
111
|
-
end
|
112
|
-
end
|
113
|
-
response_queue
|
114
|
-
end
|
115
|
-
|
116
|
-
def send_http_request(body, request_id, is_initialization: false)
|
117
|
-
@connection.post do |req|
|
118
|
-
headers = is_initialization ? build_initialization_headers : build_headers
|
119
|
-
headers.each { |key, value| req.headers[key] = value }
|
120
|
-
req.body = JSON.generate(body)
|
121
|
-
end
|
122
|
-
rescue StandardError => e
|
123
|
-
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
124
|
-
raise e
|
125
|
-
end
|
126
|
-
|
127
|
-
def handle_response(response, request_id, response_queue, wait_for_response)
|
128
|
-
case response.status
|
129
|
-
when 200
|
130
|
-
handle_200_response(response, request_id, response_queue, wait_for_response)
|
131
|
-
when 202
|
132
|
-
# Accepted - for notifications/responses only, no body expected
|
133
|
-
nil
|
134
|
-
when 400..499
|
135
|
-
handle_client_error(response)
|
136
|
-
when 404
|
137
|
-
handle_session_expired
|
138
|
-
else
|
139
|
-
raise "HTTP request failed: #{response.status} - #{response.body}"
|
140
|
-
end
|
141
|
-
rescue StandardError => e
|
142
|
-
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) } if request_id
|
143
|
-
raise e
|
144
|
-
end
|
145
|
-
|
146
|
-
def handle_200_response(response, request_id, response_queue, wait_for_response)
|
147
|
-
content_type = response.headers["content-type"]
|
148
|
-
|
149
|
-
if content_type&.include?("text/event-stream")
|
150
|
-
handle_sse_response(response, request_id, response_queue, wait_for_response)
|
151
|
-
elsif content_type&.include?("application/json")
|
152
|
-
handle_json_response(response, request_id, response_queue, wait_for_response)
|
153
|
-
else
|
154
|
-
raise "Unexpected content type: #{content_type}"
|
155
|
-
end
|
156
|
-
end
|
157
|
-
|
158
|
-
def handle_sse_response(response, request_id, response_queue, wait_for_response)
|
159
|
-
# Extract session ID from initial response if present
|
160
|
-
extract_session_id(response)
|
161
|
-
|
162
|
-
if wait_for_response && request_id
|
163
|
-
# Process SSE stream for this specific request
|
164
|
-
process_sse_for_request(response.body, request_id.to_s, response_queue)
|
165
|
-
# Wait for the response with timeout
|
166
|
-
wait_for_response_with_timeout(request_id.to_s, response_queue)
|
167
|
-
else
|
168
|
-
# Process general SSE stream
|
169
|
-
process_sse_stream(response.body)
|
170
|
-
nil
|
171
|
-
end
|
172
|
-
end
|
173
|
-
|
174
|
-
def handle_json_response(response, request_id, response_queue, wait_for_response)
|
175
|
-
# Extract session ID from response if present
|
176
|
-
extract_session_id(response)
|
177
|
-
|
178
|
-
begin
|
179
|
-
json_response = JSON.parse(response.body)
|
180
|
-
|
181
|
-
if wait_for_response && request_id && response_queue
|
182
|
-
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
183
|
-
return json_response
|
184
|
-
end
|
185
|
-
|
186
|
-
json_response
|
187
|
-
rescue JSON::ParserError => e
|
188
|
-
raise "Invalid JSON response: #{e.message}"
|
189
|
-
end
|
190
|
-
end
|
191
|
-
|
192
|
-
def extract_session_id(response)
|
193
|
-
session_id = response.headers["Mcp-Session-Id"]
|
194
|
-
@session_id = session_id if session_id
|
195
|
-
end
|
196
|
-
|
197
|
-
def handle_client_error(response)
|
198
|
-
begin
|
199
|
-
error_body = JSON.parse(response.body)
|
200
|
-
if error_body.is_a?(Hash) && error_body["error"]
|
201
|
-
error_message = error_body["error"]["message"] || error_body["error"]["code"]
|
202
|
-
|
203
|
-
if error_message.to_s.downcase.include?("session")
|
204
|
-
raise "Server error: #{error_message} (Current session ID: #{@session_id || 'none'})"
|
205
|
-
end
|
206
|
-
|
207
|
-
raise "Server error: #{error_message}"
|
208
|
-
|
209
|
-
end
|
210
|
-
rescue JSON::ParserError
|
211
|
-
# Fall through to generic error
|
212
|
-
end
|
213
|
-
|
214
|
-
raise "HTTP client error: #{response.status} - #{response.body}"
|
215
|
-
end
|
216
|
-
|
217
|
-
def handle_session_expired
|
218
|
-
@session_id = nil
|
219
|
-
raise RubyLLM::MCP::Errors::SessionExpiredError.new(
|
220
|
-
message: "Session expired, re-initialization required"
|
221
|
-
)
|
222
|
-
end
|
223
|
-
|
224
|
-
def process_sse_for_request(sse_body, request_id, response_queue)
|
225
|
-
Thread.new do
|
226
|
-
process_sse_events(sse_body) do |event_data|
|
227
|
-
if event_data.is_a?(Hash) && event_data["id"]&.to_s == request_id
|
228
|
-
response_queue.push(event_data)
|
229
|
-
@pending_mutex.synchronize { @pending_requests.delete(request_id) }
|
230
|
-
break # Found our response, stop processing
|
231
|
-
end
|
232
|
-
end
|
233
|
-
rescue StandardError => e
|
234
|
-
puts "Error processing SSE stream: #{e.message}"
|
235
|
-
response_queue.push({ "error" => { "message" => e.message } })
|
236
|
-
end
|
237
|
-
end
|
238
|
-
|
239
|
-
def process_sse_stream(sse_body)
|
240
|
-
Thread.new do
|
241
|
-
process_sse_events(sse_body) do |event_data|
|
242
|
-
# Handle server-initiated requests/notifications
|
243
|
-
handle_server_message(event_data) if event_data.is_a?(Hash)
|
244
|
-
end
|
245
|
-
rescue StandardError => e
|
246
|
-
puts "Error processing SSE stream: #{e.message}"
|
247
|
-
end
|
248
|
-
end
|
249
|
-
|
250
|
-
def process_sse_events(sse_body)
|
251
|
-
event_buffer = ""
|
252
|
-
event_id = nil
|
253
|
-
|
254
|
-
sse_body.each_line do |line|
|
255
|
-
line = line.strip
|
256
|
-
|
257
|
-
if line.empty?
|
258
|
-
# End of event, process accumulated data
|
259
|
-
unless event_buffer.empty?
|
260
|
-
begin
|
261
|
-
event_data = JSON.parse(event_buffer)
|
262
|
-
yield event_data
|
263
|
-
rescue JSON::ParserError
|
264
|
-
puts "Warning: Failed to parse SSE event data: #{event_buffer}"
|
265
|
-
end
|
266
|
-
event_buffer = ""
|
267
|
-
end
|
268
|
-
elsif line.start_with?("id:")
|
269
|
-
event_id = line[3..].strip
|
270
|
-
elsif line.start_with?("data:")
|
271
|
-
data = line[5..].strip
|
272
|
-
event_buffer += data
|
273
|
-
elsif line.start_with?("event:")
|
274
|
-
# Event type - could be used for different message types
|
275
|
-
# For now, we treat all as data events
|
276
|
-
end
|
277
|
-
end
|
278
|
-
end
|
279
|
-
|
280
|
-
def handle_server_message(message)
|
281
|
-
# Handle server-initiated requests and notifications
|
282
|
-
# This would typically be passed to a message handler
|
283
|
-
puts "Received server message: #{message.inspect}"
|
284
|
-
end
|
285
|
-
|
286
|
-
def wait_for_response_with_timeout(request_id, response_queue)
|
287
|
-
Timeout.timeout(@request_timeout / 1000) do
|
288
|
-
response_queue.pop
|
289
|
-
end
|
290
|
-
rescue Timeout::Error
|
291
|
-
@pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
|
292
|
-
raise RubyLLM::MCP::Errors::TimeoutError.new(
|
293
|
-
message: "Request timed out after #{@request_timeout / 1000} seconds"
|
294
|
-
)
|
295
|
-
end
|
296
|
-
end
|
297
|
-
end
|
298
|
-
end
|
299
|
-
end
|