actionmcp 0.70.0 → 0.71.1
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 +46 -41
- data/app/controllers/action_mcp/application_controller.rb +67 -15
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +13 -13
- data/app/controllers/action_mcp/oauth/registration_controller.rb +206 -0
- data/app/models/action_mcp/oauth_client.rb +157 -0
- data/app/models/action_mcp/oauth_token.rb +141 -0
- data/app/models/action_mcp/session/message.rb +12 -12
- data/app/models/action_mcp/session/resource.rb +2 -2
- data/app/models/action_mcp/session/sse_event.rb +2 -2
- data/app/models/action_mcp/session/subscription.rb +2 -2
- data/app/models/action_mcp/session.rb +22 -22
- data/config/routes.rb +1 -0
- data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +42 -0
- data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +37 -0
- data/lib/action_mcp/client/base.rb +3 -2
- data/lib/action_mcp/client/collection.rb +3 -3
- data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +56 -10
- data/lib/action_mcp/client.rb +16 -4
- data/lib/action_mcp/configuration.rb +27 -4
- data/lib/action_mcp/engine.rb +7 -1
- data/lib/action_mcp/filtered_logger.rb +32 -0
- data/lib/action_mcp/gateway.rb +47 -133
- data/lib/action_mcp/gateway_identifier.rb +29 -0
- 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 +183 -0
- data/lib/action_mcp/oauth/memory_storage.rb +23 -1
- data/lib/action_mcp/oauth/middleware.rb +33 -0
- data/lib/action_mcp/oauth/provider.rb +49 -13
- data/lib/action_mcp/oauth.rb +12 -0
- data/lib/action_mcp/server/capabilities.rb +0 -3
- data/lib/action_mcp/server/resources.rb +1 -1
- data/lib/action_mcp/server/tools.rb +36 -24
- data/lib/action_mcp/sse_listener.rb +0 -7
- data/lib/action_mcp/test_helper.rb +5 -0
- data/lib/action_mcp/tool.rb +94 -4
- data/lib/action_mcp/tools_registry.rb +3 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/generators/action_mcp/install/templates/mcp.yml +16 -16
- metadata +14 -1
data/lib/action_mcp/gateway.rb
CHANGED
@@ -5,170 +5,84 @@ 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
|
-
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
|
65
|
-
end
|
66
|
-
|
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
|
74
|
-
end
|
32
|
+
active_identifiers = filter_active_identifiers
|
75
33
|
|
76
|
-
|
77
|
-
|
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
|
34
|
+
if active_identifiers.empty?
|
35
|
+
raise ActionMCP::UnauthorizedError, "No authentication methods available"
|
87
36
|
end
|
88
|
-
end
|
89
|
-
|
90
|
-
def reject_unauthorized_connection
|
91
|
-
raise UnauthorizedError, "Unauthorized"
|
92
|
-
end
|
93
37
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
38
|
+
# Try identifiers in order, use the first one that succeeds
|
39
|
+
last_error = nil
|
40
|
+
active_identifiers.each do |klass|
|
41
|
+
begin
|
42
|
+
result = klass.new(@request).resolve
|
43
|
+
return { klass.identifier_name => result }
|
44
|
+
rescue ActionMCP::GatewayIdentifier::Unauthorized => e
|
45
|
+
last_error = e
|
46
|
+
# Try next identifier
|
47
|
+
next
|
101
48
|
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
49
|
end
|
113
50
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
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
|
51
|
+
# If we get here, all identifiers failed
|
52
|
+
# Use the last specific error message if available, otherwise generic message
|
53
|
+
error_message = last_error&.message || "Authentication failed"
|
54
|
+
raise ActionMCP::UnauthorizedError, error_message
|
127
55
|
end
|
128
56
|
|
129
|
-
|
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"]
|
57
|
+
private
|
136
58
|
|
137
|
-
|
138
|
-
|
139
|
-
nil # Let it try other authentication methods
|
140
|
-
end
|
59
|
+
def filter_active_identifiers
|
60
|
+
configured_methods = ActionMCP.configuration.authentication_methods || []
|
141
61
|
|
142
|
-
|
143
|
-
|
144
|
-
ActionMCP.configuration.oauth_config.present?
|
145
|
-
end
|
62
|
+
# If no authentication methods configured, use all identifiers
|
63
|
+
return self.class.identifier_classes if configured_methods.empty?
|
146
64
|
|
147
|
-
|
148
|
-
|
65
|
+
# Normalize configured methods to strings for consistent comparison
|
66
|
+
normalized_methods = configured_methods.map(&:to_s)
|
149
67
|
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
user = User.find_by(id: user_id) || User.find_by(oauth_subject: user_id)
|
154
|
-
return nil unless user
|
155
|
-
|
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
|
68
|
+
# Filter identifiers to only those matching configured authentication methods
|
69
|
+
self.class.identifier_classes.select do |klass|
|
70
|
+
normalized_methods.include?(klass.auth_method.to_s)
|
160
71
|
end
|
161
72
|
end
|
162
73
|
|
163
|
-
def
|
164
|
-
|
165
|
-
|
74
|
+
def assign_identities(identities)
|
75
|
+
identities.each do |name, value|
|
76
|
+
# define accessor on the fly
|
77
|
+
self.class.attr_reader name unless respond_to?(name)
|
78
|
+
instance_variable_set("@#{name}", value)
|
166
79
|
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
end
|
80
|
+
# also set current context if you have one
|
81
|
+
ActionMCP::Current.public_send("#{name}=", value) if
|
82
|
+
ActionMCP::Current.respond_to?("#{name}=")
|
171
83
|
end
|
84
|
+
ActionMCP::Current.gateway = self if
|
85
|
+
ActionMCP::Current.respond_to?(:gateway=)
|
172
86
|
end
|
173
87
|
end
|
174
88
|
end
|
@@ -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
|
@@ -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
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# ActiveRecord storage for OAuth tokens and codes
|
6
|
+
# This is suitable for production multi-server environments
|
7
|
+
class ActiveRecordStorage
|
8
|
+
# Authorization code storage
|
9
|
+
def store_authorization_code(code, data)
|
10
|
+
OAuthToken.create!(
|
11
|
+
token: code,
|
12
|
+
token_type: OAuthToken::AUTHORIZATION_CODE,
|
13
|
+
client_id: data[:client_id],
|
14
|
+
user_id: data[:user_id],
|
15
|
+
redirect_uri: data[:redirect_uri],
|
16
|
+
scope: data[:scope],
|
17
|
+
code_challenge: data[:code_challenge],
|
18
|
+
code_challenge_method: data[:code_challenge_method],
|
19
|
+
expires_at: data[:expires_at],
|
20
|
+
metadata: data.except(:client_id, :user_id, :redirect_uri, :scope,
|
21
|
+
:code_challenge, :code_challenge_method, :expires_at)
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
def retrieve_authorization_code(code)
|
26
|
+
token = OAuthToken.authorization_codes.active.find_by(token: code)
|
27
|
+
return nil unless token
|
28
|
+
|
29
|
+
{
|
30
|
+
client_id: token.client_id,
|
31
|
+
user_id: token.user_id,
|
32
|
+
redirect_uri: token.redirect_uri,
|
33
|
+
scope: token.scope,
|
34
|
+
code_challenge: token.code_challenge,
|
35
|
+
code_challenge_method: token.code_challenge_method,
|
36
|
+
expires_at: token.expires_at,
|
37
|
+
created_at: token.created_at
|
38
|
+
}.merge(token.metadata || {})
|
39
|
+
end
|
40
|
+
|
41
|
+
def remove_authorization_code(code)
|
42
|
+
OAuthToken.authorization_codes.where(token: code).destroy_all
|
43
|
+
end
|
44
|
+
|
45
|
+
# Access token storage
|
46
|
+
def store_access_token(token, data)
|
47
|
+
OAuthToken.create!(
|
48
|
+
token: token,
|
49
|
+
token_type: OAuthToken::ACCESS_TOKEN,
|
50
|
+
client_id: data[:client_id],
|
51
|
+
user_id: data[:user_id],
|
52
|
+
scope: data[:scope],
|
53
|
+
expires_at: data[:expires_at],
|
54
|
+
metadata: data.except(:client_id, :user_id, :scope, :expires_at)
|
55
|
+
)
|
56
|
+
end
|
57
|
+
|
58
|
+
def retrieve_access_token(token)
|
59
|
+
token_record = OAuthToken.access_tokens.find_by(token: token)
|
60
|
+
return nil unless token_record
|
61
|
+
|
62
|
+
{
|
63
|
+
client_id: token_record.client_id,
|
64
|
+
user_id: token_record.user_id,
|
65
|
+
scope: token_record.scope,
|
66
|
+
expires_at: token_record.expires_at,
|
67
|
+
created_at: token_record.created_at,
|
68
|
+
active: token_record.still_valid?
|
69
|
+
}.merge(token_record.metadata || {})
|
70
|
+
end
|
71
|
+
|
72
|
+
def remove_access_token(token)
|
73
|
+
OAuthToken.access_tokens.where(token: token).destroy_all
|
74
|
+
end
|
75
|
+
|
76
|
+
# Refresh token storage
|
77
|
+
def store_refresh_token(token, data)
|
78
|
+
OAuthToken.create!(
|
79
|
+
token: token,
|
80
|
+
token_type: OAuthToken::REFRESH_TOKEN,
|
81
|
+
client_id: data[:client_id],
|
82
|
+
user_id: data[:user_id],
|
83
|
+
scope: data[:scope],
|
84
|
+
access_token: data[:access_token],
|
85
|
+
expires_at: data[:expires_at],
|
86
|
+
metadata: data.except(:client_id, :user_id, :scope, :access_token, :expires_at)
|
87
|
+
)
|
88
|
+
end
|
89
|
+
|
90
|
+
def retrieve_refresh_token(token)
|
91
|
+
token_record = OAuthToken.refresh_tokens.active.find_by(token: token)
|
92
|
+
return nil unless token_record
|
93
|
+
|
94
|
+
{
|
95
|
+
client_id: token_record.client_id,
|
96
|
+
user_id: token_record.user_id,
|
97
|
+
scope: token_record.scope,
|
98
|
+
access_token: token_record.access_token,
|
99
|
+
expires_at: token_record.expires_at,
|
100
|
+
created_at: token_record.created_at
|
101
|
+
}.merge(token_record.metadata || {})
|
102
|
+
end
|
103
|
+
|
104
|
+
def update_refresh_token(token, new_access_token)
|
105
|
+
token_record = OAuthToken.refresh_tokens.find_by(token: token)
|
106
|
+
token_record&.update!(access_token: new_access_token)
|
107
|
+
end
|
108
|
+
|
109
|
+
def remove_refresh_token(token)
|
110
|
+
OAuthToken.refresh_tokens.where(token: token).destroy_all
|
111
|
+
end
|
112
|
+
|
113
|
+
# Client registration storage
|
114
|
+
def store_client_registration(client_id, data)
|
115
|
+
client = OAuthClient.new
|
116
|
+
|
117
|
+
# Map data fields to model attributes
|
118
|
+
client.client_id = client_id
|
119
|
+
client.client_secret = data[:client_secret]
|
120
|
+
client.client_id_issued_at = data[:client_id_issued_at]
|
121
|
+
client.registration_access_token = data[:registration_access_token]
|
122
|
+
|
123
|
+
# Handle client metadata
|
124
|
+
metadata = data[:client_metadata] || {}
|
125
|
+
%w[
|
126
|
+
client_name redirect_uris grant_types response_types
|
127
|
+
token_endpoint_auth_method scope
|
128
|
+
].each do |field|
|
129
|
+
client.send("#{field}=", metadata[field]) if metadata.key?(field)
|
130
|
+
end
|
131
|
+
|
132
|
+
# Store any additional metadata
|
133
|
+
known_fields = %w[
|
134
|
+
client_name redirect_uris grant_types response_types
|
135
|
+
token_endpoint_auth_method scope
|
136
|
+
]
|
137
|
+
additional_metadata = metadata.except(*known_fields)
|
138
|
+
client.metadata = additional_metadata if additional_metadata.present?
|
139
|
+
|
140
|
+
client.save!
|
141
|
+
data
|
142
|
+
end
|
143
|
+
|
144
|
+
def retrieve_client_registration(client_id)
|
145
|
+
client = OAuthClient.active.find_by(client_id: client_id)
|
146
|
+
return nil unless client
|
147
|
+
|
148
|
+
{
|
149
|
+
client_id: client.client_id,
|
150
|
+
client_secret: client.client_secret,
|
151
|
+
client_id_issued_at: client.client_id_issued_at,
|
152
|
+
registration_access_token: client.registration_access_token,
|
153
|
+
client_metadata: client.to_api_response
|
154
|
+
}
|
155
|
+
end
|
156
|
+
|
157
|
+
def remove_client_registration(client_id)
|
158
|
+
OAuthClient.where(client_id: client_id).destroy_all
|
159
|
+
end
|
160
|
+
|
161
|
+
# Cleanup expired tokens
|
162
|
+
def cleanup_expired
|
163
|
+
OAuthToken.cleanup_expired
|
164
|
+
end
|
165
|
+
|
166
|
+
# Statistics (for debugging/monitoring)
|
167
|
+
def stats
|
168
|
+
{
|
169
|
+
authorization_codes: OAuthToken.authorization_codes.active.count,
|
170
|
+
access_tokens: OAuthToken.access_tokens.active.count,
|
171
|
+
refresh_tokens: OAuthToken.refresh_tokens.active.count,
|
172
|
+
client_registrations: OAuthClient.active.count
|
173
|
+
}
|
174
|
+
end
|
175
|
+
|
176
|
+
# Clear all data (for testing)
|
177
|
+
def clear_all
|
178
|
+
OAuthToken.delete_all
|
179
|
+
OAuthClient.delete_all
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
@@ -9,6 +9,7 @@ module ActionMCP
|
|
9
9
|
@authorization_codes = {}
|
10
10
|
@access_tokens = {}
|
11
11
|
@refresh_tokens = {}
|
12
|
+
@client_registrations = {}
|
12
13
|
@mutex = Mutex.new
|
13
14
|
end
|
14
15
|
|
@@ -77,6 +78,25 @@ module ActionMCP
|
|
77
78
|
end
|
78
79
|
end
|
79
80
|
|
81
|
+
# Client registration storage
|
82
|
+
def store_client_registration(client_id, data)
|
83
|
+
@mutex.synchronize do
|
84
|
+
@client_registrations[client_id] = data
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def retrieve_client_registration(client_id)
|
89
|
+
@mutex.synchronize do
|
90
|
+
@client_registrations[client_id]
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def remove_client_registration(client_id)
|
95
|
+
@mutex.synchronize do
|
96
|
+
@client_registrations.delete(client_id)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
80
100
|
# Cleanup expired tokens (optional utility method)
|
81
101
|
def cleanup_expired
|
82
102
|
current_time = Time.current
|
@@ -94,7 +114,8 @@ module ActionMCP
|
|
94
114
|
{
|
95
115
|
authorization_codes: @authorization_codes.size,
|
96
116
|
access_tokens: @access_tokens.size,
|
97
|
-
refresh_tokens: @refresh_tokens.size
|
117
|
+
refresh_tokens: @refresh_tokens.size,
|
118
|
+
client_registrations: @client_registrations.size
|
98
119
|
}
|
99
120
|
end
|
100
121
|
end
|
@@ -105,6 +126,7 @@ module ActionMCP
|
|
105
126
|
@authorization_codes.clear
|
106
127
|
@access_tokens.clear
|
107
128
|
@refresh_tokens.clear
|
129
|
+
@client_registrations.clear
|
108
130
|
end
|
109
131
|
end
|
110
132
|
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "error"
|
4
|
+
|
3
5
|
module ActionMCP
|
4
6
|
module OAuth
|
5
7
|
# OAuth middleware that integrates with Omniauth for request authentication
|
@@ -15,6 +17,15 @@ module ActionMCP
|
|
15
17
|
# Skip OAuth processing for non-MCP requests or if OAuth not configured
|
16
18
|
return @app.call(env) unless should_process_oauth?(request)
|
17
19
|
|
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
|
24
|
+
|
25
|
+
# Skip OAuth processing for initialization-related requests
|
26
|
+
if initialization_related_request?(request)
|
27
|
+
return @app.call(env)
|
28
|
+
end
|
18
29
|
|
19
30
|
# Validate Bearer token for API requests
|
20
31
|
if bearer_token = extract_bearer_token(request)
|
@@ -37,6 +48,28 @@ module ActionMCP
|
|
37
48
|
true
|
38
49
|
end
|
39
50
|
|
51
|
+
def initialization_related_request?(request)
|
52
|
+
# Only check JSON-RPC POST requests to MCP endpoints
|
53
|
+
# The path might include the mount path (e.g., /action_mcp/ or just /)
|
54
|
+
return false unless request.post? && request.content_type&.include?("application/json")
|
55
|
+
|
56
|
+
# Check if this is an MCP endpoint (ends with / or is the root)
|
57
|
+
path = request.path
|
58
|
+
return false unless path == "/" || path.match?(/\/action_mcp\/?$/)
|
59
|
+
|
60
|
+
# Read and parse the request body
|
61
|
+
body = request.body.read
|
62
|
+
request.body.rewind # Reset for subsequent reads
|
63
|
+
|
64
|
+
json = JSON.parse(body)
|
65
|
+
method = json["method"]
|
66
|
+
|
67
|
+
# Check if it's an initialization-related method
|
68
|
+
%w[initialize notifications/initialized].include?(method)
|
69
|
+
rescue JSON::ParserError, StandardError
|
70
|
+
false
|
71
|
+
end
|
72
|
+
|
40
73
|
|
41
74
|
def extract_bearer_token(request)
|
42
75
|
auth_header = request.headers["Authorization"] || request.headers["authorization"]
|