ruby_llm_swarm-mcp 0.8.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
- data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
- data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
- data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
- data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
- data/lib/ruby_llm/chat.rb +34 -0
- data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
- data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
- data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
- data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
- data/lib/ruby_llm/mcp/attachment.rb +18 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
- data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
- data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
- data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
- data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
- data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
- data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
- data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
- data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +401 -0
- data/lib/ruby_llm/mcp/completion.rb +16 -0
- data/lib/ruby_llm/mcp/configuration.rb +310 -0
- data/lib/ruby_llm/mcp/content.rb +28 -0
- data/lib/ruby_llm/mcp/elicitation.rb +48 -0
- data/lib/ruby_llm/mcp/error.rb +34 -0
- data/lib/ruby_llm/mcp/errors.rb +91 -0
- data/lib/ruby_llm/mcp/logging.rb +16 -0
- data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
- data/lib/ruby_llm/mcp/native/client.rb +387 -0
- data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
- data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
- data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
- data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
- data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
- data/lib/ruby_llm/mcp/native/messages.rb +36 -0
- data/lib/ruby_llm/mcp/native/notification.rb +16 -0
- data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
- data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
- data/lib/ruby_llm/mcp/native/transport.rb +88 -0
- data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
- data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
- data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
- data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
- data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
- data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
- data/lib/ruby_llm/mcp/native.rb +12 -0
- data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
- data/lib/ruby_llm/mcp/progress.rb +35 -0
- data/lib/ruby_llm/mcp/prompt.rb +132 -0
- data/lib/ruby_llm/mcp/railtie.rb +14 -0
- data/lib/ruby_llm/mcp/resource.rb +112 -0
- data/lib/ruby_llm/mcp/resource_template.rb +85 -0
- data/lib/ruby_llm/mcp/result.rb +108 -0
- data/lib/ruby_llm/mcp/roots.rb +45 -0
- data/lib/ruby_llm/mcp/sample.rb +152 -0
- data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
- data/lib/ruby_llm/mcp/tool.rb +228 -0
- data/lib/ruby_llm/mcp/version.rb +7 -0
- data/lib/ruby_llm/mcp.rb +125 -0
- data/lib/tasks/release.rake +23 -0
- metadata +184 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Notification
|
|
6
|
+
attr_reader :type, :params
|
|
7
|
+
|
|
8
|
+
def initialize(response)
|
|
9
|
+
@type = response["method"]
|
|
10
|
+
@params = response["params"]
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Result
|
|
15
|
+
attr_reader :response, :session_id, :id, :method, :result, :params, :error, :next_cursor
|
|
16
|
+
|
|
17
|
+
REQUEST_METHODS = {
|
|
18
|
+
ping: "ping",
|
|
19
|
+
roots: "roots/list",
|
|
20
|
+
sampling: "sampling/createMessage",
|
|
21
|
+
elicitation: "elicitation/create"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(response, session_id: nil)
|
|
25
|
+
@response = response
|
|
26
|
+
@session_id = session_id
|
|
27
|
+
@id = response["id"]
|
|
28
|
+
@method = response["method"]
|
|
29
|
+
@result = response["result"] || {}
|
|
30
|
+
@params = response["params"] || {}
|
|
31
|
+
@error = response["error"] || {}
|
|
32
|
+
|
|
33
|
+
@result_is_error = response.dig("result", "isError") || false
|
|
34
|
+
@next_cursor = response.dig("result", "nextCursor")
|
|
35
|
+
|
|
36
|
+
# Track whether result/error keys exist (for JSON-RPC detection)
|
|
37
|
+
@has_result = response.key?("result")
|
|
38
|
+
@has_error = response.key?("error")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
REQUEST_METHODS.each do |method_name, method_value|
|
|
42
|
+
define_method "#{method_name}?" do
|
|
43
|
+
@method == method_value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
alias value result
|
|
48
|
+
|
|
49
|
+
def notification
|
|
50
|
+
Notification.new(@response)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_error
|
|
54
|
+
Error.new(@error)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execution_error?
|
|
58
|
+
@result_is_error
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def raise_error!
|
|
62
|
+
error = to_error
|
|
63
|
+
message = "Response error: #{error}"
|
|
64
|
+
raise Errors::ResponseError.new(message: message, error: error)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def matching_id?(request_id)
|
|
68
|
+
@id&.to_s == request_id.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def notification?
|
|
72
|
+
@id.nil? && !@method.nil?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def next_cursor?
|
|
76
|
+
!@next_cursor.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def request?
|
|
80
|
+
!@id.nil? && !@method.nil?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def response?
|
|
84
|
+
!@id.nil? && @method.nil? && (@has_result || @has_error)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def success?
|
|
88
|
+
@has_result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def tool_success?
|
|
92
|
+
success? && !@result_is_error
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def error?
|
|
96
|
+
!@error.empty?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def to_s
|
|
100
|
+
inspect
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def inspect
|
|
104
|
+
"#<#{self.class.name}:0x#{object_id.to_s(16)} id: #{@id}, result: #{@result}, error: #{@error}, method: #{@method}, params: #{@params}>" # rubocop:disable Layout/LineLength
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Roots
|
|
6
|
+
attr_reader :paths
|
|
7
|
+
|
|
8
|
+
def initialize(paths: [], adapter: nil)
|
|
9
|
+
@paths = paths
|
|
10
|
+
@adapter = adapter
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def active?
|
|
14
|
+
@paths.any?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def add(path)
|
|
18
|
+
@paths << path
|
|
19
|
+
@adapter.roots_list_change_notification
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def remove(path)
|
|
23
|
+
@paths.delete(path)
|
|
24
|
+
@adapter.roots_list_change_notification
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_request
|
|
28
|
+
@paths.map do |path|
|
|
29
|
+
name = File.basename(path, ".*")
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
uri: "file://#{path}",
|
|
33
|
+
name: name
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
{
|
|
40
|
+
paths: to_request
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Sample
|
|
6
|
+
class Hint
|
|
7
|
+
attr_reader :model, :cost_priority, :speed_priority, :intelligence_priority
|
|
8
|
+
|
|
9
|
+
def initialize(model, model_preferences)
|
|
10
|
+
@model = model
|
|
11
|
+
@model_preferences = model_preferences
|
|
12
|
+
|
|
13
|
+
@hints = model_preferences&.fetch("hints", [])
|
|
14
|
+
@cost_priority = model_preferences&.fetch("costPriority", nil)
|
|
15
|
+
@speed_priority = model_preferences&.fetch("speedPriority", nil)
|
|
16
|
+
@intelligence_priority = model_preferences&.fetch("intelligencePriority", nil)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def hints
|
|
20
|
+
@hints.map { |hint| hint["name"] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h
|
|
24
|
+
{
|
|
25
|
+
model: model,
|
|
26
|
+
hints: hints,
|
|
27
|
+
cost_priority: @cost_priority,
|
|
28
|
+
speed_priority: @speed_priority,
|
|
29
|
+
intelligence_priority: @intelligence_priority
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
REJECTED_MESSAGE = "Sampling request was rejected"
|
|
35
|
+
|
|
36
|
+
attr_reader :model_preferences, :system_prompt, :max_tokens, :raw_messages
|
|
37
|
+
|
|
38
|
+
def initialize(result, coordinator)
|
|
39
|
+
params = result.params
|
|
40
|
+
@id = result.id
|
|
41
|
+
@coordinator = coordinator
|
|
42
|
+
|
|
43
|
+
@raw_messages = params["messages"] || []
|
|
44
|
+
@model_preferences = Hint.new(params["model"], params["modelPreferences"])
|
|
45
|
+
@system_prompt = params["systemPrompt"]
|
|
46
|
+
@max_tokens = params["maxTokens"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def execute
|
|
50
|
+
return unless callback_guard_success?
|
|
51
|
+
|
|
52
|
+
model = preferred_model
|
|
53
|
+
return unless model
|
|
54
|
+
|
|
55
|
+
chat_message = chat(model)
|
|
56
|
+
@coordinator.sampling_create_message_response(
|
|
57
|
+
id: @id, message: chat_message, model: model
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def message
|
|
62
|
+
@message ||= raw_messages.map { |message| message.fetch("content")&.fetch("text") }.join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_h
|
|
66
|
+
{
|
|
67
|
+
id: @id,
|
|
68
|
+
model_preferences: @model_preferences.to_h,
|
|
69
|
+
system_prompt: @system_prompt,
|
|
70
|
+
max_tokens: @max_tokens
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
alias to_json to_h
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def callback_guard_success?
|
|
79
|
+
return true unless @coordinator.sampling_callback_enabled?
|
|
80
|
+
|
|
81
|
+
callback_result = @coordinator.sampling_callback&.call(self)
|
|
82
|
+
# If callback returns nil, it means no guard was configured - allow it
|
|
83
|
+
return true if callback_result.nil?
|
|
84
|
+
|
|
85
|
+
unless callback_result
|
|
86
|
+
@coordinator.error_response(id: @id, message: REJECTED_MESSAGE)
|
|
87
|
+
return false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
true
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
RubyLLM::MCP.logger.error("Error in callback guard: #{e.message}, #{e.backtrace.join("\n")}")
|
|
93
|
+
@coordinator.error_response(id: @id, message: "Error executing sampling request")
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def chat(model)
|
|
98
|
+
chat = RubyLLM::Chat.new(
|
|
99
|
+
model: model
|
|
100
|
+
)
|
|
101
|
+
if system_prompt
|
|
102
|
+
formated_system_message = create_message(system_message)
|
|
103
|
+
chat.add_message(formated_system_message)
|
|
104
|
+
end
|
|
105
|
+
raw_messages.each { |message| chat.add_message(create_message(message)) }
|
|
106
|
+
|
|
107
|
+
chat.complete
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def preferred_model
|
|
111
|
+
@preferred_model ||= begin
|
|
112
|
+
model = RubyLLM::MCP.config.sampling.preferred_model
|
|
113
|
+
if model.respond_to?(:call)
|
|
114
|
+
model.call(model_preferences)
|
|
115
|
+
else
|
|
116
|
+
model
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
RubyLLM::MCP.logger.error("Error in preferred model: #{e.message}, #{e.backtrace.join("\n")}")
|
|
121
|
+
@coordinator.error_response(id: @id, message: "Failed to determine preferred model: #{e.message}")
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def create_message(message)
|
|
126
|
+
role = message["role"]
|
|
127
|
+
content = create_content_for_message(message["content"])
|
|
128
|
+
|
|
129
|
+
RubyLLM::Message.new({ role: role, content: content })
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def create_content_for_message(content)
|
|
133
|
+
case content["type"]
|
|
134
|
+
when "text"
|
|
135
|
+
MCP::Content.new(text: content["text"])
|
|
136
|
+
when "image", "audio"
|
|
137
|
+
attachment = MCP::Attachment.new(content["data"], content["mimeType"])
|
|
138
|
+
MCP::Content.new(text: nil, attachments: [attachment])
|
|
139
|
+
else
|
|
140
|
+
raise RubyLLM::MCP::Errors::InvalidFormatError.new(message: "Invalid content type: #{content['type']}")
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def system_message
|
|
145
|
+
{
|
|
146
|
+
"role" => "system",
|
|
147
|
+
"content" => { "type" => "text", "text" => system_prompt }
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class ServerCapabilities
|
|
6
|
+
attr_accessor :capabilities
|
|
7
|
+
|
|
8
|
+
def initialize(capabilities = {})
|
|
9
|
+
@capabilities = capabilities
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resources_list?
|
|
13
|
+
!@capabilities["resources"].nil?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resources_list_changes?
|
|
17
|
+
@capabilities.dig("resources", "listChanged") || false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resource_subscribe?
|
|
21
|
+
@capabilities.dig("resources", "subscribe") || false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tools_list?
|
|
25
|
+
!@capabilities["tools"].nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tools_list_changes?
|
|
29
|
+
@capabilities.dig("tools", "listChanged") || false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def prompt_list?
|
|
33
|
+
!@capabilities["prompts"].nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def prompt_list_changes?
|
|
37
|
+
@capabilities.dig("prompts", "listChanged") || false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def completion?
|
|
41
|
+
!@capabilities["completions"].nil?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def logging?
|
|
45
|
+
!@capabilities["logging"].nil?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module MCP
|
|
5
|
+
class Annotation
|
|
6
|
+
attr_reader :title, :read_only_hint, :destructive_hint, :idempotent_hint, :open_world_hint
|
|
7
|
+
|
|
8
|
+
def initialize(annotation)
|
|
9
|
+
@title = annotation["title"] || ""
|
|
10
|
+
@read_only_hint = annotation["readOnlyHint"] || false
|
|
11
|
+
@destructive_hint = annotation["destructiveHint"] || true
|
|
12
|
+
@idempotent_hint = annotation["idempotentHint"] || false
|
|
13
|
+
@open_world_hint = annotation["openWorldHint"] || true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{
|
|
18
|
+
title: @title,
|
|
19
|
+
readOnlyHint: @read_only_hint,
|
|
20
|
+
destructiveHint: @destructive_hint,
|
|
21
|
+
idempotentHint: @idempotent_hint,
|
|
22
|
+
openWorldHint: @open_world_hint
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Tool < RubyLLM::Tool
|
|
28
|
+
attr_reader :name, :title, :description, :adapter, :annotations, :tool_response, :with_prefix
|
|
29
|
+
|
|
30
|
+
def initialize(adapter, tool_response, with_prefix: false)
|
|
31
|
+
super()
|
|
32
|
+
@adapter = adapter
|
|
33
|
+
|
|
34
|
+
@with_prefix = with_prefix
|
|
35
|
+
@name = format_name(tool_response["name"])
|
|
36
|
+
@mcp_name = tool_response["name"]
|
|
37
|
+
@description = tool_response["description"].to_s
|
|
38
|
+
|
|
39
|
+
@input_schema = tool_response["inputSchema"]
|
|
40
|
+
@output_schema = tool_response["outputSchema"]
|
|
41
|
+
|
|
42
|
+
@annotations = tool_response["annotations"] ? Annotation.new(tool_response["annotations"]) : nil
|
|
43
|
+
|
|
44
|
+
@normalized_input_schema = normalize_if_invalid(@input_schema)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def display_name
|
|
48
|
+
"#{@adapter.client.name}: #{@name}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def params_schema
|
|
52
|
+
@normalized_input_schema
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def execute(**params)
|
|
56
|
+
result = @adapter.execute_tool(
|
|
57
|
+
name: @mcp_name,
|
|
58
|
+
parameters: params
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if result.error?
|
|
62
|
+
error = result.to_error
|
|
63
|
+
return { error: error.to_s }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
text_values = result.value["content"].map { |content| content["text"] }.compact.join("\n")
|
|
67
|
+
if result.execution_error?
|
|
68
|
+
return { error: "Tool execution error: #{text_values}" }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if result.value.key?("structuredContent") && !@output_schema.nil?
|
|
72
|
+
is_valid = JSON::Validator.validate(@output_schema, result.value["structuredContent"])
|
|
73
|
+
unless is_valid
|
|
74
|
+
return { error: "Structued outputs was not invalid: #{result.value['structuredContent']}" }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return text_values
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if text_values.empty?
|
|
81
|
+
create_content_for_message(result.value.dig("content", 0))
|
|
82
|
+
else
|
|
83
|
+
create_content_for_message({ "type" => "text", "text" => text_values })
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_h
|
|
88
|
+
{
|
|
89
|
+
name: @name,
|
|
90
|
+
description: @description,
|
|
91
|
+
params_schema: @@normalized_input_schema,
|
|
92
|
+
annotations: @annotations&.to_h
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
alias to_json to_h
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def create_content_for_message(content)
|
|
101
|
+
case content["type"]
|
|
102
|
+
when "text"
|
|
103
|
+
MCP::Content.new(text: content["text"])
|
|
104
|
+
when "image", "audio"
|
|
105
|
+
attachment = MCP::Attachment.new(content["data"], content["mimeType"])
|
|
106
|
+
MCP::Content.new(text: nil, attachments: [attachment])
|
|
107
|
+
when "resource"
|
|
108
|
+
resource_data = {
|
|
109
|
+
"name" => name,
|
|
110
|
+
"description" => description,
|
|
111
|
+
"uri" => content.dig("resource", "uri"),
|
|
112
|
+
"mimeType" => content.dig("resource", "mimeType"),
|
|
113
|
+
"content_response" => {
|
|
114
|
+
"text" => content.dig("resource", "text"),
|
|
115
|
+
"blob" => content.dig("resource", "blob")
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
resource = Resource.new(adapter, resource_data)
|
|
120
|
+
resource.to_content
|
|
121
|
+
when "resource_link"
|
|
122
|
+
resource_data = {
|
|
123
|
+
"name" => content["name"],
|
|
124
|
+
"uri" => content["uri"],
|
|
125
|
+
"description" => content["description"],
|
|
126
|
+
"mimeType" => content["mimeType"]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
resource = Resource.new(adapter, resource_data)
|
|
130
|
+
@adapter.register_resource(resource)
|
|
131
|
+
resource.to_content
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def format_name(name)
|
|
136
|
+
if @with_prefix
|
|
137
|
+
"#{@adapter.client.name}_#{name}"
|
|
138
|
+
else
|
|
139
|
+
name
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_schema(schema)
|
|
144
|
+
return schema if schema.nil?
|
|
145
|
+
|
|
146
|
+
case schema
|
|
147
|
+
when Hash
|
|
148
|
+
normalize_hash_schema(schema)
|
|
149
|
+
when Array
|
|
150
|
+
normalize_array_schema(schema)
|
|
151
|
+
else
|
|
152
|
+
schema
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def normalize_hash_schema(schema)
|
|
157
|
+
normalized = schema.transform_values { |value| normalize_schema_value(value) }
|
|
158
|
+
ensure_object_properties(normalized)
|
|
159
|
+
normalized
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_array_schema(schema)
|
|
163
|
+
schema.map { |item| normalize_schema_value(item) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def normalize_schema_value(value)
|
|
167
|
+
case value
|
|
168
|
+
when Hash
|
|
169
|
+
normalize_schema(value)
|
|
170
|
+
when Array
|
|
171
|
+
normalize_array_schema(value)
|
|
172
|
+
else
|
|
173
|
+
value
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def ensure_object_properties(schema)
|
|
178
|
+
if schema["type"] == "object" && !schema.key?("properties")
|
|
179
|
+
schema["properties"] = {}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def normalize_if_invalid(schema)
|
|
184
|
+
return schema if schema.nil?
|
|
185
|
+
|
|
186
|
+
if valid_schema?(schema)
|
|
187
|
+
schema
|
|
188
|
+
else
|
|
189
|
+
normalize_schema(schema)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def valid_schema?(schema)
|
|
194
|
+
return true if schema.nil?
|
|
195
|
+
|
|
196
|
+
case schema
|
|
197
|
+
when Hash
|
|
198
|
+
valid_hash_schema?(schema)
|
|
199
|
+
when Array
|
|
200
|
+
schema.all? { |item| valid_schema?(item) }
|
|
201
|
+
else
|
|
202
|
+
true
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def valid_hash_schema?(schema)
|
|
207
|
+
# Check if this level has missing properties for object type
|
|
208
|
+
if schema["type"] == "object" && !schema.key?("properties")
|
|
209
|
+
return false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Recursively check nested schemas
|
|
213
|
+
schema.each_value do |value|
|
|
214
|
+
return false unless valid_schema?(value)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
begin
|
|
218
|
+
JSON::Validator.validate!(schema, {})
|
|
219
|
+
true
|
|
220
|
+
rescue JSON::Schema::SchemaError
|
|
221
|
+
false
|
|
222
|
+
rescue JSON::Schema::ValidationError
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
data/lib/ruby_llm/mcp.rb
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Standard Libraries
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "erb"
|
|
6
|
+
require "json"
|
|
7
|
+
require "logger"
|
|
8
|
+
require "open3"
|
|
9
|
+
require "rbconfig"
|
|
10
|
+
require "securerandom"
|
|
11
|
+
require "socket"
|
|
12
|
+
require "timeout"
|
|
13
|
+
require "uri"
|
|
14
|
+
require "yaml"
|
|
15
|
+
|
|
16
|
+
# Gems
|
|
17
|
+
require "httpx"
|
|
18
|
+
require "json-schema"
|
|
19
|
+
require "ruby_llm"
|
|
20
|
+
require "zeitwerk"
|
|
21
|
+
|
|
22
|
+
require_relative "chat"
|
|
23
|
+
|
|
24
|
+
module RubyLLM
|
|
25
|
+
module MCP
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def clients(config = RubyLLM::MCP.config.mcp_configuration)
|
|
29
|
+
if @clients.nil?
|
|
30
|
+
@clients = {}
|
|
31
|
+
config.map do |options|
|
|
32
|
+
@clients[options[:name]] ||= Client.new(**options)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
@clients
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_client(options)
|
|
39
|
+
clients[options[:name]] ||= Client.new(**options)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def remove_client(name)
|
|
43
|
+
client = clients.delete(name)
|
|
44
|
+
client&.stop
|
|
45
|
+
client
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def client(...)
|
|
49
|
+
Client.new(...)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def establish_connection(&)
|
|
53
|
+
clients.each_value(&:start)
|
|
54
|
+
if block_given?
|
|
55
|
+
begin
|
|
56
|
+
yield clients
|
|
57
|
+
ensure
|
|
58
|
+
close_connection
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
clients
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close_connection
|
|
66
|
+
clients.each_value do |client|
|
|
67
|
+
client.stop if client.alive?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def tools(blacklist: [], whitelist: [])
|
|
72
|
+
tools = @clients.values.map(&:tools)
|
|
73
|
+
.flatten
|
|
74
|
+
.reject { |tool| blacklist.include?(tool.name) }
|
|
75
|
+
|
|
76
|
+
tools = tools.select { |tool| whitelist.include?(tool.name) } if whitelist.any?
|
|
77
|
+
tools.uniq(&:name)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def mcp_configurations
|
|
81
|
+
config.mcp_configuration.each_with_object({}) do |config, acc|
|
|
82
|
+
acc[config[:name]] = config
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def configure
|
|
87
|
+
yield config
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def config
|
|
91
|
+
@config ||= Configuration.new
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
alias configuration config
|
|
95
|
+
module_function :configuration
|
|
96
|
+
|
|
97
|
+
def logger
|
|
98
|
+
config.logger
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
loader = Zeitwerk::Loader.for_gem_extension(RubyLLM)
|
|
104
|
+
|
|
105
|
+
loader.ignore("#{__dir__}/mcp/railtie.rb")
|
|
106
|
+
|
|
107
|
+
loader.inflector.inflect("mcp" => "MCP")
|
|
108
|
+
loader.inflector.inflect("sse" => "SSE")
|
|
109
|
+
loader.inflector.inflect("openai" => "OpenAI")
|
|
110
|
+
loader.inflector.inflect("streamable_http" => "StreamableHTTP")
|
|
111
|
+
loader.inflector.inflect("http_client" => "HTTPClient")
|
|
112
|
+
loader.inflector.inflect("http_server" => "HttpServer")
|
|
113
|
+
|
|
114
|
+
loader.inflector.inflect("ruby_llm_adapter" => "RubyLLMAdapter")
|
|
115
|
+
loader.inflector.inflect("mcp_sdk_adapter" => "MCPSdkAdapter")
|
|
116
|
+
loader.inflector.inflect("mcp_transports" => "MCPTransports")
|
|
117
|
+
|
|
118
|
+
loader.inflector.inflect("oauth_provider" => "OAuthProvider")
|
|
119
|
+
loader.inflector.inflect("browser_oauth_provider" => "BrowserOAuthProvider")
|
|
120
|
+
|
|
121
|
+
loader.setup
|
|
122
|
+
|
|
123
|
+
if defined?(Rails::Railtie)
|
|
124
|
+
require_relative "mcp/railtie"
|
|
125
|
+
end
|