actionmcp 0.71.1 → 0.80.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 +187 -16
- data/app/controllers/action_mcp/application_controller.rb +64 -49
- data/app/models/action_mcp/session/message.rb +31 -20
- data/app/models/action_mcp/session/resource.rb +35 -20
- data/app/models/action_mcp/session/sse_event.rb +23 -17
- data/app/models/action_mcp/session/subscription.rb +22 -15
- data/app/models/action_mcp/session.rb +71 -113
- data/config/routes.rb +0 -11
- data/db/migrate/20250512154359_consolidated_migration.rb +3 -3
- data/db/migrate/20250715070713_add_consents_to_action_mcp_sess.rb +7 -0
- data/db/migrate/20250727000001_remove_oauth_support.rb +59 -0
- data/lib/action_mcp/base_response.rb +1 -1
- data/lib/action_mcp/client/base.rb +9 -11
- data/lib/action_mcp/client/elicitation.rb +4 -4
- data/lib/action_mcp/client/json_rpc_handler.rb +11 -13
- data/lib/action_mcp/client/streamable_http_transport.rb +19 -74
- data/lib/action_mcp/client.rb +6 -26
- data/lib/action_mcp/configuration.rb +65 -63
- data/lib/action_mcp/engine.rb +1 -10
- data/lib/action_mcp/filtered_logger.rb +3 -7
- data/lib/action_mcp/gateway.rb +7 -11
- data/lib/action_mcp/gateway_identifier.rb +187 -3
- data/lib/action_mcp/gateway_identifiers/api_key_identifier.rb +56 -0
- data/lib/action_mcp/gateway_identifiers/devise_identifier.rb +34 -0
- data/lib/action_mcp/gateway_identifiers/request_env_identifier.rb +58 -0
- data/lib/action_mcp/gateway_identifiers/warden_identifier.rb +38 -0
- data/lib/action_mcp/gateway_identifiers.rb +26 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +0 -2
- 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} +41 -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 +2 -7
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +189 -0
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +35 -0
- data/lib/generators/action_mcp/install/install_generator.rb +1 -1
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +86 -36
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -21
- data/lib/tasks/action_mcp_tasks.rake +7 -5
- metadata +18 -100
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +0 -264
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +0 -129
- data/app/controllers/action_mcp/oauth/registration_controller.rb +0 -206
- data/app/models/action_mcp/oauth_client.rb +0 -157
- data/app/models/action_mcp/oauth_token.rb +0 -141
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +0 -19
- data/db/migrate/20250708105124_create_action_mcp_oauth_clients.rb +0 -42
- data/db/migrate/20250708105226_create_action_mcp_oauth_tokens.rb +0 -37
- data/lib/action_mcp/client/jwt_client_provider.rb +0 -134
- data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +0 -47
- data/lib/action_mcp/client/oauth_client_provider.rb +0 -234
- data/lib/action_mcp/jwt_decoder.rb +0 -26
- data/lib/action_mcp/jwt_identifier.rb +0 -28
- data/lib/action_mcp/none_identifier.rb +0 -19
- data/lib/action_mcp/o_auth_identifier.rb +0 -34
- data/lib/action_mcp/oauth/active_record_storage.rb +0 -183
- data/lib/action_mcp/oauth/error.rb +0 -79
- data/lib/action_mcp/oauth/memory_storage.rb +0 -134
- data/lib/action_mcp/oauth/middleware.rb +0 -133
- data/lib/action_mcp/oauth/provider.rb +0 -426
- data/lib/action_mcp/oauth.rb +0 -12
- data/lib/action_mcp/omniauth/mcp_strategy.rb +0 -176
- data/lib/action_mcp/server/notifications.rb +0 -58
@@ -1,426 +0,0 @@
|
|
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
|
-
# Register a new OAuth client (Dynamic Client Registration)
|
210
|
-
# @param client_info [Hash] Client registration information
|
211
|
-
# @return [Hash] Registered client information
|
212
|
-
def register_client(client_info)
|
213
|
-
# Store client registration
|
214
|
-
storage.store_client_registration(client_info[:client_id], client_info)
|
215
|
-
client_info
|
216
|
-
end
|
217
|
-
|
218
|
-
# Retrieve registered client information
|
219
|
-
# @param client_id [String] OAuth client identifier
|
220
|
-
# @return [Hash, nil] Client information or nil if not found
|
221
|
-
def get_client(client_id)
|
222
|
-
storage.retrieve_client_registration(client_id)
|
223
|
-
end
|
224
|
-
|
225
|
-
# Client Credentials Grant (for server-to-server)
|
226
|
-
# @param client_id [String] OAuth client identifier
|
227
|
-
# @param client_secret [String] OAuth client secret
|
228
|
-
# @param scope [String] Requested scope
|
229
|
-
# @return [Hash] Token response
|
230
|
-
def client_credentials_grant(client_id:, client_secret:, scope: nil)
|
231
|
-
unless oauth_config[:enable_client_credentials]
|
232
|
-
raise UnsupportedGrantTypeError, "Client credentials grant not supported"
|
233
|
-
end
|
234
|
-
|
235
|
-
# Validate client credentials
|
236
|
-
validate_client(client_id, client_secret, require_secret: true)
|
237
|
-
|
238
|
-
# Validate scope
|
239
|
-
if scope
|
240
|
-
validate_scope(scope)
|
241
|
-
else
|
242
|
-
scope = default_scope
|
243
|
-
end
|
244
|
-
|
245
|
-
# Generate access token (no user context for client credentials)
|
246
|
-
access_token = generate_access_token(
|
247
|
-
client_id: client_id,
|
248
|
-
scope: scope,
|
249
|
-
user_id: nil
|
250
|
-
)
|
251
|
-
|
252
|
-
{
|
253
|
-
access_token: access_token,
|
254
|
-
token_type: "Bearer",
|
255
|
-
expires_in: token_expires_in,
|
256
|
-
scope: scope
|
257
|
-
}
|
258
|
-
end
|
259
|
-
|
260
|
-
private
|
261
|
-
|
262
|
-
def oauth_config
|
263
|
-
@oauth_config ||= HashWithIndifferentAccess.new(ActionMCP.configuration.oauth_config || {})
|
264
|
-
end
|
265
|
-
|
266
|
-
def validate_client(client_id, client_secret, require_secret: false)
|
267
|
-
# First check if client is registered via dynamic registration
|
268
|
-
client_info = get_client(client_id)
|
269
|
-
if client_info
|
270
|
-
# Validate client secret for confidential clients
|
271
|
-
if client_info[:client_secret]
|
272
|
-
unless client_secret == client_info[:client_secret]
|
273
|
-
raise InvalidClientError, "Invalid client credentials"
|
274
|
-
end
|
275
|
-
elsif require_secret
|
276
|
-
raise InvalidClientError, "Client authentication required"
|
277
|
-
end
|
278
|
-
return true
|
279
|
-
end
|
280
|
-
|
281
|
-
# Fall back to custom provider validation
|
282
|
-
provider_class = oauth_config[:provider]
|
283
|
-
if provider_class && provider_class.respond_to?(:validate_client)
|
284
|
-
provider_class.validate_client(client_id, client_secret)
|
285
|
-
elsif require_secret && client_secret.nil?
|
286
|
-
raise InvalidClientError, "Client authentication required"
|
287
|
-
else
|
288
|
-
# In development, allow unregistered clients if configured
|
289
|
-
if Rails.env.development? && oauth_config[:allow_unregistered_clients] != false
|
290
|
-
return true
|
291
|
-
end
|
292
|
-
raise InvalidClientError, "Unknown client"
|
293
|
-
end
|
294
|
-
end
|
295
|
-
|
296
|
-
def validate_pkce(code_challenge, method, code_verifier)
|
297
|
-
raise InvalidGrantError, "Code verifier required" unless code_verifier
|
298
|
-
|
299
|
-
case method
|
300
|
-
when "S256"
|
301
|
-
expected_challenge = Base64.urlsafe_encode64(
|
302
|
-
Digest::SHA256.digest(code_verifier), padding: false
|
303
|
-
)
|
304
|
-
unless code_challenge == expected_challenge
|
305
|
-
raise InvalidGrantError, "Invalid code verifier"
|
306
|
-
end
|
307
|
-
when "plain"
|
308
|
-
unless oauth_config[:allow_plain_pkce]
|
309
|
-
raise InvalidGrantError, "Plain PKCE not allowed"
|
310
|
-
end
|
311
|
-
unless code_challenge == code_verifier
|
312
|
-
raise InvalidGrantError, "Invalid code verifier"
|
313
|
-
end
|
314
|
-
else
|
315
|
-
raise InvalidGrantError, "Unsupported code challenge method"
|
316
|
-
end
|
317
|
-
end
|
318
|
-
|
319
|
-
def validate_scope(scope)
|
320
|
-
supported_scopes = oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
|
321
|
-
requested_scopes = scope.split(" ")
|
322
|
-
unsupported = requested_scopes - supported_scopes
|
323
|
-
if unsupported.any?
|
324
|
-
raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
|
325
|
-
end
|
326
|
-
end
|
327
|
-
|
328
|
-
def default_scope
|
329
|
-
oauth_config.fetch(:default_scope, "mcp:tools mcp:resources mcp:prompts")
|
330
|
-
end
|
331
|
-
|
332
|
-
def generate_access_token(client_id:, scope:, user_id:)
|
333
|
-
token = SecureRandom.urlsafe_base64(32)
|
334
|
-
|
335
|
-
store_access_token(token, {
|
336
|
-
client_id: client_id,
|
337
|
-
scope: scope,
|
338
|
-
user_id: user_id,
|
339
|
-
created_at: Time.current,
|
340
|
-
expires_at: token_expires_in.seconds.from_now
|
341
|
-
})
|
342
|
-
|
343
|
-
token
|
344
|
-
end
|
345
|
-
|
346
|
-
def generate_refresh_token(client_id:, scope:, user_id:, access_token:)
|
347
|
-
token = SecureRandom.urlsafe_base64(32)
|
348
|
-
|
349
|
-
store_refresh_token(token, {
|
350
|
-
client_id: client_id,
|
351
|
-
scope: scope,
|
352
|
-
user_id: user_id,
|
353
|
-
access_token: access_token,
|
354
|
-
created_at: Time.current,
|
355
|
-
expires_at: refresh_token_expires_in.seconds.from_now
|
356
|
-
})
|
357
|
-
|
358
|
-
token
|
359
|
-
end
|
360
|
-
|
361
|
-
def token_expires_in
|
362
|
-
oauth_config.fetch(:access_token_expires_in, 3600) # 1 hour
|
363
|
-
end
|
364
|
-
|
365
|
-
def refresh_token_expires_in
|
366
|
-
oauth_config.fetch(:refresh_token_expires_in, 7.days.to_i) # 1 week
|
367
|
-
end
|
368
|
-
|
369
|
-
# Storage methods - these delegate to a configurable storage backend
|
370
|
-
def storage
|
371
|
-
@storage ||= begin
|
372
|
-
# Default to ActiveRecord storage for production, memory for test
|
373
|
-
default_storage = Rails.env.test? ? "ActionMCP::OAuth::MemoryStorage" : "ActionMCP::OAuth::ActiveRecordStorage"
|
374
|
-
storage_class = oauth_config.fetch(:storage, default_storage)
|
375
|
-
storage_class = storage_class.constantize if storage_class.is_a?(String)
|
376
|
-
storage_class.new
|
377
|
-
end
|
378
|
-
end
|
379
|
-
|
380
|
-
def store_authorization_code(code, data)
|
381
|
-
storage.store_authorization_code(code, data)
|
382
|
-
end
|
383
|
-
|
384
|
-
def retrieve_authorization_code(code)
|
385
|
-
storage.retrieve_authorization_code(code)
|
386
|
-
end
|
387
|
-
|
388
|
-
def remove_authorization_code(code)
|
389
|
-
storage.remove_authorization_code(code)
|
390
|
-
end
|
391
|
-
|
392
|
-
def store_access_token(token, data)
|
393
|
-
storage.store_access_token(token, data)
|
394
|
-
end
|
395
|
-
|
396
|
-
def retrieve_access_token(token)
|
397
|
-
storage.retrieve_access_token(token)
|
398
|
-
end
|
399
|
-
|
400
|
-
def remove_access_token(token)
|
401
|
-
storage.remove_access_token(token)
|
402
|
-
end
|
403
|
-
|
404
|
-
def revoke_access_token(token)
|
405
|
-
storage.remove_access_token(token)
|
406
|
-
end
|
407
|
-
|
408
|
-
def store_refresh_token(token, data)
|
409
|
-
storage.store_refresh_token(token, data)
|
410
|
-
end
|
411
|
-
|
412
|
-
def retrieve_refresh_token(token)
|
413
|
-
storage.retrieve_refresh_token(token)
|
414
|
-
end
|
415
|
-
|
416
|
-
def update_refresh_token(token, new_access_token)
|
417
|
-
storage.update_refresh_token(token, new_access_token)
|
418
|
-
end
|
419
|
-
|
420
|
-
def revoke_refresh_token(token)
|
421
|
-
storage.remove_refresh_token(token)
|
422
|
-
end
|
423
|
-
end
|
424
|
-
end
|
425
|
-
end
|
426
|
-
end
|
data/lib/action_mcp/oauth.rb
DELETED
@@ -1,12 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module ActionMCP
|
4
|
-
module OAuth
|
5
|
-
# Load OAuth components
|
6
|
-
autoload :Error, "action_mcp/oauth/error"
|
7
|
-
autoload :Provider, "action_mcp/oauth/provider"
|
8
|
-
autoload :Middleware, "action_mcp/oauth/middleware"
|
9
|
-
autoload :MemoryStorage, "action_mcp/oauth/memory_storage"
|
10
|
-
autoload :ActiveRecordStorage, "action_mcp/oauth/active_record_storage"
|
11
|
-
end
|
12
|
-
end
|
@@ -1,176 +0,0 @@
|
|
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"
|
@@ -1,58 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module ActionMCP
|
4
|
-
module Server
|
5
|
-
module Notifications
|
6
|
-
# Notify client that the resources list has changed
|
7
|
-
def send_resources_list_changed_notification
|
8
|
-
send_jsonrpc_notification("notifications/resources/list_changed")
|
9
|
-
end
|
10
|
-
|
11
|
-
# Notify client that a specific resource has been updated
|
12
|
-
def send_resource_updated_notification(uri)
|
13
|
-
send_jsonrpc_notification("notifications/resources/updated", { uri: })
|
14
|
-
end
|
15
|
-
|
16
|
-
# Notify client that the tools list has changed
|
17
|
-
def send_tools_list_changed_notification
|
18
|
-
send_jsonrpc_notification("notifications/tools/list_changed")
|
19
|
-
end
|
20
|
-
|
21
|
-
# Notify client that the prompts list has changed
|
22
|
-
def send_prompts_list_changed_notification
|
23
|
-
send_jsonrpc_notification("notifications/prompts/list_changed")
|
24
|
-
end
|
25
|
-
|
26
|
-
# Send a logging message to the client
|
27
|
-
def send_logging_message_notification(level:, data:, logger: nil)
|
28
|
-
params = {
|
29
|
-
level: level,
|
30
|
-
data: data
|
31
|
-
}
|
32
|
-
params[:logger] = logger if logger.present?
|
33
|
-
|
34
|
-
send_jsonrpc_notification("notifications/logging/message", params)
|
35
|
-
end
|
36
|
-
|
37
|
-
# Updated to match MCP 2025-03-26 specification
|
38
|
-
def send_progress_notification(progressToken:, progress:, total: nil, message: nil, **options)
|
39
|
-
params = {
|
40
|
-
progressToken: progressToken,
|
41
|
-
progress: progress
|
42
|
-
}
|
43
|
-
# Only include total and message if they are present (not nil)
|
44
|
-
params[:total] = total unless total.nil?
|
45
|
-
params[:message] = message if message.present?
|
46
|
-
params.merge!(options) if options.any?
|
47
|
-
|
48
|
-
send_jsonrpc_notification("notifications/progress", params)
|
49
|
-
end
|
50
|
-
|
51
|
-
# Backward compatibility method for old API
|
52
|
-
def send_progress_notification_legacy(token:, value:, message: nil)
|
53
|
-
Rails.logger.warn("DEPRECATION: send_progress_notification with token/value is deprecated. Use progressToken/progress instead.")
|
54
|
-
send_progress_notification(progressToken: token, progress: value, message: message)
|
55
|
-
end
|
56
|
-
end
|
57
|
-
end
|
58
|
-
end
|