actionmcp 0.70.0 → 0.71.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 +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/jwt_client_provider.rb +134 -0
- data/lib/action_mcp/configuration.rb +27 -4
- data/lib/action_mcp/filtered_logger.rb +32 -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 +10 -1
@@ -0,0 +1,42 @@
|
|
1
|
+
class CreateActionMCPOAuthClients < ActiveRecord::Migration[7.2]
|
2
|
+
def change
|
3
|
+
create_table :action_mcp_oauth_clients do |t|
|
4
|
+
t.string :client_id, null: false, index: { unique: true }
|
5
|
+
t.string :client_secret
|
6
|
+
t.string :client_name
|
7
|
+
|
8
|
+
# Store arrays as JSON for database compatibility
|
9
|
+
if connection.adapter_name.downcase.include?('postgresql')
|
10
|
+
t.text :redirect_uris, array: true, default: []
|
11
|
+
t.text :grant_types, array: true, default: [ "authorization_code" ]
|
12
|
+
t.text :response_types, array: true, default: [ "code" ]
|
13
|
+
else
|
14
|
+
# For SQLite and other databases, use JSON
|
15
|
+
t.json :redirect_uris, default: []
|
16
|
+
t.json :grant_types, default: [ "authorization_code" ]
|
17
|
+
t.json :response_types, default: [ "code" ]
|
18
|
+
end
|
19
|
+
|
20
|
+
t.string :token_endpoint_auth_method, default: "client_secret_basic"
|
21
|
+
t.text :scope
|
22
|
+
t.boolean :active, default: true
|
23
|
+
|
24
|
+
# Registration metadata
|
25
|
+
t.integer :client_id_issued_at
|
26
|
+
t.integer :client_secret_expires_at
|
27
|
+
t.string :registration_access_token # OAuth 2.1 Dynamic Client Registration
|
28
|
+
|
29
|
+
# Additional metadata as JSON for database compatibility
|
30
|
+
if connection.adapter_name.downcase.include?('postgresql')
|
31
|
+
t.jsonb :metadata, default: {}
|
32
|
+
else
|
33
|
+
t.json :metadata, default: {}
|
34
|
+
end
|
35
|
+
|
36
|
+
t.timestamps
|
37
|
+
end
|
38
|
+
|
39
|
+
add_index :action_mcp_oauth_clients, :active
|
40
|
+
add_index :action_mcp_oauth_clients, :client_id_issued_at
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class CreateActionMCPOAuthTokens < ActiveRecord::Migration[7.2]
|
2
|
+
def change
|
3
|
+
create_table :action_mcp_oauth_tokens do |t|
|
4
|
+
t.string :token, null: false, index: { unique: true }
|
5
|
+
t.string :token_type, null: false # 'access_token', 'refresh_token', 'authorization_code'
|
6
|
+
t.string :client_id, null: false
|
7
|
+
t.string :user_id
|
8
|
+
t.text :scope
|
9
|
+
t.datetime :expires_at
|
10
|
+
t.boolean :revoked, default: false
|
11
|
+
|
12
|
+
# For authorization codes
|
13
|
+
t.string :redirect_uri
|
14
|
+
t.string :code_challenge
|
15
|
+
t.string :code_challenge_method
|
16
|
+
|
17
|
+
# For refresh tokens
|
18
|
+
t.string :access_token # Reference to associated access token
|
19
|
+
|
20
|
+
# Additional data - use JSON for database compatibility
|
21
|
+
if connection.adapter_name.downcase.include?('postgresql')
|
22
|
+
t.jsonb :metadata, default: {}
|
23
|
+
else
|
24
|
+
t.json :metadata, default: {}
|
25
|
+
end
|
26
|
+
|
27
|
+
t.timestamps
|
28
|
+
end
|
29
|
+
|
30
|
+
add_index :action_mcp_oauth_tokens, :token_type
|
31
|
+
add_index :action_mcp_oauth_tokens, :client_id
|
32
|
+
add_index :action_mcp_oauth_tokens, :user_id
|
33
|
+
add_index :action_mcp_oauth_tokens, :expires_at
|
34
|
+
add_index :action_mcp_oauth_tokens, :revoked
|
35
|
+
add_index :action_mcp_oauth_tokens, [ :token_type, :expires_at ]
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "base64"
|
5
|
+
|
6
|
+
module ActionMCP
|
7
|
+
module Client
|
8
|
+
# JWT client provider for MCP client authentication
|
9
|
+
# Provides clean JWT token management for ActionMCP client connections
|
10
|
+
class JwtClientProvider
|
11
|
+
class AuthenticationError < StandardError; end
|
12
|
+
class TokenExpiredError < StandardError; end
|
13
|
+
|
14
|
+
attr_reader :storage
|
15
|
+
|
16
|
+
def initialize(token: nil, storage: nil, logger: ActionMCP.logger)
|
17
|
+
@storage = storage || MemoryStorage.new
|
18
|
+
@logger = logger
|
19
|
+
|
20
|
+
# If token provided during initialization, store it
|
21
|
+
if token
|
22
|
+
save_token(token)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Check if client has valid authentication
|
27
|
+
def authenticated?
|
28
|
+
token = current_token
|
29
|
+
return false unless token
|
30
|
+
|
31
|
+
!token_expired?(token)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Get authorization headers for HTTP requests
|
35
|
+
def authorization_headers
|
36
|
+
token = current_token
|
37
|
+
return {} unless token
|
38
|
+
|
39
|
+
if token_expired?(token)
|
40
|
+
log_debug("JWT token expired")
|
41
|
+
clear_tokens!
|
42
|
+
return {}
|
43
|
+
end
|
44
|
+
|
45
|
+
{ "Authorization" => "Bearer #{token}" }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Set/update the JWT token
|
49
|
+
def set_token(token)
|
50
|
+
save_token(token)
|
51
|
+
log_debug("JWT token updated")
|
52
|
+
end
|
53
|
+
|
54
|
+
# Clear stored tokens (logout)
|
55
|
+
def clear_tokens!
|
56
|
+
@storage.clear_token
|
57
|
+
log_debug("Cleared JWT token")
|
58
|
+
end
|
59
|
+
|
60
|
+
# Get current valid token
|
61
|
+
def access_token
|
62
|
+
token = current_token
|
63
|
+
return nil unless token
|
64
|
+
return nil if token_expired?(token)
|
65
|
+
token
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def current_token
|
71
|
+
@storage.load_token
|
72
|
+
end
|
73
|
+
|
74
|
+
def save_token(token)
|
75
|
+
@storage.save_token(token)
|
76
|
+
end
|
77
|
+
|
78
|
+
def token_expired?(token)
|
79
|
+
return false unless token
|
80
|
+
|
81
|
+
begin
|
82
|
+
payload = decode_jwt_payload(token)
|
83
|
+
exp = payload["exp"]
|
84
|
+
return false unless exp
|
85
|
+
|
86
|
+
# Add 30 second buffer for clock skew
|
87
|
+
Time.at(exp) <= Time.now + 30
|
88
|
+
rescue => e
|
89
|
+
log_debug("Error checking token expiration: #{e.message}")
|
90
|
+
true # Treat invalid tokens as expired
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def decode_jwt_payload(token)
|
95
|
+
# Split JWT into parts
|
96
|
+
parts = token.split(".")
|
97
|
+
raise AuthenticationError, "Invalid JWT format" unless parts.length == 3
|
98
|
+
|
99
|
+
# Decode payload (second part)
|
100
|
+
payload_base64 = parts[1]
|
101
|
+
# Add padding if needed
|
102
|
+
payload_base64 += "=" * (4 - payload_base64.length % 4) if payload_base64.length % 4 != 0
|
103
|
+
|
104
|
+
payload_json = Base64.urlsafe_decode64(payload_base64)
|
105
|
+
JSON.parse(payload_json)
|
106
|
+
rescue => e
|
107
|
+
raise AuthenticationError, "Failed to decode JWT: #{e.message}"
|
108
|
+
end
|
109
|
+
|
110
|
+
def log_debug(message)
|
111
|
+
@logger.debug("[ActionMCP::JwtClientProvider] #{message}")
|
112
|
+
end
|
113
|
+
|
114
|
+
# Simple memory storage for JWT tokens
|
115
|
+
class MemoryStorage
|
116
|
+
def initialize
|
117
|
+
@token = nil
|
118
|
+
end
|
119
|
+
|
120
|
+
def save_token(token)
|
121
|
+
@token = token
|
122
|
+
end
|
123
|
+
|
124
|
+
def load_token
|
125
|
+
@token
|
126
|
+
end
|
127
|
+
|
128
|
+
def clear_token
|
129
|
+
@token = nil
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -26,6 +26,7 @@ module ActionMCP
|
|
26
26
|
:active_profile,
|
27
27
|
:profiles,
|
28
28
|
:elicitation_enabled,
|
29
|
+
:verbose_logging,
|
29
30
|
# --- Authentication Options ---
|
30
31
|
:authentication_methods,
|
31
32
|
:oauth_config,
|
@@ -56,12 +57,13 @@ module ActionMCP
|
|
56
57
|
@logging_level = :info
|
57
58
|
@resources_subscribe = false
|
58
59
|
@elicitation_enabled = false
|
60
|
+
@verbose_logging = false
|
59
61
|
@active_profile = :primary
|
60
62
|
@profiles = default_profiles
|
61
63
|
|
62
64
|
# Authentication defaults
|
63
65
|
@authentication_methods = Rails.env.production? ? [ "jwt" ] : [ "none" ]
|
64
|
-
@oauth_config =
|
66
|
+
@oauth_config = HashWithIndifferentAccess.new
|
65
67
|
|
66
68
|
@sse_heartbeat_interval = 30
|
67
69
|
@post_response_preference = :json
|
@@ -73,6 +75,7 @@ module ActionMCP
|
|
73
75
|
|
74
76
|
# Gateway - default to ApplicationGateway if it exists, otherwise ActionMCP::Gateway
|
75
77
|
@gateway_class = defined?(::ApplicationGateway) ? ::ApplicationGateway : ActionMCP::Gateway
|
78
|
+
@gateway_class_name = nil
|
76
79
|
|
77
80
|
# Session Store
|
78
81
|
@session_store_type = Rails.env.production? ? :active_record : :volatile
|
@@ -88,6 +91,15 @@ module ActionMCP
|
|
88
91
|
@version || (has_rails_version ? Rails.application.version.to_s : "0.0.1")
|
89
92
|
end
|
90
93
|
|
94
|
+
def gateway_class
|
95
|
+
if @gateway_class_name
|
96
|
+
klass = @gateway_class_name.constantize
|
97
|
+
klass
|
98
|
+
else
|
99
|
+
@gateway_class
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
91
103
|
# Get active profile (considering thread-local override)
|
92
104
|
def active_profile
|
93
105
|
ActionMCP.thread_profiles.value || @active_profile
|
@@ -111,7 +123,7 @@ module ActionMCP
|
|
111
123
|
|
112
124
|
# Extract OAuth configuration if present
|
113
125
|
if app_config["oauth"]
|
114
|
-
@oauth_config = app_config["oauth"]
|
126
|
+
@oauth_config = HashWithIndifferentAccess.new(app_config["oauth"])
|
115
127
|
end
|
116
128
|
|
117
129
|
# Extract other top-level configuration settings
|
@@ -121,9 +133,10 @@ module ActionMCP
|
|
121
133
|
if app_config["profiles"]
|
122
134
|
@profiles = app_config["profiles"]
|
123
135
|
end
|
124
|
-
rescue StandardError
|
136
|
+
rescue StandardError => e
|
125
137
|
# If the config file doesn't exist in the Rails app, just use the defaults
|
126
|
-
Rails.logger.
|
138
|
+
Rails.logger.warn "[Configuration] Failed to load MCP config: #{e.class} - #{e.message}"
|
139
|
+
# No MCP config found in Rails app, using defaults from gem
|
127
140
|
end
|
128
141
|
|
129
142
|
# Apply the active profile
|
@@ -294,6 +307,16 @@ module ActionMCP
|
|
294
307
|
@connects_to = app_config["connects_to"]
|
295
308
|
end
|
296
309
|
|
310
|
+
# Extract verbose logging setting
|
311
|
+
if app_config.key?("verbose_logging")
|
312
|
+
@verbose_logging = app_config["verbose_logging"]
|
313
|
+
end
|
314
|
+
|
315
|
+
# Extract gateway class configuration
|
316
|
+
if app_config["gateway_class"]
|
317
|
+
@gateway_class_name = app_config["gateway_class"]
|
318
|
+
end
|
319
|
+
|
297
320
|
# Extract session store configuration
|
298
321
|
if app_config["session_store_type"]
|
299
322
|
@session_store_type = app_config["session_store_type"].to_sym
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
# Custom logger that filters out repetitive MCP requests
|
5
|
+
class FilteredLogger < ActiveSupport::Logger
|
6
|
+
FILTERED_PATHS = [
|
7
|
+
"/oauth/authorize",
|
8
|
+
"/.well-known/oauth-protected-resource",
|
9
|
+
"/.well-known/oauth-authorization-server"
|
10
|
+
].freeze
|
11
|
+
|
12
|
+
FILTERED_METHODS = [
|
13
|
+
"notifications/initialized",
|
14
|
+
"notifications/ping"
|
15
|
+
].freeze
|
16
|
+
|
17
|
+
def add(severity, message = nil, progname = nil, &block)
|
18
|
+
# Filter out repetitive OAuth metadata requests
|
19
|
+
if message && message.is_a?(String)
|
20
|
+
return if FILTERED_PATHS.any? { |path| message.include?(path) && message.include?("200 OK") }
|
21
|
+
|
22
|
+
# Filter out repetitive MCP notifications
|
23
|
+
return if FILTERED_METHODS.any? { |method| message.include?(method) }
|
24
|
+
|
25
|
+
# Filter out MCP protocol version debug messages
|
26
|
+
return if message.include?("MCP-Protocol-Version header validation passed")
|
27
|
+
end
|
28
|
+
|
29
|
+
super
|
30
|
+
end
|
31
|
+
end
|
32
|
+
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"]
|