actionmcp 0.71.0 → 0.72.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -15
  3. data/app/controllers/action_mcp/application_controller.rb +47 -40
  4. data/app/controllers/action_mcp/oauth/endpoints_controller.rb +11 -10
  5. data/app/controllers/action_mcp/oauth/metadata_controller.rb +6 -10
  6. data/app/controllers/action_mcp/oauth/registration_controller.rb +15 -20
  7. data/app/models/action_mcp/oauth_client.rb +7 -5
  8. data/app/models/action_mcp/oauth_token.rb +2 -1
  9. data/app/models/action_mcp/session.rb +40 -5
  10. data/config/routes.rb +4 -2
  11. data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
  12. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +17 -8
  13. data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +7 -5
  14. data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +3 -1
  15. data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
  16. data/lib/action_mcp/base_response.rb +1 -1
  17. data/lib/action_mcp/client/base.rb +12 -13
  18. data/lib/action_mcp/client/collection.rb +3 -3
  19. data/lib/action_mcp/client/elicitation.rb +4 -4
  20. data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
  21. data/lib/action_mcp/client/jwt_client_provider.rb +6 -5
  22. data/lib/action_mcp/client/oauth_client_provider.rb +8 -8
  23. data/lib/action_mcp/client/streamable_http_transport.rb +63 -27
  24. data/lib/action_mcp/client.rb +19 -4
  25. data/lib/action_mcp/configuration.rb +28 -53
  26. data/lib/action_mcp/engine.rb +5 -1
  27. data/lib/action_mcp/filtered_logger.rb +1 -1
  28. data/lib/action_mcp/gateway.rb +47 -137
  29. data/lib/action_mcp/gateway_identifier.rb +29 -0
  30. data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
  31. data/lib/action_mcp/jwt_decoder.rb +4 -2
  32. data/lib/action_mcp/jwt_identifier.rb +28 -0
  33. data/lib/action_mcp/none_identifier.rb +19 -0
  34. data/lib/action_mcp/o_auth_identifier.rb +34 -0
  35. data/lib/action_mcp/oauth/active_record_storage.rb +1 -1
  36. data/lib/action_mcp/oauth/memory_storage.rb +1 -3
  37. data/lib/action_mcp/oauth/middleware.rb +13 -18
  38. data/lib/action_mcp/oauth/provider.rb +45 -65
  39. data/lib/action_mcp/omniauth/mcp_strategy.rb +23 -37
  40. data/lib/action_mcp/prompt.rb +2 -0
  41. data/lib/action_mcp/renderable.rb +1 -1
  42. data/lib/action_mcp/resource_template.rb +6 -2
  43. data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +39 -26
  44. data/lib/action_mcp/server/base_session_store.rb +86 -0
  45. data/lib/action_mcp/server/capabilities.rb +2 -1
  46. data/lib/action_mcp/server/elicitation.rb +3 -9
  47. data/lib/action_mcp/server/error_handling.rb +14 -1
  48. data/lib/action_mcp/server/handlers/router.rb +31 -0
  49. data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
  50. data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
  51. data/lib/action_mcp/server/prompts.rb +4 -4
  52. data/lib/action_mcp/server/resources.rb +23 -4
  53. data/lib/action_mcp/server/session_store_factory.rb +1 -1
  54. data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
  55. data/lib/action_mcp/server/tools.rb +62 -43
  56. data/lib/action_mcp/server/transport_handler.rb +2 -4
  57. data/lib/action_mcp/server/volatile_session_store.rb +1 -93
  58. data/lib/action_mcp/tagged_stream_logging.rb +2 -2
  59. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
  60. data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
  61. data/lib/action_mcp/tool.rb +48 -37
  62. data/lib/action_mcp/types/float_array_type.rb +5 -3
  63. data/lib/action_mcp/version.rb +1 -1
  64. data/lib/action_mcp.rb +1 -1
  65. data/lib/generators/action_mcp/install/templates/application_gateway.rb +1 -0
  66. data/lib/tasks/action_mcp_tasks.rake +7 -5
  67. metadata +24 -18
  68. data/lib/action_mcp/server/notifications.rb +0 -58
