actionmcp 0.52.2 → 0.54.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 +14 -7
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +264 -0
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +129 -0
- data/app/models/action_mcp/session/message.rb +12 -12
- data/app/models/action_mcp/session.rb +116 -17
- data/config/routes.rb +10 -0
- data/db/migrate/20250512154359_consolidated_migration.rb +15 -12
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +19 -0
- data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +47 -0
- data/lib/action_mcp/client/oauth_client_provider.rb +234 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +25 -1
- data/lib/action_mcp/client.rb +15 -2
- data/lib/action_mcp/configuration.rb +65 -6
- data/lib/action_mcp/engine.rb +8 -0
- data/lib/action_mcp/gateway.rb +95 -6
- data/lib/action_mcp/oauth/error.rb +79 -0
- data/lib/action_mcp/oauth/memory_storage.rb +112 -0
- data/lib/action_mcp/oauth/middleware.rb +100 -0
- data/lib/action_mcp/oauth/provider.rb +390 -0
- data/lib/action_mcp/omniauth/mcp_strategy.rb +176 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +6 -1
- data/lib/generators/action_mcp/install/install_generator.rb +32 -1
- data/lib/generators/action_mcp/install/templates/mcp.yml +96 -19
- metadata +81 -3
- data/lib/generators/action_mcp/config/config_generator.rb +0 -28
- data/lib/generators/action_mcp/config/templates/mcp.yml +0 -36
@@ -0,0 +1,390 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "digest"
|
5
|
+
require "base64"
|
6
|
+
|
7
|
+
module ActionMCP
|
8
|
+
module OAuth
|
9
|
+
# OAuth 2.1 Provider implementation
|
10
|
+
# Handles authorization codes, access tokens, refresh tokens, and token validation
|
11
|
+
class Provider
|
12
|
+
class << self
|
13
|
+
# Generate authorization code for OAuth flow
|
14
|
+
# @param client_id [String] OAuth client identifier
|
15
|
+
# @param redirect_uri [String] Client redirect URI
|
16
|
+
# @param scope [String] Requested scope
|
17
|
+
# @param code_challenge [String] PKCE code challenge
|
18
|
+
# @param code_challenge_method [String] PKCE challenge method (S256, plain)
|
19
|
+
# @param user_id [String] User identifier
|
20
|
+
# @return [String] Authorization code
|
21
|
+
def generate_authorization_code(client_id:, redirect_uri:, scope:, code_challenge: nil, code_challenge_method: nil, user_id:)
|
22
|
+
# Validate scope
|
23
|
+
validate_scope(scope) if scope
|
24
|
+
|
25
|
+
code = SecureRandom.urlsafe_base64(32)
|
26
|
+
|
27
|
+
# Store authorization code with metadata
|
28
|
+
store_authorization_code(code, {
|
29
|
+
client_id: client_id,
|
30
|
+
redirect_uri: redirect_uri,
|
31
|
+
scope: scope,
|
32
|
+
code_challenge: code_challenge,
|
33
|
+
code_challenge_method: code_challenge_method,
|
34
|
+
user_id: user_id,
|
35
|
+
created_at: Time.current,
|
36
|
+
expires_at: 10.minutes.from_now
|
37
|
+
})
|
38
|
+
|
39
|
+
code
|
40
|
+
end
|
41
|
+
|
42
|
+
# Exchange authorization code for access token
|
43
|
+
# @param code [String] Authorization code
|
44
|
+
# @param client_id [String] OAuth client identifier
|
45
|
+
# @param client_secret [String] OAuth client secret (optional for public clients)
|
46
|
+
# @param redirect_uri [String] Client redirect URI
|
47
|
+
# @param code_verifier [String] PKCE code verifier
|
48
|
+
# @return [Hash] Token response with access_token, token_type, expires_in, scope
|
49
|
+
def exchange_code_for_token(code:, client_id:, client_secret: nil, redirect_uri:, code_verifier: nil)
|
50
|
+
# Retrieve and validate authorization code
|
51
|
+
code_data = retrieve_authorization_code(code)
|
52
|
+
raise InvalidGrantError, "Invalid authorization code" unless code_data
|
53
|
+
raise InvalidGrantError, "Authorization code expired" if code_data[:expires_at] < Time.current
|
54
|
+
|
55
|
+
# Validate client
|
56
|
+
validate_client(client_id, client_secret)
|
57
|
+
|
58
|
+
# Validate redirect URI matches
|
59
|
+
unless code_data[:redirect_uri] == redirect_uri
|
60
|
+
raise InvalidGrantError, "Redirect URI mismatch"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Validate client ID matches
|
64
|
+
unless code_data[:client_id] == client_id
|
65
|
+
raise InvalidGrantError, "Client ID mismatch"
|
66
|
+
end
|
67
|
+
|
68
|
+
# Validate PKCE if challenge was provided during authorization
|
69
|
+
if code_data[:code_challenge]
|
70
|
+
validate_pkce(code_data[:code_challenge], code_data[:code_challenge_method], code_verifier)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Generate access token
|
74
|
+
access_token = generate_access_token(
|
75
|
+
client_id: client_id,
|
76
|
+
scope: code_data[:scope],
|
77
|
+
user_id: code_data[:user_id]
|
78
|
+
)
|
79
|
+
|
80
|
+
# Generate refresh token if enabled
|
81
|
+
refresh_token = nil
|
82
|
+
if oauth_config["enable_refresh_tokens"]
|
83
|
+
refresh_token = generate_refresh_token(
|
84
|
+
client_id: client_id,
|
85
|
+
scope: code_data[:scope],
|
86
|
+
user_id: code_data[:user_id],
|
87
|
+
access_token: access_token
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Remove used authorization code
|
92
|
+
remove_authorization_code(code)
|
93
|
+
|
94
|
+
# Return token response
|
95
|
+
response = {
|
96
|
+
access_token: access_token,
|
97
|
+
token_type: "Bearer",
|
98
|
+
expires_in: token_expires_in,
|
99
|
+
scope: code_data[:scope]
|
100
|
+
}
|
101
|
+
response[:refresh_token] = refresh_token if refresh_token
|
102
|
+
response
|
103
|
+
end
|
104
|
+
|
105
|
+
# Refresh access token using refresh token
|
106
|
+
# @param refresh_token [String] Refresh token
|
107
|
+
# @param client_id [String] OAuth client identifier
|
108
|
+
# @param client_secret [String] OAuth client secret
|
109
|
+
# @param scope [String] Requested scope (optional, must be subset of original)
|
110
|
+
# @return [Hash] New token response
|
111
|
+
def refresh_access_token(refresh_token:, client_id:, client_secret: nil, scope: nil)
|
112
|
+
# Retrieve refresh token data
|
113
|
+
token_data = retrieve_refresh_token(refresh_token)
|
114
|
+
raise InvalidGrantError, "Invalid refresh token" unless token_data
|
115
|
+
raise InvalidGrantError, "Refresh token expired" if token_data[:expires_at] < Time.current
|
116
|
+
|
117
|
+
# Validate client
|
118
|
+
validate_client(client_id, client_secret)
|
119
|
+
|
120
|
+
# Validate client ID matches
|
121
|
+
unless token_data[:client_id] == client_id
|
122
|
+
raise InvalidGrantError, "Client ID mismatch"
|
123
|
+
end
|
124
|
+
|
125
|
+
# Validate scope if provided
|
126
|
+
if scope
|
127
|
+
requested_scopes = scope.split(" ")
|
128
|
+
original_scopes = token_data[:scope].split(" ")
|
129
|
+
unless (requested_scopes - original_scopes).empty?
|
130
|
+
raise InvalidScopeError, "Requested scope exceeds original scope"
|
131
|
+
end
|
132
|
+
else
|
133
|
+
scope = token_data[:scope]
|
134
|
+
end
|
135
|
+
|
136
|
+
# Revoke old access token
|
137
|
+
revoke_access_token(token_data[:access_token]) if token_data[:access_token]
|
138
|
+
|
139
|
+
# Generate new access token
|
140
|
+
access_token = generate_access_token(
|
141
|
+
client_id: client_id,
|
142
|
+
scope: scope,
|
143
|
+
user_id: token_data[:user_id]
|
144
|
+
)
|
145
|
+
|
146
|
+
# Update refresh token with new access token
|
147
|
+
update_refresh_token(refresh_token, access_token)
|
148
|
+
|
149
|
+
{
|
150
|
+
access_token: access_token,
|
151
|
+
token_type: "Bearer",
|
152
|
+
expires_in: token_expires_in,
|
153
|
+
scope: scope
|
154
|
+
}
|
155
|
+
end
|
156
|
+
|
157
|
+
# Validate access token and return token info
|
158
|
+
# @param access_token [String] Access token to validate
|
159
|
+
# @return [Hash] Token info with active, client_id, scope, user_id, exp
|
160
|
+
def introspect_token(access_token)
|
161
|
+
token_data = retrieve_access_token(access_token)
|
162
|
+
|
163
|
+
unless token_data
|
164
|
+
return { active: false }
|
165
|
+
end
|
166
|
+
|
167
|
+
if token_data[:expires_at] < Time.current
|
168
|
+
remove_access_token(access_token)
|
169
|
+
return { active: false }
|
170
|
+
end
|
171
|
+
|
172
|
+
{
|
173
|
+
active: true,
|
174
|
+
client_id: token_data[:client_id],
|
175
|
+
scope: token_data[:scope],
|
176
|
+
user_id: token_data[:user_id],
|
177
|
+
exp: token_data[:expires_at].to_i,
|
178
|
+
iat: token_data[:created_at].to_i,
|
179
|
+
token_type: "Bearer"
|
180
|
+
}
|
181
|
+
end
|
182
|
+
|
183
|
+
# Revoke access or refresh token
|
184
|
+
# @param token [String] Token to revoke
|
185
|
+
# @param token_type_hint [String] Type hint: "access_token" or "refresh_token"
|
186
|
+
# @return [Boolean] True if token was revoked
|
187
|
+
def revoke_token(token, token_type_hint: nil)
|
188
|
+
revoked = false
|
189
|
+
|
190
|
+
# Try access token first if hint suggests it or no hint provided
|
191
|
+
if token_type_hint == "access_token" || token_type_hint.nil?
|
192
|
+
if retrieve_access_token(token)
|
193
|
+
revoke_access_token(token)
|
194
|
+
revoked = true
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Try refresh token if not revoked yet
|
199
|
+
if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?)
|
200
|
+
if retrieve_refresh_token(token)
|
201
|
+
revoke_refresh_token(token)
|
202
|
+
revoked = true
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
revoked
|
207
|
+
end
|
208
|
+
|
209
|
+
# Client Credentials Grant (for server-to-server)
|
210
|
+
# @param client_id [String] OAuth client identifier
|
211
|
+
# @param client_secret [String] OAuth client secret
|
212
|
+
# @param scope [String] Requested scope
|
213
|
+
# @return [Hash] Token response
|
214
|
+
def client_credentials_grant(client_id:, client_secret:, scope: nil)
|
215
|
+
unless oauth_config["enable_client_credentials"]
|
216
|
+
raise UnsupportedGrantTypeError, "Client credentials grant not supported"
|
217
|
+
end
|
218
|
+
|
219
|
+
# Validate client credentials
|
220
|
+
validate_client(client_id, client_secret, require_secret: true)
|
221
|
+
|
222
|
+
# Validate scope
|
223
|
+
if scope
|
224
|
+
validate_scope(scope)
|
225
|
+
else
|
226
|
+
scope = default_scope
|
227
|
+
end
|
228
|
+
|
229
|
+
# Generate access token (no user context for client credentials)
|
230
|
+
access_token = generate_access_token(
|
231
|
+
client_id: client_id,
|
232
|
+
scope: scope,
|
233
|
+
user_id: nil
|
234
|
+
)
|
235
|
+
|
236
|
+
{
|
237
|
+
access_token: access_token,
|
238
|
+
token_type: "Bearer",
|
239
|
+
expires_in: token_expires_in,
|
240
|
+
scope: scope
|
241
|
+
}
|
242
|
+
end
|
243
|
+
|
244
|
+
private
|
245
|
+
|
246
|
+
def oauth_config
|
247
|
+
@oauth_config ||= ActionMCP.configuration.oauth_config || {}
|
248
|
+
end
|
249
|
+
|
250
|
+
def validate_client(client_id, client_secret, require_secret: false)
|
251
|
+
# This should be implemented by the application
|
252
|
+
# For now, we'll use a simple validation approach
|
253
|
+
provider_class = oauth_config["provider"]
|
254
|
+
if provider_class && provider_class.respond_to?(:validate_client)
|
255
|
+
provider_class.validate_client(client_id, client_secret)
|
256
|
+
elsif require_secret && client_secret.nil?
|
257
|
+
raise InvalidClientError, "Client authentication required"
|
258
|
+
end
|
259
|
+
# Default: allow any client for development
|
260
|
+
end
|
261
|
+
|
262
|
+
def validate_pkce(code_challenge, method, code_verifier)
|
263
|
+
raise InvalidGrantError, "Code verifier required" unless code_verifier
|
264
|
+
|
265
|
+
case method
|
266
|
+
when "S256"
|
267
|
+
expected_challenge = Base64.urlsafe_encode64(
|
268
|
+
Digest::SHA256.digest(code_verifier), padding: false
|
269
|
+
)
|
270
|
+
unless code_challenge == expected_challenge
|
271
|
+
raise InvalidGrantError, "Invalid code verifier"
|
272
|
+
end
|
273
|
+
when "plain"
|
274
|
+
unless oauth_config["allow_plain_pkce"]
|
275
|
+
raise InvalidGrantError, "Plain PKCE not allowed"
|
276
|
+
end
|
277
|
+
unless code_challenge == code_verifier
|
278
|
+
raise InvalidGrantError, "Invalid code verifier"
|
279
|
+
end
|
280
|
+
else
|
281
|
+
raise InvalidGrantError, "Unsupported code challenge method"
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def validate_scope(scope)
|
286
|
+
supported_scopes = oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
|
287
|
+
requested_scopes = scope.split(" ")
|
288
|
+
unsupported = requested_scopes - supported_scopes
|
289
|
+
if unsupported.any?
|
290
|
+
raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
def default_scope
|
295
|
+
oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
|
296
|
+
end
|
297
|
+
|
298
|
+
def generate_access_token(client_id:, scope:, user_id:)
|
299
|
+
token = SecureRandom.urlsafe_base64(32)
|
300
|
+
|
301
|
+
store_access_token(token, {
|
302
|
+
client_id: client_id,
|
303
|
+
scope: scope,
|
304
|
+
user_id: user_id,
|
305
|
+
created_at: Time.current,
|
306
|
+
expires_at: token_expires_in.seconds.from_now
|
307
|
+
})
|
308
|
+
|
309
|
+
token
|
310
|
+
end
|
311
|
+
|
312
|
+
def generate_refresh_token(client_id:, scope:, user_id:, access_token:)
|
313
|
+
token = SecureRandom.urlsafe_base64(32)
|
314
|
+
|
315
|
+
store_refresh_token(token, {
|
316
|
+
client_id: client_id,
|
317
|
+
scope: scope,
|
318
|
+
user_id: user_id,
|
319
|
+
access_token: access_token,
|
320
|
+
created_at: Time.current,
|
321
|
+
expires_at: refresh_token_expires_in.seconds.from_now
|
322
|
+
})
|
323
|
+
|
324
|
+
token
|
325
|
+
end
|
326
|
+
|
327
|
+
def token_expires_in
|
328
|
+
oauth_config["access_token_expires_in"] || 3600 # 1 hour
|
329
|
+
end
|
330
|
+
|
331
|
+
def refresh_token_expires_in
|
332
|
+
oauth_config["refresh_token_expires_in"] || 7.days.to_i # 1 week
|
333
|
+
end
|
334
|
+
|
335
|
+
# Storage methods - these delegate to a configurable storage backend
|
336
|
+
def storage
|
337
|
+
@storage ||= begin
|
338
|
+
storage_class = oauth_config["storage"] || "ActionMCP::OAuth::MemoryStorage"
|
339
|
+
storage_class = storage_class.constantize if storage_class.is_a?(String)
|
340
|
+
storage_class.new
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
def store_authorization_code(code, data)
|
345
|
+
storage.store_authorization_code(code, data)
|
346
|
+
end
|
347
|
+
|
348
|
+
def retrieve_authorization_code(code)
|
349
|
+
storage.retrieve_authorization_code(code)
|
350
|
+
end
|
351
|
+
|
352
|
+
def remove_authorization_code(code)
|
353
|
+
storage.remove_authorization_code(code)
|
354
|
+
end
|
355
|
+
|
356
|
+
def store_access_token(token, data)
|
357
|
+
storage.store_access_token(token, data)
|
358
|
+
end
|
359
|
+
|
360
|
+
def retrieve_access_token(token)
|
361
|
+
storage.retrieve_access_token(token)
|
362
|
+
end
|
363
|
+
|
364
|
+
def remove_access_token(token)
|
365
|
+
storage.remove_access_token(token)
|
366
|
+
end
|
367
|
+
|
368
|
+
def revoke_access_token(token)
|
369
|
+
storage.remove_access_token(token)
|
370
|
+
end
|
371
|
+
|
372
|
+
def store_refresh_token(token, data)
|
373
|
+
storage.store_refresh_token(token, data)
|
374
|
+
end
|
375
|
+
|
376
|
+
def retrieve_refresh_token(token)
|
377
|
+
storage.retrieve_refresh_token(token)
|
378
|
+
end
|
379
|
+
|
380
|
+
def update_refresh_token(token, new_access_token)
|
381
|
+
storage.update_refresh_token(token, new_access_token)
|
382
|
+
end
|
383
|
+
|
384
|
+
def revoke_refresh_token(token)
|
385
|
+
storage.remove_refresh_token(token)
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|
@@ -0,0 +1,176 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "omniauth-oauth2"
|
4
|
+
|
5
|
+
module ActionMCP
|
6
|
+
module Omniauth
|
7
|
+
# MCP-specific Omniauth strategy for OAuth 2.1 authentication
|
8
|
+
# This strategy integrates with ActionMCP's configuration system and provider interface
|
9
|
+
class MCPStrategy < ::OmniAuth::Strategies::OAuth2
|
10
|
+
# Strategy name used in configuration
|
11
|
+
option :name, "mcp"
|
12
|
+
|
13
|
+
# Default OAuth options with MCP-specific settings
|
14
|
+
option :client_options, {
|
15
|
+
authorize_url: "/oauth/authorize",
|
16
|
+
token_url: "/oauth/token",
|
17
|
+
auth_scheme: :request_body
|
18
|
+
}
|
19
|
+
|
20
|
+
# OAuth 2.1 compliance - PKCE is required
|
21
|
+
option :pkce, true
|
22
|
+
|
23
|
+
# Default scopes for MCP access
|
24
|
+
option :scope, "mcp:tools mcp:resources mcp:prompts"
|
25
|
+
|
26
|
+
# Use authorization code grant flow
|
27
|
+
option :response_type, "code"
|
28
|
+
|
29
|
+
# OAuth server metadata discovery
|
30
|
+
option :discovery, true
|
31
|
+
|
32
|
+
def initialize(app, *args, &block)
|
33
|
+
super
|
34
|
+
|
35
|
+
# Load configuration from ActionMCP if available
|
36
|
+
configure_from_mcp_config if defined?(ActionMCP)
|
37
|
+
end
|
38
|
+
|
39
|
+
# User info from OAuth token response or userinfo endpoint
|
40
|
+
def raw_info
|
41
|
+
@raw_info ||= begin
|
42
|
+
if options.userinfo_url
|
43
|
+
access_token.get(options.userinfo_url).parsed
|
44
|
+
else
|
45
|
+
# Extract user info from token response or use minimal info
|
46
|
+
token_response = access_token.token
|
47
|
+
{
|
48
|
+
"sub" => access_token.params["user_id"] || access_token.token,
|
49
|
+
"scope" => access_token.params["scope"] || options.scope
|
50
|
+
}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
rescue ::OAuth2::Error => e
|
54
|
+
log(:error, "Failed to fetch user info: #{e.message}")
|
55
|
+
{}
|
56
|
+
end
|
57
|
+
|
58
|
+
# User ID for Omniauth
|
59
|
+
uid { raw_info["sub"] || raw_info["user_id"] }
|
60
|
+
|
61
|
+
# User info hash
|
62
|
+
info do
|
63
|
+
{
|
64
|
+
name: raw_info["name"],
|
65
|
+
email: raw_info["email"],
|
66
|
+
username: raw_info["username"] || raw_info["preferred_username"]
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
# Extra credentials and token info
|
71
|
+
extra do
|
72
|
+
{
|
73
|
+
"raw_info" => raw_info,
|
74
|
+
"scope" => access_token.params["scope"],
|
75
|
+
"token_type" => access_token.params["token_type"] || "Bearer"
|
76
|
+
}
|
77
|
+
end
|
78
|
+
|
79
|
+
# OAuth server metadata discovery
|
80
|
+
def discovery_info
|
81
|
+
@discovery_info ||= begin
|
82
|
+
if options.discovery && options.client_options.site
|
83
|
+
discovery_url = "#{options.client_options.site}/.well-known/oauth-authorization-server"
|
84
|
+
response = client.request(:get, discovery_url)
|
85
|
+
JSON.parse(response.body)
|
86
|
+
end
|
87
|
+
rescue StandardError => e
|
88
|
+
log(:warn, "OAuth discovery failed: #{e.message}")
|
89
|
+
{}
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Override client to use discovered endpoints if available
|
94
|
+
def client
|
95
|
+
@client ||= begin
|
96
|
+
if discovery_info.any?
|
97
|
+
options.client_options.merge!(
|
98
|
+
authorize_url: discovery_info["authorization_endpoint"],
|
99
|
+
token_url: discovery_info["token_endpoint"]
|
100
|
+
) if discovery_info["authorization_endpoint"] && discovery_info["token_endpoint"]
|
101
|
+
end
|
102
|
+
super
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Token validation for API requests (not callback flow)
|
107
|
+
def self.validate_token(token, options = {})
|
108
|
+
strategy = new(nil, options)
|
109
|
+
strategy.validate_token(token)
|
110
|
+
end
|
111
|
+
|
112
|
+
def validate_token(token)
|
113
|
+
# Validate access token with OAuth server
|
114
|
+
return nil unless token
|
115
|
+
|
116
|
+
begin
|
117
|
+
response = client.request(:post, options.introspection_url || "/oauth/introspect", {
|
118
|
+
body: { token: token },
|
119
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" }
|
120
|
+
})
|
121
|
+
|
122
|
+
token_info = JSON.parse(response.body)
|
123
|
+
return nil unless token_info["active"]
|
124
|
+
|
125
|
+
token_info
|
126
|
+
rescue StandardError => e
|
127
|
+
log(:error, "Token validation failed: #{e.message}")
|
128
|
+
nil
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
private
|
133
|
+
|
134
|
+
# Configure strategy from ActionMCP configuration
|
135
|
+
def configure_from_mcp_config
|
136
|
+
oauth_config = ActionMCP.configuration.oauth_config
|
137
|
+
return unless oauth_config.is_a?(Hash)
|
138
|
+
|
139
|
+
# Set client options from MCP config
|
140
|
+
if oauth_config["issuer_url"]
|
141
|
+
options.client_options[:site] = oauth_config["issuer_url"]
|
142
|
+
end
|
143
|
+
|
144
|
+
if oauth_config["client_id"]
|
145
|
+
options.client_id = oauth_config["client_id"]
|
146
|
+
end
|
147
|
+
|
148
|
+
if oauth_config["client_secret"]
|
149
|
+
options.client_secret = oauth_config["client_secret"]
|
150
|
+
end
|
151
|
+
|
152
|
+
if oauth_config["scopes_supported"]
|
153
|
+
options.scope = Array(oauth_config["scopes_supported"]).join(" ")
|
154
|
+
end
|
155
|
+
|
156
|
+
# Enable PKCE if required (OAuth 2.1 compliance)
|
157
|
+
if oauth_config["pkce_required"]
|
158
|
+
options.pkce = true
|
159
|
+
end
|
160
|
+
|
161
|
+
# Set userinfo endpoint if provided
|
162
|
+
if oauth_config["userinfo_endpoint"]
|
163
|
+
options.userinfo_url = oauth_config["userinfo_endpoint"]
|
164
|
+
end
|
165
|
+
|
166
|
+
# Set token introspection endpoint
|
167
|
+
if oauth_config["introspection_endpoint"]
|
168
|
+
options.introspection_url = oauth_config["introspection_endpoint"]
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Register the strategy with Omniauth
|
176
|
+
OmniAuth.config.add_camelization "mcp", "MCP"
|
data/lib/action_mcp/version.rb
CHANGED
data/lib/action_mcp.rb
CHANGED
@@ -13,6 +13,10 @@ require "action_mcp/log_subscriber"
|
|
13
13
|
require "action_mcp/engine"
|
14
14
|
require "zeitwerk"
|
15
15
|
|
16
|
+
# OAuth 2.1 support via Omniauth
|
17
|
+
require "omniauth"
|
18
|
+
require "omniauth-oauth2"
|
19
|
+
|
16
20
|
lib = File.dirname(__FILE__)
|
17
21
|
|
18
22
|
Zeitwerk::Loader.for_gem.tap do |loader|
|
@@ -25,8 +29,9 @@ Zeitwerk::Loader.for_gem.tap do |loader|
|
|
25
29
|
|
26
30
|
loader.inflector.inflect("action_mcp" => "ActionMCP")
|
27
31
|
loader.inflector.inflect("sse_client" => "SSEClient")
|
28
|
-
loader.inflector.inflect("sse_server" => "SSEServer")
|
29
32
|
loader.inflector.inflect("sse_listener" => "SSEListener")
|
33
|
+
loader.inflector.inflect("oauth" => "OAuth")
|
34
|
+
loader.inflector.inflect("mcp_strategy" => "MCPStrategy")
|
30
35
|
end.setup
|
31
36
|
|
32
37
|
module ActionMCP
|
@@ -7,6 +7,8 @@ module ActionMcp
|
|
7
7
|
class InstallGenerator < Rails::Generators::Base
|
8
8
|
source_root File.expand_path("templates", __dir__)
|
9
9
|
|
10
|
+
desc "Install ActionMCP with base classes and configuration"
|
11
|
+
|
10
12
|
def create_application_prompt_file
|
11
13
|
template "application_mcp_prompt.rb", File.join("app/mcp/prompts", "application_mcp_prompt.rb")
|
12
14
|
end
|
@@ -20,13 +22,42 @@ module ActionMcp
|
|
20
22
|
File.join("app/mcp/resource_templates", "application_mcp_res_template.rb")
|
21
23
|
end
|
22
24
|
|
23
|
-
def
|
25
|
+
def create_mcp_configuration_file
|
24
26
|
template "mcp.yml", File.join("config", "mcp.yml")
|
25
27
|
end
|
26
28
|
|
27
29
|
def create_application_gateway_file
|
28
30
|
template "application_gateway.rb", File.join("app/mcp", "application_gateway.rb")
|
29
31
|
end
|
32
|
+
|
33
|
+
def show_instructions
|
34
|
+
say ""
|
35
|
+
say "ActionMCP has been installed successfully!"
|
36
|
+
say ""
|
37
|
+
say "Files created:"
|
38
|
+
say " - app/mcp/prompts/application_mcp_prompt.rb"
|
39
|
+
say " - app/mcp/tools/application_mcp_tool.rb"
|
40
|
+
say " - app/mcp/resource_templates/application_mcp_res_template.rb"
|
41
|
+
say " - app/mcp/application_gateway.rb"
|
42
|
+
say " - config/mcp.yml"
|
43
|
+
say ""
|
44
|
+
say "Configuration:"
|
45
|
+
say " The mcp.yml file contains authentication, profiles, and adapter settings."
|
46
|
+
say " You can customize authentication methods, OAuth settings, and PubSub adapters."
|
47
|
+
say ""
|
48
|
+
say "Available adapters:"
|
49
|
+
say " - simple : In-memory adapter for development"
|
50
|
+
say " - test : Test adapter for testing environments"
|
51
|
+
say " - solid_cable : Database-backed adapter (requires solid_cable gem)"
|
52
|
+
say " - redis : Redis-backed adapter (requires redis gem)"
|
53
|
+
say ""
|
54
|
+
say "Next steps:"
|
55
|
+
say " 1. Generate your first tool: rails generate action_mcp:tool MyTool"
|
56
|
+
say " 2. Generate your first prompt: rails generate action_mcp:prompt MyPrompt"
|
57
|
+
say " 3. Generate your first resource template: rails generate action_mcp:resource_template MyResource"
|
58
|
+
say " 4. Start the MCP server: bundle exec rails s -c mcp.ru -p 62770"
|
59
|
+
say ""
|
60
|
+
end
|
30
61
|
end
|
31
62
|
end
|
32
63
|
end
|