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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_llm/mcp/{install_generator.rb → install/install_generator.rb} +4 -2
  3. data/lib/generators/ruby_llm/mcp/oauth/install_generator.rb +354 -0
  4. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/mcp_token_storage.rb.tt +114 -0
  5. data/lib/generators/ruby_llm/mcp/oauth/templates/concerns/user_mcp_oauth_concern.rb.tt +90 -0
  6. data/lib/generators/ruby_llm/mcp/oauth/templates/controllers/mcp_connections_controller.rb.tt +239 -0
  7. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/cleanup_expired_oauth_states_job.rb.tt +27 -0
  8. data/lib/generators/ruby_llm/mcp/oauth/templates/jobs/example_job.rb.tt +78 -0
  9. data/lib/generators/ruby_llm/mcp/oauth/templates/lib/mcp_client.rb.tt +68 -0
  10. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_credentials.rb.tt +19 -0
  11. data/lib/generators/ruby_llm/mcp/oauth/templates/migrations/create_mcp_oauth_states.rb.tt +21 -0
  12. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_credential.rb.tt +54 -0
  13. data/lib/generators/ruby_llm/mcp/oauth/templates/models/mcp_oauth_state.rb.tt +30 -0
  14. data/lib/generators/ruby_llm/mcp/oauth/templates/views/index.html.erb +646 -0
  15. data/lib/generators/ruby_llm/mcp/oauth/templates/views/show.html.erb +560 -0
  16. data/lib/ruby_llm/mcp/auth/browser/callback_handler.rb +71 -0
  17. data/lib/ruby_llm/mcp/auth/browser/callback_server.rb +30 -0
  18. data/lib/ruby_llm/mcp/auth/browser/http_server.rb +115 -0
  19. data/lib/ruby_llm/mcp/auth/browser/opener.rb +41 -0
  20. data/lib/ruby_llm/mcp/auth/browser/pages.rb +539 -0
  21. data/lib/ruby_llm/mcp/auth/browser_oauth_provider.rb +254 -0
  22. data/lib/ruby_llm/mcp/auth/client_registrar.rb +170 -0
  23. data/lib/ruby_llm/mcp/auth/discoverer.rb +124 -0
  24. data/lib/ruby_llm/mcp/auth/flows/authorization_code_flow.rb +105 -0
  25. data/lib/ruby_llm/mcp/auth/flows/client_credentials_flow.rb +66 -0
  26. data/lib/ruby_llm/mcp/auth/grant_strategies/authorization_code.rb +31 -0
  27. data/lib/ruby_llm/mcp/auth/grant_strategies/base.rb +31 -0
  28. data/lib/ruby_llm/mcp/auth/grant_strategies/client_credentials.rb +31 -0
  29. data/lib/ruby_llm/mcp/auth/http_response_handler.rb +65 -0
  30. data/lib/ruby_llm/mcp/auth/memory_storage.rb +72 -0
  31. data/lib/ruby_llm/mcp/auth/oauth_provider.rb +226 -0
  32. data/lib/ruby_llm/mcp/auth/security.rb +44 -0
  33. data/lib/ruby_llm/mcp/auth/session_manager.rb +56 -0
  34. data/lib/ruby_llm/mcp/auth/token_manager.rb +236 -0
  35. data/lib/ruby_llm/mcp/auth/url_builder.rb +78 -0
  36. data/lib/ruby_llm/mcp/auth.rb +359 -0
  37. data/lib/ruby_llm/mcp/client.rb +49 -0
  38. data/lib/ruby_llm/mcp/configuration.rb +39 -13
  39. data/lib/ruby_llm/mcp/coordinator.rb +11 -0
  40. data/lib/ruby_llm/mcp/errors.rb +11 -0
  41. data/lib/ruby_llm/mcp/railtie.rb +2 -10
  42. data/lib/ruby_llm/mcp/tool.rb +1 -1
  43. data/lib/ruby_llm/mcp/transport.rb +94 -1
  44. data/lib/ruby_llm/mcp/transports/sse.rb +116 -22
  45. data/lib/ruby_llm/mcp/transports/stdio.rb +4 -3
  46. data/lib/ruby_llm/mcp/transports/streamable_http.rb +81 -79
  47. data/lib/ruby_llm/mcp/version.rb +1 -1
  48. data/lib/ruby_llm/mcp.rb +10 -4
  49. metadata +40 -5
  50. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/initializer.rb +0 -0
  51. /data/lib/generators/ruby_llm/mcp/{templates → install/templates}/mcps.yml +0 -0
@@ -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?
@@ -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
@@ -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
@@ -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, **config)
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:, version: :http2, headers: {})
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
- @headers = headers.merge({
30
- "Accept" => "text/event-stream",
31
- "Content-Type" => "application/json",
32
- "Cache-Control" => "no-cache",
33
- "X-CLIENT-ID" => @client_id
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
- http_client = Support::HTTPClient.connection.with(timeout: { request_timeout: @request_timeout / 1000 },
108
- headers: @headers)
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
- message = "Failed to have a successful request to #{@messages_url}: #{response.status} - #{response.body}"
115
- RubyLLM::MCP.logger.error(message)
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: message,
118
- code: response.status
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
- sse_client = HTTPX.plugin(:stream).with(headers: @headers)
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
- handle_client_error!(error_message, response.status) if response.status < 500
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
- raise StandardError, error_message
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!(error_message, status_code)
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: error_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:, args: [], env: {})
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