@@ -13,12 +13,16 @@ module ActionMCP
13
13
  SSE_TIMEOUT = 10
14
14
  ENDPOINT_TIMEOUT = 5
15
15
 
16
- attr_reader :session_id, :last_event_id
16
+ attr_reader :session_id, :last_event_id, :protocol_version
17
17
 
18
- def initialize(url, session_store:, session_id: nil, oauth_provider: nil, **options)
18
+ def initialize(url, session_store:, session_id: nil, oauth_provider: nil, jwt_provider: nil,
19
+ protocol_version: nil, **options)
19
20
  super(url, session_store: session_store, **options)
20
21
  @session_id = session_id
21
22
  @oauth_provider = oauth_provider
23
+ @jwt_provider = jwt_provider
24
+ @protocol_version = protocol_version || ActionMCP::DEFAULT_PROTOCOL_VERSION
25
+ @negotiated_protocol_version = nil
22
26
  @last_event_id = nil
23
27
  @buffer = +""
24
28
  @current_event = nil
@@ -38,8 +42,9 @@ module ActionMCP
38
42
  # Start SSE stream if server supports it
39
43
  start_sse_stream
40
44
 
41
- set_connected(true)
45
+ # Set ready first, then connected (so transport is ready when on_connect fires)
42
46
  set_ready(true)
47
+ set_connected(true)
43
48
  log_debug("StreamableHTTP connection established")
44
49
  true
45
50
  rescue StandardError => e
@@ -62,9 +67,7 @@ module ActionMCP
62
67
  end
63
68
 
64
69
  def send_message(message)
65
- unless ready?
66
- raise ConnectionError, "Transport not ready"
67
- end
70
+ raise ConnectionError, "Transport not ready" unless ready?
68
71
 
69
72
  headers = build_post_headers
70
73
  json_data = message.is_a?(String) ? message : message.to_json
@@ -98,7 +101,13 @@ module ActionMCP
98
101
  }
99
102
  headers["mcp-session-id"] = @session_id if @session_id
100
103
  headers["Last-Event-ID"] = @last_event_id if @last_event_id
104
+
105
+ # Add MCP-Protocol-Version header for GET requests when we have a negotiated version
106
+ headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
107
+
101
108
  headers.merge!(oauth_headers)
109
+ headers.merge!(jwt_headers)
110
+ log_debug("Final GET headers: #{headers}")
102
111
  headers
103
112
  end
104
113
 
@@ -108,7 +117,14 @@ module ActionMCP
108
117
  "Accept" => "application/json, text/event-stream"
109
118
  }
110
119
  headers["mcp-session-id"] = @session_id if @session_id
120
+
121
+ # Add MCP-Protocol-Version header as per 2025-06-18 spec
122
+ # Only include when we have a negotiated version from previous handshake
123
+ headers["MCP-Protocol-Version"] = @negotiated_protocol_version if @negotiated_protocol_version
124
+
111
125
  headers.merge!(oauth_headers)
126
+ headers.merge!(jwt_headers)
127
+ log_debug("Final POST headers: #{headers}")
112
128
  headers
113
129
  end
114
130
 
@@ -133,6 +149,7 @@ module ActionMCP
133
149
  @http_client.get(@url, nil, headers) do |req|
134
150
  req.options.on_data = proc do |chunk, _bytes|
135
151
  break if @stop_requested
152
+
136
153
  process_sse_chunk(chunk)
137
154
  end
138
155
  end
@@ -161,9 +178,9 @@ module ActionMCP
161
178
 
162
179
  lines.each do |line|
163
180
  if line.start_with?("id:")
164
- event_id = line[3..-1].strip
181
+ event_id = line[3..].strip
165
182
  elsif line.start_with?("data:")
166
- data_lines << line[5..-1].strip
183
+ data_lines << line[5..].strip
167
184
  end
168
185
  end
