actionmcp 0.71.1 → 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 +45 -38
- 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 +9 -11
- 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 +29 -39
- data/lib/action_mcp/client.rb +6 -3
- data/lib/action_mcp/configuration.rb +28 -53
- data/lib/action_mcp/engine.rb +1 -3
- data/lib/action_mcp/filtered_logger.rb +1 -1
- data/lib/action_mcp/gateway.rb +7 -11
- data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
- data/lib/action_mcp/jwt_decoder.rb +4 -2
- 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 +20 -18
- data/lib/action_mcp/server/notifications.rb +0 -58
@@ -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
@@ -77,9 +77,7 @@ module ActionMCP
|
|
77
77
|
end
|
78
78
|
|
79
79
|
# Add identifiers directory for gateway identifiers
|
80
|
-
if identifiers_path.exist?
|
81
|
-
app.autoloaders.main.push_dir(identifiers_path, namespace: Object)
|
82
|
-
end
|
80
|
+
app.autoloaders.main.push_dir(identifiers_path, namespace: Object) if identifiers_path.exist?
|
83
81
|
end
|
84
82
|
|
85
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
@@ -31,21 +31,17 @@ module ActionMCP
|
|
31
31
|
def authenticate!
|
32
32
|
active_identifiers = filter_active_identifiers
|
33
33
|
|
34
|
-
if active_identifiers.empty?
|
35
|
-
raise ActionMCP::UnauthorizedError, "No authentication methods available"
|
36
|
-
end
|
34
|
+
raise ActionMCP::UnauthorizedError, "No authentication methods available" if active_identifiers.empty?
|
37
35
|
|
38
36
|
# Try identifiers in order, use the first one that succeeds
|
39
37
|
last_error = nil
|
40
38
|
active_identifiers.each do |klass|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
next
|
48
|
-
end
|
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
|
49
45
|
end
|
50
46
|
|
51
47
|
# If we get here, all identifiers failed
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "jwt"
|
2
4
|
|
3
5
|
module ActionMCP
|
@@ -13,14 +15,14 @@ module ActionMCP
|
|
13
15
|
payload
|
14
16
|
rescue JWT::ExpiredSignature
|
15
17
|
raise DecodeError, "Token has expired"
|
16
|
-
rescue JWT::DecodeError
|
18
|
+
rescue JWT::DecodeError
|
17
19
|
# Simplify the error message for invalid tokens
|
18
20
|
raise DecodeError, "Invalid token"
|
19
21
|
end
|
20
22
|
end
|
21
23
|
|
22
24
|
# Defaults (can be overridden in an initializer)
|
23
|
-
self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET"
|
25
|
+
self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET", "change-me")
|
24
26
|
self.algorithm = "HS256"
|
25
27
|
end
|
26
28
|
end
|
@@ -18,7 +18,7 @@ module ActionMCP
|
|
18
18
|
code_challenge_method: data[:code_challenge_method],
|
19
19
|
expires_at: data[:expires_at],
|
20
20
|
metadata: data.except(:client_id, :user_id, :redirect_uri, :scope,
|
21
|
-
|
21
|
+
:code_challenge, :code_challenge_method, :expires_at)
|
22
22
|
)
|
23
23
|
end
|
24
24
|
|
@@ -66,9 +66,7 @@ module ActionMCP
|
|
66
66
|
|
67
67
|
def update_refresh_token(token, new_access_token)
|
68
68
|
@mutex.synchronize do
|
69
|
-
if @refresh_tokens[token]
|
70
|
-
@refresh_tokens[token][:access_token] = new_access_token
|
71
|
-
end
|
69
|
+
@refresh_tokens[token][:access_token] = new_access_token if @refresh_tokens[token]
|
72
70
|
end
|
73
71
|
end
|
74
72
|
|
@@ -18,17 +18,13 @@ module ActionMCP
|
|
18
18
|
return @app.call(env) unless should_process_oauth?(request)
|
19
19
|
|
20
20
|
# Skip OAuth processing for metadata endpoints
|
21
|
-
if request.path.start_with?("/.well-known/") || request.path.start_with?("/oauth/")
|
22
|
-
return @app.call(env)
|
23
|
-
end
|
21
|
+
return @app.call(env) if request.path.start_with?("/.well-known/") || request.path.start_with?("/oauth/")
|
24
22
|
|
25
23
|
# Skip OAuth processing for initialization-related requests
|
26
|
-
if initialization_related_request?(request)
|
27
|
-
return @app.call(env)
|
28
|
-
end
|
24
|
+
return @app.call(env) if initialization_related_request?(request)
|
29
25
|
|
30
26
|
# Validate Bearer token for API requests
|
31
|
-
if bearer_token = extract_bearer_token(request)
|
27
|
+
if (bearer_token = extract_bearer_token(request))
|
32
28
|
validate_oauth_token(request, bearer_token)
|
33
29
|
end
|
34
30
|
|
@@ -39,7 +35,7 @@ module ActionMCP
|
|
39
35
|
|
40
36
|
private
|
41
37
|
|
42
|
-
def should_process_oauth?(
|
38
|
+
def should_process_oauth?(_request)
|
43
39
|
# Check if OAuth is enabled in configuration
|
44
40
|
auth_methods = ActionMCP.configuration.authentication_methods
|
45
41
|
return false unless auth_methods&.include?("oauth")
|
@@ -55,7 +51,7 @@ module ActionMCP
|
|
55
51
|
|
56
52
|
# Check if this is an MCP endpoint (ends with / or is the root)
|
57
53
|
path = request.path
|
58
|
-
return false unless path == "/" || path.match?(
|
54
|
+
return false unless path == "/" || path.match?(%r{/action_mcp/?$})
|
59
55
|
|
60
56
|
# Read and parse the request body
|
61
57
|
body = request.body.read
|
@@ -70,7 +66,6 @@ module ActionMCP
|
|
70
66
|
false
|
71
67
|
end
|
72
68
|
|
73
|
-
|
74
69
|
def extract_bearer_token(request)
|
75
70
|
auth_header = request.headers["Authorization"] || request.headers["authorization"]
|
76
71
|
return nil unless auth_header&.start_with?("Bearer ")
|
@@ -82,23 +77,23 @@ module ActionMCP
|
|
82
77
|
# Use the OAuth provider for token introspection
|
83
78
|
token_info = ActionMCP::OAuth::Provider.introspect_token(token)
|
84
79
|
|
85
|
-
|
86
|
-
# Store OAuth token info in request environment for Gateway
|
87
|
-
request.env["action_mcp.oauth_token_info"] = token_info
|
88
|
-
request.env["action_mcp.oauth_token"] = token
|
89
|
-
else
|
80
|
+
unless token_info && token_info[:active]
|
90
81
|
raise ActionMCP::OAuth::InvalidTokenError, "Invalid or expired OAuth token"
|
91
82
|
end
|
83
|
+
|
84
|
+
# Store OAuth token info in request environment for Gateway
|
85
|
+
request.env["action_mcp.oauth_token_info"] = token_info
|
86
|
+
request.env["action_mcp.oauth_token"] = token
|
92
87
|
end
|
93
88
|
|
94
89
|
def oauth_error_response(error)
|
95
90
|
status = case error
|
96
91
|
when ActionMCP::OAuth::InvalidTokenError
|
97
|
-
|
92
|
+
401
|
98
93
|
when ActionMCP::OAuth::InsufficientScopeError
|
99
|
-
|
94
|
+
403
|
100
95
|
else
|
101
|
-
|
96
|
+
400
|
102
97
|
end
|
103
98
|
|
104
99
|
headers = {
|
@@ -18,7 +18,8 @@ module ActionMCP
|
|
18
18
|
# @param code_challenge_method [String] PKCE challenge method (S256, plain)
|
19
19
|
# @param user_id [String] User identifier
|
20
20
|
# @return [String] Authorization code
|
21
|
-
def generate_authorization_code(client_id:, redirect_uri:, scope:, code_challenge: nil,
|
21
|
+
def generate_authorization_code(client_id:, redirect_uri:, scope:, user_id:, code_challenge: nil,
|
22
|
+
code_challenge_method: nil)
|
22
23
|
# Validate scope
|
23
24
|
validate_scope(scope) if scope
|
24
25
|
|
@@ -26,15 +27,15 @@ module ActionMCP
|
|
26
27
|
|
27
28
|
# Store authorization code with metadata
|
28
29
|
store_authorization_code(code, {
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
30
|
+
client_id: client_id,
|
31
|
+
redirect_uri: redirect_uri,
|
32
|
+
scope: scope,
|
33
|
+
code_challenge: code_challenge,
|
34
|
+
code_challenge_method: code_challenge_method,
|
35
|
+
user_id: user_id,
|
36
|
+
created_at: Time.current,
|
37
|
+
expires_at: 10.minutes.from_now
|
38
|
+
})
|
38
39
|
|
39
40
|
code
|
40
41
|
end
|
@@ -46,7 +47,7 @@ module ActionMCP
|
|
46
47
|
# @param redirect_uri [String] Client redirect URI
|
47
48
|
# @param code_verifier [String] PKCE code verifier
|
48
49
|
# @return [Hash] Token response with access_token, token_type, expires_in, scope
|
49
|
-
def exchange_code_for_token(code:, client_id:, client_secret: nil,
|
50
|
+
def exchange_code_for_token(code:, client_id:, redirect_uri:, client_secret: nil, code_verifier: nil)
|
50
51
|
# Retrieve and validate authorization code
|
51
52
|
code_data = retrieve_authorization_code(code)
|
52
53
|
raise InvalidGrantError, "Invalid authorization code" unless code_data
|
@@ -56,14 +57,10 @@ module ActionMCP
|
|
56
57
|
validate_client(client_id, client_secret)
|
57
58
|
|
58
59
|
# Validate redirect URI matches
|
59
|
-
unless code_data[:redirect_uri] == redirect_uri
|
60
|
-
raise InvalidGrantError, "Redirect URI mismatch"
|
61
|
-
end
|
60
|
+
raise InvalidGrantError, "Redirect URI mismatch" unless code_data[:redirect_uri] == redirect_uri
|
62
61
|
|
63
62
|
# Validate client ID matches
|
64
|
-
unless code_data[:client_id] == client_id
|
65
|
-
raise InvalidGrantError, "Client ID mismatch"
|
66
|
-
end
|
63
|
+
raise InvalidGrantError, "Client ID mismatch" unless code_data[:client_id] == client_id
|
67
64
|
|
68
65
|
# Validate PKCE if challenge was provided during authorization
|
69
66
|
if code_data[:code_challenge]
|
@@ -118,9 +115,7 @@ module ActionMCP
|
|
118
115
|
validate_client(client_id, client_secret)
|
119
116
|
|
120
117
|
# Validate client ID matches
|
121
|
-
unless token_data[:client_id] == client_id
|
122
|
-
raise InvalidGrantError, "Client ID mismatch"
|
123
|
-
end
|
118
|
+
raise InvalidGrantError, "Client ID mismatch" unless token_data[:client_id] == client_id
|
124
119
|
|
125
120
|
# Validate scope if provided
|
126
121
|
if scope
|
@@ -160,9 +155,7 @@ module ActionMCP
|
|
160
155
|
def introspect_token(access_token)
|
161
156
|
token_data = retrieve_access_token(access_token)
|
162
157
|
|
163
|
-
unless token_data
|
164
|
-
return { active: false }
|
165
|
-
end
|
158
|
+
return { active: false } unless token_data
|
166
159
|
|
167
160
|
if token_data[:expires_at] < Time.current
|
168
161
|
remove_access_token(access_token)
|
@@ -188,19 +181,15 @@ module ActionMCP
|
|
188
181
|
revoked = false
|
189
182
|
|
190
183
|
# Try access token first if hint suggests it or no hint provided
|
191
|
-
if token_type_hint == "access_token" || token_type_hint.nil?
|
192
|
-
|
193
|
-
|
194
|
-
revoked = true
|
195
|
-
end
|
184
|
+
if (token_type_hint == "access_token" || token_type_hint.nil?) && retrieve_access_token(token)
|
185
|
+
revoke_access_token(token)
|
186
|
+
revoked = true
|
196
187
|
end
|
197
188
|
|
198
189
|
# Try refresh token if not revoked yet
|
199
|
-
if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?)
|
200
|
-
|
201
|
-
|
202
|
-
revoked = true
|
203
|
-
end
|
190
|
+
if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?) && retrieve_refresh_token(token)
|
191
|
+
revoke_refresh_token(token)
|
192
|
+
revoked = true
|
204
193
|
end
|
205
194
|
|
206
195
|
revoked
|
@@ -269,9 +258,7 @@ module ActionMCP
|
|
269
258
|
if client_info
|
270
259
|
# Validate client secret for confidential clients
|
271
260
|
if client_info[:client_secret]
|
272
|
-
unless client_secret == client_info[:client_secret]
|
273
|
-
raise InvalidClientError, "Invalid client credentials"
|
274
|
-
end
|
261
|
+
raise InvalidClientError, "Invalid client credentials" unless client_secret == client_info[:client_secret]
|
275
262
|
elsif require_secret
|
276
263
|
raise InvalidClientError, "Client authentication required"
|
277
264
|
end
|
@@ -280,15 +267,14 @@ module ActionMCP
|
|
280
267
|
|
281
268
|
# Fall back to custom provider validation
|
282
269
|
provider_class = oauth_config[:provider]
|
283
|
-
if provider_class
|
270
|
+
if provider_class.respond_to?(:validate_client)
|
284
271
|
provider_class.validate_client(client_id, client_secret)
|
285
272
|
elsif require_secret && client_secret.nil?
|
286
273
|
raise InvalidClientError, "Client authentication required"
|
287
274
|
else
|
288
275
|
# In development, allow unregistered clients if configured
|
289
|
-
if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
|
290
|
-
|
291
|
-
end
|
276
|
+
return true if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
|
277
|
+
|
292
278
|
raise InvalidClientError, "Unknown client"
|
293
279
|
end
|
294
280
|
end
|
@@ -301,16 +287,10 @@ module ActionMCP
|
|
301
287
|
expected_challenge = Base64.urlsafe_encode64(
|
302
288
|
Digest::SHA256.digest(code_verifier), padding: false
|
303
289
|
)
|
304
|
-
unless code_challenge == expected_challenge
|
305
|
-
raise InvalidGrantError, "Invalid code verifier"
|
306
|
-
end
|
290
|
+
raise InvalidGrantError, "Invalid code verifier" unless code_challenge == expected_challenge
|
307
291
|
when "plain"
|
308
|
-
unless oauth_config[:allow_plain_pkce]
|
309
|
-
|
310
|
-
end
|
311
|
-
unless code_challenge == code_verifier
|
312
|
-
raise InvalidGrantError, "Invalid code verifier"
|
313
|
-
end
|
292
|
+
raise InvalidGrantError, "Plain PKCE not allowed" unless oauth_config[:allow_plain_pkce]
|
293
|
+
raise InvalidGrantError, "Invalid code verifier" unless code_challenge == code_verifier
|
314
294
|
else
|
315
295
|
raise InvalidGrantError, "Unsupported code challenge method"
|
316
296
|
end
|
@@ -320,9 +300,9 @@ module ActionMCP
|
|
320
300
|
supported_scopes = oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
|
321
301
|
requested_scopes = scope.split(" ")
|
322
302
|
unsupported = requested_scopes - supported_scopes
|
323
|
-
|
324
|
-
|
325
|
-
|
303
|
+
return unless unsupported.any?
|
304
|
+
|
305
|
+
raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
|
326
306
|
end
|
327
307
|
|
328
308
|
def default_scope
|
@@ -333,12 +313,12 @@ module ActionMCP
|
|
333
313
|
token = SecureRandom.urlsafe_base64(32)
|
334
314
|
|
335
315
|
store_access_token(token, {
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
316
|
+
client_id: client_id,
|
317
|
+
scope: scope,
|
318
|
+
user_id: user_id,
|
319
|
+
created_at: Time.current,
|
320
|
+
expires_at: token_expires_in.seconds.from_now
|
321
|
+
})
|
342
322
|
|
343
323
|
token
|
344
324
|
end
|
@@ -347,13 +327,13 @@ module ActionMCP
|
|
347
327
|
token = SecureRandom.urlsafe_base64(32)
|
348
328
|
|
349
329
|
store_refresh_token(token, {
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
330
|
+
client_id: client_id,
|
331
|
+
scope: scope,
|
332
|
+
user_id: user_id,
|
333
|
+
access_token: access_token,
|
334
|
+
created_at: Time.current,
|
335
|
+
expires_at: refresh_token_expires_in.seconds.from_now
|
336
|
+
})
|
357
337
|
|
358
338
|
token
|
359
339
|
end
|
@@ -38,17 +38,15 @@ module ActionMCP
|
|
38
38
|
|
39
39
|
# User info from OAuth token response or userinfo endpoint
|
40
40
|
def raw_info
|
41
|
-
@raw_info ||=
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
}
|
51
|
-
end
|
41
|
+
@raw_info ||= if options.userinfo_url
|
42
|
+
access_token.get(options.userinfo_url).parsed
|
43
|
+
else
|
44
|
+
# Extract user info from token response or use minimal info
|
45
|
+
access_token.token
|
46
|
+
{
|
47
|
+
"sub" => access_token.params["user_id"] || access_token.token,
|
48
|
+
"scope" => access_token.params["scope"] || options.scope
|
49
|
+
}
|
52
50
|
end
|
53
51
|
rescue ::OAuth2::Error => e
|
54
52
|
log(:error, "Failed to fetch user info: #{e.message}")
|
@@ -93,11 +91,11 @@ module ActionMCP
|
|
93
91
|
# Override client to use discovered endpoints if available
|
94
92
|
def client
|
95
93
|
@client ||= begin
|
96
|
-
if discovery_info.any?
|
94
|
+
if discovery_info.any? && discovery_info["authorization_endpoint"] && discovery_info["token_endpoint"]
|
97
95
|
options.client_options.merge!(
|
98
96
|
authorize_url: discovery_info["authorization_endpoint"],
|
99
97
|
token_url: discovery_info["token_endpoint"]
|
100
|
-
)
|
98
|
+
)
|
101
99
|
end
|
102
100
|
super
|
103
101
|
end
|
@@ -115,9 +113,9 @@ module ActionMCP
|
|
115
113
|
|
116
114
|
begin
|
117
115
|
response = client.request(:post, options.introspection_url || "/oauth/introspect", {
|
118
|
-
|
119
|
-
|
120
|
-
|
116
|
+
body: { token: token },
|
117
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" }
|
118
|
+
})
|
121
119
|
|
122
120
|
token_info = JSON.parse(response.body)
|
123
121
|
return nil unless token_info["active"]
|
@@ -137,36 +135,24 @@ module ActionMCP
|
|
137
135
|
return unless oauth_config.is_a?(Hash)
|
138
136
|
|
139
137
|
# Set client options from MCP config
|
140
|
-
if oauth_config["issuer_url"]
|
141
|
-
options.client_options[:site] = oauth_config["issuer_url"]
|
142
|
-
end
|
138
|
+
options.client_options[:site] = oauth_config["issuer_url"] if oauth_config["issuer_url"]
|
143
139
|
|
144
|
-
if oauth_config["client_id"]
|
145
|
-
options.client_id = oauth_config["client_id"]
|
146
|
-
end
|
140
|
+
options.client_id = oauth_config["client_id"] if oauth_config["client_id"]
|
147
141
|
|
148
|
-
if oauth_config["client_secret"]
|
149
|
-
options.client_secret = oauth_config["client_secret"]
|
150
|
-
end
|
142
|
+
options.client_secret = oauth_config["client_secret"] if oauth_config["client_secret"]
|
151
143
|
|
152
|
-
if oauth_config["scopes_supported"]
|
153
|
-
options.scope = Array(oauth_config["scopes_supported"]).join(" ")
|
154
|
-
end
|
144
|
+
options.scope = Array(oauth_config["scopes_supported"]).join(" ") if oauth_config["scopes_supported"]
|
155
145
|
|
156
146
|
# Enable PKCE if required (OAuth 2.1 compliance)
|
157
|
-
if oauth_config["pkce_required"]
|
158
|
-
options.pkce = true
|
159
|
-
end
|
147
|
+
options.pkce = true if oauth_config["pkce_required"]
|
160
148
|
|
161
149
|
# Set userinfo endpoint if provided
|
162
|
-
if oauth_config["userinfo_endpoint"]
|
163
|
-
options.userinfo_url = oauth_config["userinfo_endpoint"]
|
164
|
-
end
|
150
|
+
options.userinfo_url = oauth_config["userinfo_endpoint"] if oauth_config["userinfo_endpoint"]
|
165
151
|
|
166
152
|
# Set token introspection endpoint
|
167
|
-
|
168
|
-
|
169
|
-
|
153
|
+
return unless oauth_config["introspection_endpoint"]
|
154
|
+
|
155
|
+
options.introspection_url = oauth_config["introspection_endpoint"]
|
170
156
|
end
|
171
157
|
end
|
172
158
|
end
|