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.
- checksums.yaml +4 -4
- data/README.md +186 -15
- data/app/controllers/action_mcp/application_controller.rb +47 -40
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +11 -10
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +6 -10
- data/app/controllers/action_mcp/oauth/registration_controller.rb +15 -20
- data/app/models/action_mcp/oauth_client.rb +7 -5
- data/app/models/action_mcp/oauth_token.rb +2 -1
- data/app/models/action_mcp/session.rb +40 -5
- data/config/routes.rb +4 -2
- data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +17 -8
- data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +7 -5
- data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +3 -1
- data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
- data/lib/action_mcp/base_response.rb +1 -1
- data/lib/action_mcp/client/base.rb +12 -13
- data/lib/action_mcp/client/collection.rb +3 -3
- data/lib/action_mcp/client/elicitation.rb +4 -4
- data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
- data/lib/action_mcp/client/jwt_client_provider.rb +6 -5
- data/lib/action_mcp/client/oauth_client_provider.rb +8 -8
- data/lib/action_mcp/client/streamable_http_transport.rb +63 -27
- data/lib/action_mcp/client.rb +19 -4
- data/lib/action_mcp/configuration.rb +28 -53
- data/lib/action_mcp/engine.rb +5 -1
- data/lib/action_mcp/filtered_logger.rb +1 -1
- data/lib/action_mcp/gateway.rb +47 -137
- data/lib/action_mcp/gateway_identifier.rb +29 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
- data/lib/action_mcp/jwt_decoder.rb +4 -2
- data/lib/action_mcp/jwt_identifier.rb +28 -0
- data/lib/action_mcp/none_identifier.rb +19 -0
- data/lib/action_mcp/o_auth_identifier.rb +34 -0
- data/lib/action_mcp/oauth/active_record_storage.rb +1 -1
- data/lib/action_mcp/oauth/memory_storage.rb +1 -3
- data/lib/action_mcp/oauth/middleware.rb +13 -18
- data/lib/action_mcp/oauth/provider.rb +45 -65
- data/lib/action_mcp/omniauth/mcp_strategy.rb +23 -37
- data/lib/action_mcp/prompt.rb +2 -0
- data/lib/action_mcp/renderable.rb +1 -1
- data/lib/action_mcp/resource_template.rb +6 -2
- data/lib/action_mcp/server/{memory_session.rb → base_session.rb} +39 -26
- data/lib/action_mcp/server/base_session_store.rb +86 -0
- data/lib/action_mcp/server/capabilities.rb +2 -1
- data/lib/action_mcp/server/elicitation.rb +3 -9
- data/lib/action_mcp/server/error_handling.rb +14 -1
- data/lib/action_mcp/server/handlers/router.rb +31 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +2 -5
- data/lib/action_mcp/server/{messaging.rb → messaging_service.rb} +38 -14
- data/lib/action_mcp/server/prompts.rb +4 -4
- data/lib/action_mcp/server/resources.rb +23 -4
- data/lib/action_mcp/server/session_store_factory.rb +1 -1
- data/lib/action_mcp/server/solid_mcp_adapter.rb +9 -10
- data/lib/action_mcp/server/tools.rb +62 -43
- data/lib/action_mcp/server/transport_handler.rb +2 -4
- data/lib/action_mcp/server/volatile_session_store.rb +1 -93
- data/lib/action_mcp/tagged_stream_logging.rb +2 -2
- data/lib/action_mcp/test_helper/progress_notification_assertions.rb +4 -4
- data/lib/action_mcp/test_helper/session_store_assertions.rb +5 -1
- data/lib/action_mcp/tool.rb +48 -37
- data/lib/action_mcp/types/float_array_type.rb +5 -3
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +1 -1
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +1 -0
- data/lib/tasks/action_mcp_tasks.rake +7 -5
- metadata +24 -18
- 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,
|
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
|
-
|
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
|
181
|
+
event_id = line[3..].strip
|
165
182
|
elsif line.start_with?("data:")
|
166
|
-
data_lines << line[5
|
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,
|
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
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
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:
|
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
|
300
|
-
return unless @
|
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
|
-
|
305
|
-
|
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
|
data/lib/action_mcp/client.rb
CHANGED
@@ -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
|
-
|
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,
|
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,
|
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 =
|
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"
|
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
|
-
|
97
|
-
|
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
|
-
|
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
|
-
|
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]
|
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
|
-
|
244
|
-
|
233
|
+
profile[:prompts]&.include?("all") ||
|
234
|
+
profile[:resources]&.include?("all")
|
245
235
|
|
246
|
-
|
247
|
-
|
248
|
-
|
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
|
-
|
331
|
-
|
332
|
-
|
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
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -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
|
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
|
data/lib/action_mcp/gateway.rb
CHANGED
@@ -5,170 +5,80 @@ module ActionMCP
|
|
5
5
|
|
6
6
|
class Gateway
|
7
7
|
class << self
|
8
|
-
|
9
|
-
|
10
|
-
@
|
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
|
15
|
-
@
|
13
|
+
def identifier_classes
|
14
|
+
@identifier_classes || []
|
16
15
|
end
|
17
16
|
end
|
18
17
|
|
19
|
-
|
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
|
-
|
22
|
+
# called by your rack/websocket layer
|
23
|
+
def call
|
30
24
|
identities = authenticate!
|
31
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
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
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
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
|
148
|
-
|
55
|
+
def filter_active_identifiers
|
56
|
+
configured_methods = ActionMCP.configuration.authentication_methods || []
|
149
57
|
|
150
|
-
|
151
|
-
return
|
58
|
+
# If no authentication methods configured, use all identifiers
|
59
|
+
return self.class.identifier_classes if configured_methods.empty?
|
152
60
|
|
153
|
-
|
154
|
-
|
61
|
+
# Normalize configured methods to strings for consistent comparison
|
62
|
+
normalized_methods = configured_methods.map(&:to_s)
|
155
63
|
|
156
|
-
#
|
157
|
-
self.class.
|
158
|
-
|
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
|
164
|
-
|
165
|
-
|
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
|
-
|
168
|
-
|
169
|
-
|
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
|