169
186
 
@@ -180,11 +197,9 @@ module ActionMCP
180
197
  end
181
198
  end
182
199
 
183
- def handle_post_response(response, original_message)
200
+ def handle_post_response(response, _original_message)
184
201
  # Extract session ID from response headers
185
- if response.headers["mcp-session-id"]
186
- @session_id = response.headers["mcp-session-id"]
187
- end
202
+ @session_id = response.headers["mcp-session-id"] if response.headers["mcp-session-id"]
188
203
 
189
204
  case response.status
190
205
  when 200
@@ -216,12 +231,17 @@ module ActionMCP
216
231
  end
217
232
 
218
233
  def handle_json_response(response)
219
- begin
220
- message = MultiJson.load(response.body)
221
- handle_message(message)
222
- rescue MultiJson::ParseError => e
223
- log_error("Failed to parse JSON response: #{e}")
234
+ message = MultiJson.load(response.body)
235
+
236
+ # Check if this is an initialize response to capture negotiated protocol version
237
+ if message.is_a?(Hash) && message["result"] && message["result"]["protocolVersion"]
238
+ @negotiated_protocol_version = message["result"]["protocolVersion"]
239
+ log_debug("Negotiated protocol version: #{@negotiated_protocol_version}")
224
240
  end
241
+
242
+ handle_message(message)
243
+ rescue MultiJson::ParseError => e
244
+ log_error("Failed to parse JSON response: #{e}")
225
245
  end
226
246
 
227
247
  def handle_sse_response_stream(response)
@@ -232,10 +252,8 @@ module ActionMCP
232
252
  end
233
253
 
234
254
  def handle_error_response(response)
235
- error_msg = "HTTP #{response.status}: #{response.reason_phrase}"
236
- if response.body && !response.body.empty?
237
- error_msg << " - #{response.body}"
238
- end
255
+ error_msg = +"HTTP #{response.status}: #{response.reason_phrase}"
256
+ error_msg << " - #{response.body}" if response.body && !response.body.empty?
239
257
  raise ConnectionError, error_msg
240
258
  end
241
259
 
@@ -280,7 +298,7 @@ module ActionMCP
280
298
  id: @session_id,
281
299
  last_event_id: @last_event_id,
282
300
  session_data: {},
283
- protocol_version: PROTOCOL_VERSION
301
+ protocol_version: @protocol_version
284
302
  }
285
303
 
286
304
  @session_store.save_session(@session_id, session_data)
@@ -290,21 +308,39 @@ module ActionMCP
290
308
  def oauth_headers
291
309
  return {} unless @oauth_provider&.authenticated?
292
310
 
293
- @oauth_provider.authorization_headers
311
+ headers = @oauth_provider.authorization_headers
312
+ log_debug("OAuth headers: #{headers}") unless headers.empty?
313
+ headers
294
314
  rescue StandardError => e
295
315
  log_error("Failed to get OAuth headers: #{e.message}")
296
316
  {}
297
317
  end
298
318
 
299
- def handle_authentication_error(response)
300
- return unless @oauth_provider
319
+ def jwt_headers
320
+ return {} unless @jwt_provider&.authenticated?
301
321
 
322
+ headers = @jwt_provider.authorization_headers
323
+ log_debug("JWT headers: #{headers}") unless headers.empty?
324
+ headers
325
+ rescue StandardError => e
326
+ log_error("Failed to get JWT headers: #{e.message}")
327
+ {}
328
+ end
329
+
330
+ def handle_authentication_error(response)
302
331
  # Check for OAuth challenge in WWW-Authenticate header
303
332
  www_auth = response.headers["www-authenticate"]
304
- if www_auth&.include?("Bearer")
305
- log_debug("Received OAuth challenge, clearing tokens")
333
+ return unless www_auth&.include?("Bearer")
334
+
335
+ if @oauth_provider
336
+ log_debug("Received OAuth challenge, clearing OAuth tokens")
306
337
  @oauth_provider.clear_tokens!
307
338
  end
