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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +121 -2
  3. data/lib/ruby_llm/mcp/capabilities.rb +22 -2
  4. data/lib/ruby_llm/mcp/client.rb +106 -18
  5. data/lib/ruby_llm/mcp/configuration.rb +66 -0
  6. data/lib/ruby_llm/mcp/coordinator.rb +197 -33
  7. data/lib/ruby_llm/mcp/error.rb +34 -0
  8. data/lib/ruby_llm/mcp/errors.rb +37 -4
  9. data/lib/ruby_llm/mcp/logging.rb +16 -0
  10. data/lib/ruby_llm/mcp/parameter.rb +2 -0
  11. data/lib/ruby_llm/mcp/progress.rb +33 -0
  12. data/lib/ruby_llm/mcp/prompt.rb +12 -5
  13. data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +5 -2
  14. data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +6 -3
  15. data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +6 -3
  16. data/lib/ruby_llm/mcp/requests/base.rb +3 -3
  17. data/lib/ruby_llm/mcp/requests/cancelled_notification.rb +32 -0
  18. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +3 -3
  19. data/lib/ruby_llm/mcp/requests/completion_resource.rb +3 -3
  20. data/lib/ruby_llm/mcp/requests/initialization.rb +24 -18
  21. data/lib/ruby_llm/mcp/requests/initialize_notification.rb +15 -9
  22. data/lib/ruby_llm/mcp/requests/logging_set_level.rb +28 -0
  23. data/lib/ruby_llm/mcp/requests/meta.rb +30 -0
  24. data/lib/ruby_llm/mcp/requests/ping.rb +20 -0
  25. data/lib/ruby_llm/mcp/requests/ping_response.rb +28 -0
  26. data/lib/ruby_llm/mcp/requests/prompt_call.rb +3 -3
  27. data/lib/ruby_llm/mcp/requests/prompt_list.rb +1 -1
  28. data/lib/ruby_llm/mcp/requests/resource_list.rb +1 -1
  29. data/lib/ruby_llm/mcp/requests/resource_read.rb +4 -4
  30. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +1 -1
  31. data/lib/ruby_llm/mcp/requests/resources_subscribe.rb +30 -0
  32. data/lib/ruby_llm/mcp/requests/tool_call.rb +6 -3
  33. data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -11
  34. data/lib/ruby_llm/mcp/resource.rb +26 -5
  35. data/lib/ruby_llm/mcp/resource_template.rb +11 -6
  36. data/lib/ruby_llm/mcp/result.rb +90 -0
  37. data/lib/ruby_llm/mcp/tool.rb +28 -3
  38. data/lib/ruby_llm/mcp/transport/sse.rb +81 -75
  39. data/lib/ruby_llm/mcp/transport/stdio.rb +33 -17
  40. data/lib/ruby_llm/mcp/transport/streamable_http.rb +647 -0
  41. data/lib/ruby_llm/mcp/version.rb +1 -1
  42. data/lib/ruby_llm/mcp.rb +18 -0
  43. data/lib/tasks/release.rake +23 -0
  44. metadata +20 -50
  45. 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.3.1
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-06-30 00:00:00.000000000 Z
11
+ date: 2025-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: faraday
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/streamable.rb
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