ruby_llm-mcp 0.0.2 → 0.2.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.
@@ -5,20 +5,36 @@ module RubyLLM
5
5
  module Providers
6
6
  module Anthropic
7
7
  module ComplexParameterSupport
8
+ module_function
9
+
8
10
  def clean_parameters(parameters)
9
11
  parameters.transform_values do |param|
10
- format = {
11
- type: param.type,
12
- description: param.description
13
- }.compact
12
+ build_properties(param).compact
13
+ end
14
+ end
14
15
 
15
- if param.type == "array"
16
- format[:items] = param.items
17
- elsif param.type == "object"
18
- format[:properties] = clean_parameters(param.properties)
19
- end
16
+ def required_parameters(parameters)
17
+ parameters.select { |_, param| param.required }.keys
18
+ end
20
19
 
21
- format
20
+ def build_properties(param)
21
+ case param.type
22
+ when :array
23
+ {
24
+ type: param.type,
25
+ items: { type: param.item_type }
26
+ }
27
+ when :object
28
+ {
29
+ type: param.type,
30
+ properties: clean_parameters(param.properties),
31
+ required: required_parameters(param.properties)
32
+ }
33
+ else
34
+ {
35
+ type: param.type,
36
+ description: param.description
37
+ }
22
38
  end
23
39
  end
24
40
  end
@@ -5,18 +5,29 @@ module RubyLLM
5
5
  module Providers
6
6
  module OpenAI
7
7
  module ComplexParameterSupport
8
+ module_function
9
+
8
10
  def param_schema(param)
9
- format = {
10
- type: param.type,
11
- description: param.description
12
- }.compact
11
+ properties = case param.type
12
+ when :array
13
+ {
14
+ type: param.type,
15
+ items: { type: param.item_type }
16
+ }
17
+ when :object
18
+ {
19
+ type: param.type,
20
+ properties: param.properties.transform_values { |value| param_schema(value) },
21
+ required: param.properties.select { |_, p| p.required }.keys
22
+ }
23
+ else
24
+ {
25
+ type: param.type,
26
+ description: param.description
27
+ }
28
+ end
13
29
 
14
- if param.type == "array"
15
- format[:items] = param.items
16
- elsif param.type == "object"
17
- format[:properties] = param.properties.transform_values { |value| param_schema(value) }
18
- end
19
- format
30
+ properties.compact
20
31
  end
21
32
  end
22
33
  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: RubyLLM::MCP::Client::PROTOCOL_VERSION,
16
- capabilities: {
17
- tools: {
18
- listChanged: true
19
- }
20
- },
15
+ protocolVersion: @client.protocol_version,
16
+ capabilities: {},
21
17
  clientInfo: {
22
- name: "RubyLLM MCP Client",
18
+ name: "RubyLLM-MCP Client",
23
19
  version: RubyLLM::MCP::VERSION
24
20
  }
25
21
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  class RubyLLM::MCP::Requests::Notification < RubyLLM::MCP::Requests::Base
4
4
  def call
5
- client.request(notification_body, wait_for_response: false)
5
+ client.request(notification_body, add_id: false, wait_for_response: false)
6
6
  end
7
7
 