339
+
340
+ return unless @jwt_provider
341
+
342
+ log_debug("Received Bearer challenge, clearing JWT tokens")
343
+ @jwt_provider.clear_tokens!
308
344
  end
309
345
 
310
346
  def user_agent
@@ -4,6 +4,7 @@ require_relative "client/transport"
4
4
  require_relative "client/session_store"
5
5
  require_relative "client/streamable_http_transport"
6
6
  require_relative "client/oauth_client_provider"
7
+ require_relative "client/jwt_client_provider"
7
8
 
8
9
  module ActionMCP
9
10
  # Creates a client appropriate for the given endpoint.
@@ -13,6 +14,8 @@ module ActionMCP
13
14
  # @param session_store [Symbol] The session store type (:memory, :active_record)
14
15
  # @param session_id [String] Optional session ID for resuming connections
15
16
  # @param oauth_provider [ActionMCP::Client::OauthClientProvider] Optional OAuth provider for authentication
17
+ # @param jwt_provider [ActionMCP::Client::JwtClientProvider] Optional JWT provider for authentication
18
+ # @param protocol_version [String] The MCP protocol version to use (defaults to ActionMCP::DEFAULT_PROTOCOL_VERSION)
16
19
  # @param logger [Logger] The logger to use. Default is Logger.new($stdout).
17
20
  # @param options [Hash] Additional options to pass to the client constructor.
18
21
  #
@@ -46,7 +49,17 @@ module ActionMCP
46
49
  # "http://127.0.0.1:3001/action_mcp",
47
50
  # oauth_provider: oauth_provider
48
51
  # )
49
- def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil, oauth_provider: nil, logger: Logger.new($stdout), **options)
52
+ #
53
+ # @example With JWT authentication
54
+ # jwt_provider = ActionMCP::Client::JwtClientProvider.new(
55
+ # token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
56
+ # )
57
+ # client = ActionMCP.create_client(
58
+ # "http://127.0.0.1:3001/action_mcp",
59
+ # jwt_provider: jwt_provider
60
+ # )
61
+ def self.create_client(endpoint, transport: :streamable_http, session_store: nil, session_id: nil,
62
+ oauth_provider: nil, jwt_provider: nil, protocol_version: nil, logger: Logger.new($stdout), **options)
50
63
  unless endpoint =~ %r{\Ahttps?://}
51
64
  raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
52
65
  end
@@ -55,11 +68,13 @@ module ActionMCP
55
68
  store = Client::SessionStoreFactory.create(session_store, **options)
56
69
 
57
70
  # Create transport
58
- transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id, oauth_provider: oauth_provider, logger: logger, **options)
71
+ transport_instance = create_transport(transport, endpoint, session_store: store, session_id: session_id,
72
+ oauth_provider: oauth_provider, jwt_provider: jwt_provider, protocol_version: protocol_version, logger: logger, **options)
59
73
 
60
74
  logger.info("Creating #{transport} client for endpoint: #{endpoint}")
61
- # Pass session_id to the client
62
- Client::Base.new(transport: transport_instance, logger: logger, session_id: session_id, **options)
75
+ # Pass session_id and protocol_version to the client
76
+ Client::Base.new(transport: transport_instance, logger: logger, session_id: session_id,
77
+ protocol_version: protocol_version, **options)
63
78
  end
64
79
 
65
80
  private_class_method def self.create_transport(type, endpoint, **options)
@@ -53,7 +53,7 @@ module ActionMCP
53
53
 
54
54
  def initialize
55
55
  @logging_enabled = true
56
- @list_changed = false
56
+ @list_changed = true
57
57
  @logging_level = :info
58
58
  @resources_subscribe = false
59
59
  @elicitation_enabled = false
@@ -67,7 +67,7 @@ module ActionMCP
67
67
 
68
68
  @sse_heartbeat_interval = 30
69
69
  @post_response_preference = :json
70
- @protocol_version = "2025-03-26" # Default to legacy for backwards compatibility
70
+ @protocol_version = "2025-03-26" # Default to legacy for backwards compatibility
71
71
 
