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
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class GatewayIdentifier
|
5
|
+
class Unauthorized < StandardError; end
|
6
|
+
|
7
|
+
class << self
|
8
|
+
# e.g. JwtIdentifier.identifier_name => :user
|
9
|
+
attr_reader :identifier_name, :auth_method
|
10
|
+
|
11
|
+
def identifier(name)
|
12
|
+
@identifier_name = name.to_sym
|
13
|
+
end
|
14
|
+
|
15
|
+
def authenticates(method)
|
16
|
+
@auth_method = method.to_s
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(request)
|
21
|
+
@request = request
|
22
|
+
end
|
23
|
+
|
24
|
+
# must return a truthy identity object, or raise Unauthorized
|
25
|
+
def resolve
|
26
|
+
raise NotImplementedError, "#{self.class}#resolve must be implemented"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -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
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class JwtIdentifier < GatewayIdentifier
|
5
|
+
identifier :user
|
6
|
+
authenticates :jwt
|
7
|
+
|
8
|
+
def resolve
|
9
|
+
token = extract_bearer_token
|
10
|
+
raise Unauthorized, "Missing JWT" unless token
|
11
|
+
|
12
|
+
payload = ActionMCP::JwtDecoder.decode(token)
|
13
|
+
user = User.find_by(id: payload["sub"] || payload["user_id"])
|
14
|
+
return user if user
|
15
|
+
|
16
|
+
raise Unauthorized, "Invalid JWT user"
|
17
|
+
rescue ActionMCP::JwtDecoder::DecodeError => e
|
18
|
+
raise Unauthorized, "Invalid JWT token: #{e.message}"
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def extract_bearer_token
|
24
|
+
header = @request.env["HTTP_AUTHORIZATION"] || ""
|
25
|
+
header[/\ABearer (.+)\z/, 1]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class NoneIdentifier < GatewayIdentifier
|
5
|
+
identifier :user
|
6
|
+
authenticates :none
|
7
|
+
|
8
|
+
def resolve
|
9
|
+
Rails.env.production? &&
|
10
|
+
raise(Unauthorized, "No auth allowed in production")
|
11
|
+
|
12
|
+
return "anonymous_user" unless defined?(User)
|
13
|
+
|
14
|
+
User.find_or_create_by!(email: "dev@localhost") do |user|
|
15
|
+
user.name = "Development User" if user.respond_to?(:name=)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class OAuthIdentifier < GatewayIdentifier
|
5
|
+
identifier :user
|
6
|
+
authenticates :oauth
|
7
|
+
|
8
|
+
def resolve
|
9
|
+
info = @request.env["action_mcp.oauth_token_info"] or
|
10
|
+
raise Unauthorized, "Missing OAuth info"
|
11
|
+
|
12
|
+
uid = info["user_id"] || info["sub"] || info[:user_id]
|
13
|
+
raise Unauthorized, "Invalid OAuth info" unless uid
|
14
|
+
|
15
|
+
# Try to find existing user or create one for demo purposes
|
16
|
+
user = User.find_by(email: uid) ||
|
17
|
+
User.find_by(email: "#{uid}@example.com") ||
|
18
|
+
create_oauth_user(uid)
|
19
|
+
|
20
|
+
user || raise(Unauthorized, "Unable to resolve OAuth user")
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def create_oauth_user(uid)
|
26
|
+
return nil unless defined?(User)
|
27
|
+
|
28
|
+
email = uid.include?("@") ? uid : "#{uid}@example.com"
|
29
|
+
User.create!(email: email)
|
30
|
+
rescue ActiveRecord::RecordInvalid
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
end
|
34
|
+
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
|
data/lib/action_mcp/prompt.rb
CHANGED
@@ -28,6 +28,7 @@ module ActionMCP
|
|
28
28
|
# @return [String] The default prompt name.
|
29
29
|
def self.default_prompt_name
|
30
30
|
return "" if name.nil?
|
31
|
+
|
31
32
|
name.demodulize.underscore.sub(/_prompt$/, "")
|
32
33
|
end
|
33
34
|
|
@@ -55,6 +56,7 @@ module ActionMCP
|
|
55
56
|
def meta(data = nil)
|
56
57
|
if data
|
57
58
|
raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
|
59
|
+
|
58
60
|
self._meta = _meta.merge(data)
|
59
61
|
else
|
60
62
|
_meta
|
@@ -49,7 +49,7 @@ module ActionMCP
|
|
49
49
|
#
|
50
50
|
def render_resource_link(uri:, name: nil, description: nil, mime_type: nil, annotations: nil)
|
51
51
|
Content::ResourceLink.new(uri, name: name, description: description,
|
52
|
-
|
52
|
+
mime_type: mime_type, annotations: annotations)
|
53
53
|
end
|
54
54
|
end
|
55
55
|
end
|
@@ -27,7 +27,9 @@ module ActionMCP
|
|
27
27
|
def abstract!
|
28
28
|
@abstract = true
|
29
29
|
# Unregister from the appropriate registry if already registered
|
30
|
-
|
30
|
+
return unless ActionMCP::ResourceTemplatesRegistry.items.values.include?(self)
|
31
|
+
|
32
|
+
ActionMCP::ResourceTemplatesRegistry.unregister(self)
|
31
33
|
end
|
32
34
|
|
33
35
|
def inherited(subclass)
|
@@ -92,6 +94,7 @@ module ActionMCP
|
|
92
94
|
def meta(data = nil)
|
93
95
|
if data
|
94
96
|
raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
|
97
|
+
|
95
98
|
@_meta ||= {}
|
96
99
|
@_meta = @_meta.merge(data)
|
97
100
|
else
|
@@ -110,13 +113,14 @@ module ActionMCP
|
|
110
113
|
}.compact
|
111
114
|
|
112
115
|
# Add _meta if present
|
113
|
-
result[:_meta] = @_meta if @_meta
|
116
|
+
result[:_meta] = @_meta if @_meta&.any?
|
114
117
|
|
115
118
|
result
|
116
119
|
end
|
117
120
|
|
118
121
|
def capability_name
|
119
122
|
return "" if name.nil?
|
123
|
+
|
120
124
|
@capability_name ||= name.demodulize.underscore.sub(/_template$/, "")
|
121
125
|
end
|
122
126
|
|