sc-ruby_llm-mcp 0.3.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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +446 -0
  4. data/lib/ruby_llm/chat.rb +33 -0
  5. data/lib/ruby_llm/mcp/attachment.rb +18 -0
  6. data/lib/ruby_llm/mcp/capabilities.rb +29 -0
  7. data/lib/ruby_llm/mcp/client.rb +104 -0
  8. data/lib/ruby_llm/mcp/completion.rb +15 -0
  9. data/lib/ruby_llm/mcp/content.rb +20 -0
  10. data/lib/ruby_llm/mcp/coordinator.rb +112 -0
  11. data/lib/ruby_llm/mcp/errors.rb +28 -0
  12. data/lib/ruby_llm/mcp/parameter.rb +19 -0
  13. data/lib/ruby_llm/mcp/prompt.rb +106 -0
  14. data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +65 -0
  15. data/lib/ruby_llm/mcp/providers/gemini/complex_parameter_support.rb +61 -0
  16. data/lib/ruby_llm/mcp/providers/openai/complex_parameter_support.rb +52 -0
  17. data/lib/ruby_llm/mcp/requests/base.rb +31 -0
  18. data/lib/ruby_llm/mcp/requests/completion_prompt.rb +40 -0
  19. data/lib/ruby_llm/mcp/requests/completion_resource.rb +40 -0
  20. data/lib/ruby_llm/mcp/requests/initialization.rb +24 -0
  21. data/lib/ruby_llm/mcp/requests/initialize_notification.rb +14 -0
  22. data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
  23. data/lib/ruby_llm/mcp/requests/prompt_list.rb +23 -0
  24. data/lib/ruby_llm/mcp/requests/resource_list.rb +21 -0
  25. data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
  26. data/lib/ruby_llm/mcp/requests/resource_template_list.rb +21 -0
  27. data/lib/ruby_llm/mcp/requests/tool_call.rb +32 -0
  28. data/lib/ruby_llm/mcp/requests/tool_list.rb +17 -0
  29. data/lib/ruby_llm/mcp/resource.rb +77 -0
  30. data/lib/ruby_llm/mcp/resource_template.rb +79 -0
  31. data/lib/ruby_llm/mcp/tool.rb +115 -0
  32. data/lib/ruby_llm/mcp/transport/sse.rb +244 -0
  33. data/lib/ruby_llm/mcp/transport/stdio.rb +210 -0
  34. data/lib/ruby_llm/mcp/transport/streamable.rb +299 -0
  35. data/lib/ruby_llm/mcp/version.rb +7 -0
  36. data/lib/ruby_llm/mcp.rb +27 -0
  37. metadata +175 -0