72
72
  # Resumability defaults
73
73
  @sse_event_retention_period = 15.minutes
@@ -93,8 +93,8 @@ module ActionMCP
93
93
 
94
94
  def gateway_class
95
95
  if @gateway_class_name
96
- klass = @gateway_class_name.constantize
97
- klass
96
+ @gateway_class_name.constantize
97
+
98
98
  else
99
99
  @gateway_class
100
100
  end
@@ -117,22 +117,16 @@ module ActionMCP
117
117
  raise "Invalid MCP config file" unless app_config.is_a?(Hash)
118
118
 
119
119
  # Extract authentication configuration if present
120
- if app_config["authentication"]
121
- @authentication_methods = Array(app_config["authentication"])
122
- end
120
+ @authentication_methods = Array(app_config["authentication"]) if app_config["authentication"]
123
121
 
124
122
  # Extract OAuth configuration if present
125
- if app_config["oauth"]
126
- @oauth_config = HashWithIndifferentAccess.new(app_config["oauth"])
127
- end
123
+ @oauth_config = HashWithIndifferentAccess.new(app_config["oauth"]) if app_config["oauth"]
128
124
 
129
125
  # Extract other top-level configuration settings
130
126
  extract_top_level_settings(app_config)
131
127
 
132
128
  # Extract profiles configuration
133
- if app_config["profiles"]
134
- @profiles = app_config["profiles"]
135
- end
129
+ @profiles = app_config["profiles"] if app_config["profiles"]
136
130
  rescue StandardError => e
137
131
  # If the config file doesn't exist in the Rails app, just use the defaults
138
132
  Rails.logger.warn "[Configuration] Failed to load MCP config: #{e.class} - #{e.message}"
@@ -192,20 +186,16 @@ module ActionMCP
192
186
 
193
187
  # Check profile configuration instead of registry contents
194
188
  # If profile includes tools (either "all" or specific tools), advertise tools capability
195
- if profile && profile[:tools] && profile[:tools].any?
196
- capabilities[:tools] = { listChanged: @list_changed }
197
- end
189
+ capabilities[:tools] = { listChanged: @list_changed } if profile && profile[:tools]&.any?
198
190
 
199
191
  # If profile includes prompts, advertise prompts capability
200
- if profile && profile[:prompts] && profile[:prompts].any?
201
- capabilities[:prompts] = { listChanged: @list_changed }
202
- end
192
+ capabilities[:prompts] = { listChanged: @list_changed } if profile && profile[:prompts]&.any?
203
193
 
204
194
  capabilities[:logging] = {} if @logging_enabled
205
195
 
206
196
  # If profile includes resources, advertise resources capability
207
- if profile && profile[:resources] && profile[:resources].any?
208
- capabilities[:resources] = { subscribe: @resources_subscribe }
197
+ if profile && profile[:resources]&.any?
198
+ capabilities[:resources] = { subscribe: @resources_subscribe, listChanged: @list_changed }
209
199
  end
210
200
 
211
201
  capabilities[:elicitation] = {} if @elicitation_enabled
@@ -240,12 +230,12 @@ module ActionMCP
240
230
 
241
231
  # Check if any component type includes "all"
242
232
  needs_eager_load = profile[:tools]&.include?("all") ||
243
- profile[:prompts]&.include?("all") ||
244
- profile[:resources]&.include?("all")
233
+ profile[:prompts]&.include?("all") ||
234
+ profile[:resources]&.include?("all")
245
235
 
246
- if needs_eager_load
247
- ensure_mcp_components_loaded
248
- end
236
+ return unless needs_eager_load
237
+
238
+ ensure_mcp_components_loaded
249
239
  end
250
240
 
251
241
  private
@@ -285,51 +275,35 @@ module ActionMCP
285
275
  end
286
276
 
287
277
  # Extract thread pool settings
288
- if app_config["min_threads"]
289
- @min_threads = app_config["min_threads"]
290
- end
278
+ @min_threads = app_config["min_threads"] if app_config["min_threads"]
291
279
 