8
8
  def notification_body
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class PromptCall
7
+ def initialize(client, name:, arguments: {})
8
+ @client = client
9
+ @name = name
10
+ @arguments = arguments
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: "prompts/get",
23
+ params: {
24
+ name: @name,
25
+ arguments: @arguments
26
+ }
27
+ }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ module Requests
6
+ class PromptList < Base
7
+ def call
8
+ client.request(request_body)
9
+ end
10
+
11
+ private
12
+
13
+ def request_body
14
+ {
15
+ jsonrpc: "2.0",
16
+ method: "prompts/list",
17
+ params: {}
18
+ }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -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,99 @@
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
+ @content = resource["content"] || nil
15
+ @template = template
16
+ end
17
+
18
+ def content(arguments: {})
19
+ return @content if @content && !template?
20
+
21
+ response = if template?
22
+ templated_uri = apply_template(@uri, arguments)
23
+
24
+ read_response(uri: templated_uri)
25
+ else
26
+ read_response
27
+ end
28
+
29
+ content = response.dig("result", "contents", 0)
30
+ @type = if content.key?("type")
31
+ content["type"]
32
+ else
33
+ "text"
34
+ end
35
+
36
+ @content = content["text"] || content["blob"]
37
+ end
38
+
39
+ def include(chat, **args)
40
+ message = Message.new(
41
+ role: "user",
42
+ content: to_content(**args)
43
+ )
44
+
45
+ chat.add_message(message)
46
+ end
47
+
48
+ def to_content(arguments: {})
49
+ content = content(arguments: arguments)
50
+
51
+ case @type
52
+ when "text"
53
+ MCP::Content.new(content)
54
+ when "blob"
55
+ attachment = MCP::Attachment.new(content, mime_type)
56
+ MCP::Content.new(text: "#{name}: #{description}", attachments: [attachment])
57
+ end
58
+ end
59
+
60
+ def arguments_search(argument, value)
61
+ if template? && @mcp_client.capabilities.completion?
62
+ response = @mcp_client.completion(type: :resource, name: @name, argument: argument, value: value)
63
+ response = response.dig("result", "completion")
64
+
65
+ Completion.new(values: response["values"], total: response["total"], has_more: response["hasMore"])
66
+ else
67
+ raise Errors::CompletionNotAvailable, "Completion is not available for this MCP server"
68
+ end
69
+ end
70
+
71
+ def template?
72
+ @template
73
+ end
74
+
75
+ private
76
+
77
+ def read_response(uri: @uri)
78
+ parsed = URI.parse(uri)
79
+ case parsed.scheme
80
+ when "http", "https"
81
+ fetch_uri_content(uri)
82
+ else # file:// or git://
83
+ @read_response ||= @mcp_client.resource_read_request(uri: uri)
84
+ end
85
+ end
86
+
87
+ def fetch_uri_content(uri)
88
+ response = Faraday.get(uri)
89
+ { "result" => { "contents" => [{ "text" => response.body }] } }
90
+ end
91
+
92
+ def apply_template(uri, arguments)
93
+ uri.gsub(/\{(\w+)\}/) do
94
+ arguments[::Regexp.last_match(1).to_sym] || "{#{::Regexp.last_match(1)}}"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -15,10 +15,17 @@ module RubyLLM
15
15
  end
16
16
 
17
17
  def execute(**params)
18
- @mcp_client.execute_tool(
18
+ response = @mcp_client.execute_tool(
19
19
  name: @name,
20
20
  parameters: params
21
21
  )
22
+
23
+ text_values = response.dig("result", "content").map { |content| content["text"] }.compact.join("\n")
24
+ if text_values.empty?
25
+ create_content_for_message(response.dig("result", "content", 0))
26
+ else
27
+ create_content_for_message({ type: "text", text: text_values })
28
+ end
22
29
  end
23
30
 
24
31
  private
@@ -45,6 +52,19 @@ module RubyLLM
45
52
 
46
53
  params
47
54
  end
55
+
56
+ def create_content_for_message(content)
57
+ case content["type"]
58
+ when "text"
59
+ MCP::Content.new(text: content["text"])
60
+ when "image", "audio"
61
+ attachment = MCP::Attachment.new(content["content"], content["mime_type"])
62
+ MCP::Content.new(text: nil, attachments: [attachment])
63
+ when "resource"
64
+ resource = Resource.new(mcp_client, content["resource"])
65
+ resource.to_content
66
+ end
67
+ end
48
68
  end
49
69
  end
50
70
  end
@@ -14,7 +14,12 @@ module RubyLLM
14
14
 
15
15
  def initialize(url, headers: {})
16
16
  @event_url = url
17
- @messages_url = url.gsub("sse", "messages")
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
- def request(body, wait_for_response: true)
43
+ # rubocop:disable Metrics/MethodLength
44
+ def request(body, add_id: true, wait_for_response: true)
39
45
  # Generate a unique request ID
40
- @id_mutex.synchronize { @id_counter += 1 }
41
- request_id = @id_counter
42
- body["id"] = 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
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
- sleep 0.1 # Wait for the SSE connection to be established
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 && @sse_thread.alive?
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 = begin
164
- JSON.parse(raw_event[:data])
165
- rescue StandardError
166
- nil
167
- end
168
- return if event.nil?
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
- request_id = event["id"]&.to_s
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
- @id_mutex.synchronize { @id_counter += 1 }
32
- request_id = @id_counter
33
- body["id"] = request_id
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
- puts "Error parsing response as JSON: #{e.message}"
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