@@ -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,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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Resource
6
+ attr_reader :uri, :name, :description, :mime_type, :coordinator
7
+
8
+ def initialize(coordinator, resource)
9
+ @coordinator = coordinator
10
+ @uri = resource["uri"]
11
+ @name = resource["name"]
12
+ @description = resource["description"]
13
+ @mime_type = resource["mimeType"]
14
+ if resource.key?("content_response")
15
+ @content_response = resource["content_response"]
16
+ @content = @content_response["text"] || @content_response["blob"]
17
+ end
18
+ end
19
+
20
+ def content
21
+ return @content unless @content.nil?
22
+
23
+ response = read_response
24
+ @content_response = response.dig("result", "contents", 0)
25
+ @content = @content_response["text"] || @content_response["blob"]
26
+ end
27
+
28
+ def include(chat, **args)
29
+ message = Message.new(
30
+ role: "user",
31
+ content: to_content(**args)
32
+ )
33
+
34
+ chat.add_message(message)
35
+ end
36
+
37
+ def to_content
38
+ content = self.content
39
+
40
+ case content_type
41
+ when "text"
42
+ MCP::Content.new(text: "#{name}: #{description}\n\n#{content}")
43
+ when "blob"
44
+ attachment = MCP::Attachment.new(content, mime_type)
45
+ MCP::Content.new(text: "#{name}: #{description}", attachments: [attachment])
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def content_type
52
+ return "text" if @content_response.nil?
53
+
54
+ if @content_response.key?("blob")
55
+ "blob"
56
+ else
57
+ "text"
58
+ end
59
+ end
60
+
61
+ def read_response(uri: @uri)
62
+ parsed = URI.parse(uri)
63
+ case parsed.scheme
64
+ when "http", "https"
65
+ fetch_uri_content(uri)
66
+ else # file:// or git://
67
+ @read_response ||= @coordinator.resource_read(uri: uri)
68
+ end
69
+ end
70
+
71
+ def fetch_uri_content(uri)
72
+ response = Faraday.get(uri)
73
+ { "result" => { "contents" => [{ "text" => response.body }] } }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class ResourceTemplate
6
+ attr_reader :uri, :name, :description, :mime_type, :coordinator, :template
7
+
8
+ def initialize(coordinator, resource)
9
+ @coordinator = coordinator
10
+ @uri = resource["uriTemplate"]
11
+ @name = resource["name"]
12
+ @description = resource["description"]
13
+ @mime_type = resource["mimeType"]
14
+ end
15
+
16
+ def fetch_resource(arguments: {})
17
+ uri = apply_template(@uri, arguments)
18
+ response = read_response(uri)
19
+ content_response = response.dig("result", "contents", 0)
20
+
21
+ Resource.new(coordinator, {
22
+ "uri" => uri,
23
+ "name" => "#{@name} (#{uri})",
24
+ "description" => @description,
25
+ "mimeType" => @mime_type,
26
+ "content_response" => content_response
27
+ })
28
+ end
29
+
30
+ def to_content(arguments: {})
31
+ fetch_resource(arguments: arguments).to_content
32
+ end
33
+
34
+ def complete(argument, value)
35
+ if @coordinator.capabilities.completion?
36
+ response = @coordinator.completion_resource(uri: @uri, argument: argument, value: value)
37
+ response = response.dig("result", "completion")
38
+
39
+ Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
40
+ else
41
+ raise Errors::CompletionNotAvailable.new(message: "Completion is not available for this MCP server")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def content_type
48
+ if @content.key?("type")
49
+ @content["type"]
50
+ else
51
+ "text"
52
+ end
53
+ end
54
+
55
+ def read_response(uri)
56
+ parsed = URI.parse(uri)
57
+ case parsed.scheme
58
+ when "http", "https"
59
+ fetch_uri_content(uri)
60
+ else # file:// or git://
61
+ @coordinator.resource_read(uri: uri)
62
+ end
63
+ end
64
+
65
+ def fetch_uri_content(uri)
66
+ response = Faraday.get(uri)
67
+ { "result" => { "contents" => [{ "text" => response.body }] } }
68
+ end
69
+
70
+ def apply_template(uri, arguments)
71
+ uri.gsub(/\{(\w+)\}/) do
72
+ arguments[::Regexp.last_match(1).to_s] ||
73
+ arguments[::Regexp.last_match(1).to_sym] ||
74
+ "{#{::Regexp.last_match(1)}}"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ class Tool < RubyLLM::Tool
6
+ attr_reader :name, :description, :parameters, :coordinator, :tool_response
7
+
8
+ def initialize(coordinator, tool_response)
9
+ super()
10
+ @coordinator = coordinator
11
+
12
+ @name = tool_response["name"]
13
+ @description = tool_response["description"].to_s
14
+ @parameters = create_parameters(tool_response["inputSchema"])
15
+ end
16
+
17
+ def execute(**params)
18
+ response = @coordinator.execute_tool(
19
+ name: @name,
20
+ parameters: params
21
+ )
22
+
23
+ text_values = response.dig("result", "content").map { |content| content["text"] }.compact.join("\n")
24
+
25
+ if text_values.empty?
26
+ create_content_for_message(response.dig("result", "content", 0))
27
+ else
28
+ create_content_for_message({ "type" => "text", "text" => text_values })
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def create_parameters(input_schema)
35
+ params = {}
36
+ return params if input_schema["properties"].nil?
37
+
38
+ input_schema["properties"].each_key do |key|
39
+ param_data = input_schema.dig("properties", key)
40
+
41
+ param = if param_data.key?("oneOf") || param_data.key?("anyOf") || param_data.key?("allOf")
42
+ process_union_parameter(key, param_data)
43
+ else
44
+ process_parameter(key, param_data)
45
+ end
46
+
47
+ params[key] = param
48
+ end
49
+
50
+ params
51
+ end
52
+
53
+ def process_union_parameter(key, param_data)
54
+ union_type = param_data.keys.first
55
+ param = RubyLLM::MCP::Parameter.new(
56
+ key,
57
+ type: :union,
58
+ union_type: union_type
59
+ )
60
+
61
+ param.properties = param_data[union_type].map do |value|
62
+ process_parameter(key, value, lifted_type: param_data["type"])
63
+ end.compact
64
+
65
+ param
66
+ end
67
+
68
+ def process_parameter(key, param_data, lifted_type: nil)
69
+ param = RubyLLM::MCP::Parameter.new(
70
+ key,
71
+ type: param_data["type"] || lifted_type,
72
+ desc: param_data["description"],
73
+ required: param_data["required"]
74
+ )
75
+
76
+ if param.type == :array
77
+ items = param_data["items"]
78
+ param.items = items
79
+ if items.key?("properties")
80
+ param.properties = create_parameters(items)
81
+ end
82
+ if param_data.key?("enum")
83
+ param.enum = param_data["enum"]
84
+ end
85
+ elsif param.type == :object
86
+ if param_data.key?("properties")
87
+ param.properties = create_parameters(param_data)
88
+ end
89
+ end
90
+
91
+ param
92
+ end
93
+
94
+ def create_content_for_message(content)
95
+ case content["type"]
96
+ when "text"
97
+ MCP::Content.new(text: content["text"])
98
+ when "image", "audio"
99
+ attachment = MCP::Attachment.new(content["data"], content["mimeType"])
100
+ MCP::Content.new(text: nil, attachments: [attachment])
101
+ when "resource"
102
+ resource_data = {
103
+ "name" => name,
104
+ "description" => description,
105
+ "uri" => content.dig("resource", "uri"),
106
+ "content" => content["resource"]
107
+ }
108
+
109
+ resource = Resource.new(coordinator, resource_data)
110
+ resource.to_content
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,244 @@
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: {}, request_timeout: 8000)
16
+ @event_url = url
17
+ @messages_url = nil
18
+ @request_timeout = request_timeout
19
+
20
+ uri = URI.parse(url)
21
+ @root_url = "#{uri.scheme}://#{uri.host}"
22
+ @root_url += ":#{uri.port}" if uri.port != uri.default_port
23
+
24
+ @client_id = SecureRandom.uuid
25
+ @headers = headers.merge({
26
+ "Accept" => "text/event-stream",
27
+ "Cache-Control" => "no-cache",
28
+ "Connection" => "keep-alive",
29
+ "X-CLIENT-ID" => @client_id
30
+ })
31
+
32
+ @id_counter = 0
33
+ @id_mutex = Mutex.new
34
+ @pending_requests = {}
35
+ @pending_mutex = Mutex.new
36
+ @connection_mutex = Mutex.new
37
+ @running = true
38
+ @sse_thread = nil
39
+
40
+ # Start the SSE listener thread
41
+ start_sse_listener
42
+ end
43
+
44
+ def request(body, add_id: true, wait_for_response: true) # rubocop:disable Metrics/MethodLength
45
+ # Generate a unique request ID
46
+ if add_id
47
+ @id_mutex.synchronize { @id_counter += 1 }
48
+ request_id = @id_counter
49
+ body["id"] = request_id
50
+ end
51
+
52
+ # Create a queue for this request's response
53
+ response_queue = Queue.new
54
+ if wait_for_response
55
+ @pending_mutex.synchronize do
56
+ @pending_requests[request_id.to_s] = response_queue
57
+ end
58
+ end
59
+
60
+ # Send the request using Faraday
61
+ begin
62
+ conn = Faraday.new do |f|
63
+ f.options.timeout = @request_timeout / 1000
64
+ f.options.open_timeout = 5
65
+ end
66
+
67
+ response = conn.post(@messages_url) do |req|
68
+ @headers.each do |key, value|
69
+ req.headers[key] = value
70
+ end
71
+ req.headers["Content-Type"] = "application/json"
72
+ req.body = JSON.generate(body)
73
+ end
74
+
75
+ unless response.status == 200
76
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
77
+ raise "Failed to request #{@messages_url}: #{response.status} - #{response.body}"
78
+ end
79
+ rescue StandardError => e
80
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
81
+ raise e
82
+ end
83
+ return unless wait_for_response
84
+
85
+ begin
86
+ Timeout.timeout(@request_timeout / 1000) do
87
+ response_queue.pop
88
+ end
89
+ rescue Timeout::Error
90
+ @pending_mutex.synchronize { @pending_requests.delete(request_id.to_s) }
91
+ raise RubyLLM::MCP::Errors::TimeoutError.new(
92
+ message: "Request timed out after #{@request_timeout / 1000} seconds"
93
+ )
94
+ end
95
+ end
96
+
97
+ def alive?
98
+ @running
99
+ end
100
+
101
+ def close
102
+ @running = false
103
+ @sse_thread&.join(1) # Give the thread a second to clean up
104
+ @sse_thread = nil
105
+ end
106
+
107
+ private
108
+
109
+ def start_sse_listener
110
+ @connection_mutex.synchronize do
111
+ return if sse_thread_running?
112
+
113
+ response_queue = Queue.new
114
+ @pending_mutex.synchronize do
115
+ @pending_requests["endpoint"] = response_queue
116
+ end
117
+
118
+ @sse_thread = Thread.new do
119
+ listen_for_events while @running
120
+ end
121
+ @sse_thread.abort_on_exception = true
122
+
123
+ endpoint = response_queue.pop
124
+ set_message_endpoint(endpoint)
125
+
126
+ @pending_mutex.synchronize { @pending_requests.delete("endpoint") }
127
+ end
128
+ end
129
+
130
+ def set_message_endpoint(endpoint)
131
+ uri = URI.parse(endpoint)
132
+
133
+ @messages_url = if uri.host.nil?
134
+ "#{@root_url}#{endpoint}"
135
+ else
136
+ endpoint
137
+ end
138
+ end
139
+
140
+ def sse_thread_running?
141
+ @sse_thread&.alive?
142
+ end
143
+
144
+ def listen_for_events
145
+ stream_events_from_server
146
+ rescue Faraday::Error => e
147
+ handle_connection_error("SSE connection failed", e)
148
+ rescue StandardError => e
149
+ handle_connection_error("SSE connection error", e)
150
+ end
151
+
152
+ def stream_events_from_server
153
+ buffer = +""
154
+ create_sse_connection.get(@event_url) do |req|
155
+ setup_request_headers(req)
156
+ setup_streaming_callback(req, buffer)
157
+ end
158
+ end
159
+
160
+ def create_sse_connection
161
+ Faraday.new do |f|
162
+ f.options.timeout = 300 # 5 minutes
163
+ f.response :raise_error # raise errors on non-200 responses
164
+ end
165
+ end
166
+
167
+ def setup_request_headers(request)
168
+ @headers.each do |key, value|
169
+ request.headers[key] = value
170
+ end
171
+ end
172
+
173
+ def setup_streaming_callback(request, buffer)
174
+ request.options.on_data = proc do |chunk, _size, _env|
175
+ buffer << chunk
176
+ process_buffer_events(buffer)
177
+ end
178
+ end
179
+
180
+ def process_buffer_events(buffer)
181
+ while (event = extract_event(buffer))
182
+ event_data, buffer = event
183
+ process_event(event_data) if event_data
184
+ end
185
+ end
186
+
187
+ def handle_connection_error(message, error)
188
+ puts "#{message}: #{error.message}. Reconnecting in 3 seconds..."
189
+ sleep 3
190
+ end
191
+
192
+ def process_event(raw_event)
193
+ return if raw_event[:data].nil?
194
+
195
+ if raw_event[:event] == "endpoint"
196
+ request_id = "endpoint"
197
+ event = raw_event[:data]
198
+ else
199
+ event = begin
200
+ JSON.parse(raw_event[:data])
201
+ rescue StandardError
202
+ nil
203
+ end
204
+ return if event.nil?
205
+
206
+ request_id = event["id"]&.to_s
207
+ end
208
+
209
+ @pending_mutex.synchronize do
210
+ if request_id && @pending_requests.key?(request_id)
211
+ response_queue = @pending_requests.delete(request_id)
212
+ response_queue&.push(event)
213
+ end
214
+ end
215
+ rescue JSON::ParserError => e
216
+ puts "Error parsing event data: #{e.message}"
217
+ end
218
+
219
+ def extract_event(buffer)
220
+ return nil unless buffer.include?("\n\n")
221
+
222
+ raw, rest = buffer.split("\n\n", 2)
223
+ [parse_event(raw), rest]
224
+ end
225
+
226
+ def parse_event(raw)
227
+ event = {}
228
+ raw.each_line do |line|
229
+ case line
230
+ when /^data:\s*(.*)/
231
+ (event[:data] ||= []) << ::Regexp.last_match(1)
232
+ when /^event:\s*(.*)/
233
+ event[:event] = ::Regexp.last_match(1)
234
+ when /^id:\s*(.*)/
235
+ event[:id] = ::Regexp.last_match(1)
236
+ end
237
+ end
238
+ event[:data] = event[:data]&.join("\n")
239
+ event
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end