292
- if app_config["max_threads"]
293
- @max_threads = app_config["max_threads"]
294
- end
280
+ @max_threads = app_config["max_threads"] if app_config["max_threads"]
295
281
 
296
- if app_config["max_queue"]
297
- @max_queue = app_config["max_queue"]
298
- end
282
+ @max_queue = app_config["max_queue"] if app_config["max_queue"]
299
283
 
300
284
  # Extract polling interval for solid_cable
301
- if app_config["polling_interval"]
302
- @polling_interval = app_config["polling_interval"]
303
- end
285
+ @polling_interval = app_config["polling_interval"] if app_config["polling_interval"]
304
286
 
305
287
  # Extract connects_to setting
306
- if app_config["connects_to"]
307
- @connects_to = app_config["connects_to"]
308
- end
288
+ @connects_to = app_config["connects_to"] if app_config["connects_to"]
309
289
 
310
290
  # Extract verbose logging setting
311
- if app_config.key?("verbose_logging")
312
- @verbose_logging = app_config["verbose_logging"]
313
- end
291
+ @verbose_logging = app_config["verbose_logging"] if app_config.key?("verbose_logging")
314
292
 
315
293
  # Extract gateway class configuration
316
- if app_config["gateway_class"]
317
- @gateway_class_name = app_config["gateway_class"]
318
- end
294
+ @gateway_class_name = app_config["gateway_class"] if app_config["gateway_class"]
319
295
 
320
296
  # Extract session store configuration
321
- if app_config["session_store_type"]
322
- @session_store_type = app_config["session_store_type"].to_sym
323
- end
297
+ @session_store_type = app_config["session_store_type"].to_sym if app_config["session_store_type"]
324
298
 
325
299
  # Extract client and server session store types
326
300
  if app_config["client_session_store_type"]
327
301
  @client_session_store_type = app_config["client_session_store_type"].to_sym
328
302
  end
329
303
 
330
- if app_config["server_session_store_type"]
331
- @server_session_store_type = app_config["server_session_store_type"].to_sym
332
- end
304
+ return unless app_config["server_session_store_type"]
305
+
306
+ @server_session_store_type = app_config["server_session_store_type"].to_sym
333
307
  end
334
308
 
335
309
  def should_include_all?(type)
@@ -377,6 +351,7 @@ module ActionMCP
377
351
  Dir.glob(mcp_path.join("**/*.rb")).sort.each do |file|
378
352
  # Skip base classes we already loaded
379
353
  next if base_files.any? { |base| file == base.to_s }
354
+
380
355
  require_dependency file
381
356
  end
382
357
  end
@@ -61,9 +61,10 @@ module ActionMCP
61
61
  end
62
62
  end
63
63
 
64
- # Configure autoloading for the mcp/tools directory
64
+ # Configure autoloading for the mcp/tools directory and identifiers
65
65
  initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
66
66
  mcp_path = app.root.join("app/mcp")
67
+ identifiers_path = app.root.join("app/identifiers")
67
68
 
68
69
  if mcp_path.exist?
69
70
  # First add the parent mcp directory
@@ -74,6 +75,9 @@ module ActionMCP
74
75
  app.autoloaders.main.collapse(dir)
75
76
  end
76
77
  end
78
+
79
+ # Add identifiers directory for gateway identifiers
80
+ app.autoloaders.main.push_dir(identifiers_path, namespace: Object) if identifiers_path.exist?
77
81
  end
78
82
 
79
83
  # Initialize the ActionMCP logger.
@@ -16,7 +16,7 @@ module ActionMCP
16
16
 
17
17
  def add(severity, message = nil, progname = nil, &block)
18
18
  # Filter out repetitive OAuth metadata requests
19
- if message && message.is_a?(String)
19
+ if message.is_a?(String)
20
20
  return if FILTERED_PATHS.any? { |path| message.include?(path) && message.include?("200 OK") }
21
21
 
