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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +277 -0
  4. data/lib/generators/ruby_llm/mcp/install/install_generator.rb +42 -0
  5. data/lib/generators/ruby_llm/mcp/install/templates/initializer.rb +56 -0
  6. data/lib/generators/ruby_llm/mcp/install/templates/mcps.yml +29 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  16. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  17. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  18. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  19. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  20. data/lib/ruby_llm/chat.rb +34 -0
  21. data/lib/ruby_llm/mcp/adapters/base_adapter.rb +179 -0
  22. data/lib/ruby_llm/mcp/adapters/mcp_sdk_adapter.rb +292 -0
  23. data/lib/ruby_llm/mcp/adapters/mcp_transports/coordinator_stub.rb +33 -0
  24. data/lib/ruby_llm/mcp/adapters/mcp_transports/sse.rb +52 -0
  25. data/lib/ruby_llm/mcp/adapters/mcp_transports/stdio.rb +52 -0
  26. data/lib/ruby_llm/mcp/adapters/mcp_transports/streamable_http.rb +86 -0
  27. data/lib/ruby_llm/mcp/adapters/ruby_llm_adapter.rb +92 -0
  28. data/lib/ruby_llm/mcp/attachment.rb +18 -0
  29. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  30. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  31. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +112 -0
  32. data/lib/ruby_llm/mcp/auth/browser/opener.rb +39 -0
  33. data/lib/ruby_llm/mcp/auth/browser/pages.rb +607 -0
  34. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +280 -0
  35. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  36. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  37. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  38. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  39. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  40. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  41. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  42. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +63 -0
  43. data/lib/ruby_llm/mcp/auth/memory_storage.rb +90 -0
  44. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +305 -0
  45. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  46. data/lib/ruby_llm/mcp/auth/session_manager.rb +54 -0
  47. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  48. data/lib/ruby_llm/mcp/auth/transport_oauth_helper.rb +107 -0
  49. data/lib/ruby_llm/mcp/auth/url_builder.rb +76 -0
  50. data/lib/ruby_llm/mcp/auth.rb +359 -0
  51. data/lib/ruby_llm/mcp/client.rb +401 -0
  52. data/lib/ruby_llm/mcp/completion.rb +16 -0
  53. data/lib/ruby_llm/mcp/configuration.rb +310 -0
  54. data/lib/ruby_llm/mcp/content.rb +28 -0
  55. data/lib/ruby_llm/mcp/elicitation.rb +48 -0
  56. data/lib/ruby_llm/mcp/error.rb +34 -0
  57. data/lib/ruby_llm/mcp/errors.rb +91 -0
  58. data/lib/ruby_llm/mcp/logging.rb +16 -0
  59. data/lib/ruby_llm/mcp/native/cancellable_operation.rb +57 -0
  60. data/lib/ruby_llm/mcp/native/client.rb +387 -0
  61. data/lib/ruby_llm/mcp/native/json_rpc.rb +170 -0
  62. data/lib/ruby_llm/mcp/native/messages/helpers.rb +39 -0
  63. data/lib/ruby_llm/mcp/native/messages/notifications.rb +42 -0
  64. data/lib/ruby_llm/mcp/native/messages/requests.rb +206 -0
  65. data/lib/ruby_llm/mcp/native/messages/responses.rb +106 -0
  66. data/lib/ruby_llm/mcp/native/messages.rb +36 -0
  67. data/lib/ruby_llm/mcp/native/notification.rb +16 -0
  68. data/lib/ruby_llm/mcp/native/protocol.rb +36 -0
  69. data/lib/ruby_llm/mcp/native/response_handler.rb +110 -0
  70. data/lib/ruby_llm/mcp/native/transport.rb +88 -0
  71. data/lib/ruby_llm/mcp/native/transports/sse.rb +607 -0
  72. data/lib/ruby_llm/mcp/native/transports/stdio.rb +356 -0
  73. data/lib/ruby_llm/mcp/native/transports/streamable_http.rb +926 -0
  74. data/lib/ruby_llm/mcp/native/transports/support/http_client.rb +28 -0
  75. data/lib/ruby_llm/mcp/native/transports/support/rate_limit.rb +49 -0
  76. data/lib/ruby_llm/mcp/native/transports/support/timeout.rb +36 -0
  77. data/lib/ruby_llm/mcp/native.rb +12 -0
  78. data/lib/ruby_llm/mcp/notification_handler.rb +100 -0
  79. data/lib/ruby_llm/mcp/progress.rb +35 -0
  80. data/lib/ruby_llm/mcp/prompt.rb +132 -0
  81. data/lib/ruby_llm/mcp/railtie.rb +14 -0
  82. data/lib/ruby_llm/mcp/resource.rb +112 -0
  83. data/lib/ruby_llm/mcp/resource_template.rb +85 -0
  84. data/lib/ruby_llm/mcp/result.rb +108 -0
  85. data/lib/ruby_llm/mcp/roots.rb +45 -0
  86. data/lib/ruby_llm/mcp/sample.rb +152 -0
  87. data/lib/ruby_llm/mcp/server_capabilities.rb +49 -0
  88. data/lib/ruby_llm/mcp/tool.rb +228 -0
  89. data/lib/ruby_llm/mcp/version.rb +7 -0
  90. data/lib/ruby_llm/mcp.rb +125 -0
  91. data/lib/tasks/release.rake +23 -0
  92. 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module MCP
5
+ VERSION = "0.8.0"
6
+ end
7
+ end
@@ -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