ruby_llm-mcp 0.3.0 → 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 +104 -136
- data/lib/ruby_llm/mcp/configuration.rb +66 -0
- data/lib/ruby_llm/mcp/coordinator.rb +276 -0
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +38 -3
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/parameter.rb +5 -2
- data/lib/ruby_llm/mcp/progress.rb +33 -0
- data/lib/ruby_llm/mcp/prompt.rb +20 -13
- data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +7 -3
- data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +8 -4
- data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +8 -4
- 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 +20 -0
- 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 +28 -7
- data/lib/ruby_llm/mcp/resource_template.rb +17 -12
- data/lib/ruby_llm/mcp/result.rb +90 -0
- data/lib/ruby_llm/mcp/tool.rb +36 -10
- data/lib/ruby_llm/mcp/transport/sse.rb +82 -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 +22 -51
- data/lib/ruby_llm/mcp/requests/notification.rb +0 -14
- 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,32 +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
|
76
|
+
- lib/ruby_llm/mcp/coordinator.rb
|
77
|
+
- lib/ruby_llm/mcp/error.rb
|
117
78
|
- lib/ruby_llm/mcp/errors.rb
|
79
|
+
- lib/ruby_llm/mcp/logging.rb
|
118
80
|
- lib/ruby_llm/mcp/parameter.rb
|
81
|
+
- lib/ruby_llm/mcp/progress.rb
|
119
82
|
- lib/ruby_llm/mcp/prompt.rb
|
120
83
|
- lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb
|
121
84
|
- lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb
|
122
85
|
- lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb
|
123
86
|
- lib/ruby_llm/mcp/requests/base.rb
|
87
|
+
- lib/ruby_llm/mcp/requests/cancelled_notification.rb
|
124
88
|
- lib/ruby_llm/mcp/requests/completion_prompt.rb
|
125
89
|
- lib/ruby_llm/mcp/requests/completion_resource.rb
|
126
90
|
- lib/ruby_llm/mcp/requests/initialization.rb
|
127
|
-
- lib/ruby_llm/mcp/requests/
|
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
|
128
96
|
- lib/ruby_llm/mcp/requests/prompt_call.rb
|
129
97
|
- lib/ruby_llm/mcp/requests/prompt_list.rb
|
130
98
|
- lib/ruby_llm/mcp/requests/resource_list.rb
|
131
99
|
- lib/ruby_llm/mcp/requests/resource_read.rb
|
132
100
|
- lib/ruby_llm/mcp/requests/resource_template_list.rb
|
101
|
+
- lib/ruby_llm/mcp/requests/resources_subscribe.rb
|
133
102
|
- lib/ruby_llm/mcp/requests/tool_call.rb
|
134
103
|
- lib/ruby_llm/mcp/requests/tool_list.rb
|
135
104
|
- lib/ruby_llm/mcp/resource.rb
|
136
105
|
- lib/ruby_llm/mcp/resource_template.rb
|
106
|
+
- lib/ruby_llm/mcp/result.rb
|
137
107
|
- lib/ruby_llm/mcp/tool.rb
|
138
108
|
- lib/ruby_llm/mcp/transport/sse.rb
|
139
109
|
- lib/ruby_llm/mcp/transport/stdio.rb
|
140
|
-
- lib/ruby_llm/mcp/transport/
|
110
|
+
- lib/ruby_llm/mcp/transport/streamable_http.rb
|
141
111
|
- lib/ruby_llm/mcp/version.rb
|
112
|
+
- lib/tasks/release.rake
|
142
113
|
homepage: https://github.com/patvice/ruby_llm-mcp
|
143
114
|
licenses:
|
144
115
|
- MIT
|
@@ -1,14 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
class RubyLLM::MCP::Requests::Notification < RubyLLM::MCP::Requests::Base
|
4
|
-
def call
|
5
|
-
client.request(notification_body, add_id: false, 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
|
@@ -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
|