ruby_llm-mcp 0.7.1 → 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 +4 -4
- data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
- 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/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 +115 -0
- data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
- data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
- data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -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 +65 -0
- data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
- data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
- data/lib/ruby_llm/mcp/auth/security.rb +44 -0
- data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
- data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
- data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
- data/lib/ruby_llm/mcp/auth.rb +359 -0
- data/lib/ruby_llm/mcp/client.rb +49 -0
- data/lib/ruby_llm/mcp/configuration.rb +39 -13
- data/lib/ruby_llm/mcp/coordinator.rb +11 -0
- data/lib/ruby_llm/mcp/errors.rb +11 -0
- data/lib/ruby_llm/mcp/railtie.rb +2 -10
- data/lib/ruby_llm/mcp/tool.rb +1 -1
- data/lib/ruby_llm/mcp/transport.rb +94 -1
- data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
- data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
- data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
- data/lib/ruby_llm/mcp/version.rb +1 -1
- data/lib/ruby_llm/mcp.rb +10 -4
- metadata +40 -5
- /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
- /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/mcps.yml +0 -0
data/lib/ruby_llm/mcp/client.rb
CHANGED
|
@@ -17,6 +17,11 @@ module RubyLLM
|
|
|
17
17
|
@transport_type = transport_type.to_sym
|
|
18
18
|
@request_timeout = request_timeout
|
|
19
19
|
|
|
20
|
+
# Store OAuth config before coordinator setup
|
|
21
|
+
@oauth_config = config[:oauth] || config["oauth"]
|
|
22
|
+
@oauth_provider = nil
|
|
23
|
+
@oauth_storage = nil
|
|
24
|
+
|
|
20
25
|
@coordinator = setup_coordinator
|
|
21
26
|
|
|
22
27
|
@on = {}
|
|
@@ -49,6 +54,40 @@ module RubyLLM
|
|
|
49
54
|
@coordinator.restart_transport
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
# Get or create OAuth provider for this client
|
|
58
|
+
# @param type [Symbol] OAuth provider type (:standard or :browser, defaults to :standard)
|
|
59
|
+
# @param options [Hash] additional options passed to provider
|
|
60
|
+
# @return [OAuthProvider, BrowserOAuthProvider] OAuth provider instance
|
|
61
|
+
def oauth(type: :standard, **options)
|
|
62
|
+
# Return existing provider if already created
|
|
63
|
+
return @oauth_provider if @oauth_provider
|
|
64
|
+
|
|
65
|
+
# Get provider from transport if it already exists
|
|
66
|
+
transport_oauth = @coordinator.transport_oauth_provider
|
|
67
|
+
return transport_oauth if transport_oauth
|
|
68
|
+
|
|
69
|
+
# Create new provider lazily
|
|
70
|
+
server_url = @config[:url] || @config["url"]
|
|
71
|
+
unless server_url
|
|
72
|
+
raise Errors::ConfigurationError.new(
|
|
73
|
+
message: "Cannot create OAuth provider without server URL in config"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
oauth_options = {
|
|
78
|
+
server_url: server_url,
|
|
79
|
+
scope: @oauth_config&.dig(:scope) || @oauth_config&.dig("scope"),
|
|
80
|
+
storage: oauth_storage,
|
|
81
|
+
**options
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@oauth_provider = Auth.create_oauth(
|
|
85
|
+
server_url,
|
|
86
|
+
type: type,
|
|
87
|
+
**oauth_options
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
52
91
|
def tools(refresh: false)
|
|
53
92
|
return [] unless capabilities.tools_list?
|
|
54
93
|
|
|
@@ -260,6 +299,16 @@ module RubyLLM
|
|
|
260
299
|
@on_logging_level = MCP.config.on_logging_level
|
|
261
300
|
@on[:elicitation] = MCP.config.on_elicitation
|
|
262
301
|
end
|
|
302
|
+
|
|
303
|
+
# Get or create OAuth storage shared with transport
|
|
304
|
+
def oauth_storage
|
|
305
|
+
# Try to get storage from transport's OAuth provider
|
|
306
|
+
transport_oauth = @coordinator.transport_oauth_provider
|
|
307
|
+
return transport_oauth.storage if transport_oauth
|
|
308
|
+
|
|
309
|
+
# Create new storage shared with client
|
|
310
|
+
@oauth_storage ||= Auth::MemoryStorage.new
|
|
311
|
+
end
|
|
263
312
|
end
|
|
264
313
|
end
|
|
265
314
|
end
|
|
@@ -42,6 +42,36 @@ module RubyLLM
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
class OAuth
|
|
46
|
+
attr_accessor :client_name,
|
|
47
|
+
:client_uri,
|
|
48
|
+
:software_id,
|
|
49
|
+
:software_version,
|
|
50
|
+
:logo_uri,
|
|
51
|
+
:contacts,
|
|
52
|
+
:tos_uri,
|
|
53
|
+
:policy_uri,
|
|
54
|
+
:jwks_uri,
|
|
55
|
+
:jwks,
|
|
56
|
+
:browser_success_page,
|
|
57
|
+
:browser_error_page
|
|
58
|
+
|
|
59
|
+
def initialize
|
|
60
|
+
@client_name = "RubyLLM MCP Client"
|
|
61
|
+
@client_uri = nil
|
|
62
|
+
@software_id = "ruby_llm-mcp"
|
|
63
|
+
@software_version = RubyLLM::MCP::VERSION
|
|
64
|
+
@logo_uri = nil
|
|
65
|
+
@contacts = nil
|
|
66
|
+
@tos_uri = nil
|
|
67
|
+
@policy_uri = nil
|
|
68
|
+
@jwks_uri = nil
|
|
69
|
+
@jwks = nil
|
|
70
|
+
@browser_success_page = nil
|
|
71
|
+
@browser_error_page = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
45
75
|
class ConfigFile
|
|
46
76
|
attr_reader :file_path
|
|
47
77
|
|
|
@@ -87,7 +117,6 @@ module RubyLLM
|
|
|
87
117
|
attr_accessor :request_timeout,
|
|
88
118
|
:log_file,
|
|
89
119
|
:log_level,
|
|
90
|
-
:has_support_complex_parameters,
|
|
91
120
|
:roots,
|
|
92
121
|
:sampling,
|
|
93
122
|
:max_connections,
|
|
@@ -95,7 +124,8 @@ module RubyLLM
|
|
|
95
124
|
:protocol_version,
|
|
96
125
|
:config_path,
|
|
97
126
|
:launch_control,
|
|
98
|
-
:on_logging_level
|
|
127
|
+
:on_logging_level,
|
|
128
|
+
:oauth
|
|
99
129
|
|
|
100
130
|
attr_writer :logger, :mcp_configuration
|
|
101
131
|
|
|
@@ -103,6 +133,7 @@ module RubyLLM
|
|
|
103
133
|
|
|
104
134
|
def initialize
|
|
105
135
|
@sampling = Sampling.new
|
|
136
|
+
@oauth = OAuth.new
|
|
106
137
|
set_defaults
|
|
107
138
|
end
|
|
108
139
|
|
|
@@ -110,14 +141,6 @@ module RubyLLM
|
|
|
110
141
|
set_defaults
|
|
111
142
|
end
|
|
112
143
|
|
|
113
|
-
def support_complex_parameters!
|
|
114
|
-
warn "[DEPRECATION] config.support_complex_parameters! is no longer needed and will be removed in version 0.8.0"
|
|
115
|
-
return if @has_support_complex_parameters
|
|
116
|
-
|
|
117
|
-
@has_support_complex_parameters = true
|
|
118
|
-
RubyLLM::MCP.support_complex_parameters!
|
|
119
|
-
end
|
|
120
|
-
|
|
121
144
|
def logger
|
|
122
145
|
@logger ||= Logger.new(
|
|
123
146
|
log_file,
|
|
@@ -188,9 +211,6 @@ module RubyLLM
|
|
|
188
211
|
@log_level = ENV["RUBYLLM_MCP_DEBUG"] ? Logger::DEBUG : Logger::INFO
|
|
189
212
|
@logger = nil
|
|
190
213
|
|
|
191
|
-
# Complex parameters support
|
|
192
|
-
@has_support_complex_parameters = false
|
|
193
|
-
|
|
194
214
|
# MCPs configuration
|
|
195
215
|
@mcps_config_path = nil
|
|
196
216
|
@mcp_configuration = []
|
|
@@ -201,6 +221,12 @@ module RubyLLM
|
|
|
201
221
|
# Roots configuration
|
|
202
222
|
@roots = []
|
|
203
223
|
|
|
224
|
+
# Protocol configuration
|
|
225
|
+
@protocol_version = Protocol.latest_version
|
|
226
|
+
|
|
227
|
+
# OAuth configuration
|
|
228
|
+
@oauth = OAuth.new
|
|
229
|
+
|
|
204
230
|
# Sampling configuration
|
|
205
231
|
@sampling.reset!
|
|
206
232
|
|
|
@@ -283,6 +283,17 @@ module RubyLLM
|
|
|
283
283
|
@transport ||= RubyLLM::MCP::Transport.new(@transport_type, self, config: @config)
|
|
284
284
|
end
|
|
285
285
|
|
|
286
|
+
# Get OAuth provider from transport if available
|
|
287
|
+
# @return [OAuthProvider, BrowserOAuthProvider, nil] OAuth provider or nil
|
|
288
|
+
def transport_oauth_provider
|
|
289
|
+
return nil unless @transport
|
|
290
|
+
|
|
291
|
+
transport_protocol = @transport.transport_protocol
|
|
292
|
+
return nil unless transport_protocol.respond_to?(:oauth_provider)
|
|
293
|
+
|
|
294
|
+
transport_protocol.oauth_provider
|
|
295
|
+
end
|
|
296
|
+
|
|
286
297
|
private
|
|
287
298
|
|
|
288
299
|
def sampling_enabled?
|
data/lib/ruby_llm/mcp/errors.rb
CHANGED
|
@@ -36,6 +36,17 @@ module RubyLLM
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
class AuthenticationRequiredError < BaseError
|
|
40
|
+
attr_reader :code
|
|
41
|
+
|
|
42
|
+
def initialize(message: "Authentication required", code: 401)
|
|
43
|
+
@code = code
|
|
44
|
+
super(message: message)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class ConfigurationError < BaseError; end
|
|
49
|
+
|
|
39
50
|
class SessionExpiredError < BaseError; end
|
|
40
51
|
|
|
41
52
|
class TimeoutError < BaseError
|
data/lib/ruby_llm/mcp/railtie.rb
CHANGED
|
@@ -3,17 +3,9 @@
|
|
|
3
3
|
module RubyLLM
|
|
4
4
|
module MCP
|
|
5
5
|
class Railtie < Rails::Railtie
|
|
6
|
-
config.after_initialize do
|
|
7
|
-
if RubyLLM::MCP.config.launch_control == :automatic
|
|
8
|
-
RubyLLM::MCP.clients.each_value.map(&:start)
|
|
9
|
-
at_exit do
|
|
10
|
-
RubyLLM::MCP.clients.each_value.map(&:stop)
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
|
|
15
6
|
generators do
|
|
16
|
-
require_relative "../../generators/ruby_llm/mcp/install_generator"
|
|
7
|
+
require_relative "../../generators/ruby_llm/mcp/install/install_generator"
|
|
8
|
+
require_relative "../../generators/ruby_llm/mcp/oauth/install_generator"
|
|
17
9
|
end
|
|
18
10
|
end
|
|
19
11
|
end
|
data/lib/ruby_llm/mcp/tool.rb
CHANGED
|
@@ -25,7 +25,7 @@ module RubyLLM
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
class Tool < RubyLLM::Tool
|
|
28
|
-
attr_reader :name, :title, :description, :coordinator, :tool_response, :with_prefix
|
|
28
|
+
attr_reader :name, :title, :description, :annotations, :coordinator, :tool_response, :with_prefix
|
|
29
29
|
|
|
30
30
|
def initialize(coordinator, tool_response, with_prefix: false)
|
|
31
31
|
super()
|
|
@@ -50,8 +50,101 @@ module RubyLLM
|
|
|
50
50
|
raise Errors::InvalidTransportType.new(message: message)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
transport_config = prepare_transport_config
|
|
53
54
|
transport_klass = RubyLLM::MCP::Transport.transports[transport_type]
|
|
54
|
-
transport_klass.new(coordinator: coordinator, **
|
|
55
|
+
transport_klass.new(coordinator: coordinator, **transport_config)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prepare_transport_config
|
|
59
|
+
transport_config = config.dup
|
|
60
|
+
oauth_provider = create_oauth_provider(transport_config) if oauth_config_present?(transport_config)
|
|
61
|
+
|
|
62
|
+
# Extract transport-specific parameters and consolidate into options
|
|
63
|
+
if %i[sse streamable streamable_http].include?(transport_type)
|
|
64
|
+
prepare_http_transport_config(transport_config, oauth_provider)
|
|
65
|
+
elsif transport_type == :stdio
|
|
66
|
+
prepare_stdio_transport_config(transport_config)
|
|
67
|
+
else
|
|
68
|
+
transport_config
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prepare_http_transport_config(config, oauth_provider)
|
|
73
|
+
options = {
|
|
74
|
+
version: config.delete(:version) || config.delete("version"),
|
|
75
|
+
headers: config.delete(:headers) || config.delete("headers"),
|
|
76
|
+
oauth_provider: oauth_provider,
|
|
77
|
+
reconnection: config.delete(:reconnection) || config.delete("reconnection"),
|
|
78
|
+
reconnection_options: config.delete(:reconnection_options) || config.delete("reconnection_options"),
|
|
79
|
+
rate_limit: config.delete(:rate_limit) || config.delete("rate_limit"),
|
|
80
|
+
session_id: config.delete(:session_id) || config.delete("session_id")
|
|
81
|
+
}.compact
|
|
82
|
+
|
|
83
|
+
config[:options] = options
|
|
84
|
+
config
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def prepare_stdio_transport_config(config)
|
|
88
|
+
options = {
|
|
89
|
+
args: config.delete(:args) || config.delete("args"),
|
|
90
|
+
env: config.delete(:env) || config.delete("env")
|
|
91
|
+
}.compact
|
|
92
|
+
|
|
93
|
+
config[:options] = options unless options.empty?
|
|
94
|
+
config
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if OAuth configuration is present
|
|
98
|
+
def oauth_config_present?(config)
|
|
99
|
+
oauth_config = config[:oauth] || config["oauth"]
|
|
100
|
+
return false if oauth_config.nil?
|
|
101
|
+
|
|
102
|
+
# If it's an OAuth provider instance, it's present
|
|
103
|
+
return true if oauth_config.respond_to?(:access_token)
|
|
104
|
+
|
|
105
|
+
# If it's a hash, check if it's not empty
|
|
106
|
+
!oauth_config.empty?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Create OAuth provider from configuration
|
|
110
|
+
# Accepts either a provider instance or a configuration hash
|
|
111
|
+
def create_oauth_provider(config)
|
|
112
|
+
oauth_config = config.delete(:oauth) || config.delete("oauth")
|
|
113
|
+
return nil unless oauth_config
|
|
114
|
+
|
|
115
|
+
# If provider key exists with an instance, use it
|
|
116
|
+
if oauth_config.is_a?(Hash) && (oauth_config[:provider] || oauth_config["provider"])
|
|
117
|
+
return oauth_config[:provider] || oauth_config["provider"]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# If oauth_config itself is a provider instance, use it directly
|
|
121
|
+
if oauth_config.respond_to?(:access_token) && oauth_config.respond_to?(:start_authorization_flow)
|
|
122
|
+
return oauth_config
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Otherwise create new provider from config hash
|
|
126
|
+
# Determine server URL based on transport type
|
|
127
|
+
server_url = determine_server_url(config)
|
|
128
|
+
return nil unless server_url
|
|
129
|
+
|
|
130
|
+
redirect_uri = oauth_config[:redirect_uri] || oauth_config["redirect_uri"] || "http://localhost:8080/callback"
|
|
131
|
+
scope = oauth_config[:scope] || oauth_config["scope"]
|
|
132
|
+
storage = oauth_config[:storage] || oauth_config["storage"]
|
|
133
|
+
grant_type = oauth_config[:grant_type] || oauth_config["grant_type"] || :authorization_code
|
|
134
|
+
|
|
135
|
+
RubyLLM::MCP::Auth::OAuthProvider.new(
|
|
136
|
+
server_url: server_url,
|
|
137
|
+
redirect_uri: redirect_uri,
|
|
138
|
+
scope: scope,
|
|
139
|
+
logger: MCP.logger,
|
|
140
|
+
storage: storage,
|
|
141
|
+
grant_type: grant_type
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Determine server URL from transport config
|
|
146
|
+
def determine_server_url(config)
|
|
147
|
+
config[:url] || config["url"]
|
|
55
148
|
end
|
|
56
149
|
end
|
|
57
150
|
end
|
|
@@ -12,26 +12,28 @@ module RubyLLM
|
|
|
12
12
|
class SSE
|
|
13
13
|
include Support::Timeout
|
|
14
14
|
|
|
15
|
-
attr_reader :headers, :id, :coordinator
|
|
15
|
+
attr_reader :headers, :id, :coordinator, :oauth_provider
|
|
16
16
|
|
|
17
|
-
def initialize(url:, coordinator:, request_timeout:,
|
|
17
|
+
def initialize(url:, coordinator:, request_timeout:, options: {})
|
|
18
18
|
@event_url = url
|
|
19
19
|
@messages_url = nil
|
|
20
20
|
@coordinator = coordinator
|
|
21
21
|
@request_timeout = request_timeout
|
|
22
|
-
@version = version
|
|
22
|
+
@version = options[:version] || options["version"] || :http2
|
|
23
|
+
@oauth_provider = options[:oauth_provider] || options["oauth_provider"]
|
|
23
24
|
|
|
24
25
|
uri = URI.parse(url)
|
|
25
26
|
@root_url = "#{uri.scheme}://#{uri.host}"
|
|
26
27
|
@root_url += ":#{uri.port}" if uri.port != uri.default_port
|
|
27
28
|
|
|
28
29
|
@client_id = SecureRandom.uuid
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
custom_headers = options[:headers] || options["headers"] || {}
|
|
31
|
+
@headers = custom_headers.merge({
|
|
32
|
+
"Accept" => "text/event-stream",
|
|
33
|
+
"Content-Type" => "application/json",
|
|
34
|
+
"Cache-Control" => "no-cache",
|
|
35
|
+
"X-CLIENT-ID" => @client_id
|
|
36
|
+
})
|
|
35
37
|
|
|
36
38
|
@id_counter = 0
|
|
37
39
|
@id_mutex = Mutex.new
|
|
@@ -42,6 +44,7 @@ module RubyLLM
|
|
|
42
44
|
@sse_thread = nil
|
|
43
45
|
|
|
44
46
|
RubyLLM::MCP.logger.info "Initializing SSE transport to #{@event_url} with client ID #{@client_id}"
|
|
47
|
+
RubyLLM::MCP.logger.debug "OAuth provider: #{@oauth_provider ? 'present' : 'none'}" if @oauth_provider
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
def request(body, add_id: true, wait_for_response: true)
|
|
@@ -104,20 +107,53 @@ module RubyLLM
|
|
|
104
107
|
private
|
|
105
108
|
|
|
106
109
|
def send_request(body, request_id)
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
request_headers = build_request_headers
|
|
111
|
+
http_client = Support::HTTPClient.connection.with(
|
|
112
|
+
timeout: { request_timeout: @request_timeout / 1000 },
|
|
113
|
+
headers: request_headers
|
|
114
|
+
)
|
|
109
115
|
response = http_client.post(@messages_url, body: JSON.generate(body))
|
|
110
116
|
handle_httpx_error_response!(response,
|
|
111
117
|
context: { location: "message endpoint request", request_id: request_id })
|
|
112
118
|
|
|
113
119
|
unless [200, 202].include?(response.status)
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
handle_send_request_error(response)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def handle_send_request_error(response)
|
|
125
|
+
response_body = response.respond_to?(:body) ? response.body.to_s : "Unknown error"
|
|
126
|
+
status_code = response.respond_to?(:status) ? response.status : "Unknown"
|
|
127
|
+
|
|
128
|
+
# Try to parse JSON error
|
|
129
|
+
error_message = begin
|
|
130
|
+
error_body = JSON.parse(response_body)
|
|
131
|
+
if error_body.is_a?(Hash) && error_body["error"]
|
|
132
|
+
msg = error_body["error"]["message"] || error_body["error"]["code"] || error_body["error"].to_s
|
|
133
|
+
msg.to_s.strip.empty? ? "Empty error (full response: #{response_body})" : msg
|
|
134
|
+
else
|
|
135
|
+
response_body
|
|
136
|
+
end
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
response_body
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
full_message = "Failed to have a successful request to #{@messages_url}: #{status_code} - #{error_message}"
|
|
142
|
+
RubyLLM::MCP.logger.error(full_message)
|
|
143
|
+
|
|
144
|
+
# Special handling for 403 with OAuth
|
|
145
|
+
if status_code == 403 && @oauth_provider
|
|
116
146
|
raise Errors::TransportError.new(
|
|
117
|
-
message:
|
|
118
|
-
|
|
147
|
+
message: "Authorization failed (403 Forbidden): #{error_message}. Check token scope and resource \
|
|
148
|
+
permissions at #{@oauth_provider.server_url}.",
|
|
149
|
+
code: status_code
|
|
119
150
|
)
|
|
120
151
|
end
|
|
152
|
+
|
|
153
|
+
raise Errors::TransportError.new(
|
|
154
|
+
message: full_message,
|
|
155
|
+
code: status_code
|
|
156
|
+
)
|
|
121
157
|
end
|
|
122
158
|
|
|
123
159
|
def start_sse_listener
|
|
@@ -173,28 +209,86 @@ module RubyLLM
|
|
|
173
209
|
end
|
|
174
210
|
|
|
175
211
|
def create_sse_client
|
|
176
|
-
|
|
212
|
+
stream_headers = build_request_headers
|
|
213
|
+
sse_client = HTTPX.plugin(:stream).with(headers: stream_headers)
|
|
177
214
|
return sse_client unless @version == :http1
|
|
178
215
|
|
|
179
216
|
sse_client.with(ssl: { alpn_protocols: ["http/1.1"] })
|
|
180
217
|
end
|
|
181
218
|
|
|
219
|
+
# Build request headers with OAuth authorization if available
|
|
220
|
+
def build_request_headers
|
|
221
|
+
headers = @headers.dup
|
|
222
|
+
|
|
223
|
+
if @oauth_provider
|
|
224
|
+
RubyLLM::MCP.logger.debug "OAuth provider present, attempting to get token..."
|
|
225
|
+
RubyLLM::MCP.logger.debug " Server URL: #{@oauth_provider.server_url}"
|
|
226
|
+
|
|
227
|
+
token = @oauth_provider.access_token
|
|
228
|
+
if token
|
|
229
|
+
headers["Authorization"] = token.to_header
|
|
230
|
+
RubyLLM::MCP.logger.debug "✓ Applied OAuth authorization header: #{token.to_header[0..30]}..."
|
|
231
|
+
else
|
|
232
|
+
RubyLLM::MCP.logger.warn "✗ OAuth provider present but no valid token available!"
|
|
233
|
+
RubyLLM::MCP.logger.warn " This means the token is not in storage or has expired"
|
|
234
|
+
RubyLLM::MCP.logger.warn " Check that authentication completed successfully"
|
|
235
|
+
end
|
|
236
|
+
else
|
|
237
|
+
RubyLLM::MCP.logger.debug "No OAuth provider configured for this transport"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
headers
|
|
241
|
+
end
|
|
242
|
+
|
|
182
243
|
def validate_sse_response!(response)
|
|
183
244
|
return unless response.status >= 400
|
|
184
245
|
|
|
185
246
|
error_body = read_error_body(response)
|
|
186
|
-
error_message = "HTTP #{response.status} error from SSE endpoint: #{error_body}"
|
|
187
|
-
RubyLLM::MCP.logger.error error_message
|
|
188
247
|
|
|
189
|
-
|
|
248
|
+
# Try to parse as JSON to get better error details
|
|
249
|
+
error_message = begin
|
|
250
|
+
error_data = JSON.parse(error_body)
|
|
251
|
+
if error_data.is_a?(Hash) && error_data["error"]
|
|
252
|
+
msg = error_data["error"]["message"] || error_data["error"]["code"] || error_data["error"].to_s
|
|
253
|
+
# If we still don't have a message, include the full error object
|
|
254
|
+
msg.to_s.strip.empty? ? "Empty error (full response: #{error_body})" : msg
|
|
255
|
+
else
|
|
256
|
+
error_body
|
|
257
|
+
end
|
|
258
|
+
rescue JSON::ParserError
|
|
259
|
+
error_body
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
full_error_message = "HTTP #{response.status} error from SSE endpoint: #{error_message}"
|
|
263
|
+
RubyLLM::MCP.logger.error full_error_message
|
|
190
264
|
|
|
191
|
-
|
|
265
|
+
handle_client_error!(full_error_message, response.status, error_message) if response.status < 500
|
|
266
|
+
|
|
267
|
+
raise StandardError, full_error_message
|
|
192
268
|
end
|
|
193
269
|
|
|
194
|
-
def handle_client_error!(
|
|
270
|
+
def handle_client_error!(full_error_message, status_code, error_message)
|
|
195
271
|
@running = false
|
|
272
|
+
|
|
273
|
+
# Special handling for 401 Unauthorized - OAuth authentication required
|
|
274
|
+
if status_code == 401
|
|
275
|
+
raise Errors::AuthenticationRequiredError.new(
|
|
276
|
+
message: "OAuth authentication required. Server returned 401 Unauthorized.",
|
|
277
|
+
code: 401
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Special handling for 403 Forbidden with OAuth
|
|
282
|
+
if status_code == 403 && @oauth_provider
|
|
283
|
+
raise Errors::TransportError.new(
|
|
284
|
+
message: "Authorization failed (403 Forbidden): #{error_message}. \
|
|
285
|
+
Check token scope and resource permissions at #{@oauth_provider.server_url}.",
|
|
286
|
+
code: status_code
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
196
290
|
raise Errors::TransportError.new(
|
|
197
|
-
message:
|
|
291
|
+
message: full_error_message,
|
|
198
292
|
code: status_code
|
|
199
293
|
)
|
|
200
294
|
end
|
|
@@ -13,13 +13,14 @@ module RubyLLM
|
|
|
13
13
|
|
|
14
14
|
attr_reader :command, :stdin, :stdout, :stderr, :id, :coordinator
|
|
15
15
|
|
|
16
|
-
def initialize(command:, coordinator:, request_timeout:,
|
|
16
|
+
def initialize(command:, coordinator:, request_timeout:, options: {})
|
|
17
17
|
@request_timeout = request_timeout
|
|
18
18
|
@command = command
|
|
19
19
|
@coordinator = coordinator
|
|
20
|
-
@args = args
|
|
21
|
-
@env = env || {}
|
|
20
|
+
@args = options[:args] || options["args"] || []
|
|
21
|
+
@env = options[:env] || options["env"] || {}
|
|
22
22
|
@client_id = SecureRandom.uuid
|
|
23
|
+
# NOTE: Stdio transport doesn't use OAuth (local process communication)
|
|
23
24
|
|
|
24
25
|
@id_counter = 0
|
|
25
26
|
@id_mutex = Mutex.new
|