22
22
  # Filter out repetitive MCP notifications
@@ -5,170 +5,80 @@ module ActionMCP
5
5
 
6
6
  class Gateway
7
7
  class << self
8
- def identified_by(*attrs)
9
- @identifiers ||= []
10
- @identifiers.concat(attrs.map(&:to_sym)).uniq!
11
- attr_accessor(*attrs)
8
+ # pluck in one or many GatewayIdentifier classes
9
+ def identified_by(*klasses)
10
+ @identifier_classes = klasses.flatten
12
11
  end
13
12
 
14
- def identifiers
15
- @identifiers ||= []
13
+ def identifier_classes
14
+ @identifier_classes || []
16
15
  end
17
16
  end
18
17
 
19
- identified_by :user
20
-
21
- attr_reader :request
22
-
23
- def call(request)
18
+ def initialize(request)
24
19
  @request = request
25
- connect
26
- self
27
20
  end
28
21
 
29
- def connect
22
+ # called by your rack/websocket layer
23
+ def call
30
24
  identities = authenticate!
31
- reject_unauthorized_connection unless identities.is_a?(Hash)
32
-
33
- # Assign all identities (e.g., :user, :account)
34
- self.class.identifiers.each do |id|
35
- value = identities[id]
36
- reject_unauthorized_connection unless value
37
-
38
- public_send("#{id}=", value)
39
-
40
- # Set to ActionMCP::Current
41
- ActionMCP::Current.public_send("#{id}=", value)
42
- end
43
-
44
- # Also set the gateway instance itself
45
- ActionMCP::Current.gateway = self
25
+ assign_identities(identities)
26
+ self
46
27
  end
47
28
 
48
-
49
29
  protected
50
30
 
51
31
  def authenticate!
52
- auth_methods = ActionMCP.configuration.authentication_methods || [ "jwt" ]
53
-
54
- auth_methods.each do |method|
55
- case method
56
- when "none"
57
- return default_user_identity
58
- when "jwt"
59
- result = jwt_authenticate
60
- return result if result
61
- when "oauth"
62
- result = oauth_authenticate
63
- return result if result
64
- end
32
+ active_identifiers = filter_active_identifiers
33
+
34
+ raise ActionMCP::UnauthorizedError, "No authentication methods available" if active_identifiers.empty?
35
+
36
+ # Try identifiers in order, use the first one that succeeds
37
+ last_error = nil
38
+ active_identifiers.each do |klass|
39
+ result = klass.new(@request).resolve
40
+ return { klass.identifier_name => result }
41
+ rescue ActionMCP::GatewayIdentifier::Unauthorized => e
42
+ last_error = e
43
+ # Try next identifier
44
+ next
65
45
  end
66
46
 
67
- raise UnauthorizedError, "No valid authentication found"
68
- end
69
-
70
- def extract_bearer_token
71
- header = request.headers["Authorization"] || request.headers["authorization"]
72
- return nil unless header&.start_with?("Bearer ")
73
- header.split(" ", 2).last
47
+ # If we get here, all identifiers failed
48
+ # Use the last specific error message if available, otherwise generic message
49
+ error_message = last_error&.message || "Authentication failed"
50
+ raise ActionMCP::UnauthorizedError, error_message
74
51
  end
75
52
 
