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.
- checksums.yaml +4 -4
- data/README.md +174 -9
- data/lib/ruby_llm/chat.rb +27 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/capabilities.rb +29 -0
- data/lib/ruby_llm/mcp/client.rb +85 -16
- data/lib/ruby_llm/mcp/completion.rb +15 -0
- data/lib/ruby_llm/mcp/content.rb +20 -0
- data/lib/ruby_llm/mcp/errors.rb +11 -1
- data/lib/ruby_llm/mcp/prompt.rb +95 -0
- data/lib/ruby_llm/mcp/providers/anthropic/complex_parameter_support.rb +26 -10
- data/lib/ruby_llm/mcp/providers/open_ai/complex_parameter_support.rb +21 -10
- data/lib/ruby_llm/mcp/requests/completion.rb +50 -0
- data/lib/ruby_llm/mcp/requests/initialization.rb +3 -7
- data/lib/ruby_llm/mcp/requests/notification.rb +1 -1
- data/lib/ruby_llm/mcp/requests/prompt_call.rb +32 -0
- data/lib/ruby_llm/mcp/requests/prompt_list.rb +23 -0
- data/lib/ruby_llm/mcp/requests/resource_list.rb +21 -0
- data/lib/ruby_llm/mcp/requests/resource_read.rb +30 -0
- data/lib/ruby_llm/mcp/requests/resource_template_list.rb +21 -0
- data/lib/ruby_llm/mcp/resource.rb +99 -0
- data/lib/ruby_llm/mcp/tool.rb +21 -1
- data/lib/ruby_llm/mcp/transport/sse.rb +47 -15
- data/lib/ruby_llm/mcp/transport/stdio.rb +7 -7
- data/lib/ruby_llm/mcp/transport/streamable.rb +274 -4
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +4 -2
- metadata +18 -6
- data/lib/ruby_llm/overrides.rb +0 -21
@@ -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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
}.compact
|
12
|
+
build_properties(param).compact
|
13
|
+
end
|
14
|
+
end
|
14
15
|
|
15
|
-
|
16
|
-
|
17
|
-
|
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
|
-
|
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
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
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:
|
16
|
-
capabilities: {
|
17
|
-
tools: {
|
18
|
-
listChanged: true
|
19
|
-
}
|
20
|
-
},
|
15
|
+
protocolVersion: @client.protocol_version,
|
16
|
+
capabilities: {},
|
21
17
|
clientInfo: {
|
22
|
-
name: "RubyLLM
|
18
|
+
name: "RubyLLM-MCP Client",
|
23
19
|
version: RubyLLM::MCP::VERSION
|
24
20
|
}
|
25
21
|
}
|
@@ -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
|
data/lib/ruby_llm/mcp/tool.rb
CHANGED
@@ -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 =
|
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
|
-
|
43
|
+
# rubocop:disable Metrics/MethodLength
|
44
|
+
def request(body, add_id: true, wait_for_response: true)
|
39
45
|
# Generate a unique request ID
|
40
|
-
|
41
|
-
|
42
|
-
|
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
|
-
|
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
|
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
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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
|
-
|
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
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
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
|
|