76
- def resolve_user(payload)
77
- return nil unless payload.is_a?(Hash)
78
- user_id = payload["user_id"] || payload["sub"]
79
- return nil unless user_id
80
- user = User.find_by(id: user_id)
81
- return nil unless user
82
-
83
- # Return a hash with all identified_by attributes
84
- self.class.identifiers.each_with_object({}) do |identifier, hash|
85
- hash[identifier] = user if identifier == :user
86
- # Add support for other identifiers as needed
87
- end
88
- end
89
-
90
- def reject_unauthorized_connection
91
- raise UnauthorizedError, "Unauthorized"
92
- end
93
-
94
- # Default user identity for "none" authentication
95
- def default_user_identity
96
- # Return a hash with all identified_by attributes set to a default user
97
- self.class.identifiers.each_with_object({}) do |identifier, hash|
98
- if identifier == :user
99
- # Create or find a default user for development
100
- hash[identifier] = find_or_create_default_user
101
- end
102
- # Add support for other identifiers as needed
103
- end
104
- end
105
-
106
- # JWT authentication (existing implementation)
107
- def jwt_authenticate
108
- token = extract_bearer_token
109
- unless token
110
- raise UnauthorizedError, "Missing token" if ActionMCP.configuration.authentication_methods == [ "jwt" ]
111
- return nil
112
- end
113
-
114
- payload = ActionMCP::JwtDecoder.decode(token)
115
- result = resolve_user(payload)
116
- unless result
117
- raise UnauthorizedError, "Unauthorized" if ActionMCP.configuration.authentication_methods == [ "jwt" ]
118
- return nil
119
- end
120
- result
121
- rescue ActionMCP::JwtDecoder::DecodeError => e
122
- if ActionMCP.configuration.authentication_methods == [ "jwt" ]
123
- raise UnauthorizedError, "Invalid token"
124
- else
125
- nil # Let it try other authentication methods
126
- end
127
- end
128
-
129
- # OAuth authentication via middleware
130
- def oauth_authenticate
131
- return nil unless oauth_enabled?
132
-
133
- # Check if OAuth middleware has already validated the token
134
- token_info = request.env["action_mcp.oauth_token_info"]
135
- return nil unless token_info && token_info["active"]
136
-
137
- resolve_user_from_oauth(token_info)
138
- rescue ActionMCP::OAuth::Error
139
- nil # Let it try other authentication methods
140
- end
141
-
142
- def oauth_enabled?
143
- ActionMCP.configuration.authentication_methods&.include?("oauth") &&
144
- ActionMCP.configuration.oauth_config.present?
145
- end
53
+ private
146
54
 
147
- def resolve_user_from_oauth(token_info)
148
- return nil unless token_info.is_a?(Hash)
55
+ def filter_active_identifiers
56
+ configured_methods = ActionMCP.configuration.authentication_methods || []
149
57
 
150
- user_id = token_info["sub"] || token_info["user_id"]
151
- return nil unless user_id
58
+ # If no authentication methods configured, use all identifiers
59
+ return self.class.identifier_classes if configured_methods.empty?
152
60
 
153
- user = User.find_by(id: user_id) || User.find_by(oauth_subject: user_id)
154
- return nil unless user
61
+ # Normalize configured methods to strings for consistent comparison
62
+ normalized_methods = configured_methods.map(&:to_s)
155
63
 
156
- # Return a hash with all identified_by attributes
157
- self.class.identifiers.each_with_object({}) do |identifier, hash|
158
- hash[identifier] = user if identifier == :user
159
- # Add support for other identifiers as needed
64
+ # Filter identifiers to only those matching configured authentication methods
65
+ self.class.identifier_classes.select do |klass|
66
+ normalized_methods.include?(klass.auth_method.to_s)
160
67
  end
161
68
  end
162
69
 
163
- def find_or_create_default_user
164
- # Only for development/testing with "none" authentication
165
- return nil unless Rails.env.development? || Rails.env.test?
70
+ def assign_identities(identities)
71
+ identities.each do |name, value|
72
+ # define accessor on the fly
73
+ self.class.attr_reader name unless respond_to?(name)
74
+ instance_variable_set("@#{name}", value)
166
75
 
167
- if defined?(User)
168
- User.find_or_create_by(email: "dev@localhost") do |user|
169
- user.name = "Development User" if user.respond_to?(:name=)
170
- end
76
+ # also set current context if you have one
77
+ ActionMCP::Current.public_send("#{name}=", value) if
78
+ ActionMCP::Current.respond_to?("#{name}=")
171
79
  end
80
+ ActionMCP::Current.gateway = self if
81
+ ActionMCP::Current.respond_to?(:gateway=)
172
82
  end
173
83
  end
174
84
  end