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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8f3d6323b7f8ce694050a031c566f30e33d637116e2869cda61fbbaad0702411
|
4
|
+
data.tar.gz: ef8bfccea1b3109146b3e490340a44c4a6071e08c6aed3bd72ddecd5fd95638a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2665d85224ce5b07a115936d1f1d159b70c8068a8a221e2f196bebd9564b9e753a73acd15b86241b04d277e70dc6dc5db818fecda505cfab9beb999d8483aecb
|
7
|
+
data.tar.gz: 56a568c5aca9693534a46d1013a9fdd8cb168a92e10b6c947dc02bb345f0c0a4f5b812edac990ddaa84c0ba589c500d47b7841f22b8542b1f36a120d2c8a1756
|
data/README.md
CHANGED
@@ -476,16 +476,21 @@ This ensures all thread pools are properly terminated and tasks are completed.
|
|
476
476
|
|
477
477
|
**ActionMCP** runs as a standalone Rack application. **Do not attempt to mount it in your application's `routes.rb`**—it is not designed to be mounted as an engine at a custom path. When you use `run ActionMCP::Engine` in your `mcp.ru`, the MCP endpoint is always available at the root path (`/`).
|
478
478
|
|
479
|
-
### Installing
|
479
|
+
### Installing ActionMCP
|
480
480
|
|
481
|
-
ActionMCP includes
|
481
|
+
ActionMCP includes generators to help you set up your project quickly. The install generator creates all necessary base classes and configuration files:
|
482
482
|
|
483
483
|
```bash
|
484
|
-
#
|
485
|
-
bin/rails generate action_mcp:
|
484
|
+
# Install ActionMCP with base classes and configuration
|
485
|
+
bin/rails generate action_mcp:install
|
486
486
|
```
|
487
487
|
|
488
|
-
This will create
|
488
|
+
This will create:
|
489
|
+
- `app/mcp/prompts/application_mcp_prompt.rb` - Base prompt class
|
490
|
+
- `app/mcp/tools/application_mcp_tool.rb` - Base tool class
|
491
|
+
- `app/mcp/resource_templates/application_mcp_res_template.rb` - Base resource template class
|
492
|
+
- `app/mcp/application_gateway.rb` - Gateway for authentication
|
493
|
+
- `config/mcp.yml` - Configuration file with example settings for all environments
|
489
494
|
|
490
495
|
> **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
|
491
496
|
|
@@ -493,6 +498,8 @@ This will create `config/mcp.yml` with example configurations for all environmen
|
|
493
498
|
|
494
499
|
ActionMCP provides a Gateway system similar to ActionCable's Connection for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components.
|
495
500
|
|
501
|
+
ActionMCP supports multiple authentication methods including OAuth 2.1, JWT tokens, and no authentication for development. For detailed OAuth 2.1 configuration and usage, see the [OAuth Authentication Guide](OAUTH.md).
|
502
|
+
|
496
503
|
### Creating an ApplicationGateway
|
497
504
|
|
498
505
|
When you run the install generator, it creates an `ApplicationGateway` class:
|
@@ -645,14 +652,14 @@ location /mcp/ {
|
|
645
652
|
|
646
653
|
ActionMCP includes Rails generators to help you quickly set up your MCP server components.
|
647
654
|
|
648
|
-
|
655
|
+
First, install ActionMCP to create base classes and configuration:
|
649
656
|
|
650
657
|
```bash
|
651
658
|
bin/rails action_mcp:install:migrations # to copy the migrations
|
652
659
|
bin/rails generate action_mcp:install
|
653
660
|
```
|
654
661
|
|
655
|
-
This will create the base application classes in your app directory.
|
662
|
+
This will create the base application classes, configuration file, and authentication gateway in your app directory.
|
656
663
|
|
657
664
|
### Generate a New Prompt
|
658
665
|
|
@@ -0,0 +1,264 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ostruct"
|
4
|
+
|
5
|
+
module ActionMCP
|
6
|
+
module OAuth
|
7
|
+
# OAuth 2.1 endpoints controller
|
8
|
+
# Handles authorization, token, introspection, and revocation endpoints
|
9
|
+
class EndpointsController < ActionController::Base
|
10
|
+
protect_from_forgery with: :null_session
|
11
|
+
before_action :check_oauth_enabled
|
12
|
+
|
13
|
+
# GET /oauth/authorize
|
14
|
+
# Authorization endpoint for OAuth 2.1 authorization code flow
|
15
|
+
def authorize
|
16
|
+
# Extract parameters
|
17
|
+
client_id = params[:client_id]
|
18
|
+
redirect_uri = params[:redirect_uri]
|
19
|
+
response_type = params[:response_type]
|
20
|
+
scope = params[:scope]
|
21
|
+
state = params[:state]
|
22
|
+
code_challenge = params[:code_challenge]
|
23
|
+
code_challenge_method = params[:code_challenge_method]
|
24
|
+
|
25
|
+
# Validate required parameters
|
26
|
+
if client_id.blank? || redirect_uri.blank? || response_type.blank?
|
27
|
+
return render_error("invalid_request", "Missing required parameters")
|
28
|
+
end
|
29
|
+
|
30
|
+
# Validate response type
|
31
|
+
unless response_type == "code"
|
32
|
+
return render_error("unsupported_response_type", "Only authorization code flow supported")
|
33
|
+
end
|
34
|
+
|
35
|
+
# Validate PKCE if required
|
36
|
+
if oauth_config["pkce_required"] && code_challenge.blank?
|
37
|
+
return render_error("invalid_request", "PKCE required")
|
38
|
+
end
|
39
|
+
|
40
|
+
# In a real implementation, this would show a consent page
|
41
|
+
# For now, we'll auto-approve for configured clients
|
42
|
+
if auto_approve_client?(client_id)
|
43
|
+
# Generate authorization code
|
44
|
+
user_id = current_user&.id || "anonymous"
|
45
|
+
|
46
|
+
begin
|
47
|
+
code = ActionMCP::OAuth::Provider.generate_authorization_code(
|
48
|
+
client_id: client_id,
|
49
|
+
redirect_uri: redirect_uri,
|
50
|
+
scope: scope || default_scope,
|
51
|
+
code_challenge: code_challenge,
|
52
|
+
code_challenge_method: code_challenge_method,
|
53
|
+
user_id: user_id
|
54
|
+
)
|
55
|
+
|
56
|
+
# Redirect back to client with authorization code
|
57
|
+
redirect_params = { code: code }
|
58
|
+
redirect_params[:state] = state if state
|
59
|
+
redirect_to "#{redirect_uri}?#{redirect_params.to_query}", allow_other_host: true
|
60
|
+
rescue ActionMCP::OAuth::Error => e
|
61
|
+
render_error(e.oauth_error_code, e.message)
|
62
|
+
end
|
63
|
+
else
|
64
|
+
# In production, show consent page
|
65
|
+
render_consent_page(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# POST /oauth/token
|
70
|
+
# Token endpoint for exchanging authorization codes and refreshing tokens
|
71
|
+
def token
|
72
|
+
grant_type = params[:grant_type]
|
73
|
+
|
74
|
+
case grant_type
|
75
|
+
when "authorization_code"
|
76
|
+
handle_authorization_code_grant
|
77
|
+
when "refresh_token"
|
78
|
+
handle_refresh_token_grant
|
79
|
+
when "client_credentials"
|
80
|
+
handle_client_credentials_grant
|
81
|
+
else
|
82
|
+
render_token_error("unsupported_grant_type", "Unsupported grant type")
|
83
|
+
end
|
84
|
+
rescue ActionMCP::OAuth::Error => e
|
85
|
+
render_token_error(e.oauth_error_code, e.message)
|
86
|
+
end
|
87
|
+
|
88
|
+
# POST /oauth/introspect
|
89
|
+
# Token introspection endpoint (RFC 7662)
|
90
|
+
def introspect
|
91
|
+
token = params[:token]
|
92
|
+
return render_introspection_error unless token
|
93
|
+
|
94
|
+
# Authenticate client for introspection
|
95
|
+
client_id, client_secret = extract_client_credentials
|
96
|
+
return render_introspection_error unless client_id
|
97
|
+
|
98
|
+
begin
|
99
|
+
token_info = ActionMCP::OAuth::Provider.introspect_token(token)
|
100
|
+
render json: token_info
|
101
|
+
rescue ActionMCP::OAuth::Error
|
102
|
+
render json: { active: false }
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# POST /oauth/revoke
|
107
|
+
# Token revocation endpoint (RFC 7009)
|
108
|
+
def revoke
|
109
|
+
token = params[:token]
|
110
|
+
token_type_hint = params[:token_type_hint]
|
111
|
+
|
112
|
+
return head :bad_request unless token
|
113
|
+
|
114
|
+
# Authenticate client
|
115
|
+
client_id, client_secret = extract_client_credentials
|
116
|
+
return head :unauthorized unless client_id
|
117
|
+
|
118
|
+
begin
|
119
|
+
ActionMCP::OAuth::Provider.revoke_token(token, token_type_hint: token_type_hint)
|
120
|
+
head :ok
|
121
|
+
rescue ActionMCP::OAuth::Error
|
122
|
+
head :bad_request
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def check_oauth_enabled
|
129
|
+
auth_methods = ActionMCP.configuration.authentication_methods
|
130
|
+
unless auth_methods&.include?("oauth")
|
131
|
+
head :not_found
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def oauth_config
|
136
|
+
@oauth_config ||= ActionMCP.configuration.oauth_config || {}
|
137
|
+
end
|
138
|
+
|
139
|
+
def handle_authorization_code_grant
|
140
|
+
code = params[:code]
|
141
|
+
client_id = params[:client_id]
|
142
|
+
client_secret = params[:client_secret]
|
143
|
+
redirect_uri = params[:redirect_uri]
|
144
|
+
code_verifier = params[:code_verifier]
|
145
|
+
|
146
|
+
# Extract client credentials from Authorization header if not in params
|
147
|
+
if client_id.blank?
|
148
|
+
client_id, client_secret = extract_client_credentials
|
149
|
+
end
|
150
|
+
|
151
|
+
return render_token_error("invalid_request", "Missing required parameters") if code.blank? || client_id.blank?
|
152
|
+
|
153
|
+
token_response = ActionMCP::OAuth::Provider.exchange_code_for_token(
|
154
|
+
code: code,
|
155
|
+
client_id: client_id,
|
156
|
+
client_secret: client_secret,
|
157
|
+
redirect_uri: redirect_uri,
|
158
|
+
code_verifier: code_verifier
|
159
|
+
)
|
160
|
+
|
161
|
+
render json: token_response
|
162
|
+
end
|
163
|
+
|
164
|
+
def handle_refresh_token_grant
|
165
|
+
refresh_token = params[:refresh_token]
|
166
|
+
scope = params[:scope]
|
167
|
+
|
168
|
+
# Extract client credentials
|
169
|
+
client_id, client_secret = extract_client_credentials
|
170
|
+
client_id ||= params[:client_id]
|
171
|
+
client_secret ||= params[:client_secret]
|
172
|
+
|
173
|
+
return render_token_error("invalid_request", "Missing required parameters") if refresh_token.blank? || client_id.blank?
|
174
|
+
|
175
|
+
token_response = ActionMCP::OAuth::Provider.refresh_access_token(
|
176
|
+
refresh_token: refresh_token,
|
177
|
+
client_id: client_id,
|
178
|
+
client_secret: client_secret,
|
179
|
+
scope: scope
|
180
|
+
)
|
181
|
+
|
182
|
+
render json: token_response
|
183
|
+
end
|
184
|
+
|
185
|
+
def handle_client_credentials_grant
|
186
|
+
scope = params[:scope]
|
187
|
+
|
188
|
+
# Extract client credentials
|
189
|
+
client_id, client_secret = extract_client_credentials
|
190
|
+
client_id ||= params[:client_id]
|
191
|
+
client_secret ||= params[:client_secret]
|
192
|
+
|
193
|
+
return render_token_error("invalid_request", "Missing client credentials") if client_id.blank?
|
194
|
+
|
195
|
+
token_response = ActionMCP::OAuth::Provider.client_credentials_grant(
|
196
|
+
client_id: client_id,
|
197
|
+
client_secret: client_secret,
|
198
|
+
scope: scope
|
199
|
+
)
|
200
|
+
|
201
|
+
render json: token_response
|
202
|
+
end
|
203
|
+
|
204
|
+
def extract_client_credentials
|
205
|
+
auth_header = request.headers["Authorization"]
|
206
|
+
if auth_header&.start_with?("Basic ")
|
207
|
+
encoded = auth_header.split(" ", 2).last
|
208
|
+
decoded = Base64.decode64(encoded)
|
209
|
+
decoded.split(":", 2)
|
210
|
+
else
|
211
|
+
[ nil, nil ]
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
def auto_approve_client?(client_id)
|
216
|
+
# In development/testing, auto-approve known clients
|
217
|
+
# In production, this should check a proper client registry
|
218
|
+
Rails.env.development? || Rails.env.test? || oauth_config["auto_approve_clients"]&.include?(client_id)
|
219
|
+
end
|
220
|
+
|
221
|
+
def current_user
|
222
|
+
# This should be implemented by the application
|
223
|
+
# For now, return a default user for development
|
224
|
+
if Rails.env.development? || Rails.env.test?
|
225
|
+
OpenStruct.new(id: "dev_user", email: "dev@example.com")
|
226
|
+
else
|
227
|
+
# In production, this should integrate with your authentication system
|
228
|
+
nil
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def default_scope
|
233
|
+
oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
|
234
|
+
end
|
235
|
+
|
236
|
+
def render_error(error_code, description)
|
237
|
+
render json: {
|
238
|
+
error: error_code,
|
239
|
+
error_description: description
|
240
|
+
}, status: :bad_request
|
241
|
+
end
|
242
|
+
|
243
|
+
def render_token_error(error_code, description)
|
244
|
+
render json: {
|
245
|
+
error: error_code,
|
246
|
+
error_description: description
|
247
|
+
}, status: :bad_request
|
248
|
+
end
|
249
|
+
|
250
|
+
def render_introspection_error
|
251
|
+
render json: { active: false }, status: :bad_request
|
252
|
+
end
|
253
|
+
|
254
|
+
def render_consent_page(client_id, redirect_uri, scope, state, code_challenge, code_challenge_method)
|
255
|
+
# In production, this would render a proper consent page
|
256
|
+
# For now, just auto-deny unknown clients
|
257
|
+
render json: {
|
258
|
+
error: "access_denied",
|
259
|
+
error_description: "User denied authorization"
|
260
|
+
}, status: :forbidden
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# Controller for OAuth 2.1 metadata endpoints
|
6
|
+
# Provides server discovery information as per RFC 8414
|
7
|
+
class MetadataController < ActionController::Base
|
8
|
+
before_action :check_oauth_enabled
|
9
|
+
|
10
|
+
# GET /.well-known/oauth-authorization-server
|
11
|
+
# Returns OAuth Authorization Server Metadata as per RFC 8414
|
12
|
+
def authorization_server
|
13
|
+
metadata = {
|
14
|
+
issuer: issuer_url,
|
15
|
+
authorization_endpoint: authorization_endpoint,
|
16
|
+
token_endpoint: token_endpoint,
|
17
|
+
introspection_endpoint: introspection_endpoint,
|
18
|
+
revocation_endpoint: revocation_endpoint,
|
19
|
+
response_types_supported: response_types_supported,
|
20
|
+
grant_types_supported: grant_types_supported,
|
21
|
+
token_endpoint_auth_methods_supported: token_endpoint_auth_methods_supported,
|
22
|
+
scopes_supported: scopes_supported,
|
23
|
+
code_challenge_methods_supported: code_challenge_methods_supported,
|
24
|
+
service_documentation: service_documentation
|
25
|
+
}
|
26
|
+
|
27
|
+
# Add optional fields based on configuration
|
28
|
+
if oauth_config["enable_dynamic_registration"]
|
29
|
+
metadata[:registration_endpoint] = registration_endpoint
|
30
|
+
end
|
31
|
+
|
32
|
+
if oauth_config["jwks_uri"]
|
33
|
+
metadata[:jwks_uri] = oauth_config["jwks_uri"]
|
34
|
+
end
|
35
|
+
|
36
|
+
render json: metadata
|
37
|
+
end
|
38
|
+
|
39
|
+
# GET /.well-known/oauth-protected-resource
|
40
|
+
# Returns Protected Resource Metadata as per RFC 8705
|
41
|
+
def protected_resource
|
42
|
+
metadata = {
|
43
|
+
resource: issuer_url,
|
44
|
+
authorization_servers: [ issuer_url ],
|
45
|
+
scopes_supported: scopes_supported,
|
46
|
+
bearer_methods_supported: [ "header" ],
|
47
|
+
resource_documentation: resource_documentation
|
48
|
+
}
|
49
|
+
|
50
|
+
render json: metadata
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def check_oauth_enabled
|
56
|
+
auth_methods = ActionMCP.configuration.authentication_methods
|
57
|
+
unless auth_methods&.include?("oauth")
|
58
|
+
head :not_found
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def oauth_config
|
63
|
+
@oauth_config ||= ActionMCP.configuration.oauth_config || {}
|
64
|
+
end
|
65
|
+
|
66
|
+
def issuer_url
|
67
|
+
@issuer_url ||= oauth_config["issuer_url"] || request.base_url
|
68
|
+
end
|
69
|
+
|
70
|
+
def authorization_endpoint
|
71
|
+
"#{issuer_url}/oauth/authorize"
|
72
|
+
end
|
73
|
+
|
74
|
+
def token_endpoint
|
75
|
+
"#{issuer_url}/oauth/token"
|
76
|
+
end
|
77
|
+
|
78
|
+
def introspection_endpoint
|
79
|
+
"#{issuer_url}/oauth/introspect"
|
80
|
+
end
|
81
|
+
|
82
|
+
def revocation_endpoint
|
83
|
+
"#{issuer_url}/oauth/revoke"
|
84
|
+
end
|
85
|
+
|
86
|
+
def registration_endpoint
|
87
|
+
"#{issuer_url}/oauth/register"
|
88
|
+
end
|
89
|
+
|
90
|
+
def response_types_supported
|
91
|
+
[ "code" ]
|
92
|
+
end
|
93
|
+
|
94
|
+
def grant_types_supported
|
95
|
+
grants = [ "authorization_code" ]
|
96
|
+
grants << "refresh_token" if oauth_config["enable_refresh_tokens"]
|
97
|
+
grants << "client_credentials" if oauth_config["enable_client_credentials"]
|
98
|
+
grants
|
99
|
+
end
|
100
|
+
|
101
|
+
def token_endpoint_auth_methods_supported
|
102
|
+
methods = [ "client_secret_basic", "client_secret_post" ]
|
103
|
+
methods << "none" if oauth_config["allow_public_clients"]
|
104
|
+
methods
|
105
|
+
end
|
106
|
+
|
107
|
+
def scopes_supported
|
108
|
+
oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
|
109
|
+
end
|
110
|
+
|
111
|
+
def code_challenge_methods_supported
|
112
|
+
methods = []
|
113
|
+
if oauth_config["pkce_required"] || oauth_config["pkce_supported"]
|
114
|
+
methods << "S256"
|
115
|
+
methods << "plain" if oauth_config["allow_plain_pkce"]
|
116
|
+
end
|
117
|
+
methods
|
118
|
+
end
|
119
|
+
|
120
|
+
def service_documentation
|
121
|
+
oauth_config["service_documentation"] || "#{request.base_url}/docs"
|
122
|
+
end
|
123
|
+
|
124
|
+
def resource_documentation
|
125
|
+
oauth_config["resource_documentation"] || "#{request.base_url}/docs/api"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -4,17 +4,17 @@
|
|
4
4
|
#
|
5
5
|
# Table name: action_mcp_session_messages
|
6
6
|
#
|
7
|
-
# id
|
8
|
-
# direction
|
9
|
-
# is_ping
|
10
|
-
# message_json
|
11
|
-
# message_type
|
12
|
-
# request_acknowledged
|
13
|
-
# request_cancelled
|
14
|
-
# created_at
|
15
|
-
# updated_at
|
16
|
-
# jsonrpc_id
|
17
|
-
# session_id
|
7
|
+
# id :bigint not null, primary key
|
8
|
+
# direction :string default("client"), not null
|
9
|
+
# is_ping :boolean default(FALSE), not null
|
10
|
+
# message_json :json
|
11
|
+
# message_type :string not null
|
12
|
+
# request_acknowledged :boolean default(FALSE), not null
|
13
|
+
# request_cancelled :boolean default(FALSE), not null
|
14
|
+
# created_at :datetime not null
|
15
|
+
# updated_at :datetime not null
|
16
|
+
# jsonrpc_id :string
|
17
|
+
# session_id :string not null
|
18
18
|
#
|
19
19
|
# Indexes
|
20
20
|
#
|
@@ -22,7 +22,7 @@
|
|
22
22
|
#
|
23
23
|
# Foreign Keys
|
24
24
|
#
|
25
|
-
#
|
25
|
+
# fk_rails_... (session_id => action_mcp_sessions.id) ON DELETE => cascade ON UPDATE => cascade
|
26
26
|
#
|
27
27
|
module ActionMCP
|
28
28
|
class Session
|
@@ -4,23 +4,34 @@
|
|
4
4
|
#
|
5
5
|
# Table name: action_mcp_sessions
|
6
6
|
#
|
7
|
-
# id
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
18
|
-
#
|
19
|
-
#
|
20
|
-
#
|
21
|
-
#
|
22
|
-
#
|
23
|
-
#
|
7
|
+
# id :string not null, primary key
|
8
|
+
# authentication_method :string default("none")
|
9
|
+
# client_capabilities :json
|
10
|
+
# client_info :json
|
11
|
+
# ended_at :datetime
|
12
|
+
# initialized :boolean default(FALSE), not null
|
13
|
+
# messages_count :integer default(0), not null
|
14
|
+
# oauth_access_token :string
|
15
|
+
# oauth_refresh_token :string
|
16
|
+
# oauth_token_expires_at :datetime
|
17
|
+
# oauth_user_context :json
|
18
|
+
# prompt_registry :json
|
19
|
+
# protocol_version :string
|
20
|
+
# resource_registry :json
|
21
|
+
# role :string default("server"), not null
|
22
|
+
# server_capabilities :json
|
23
|
+
# server_info :json
|
24
|
+
# sse_event_counter :integer default(0), not null
|
25
|
+
# status :string default("pre_initialize"), not null
|
26
|
+
# tool_registry :json
|
27
|
+
# created_at :datetime not null
|
28
|
+
# updated_at :datetime not null
|
29
|
+
#
|
30
|
+
# Indexes
|
31
|
+
#
|
32
|
+
# index_action_mcp_sessions_on_authentication_method (authentication_method)
|
33
|
+
# index_action_mcp_sessions_on_oauth_access_token (oauth_access_token) UNIQUE
|
34
|
+
# index_action_mcp_sessions_on_oauth_token_expires_at (oauth_token_expires_at)
|
24
35
|
#
|
25
36
|
module ActionMCP
|
26
37
|
##
|
@@ -323,6 +334,94 @@ module ActionMCP
|
|
323
334
|
end
|
324
335
|
end
|
325
336
|
|
337
|
+
# OAuth Session Management
|
338
|
+
# Required by MCP 2025-03-26 specification for session binding
|
339
|
+
|
340
|
+
# Store OAuth token and user context in session
|
341
|
+
def store_oauth_token(access_token:, refresh_token: nil, expires_at:, user_context: {})
|
342
|
+
update!(
|
343
|
+
oauth_access_token: access_token,
|
344
|
+
oauth_refresh_token: refresh_token,
|
345
|
+
oauth_token_expires_at: expires_at,
|
346
|
+
oauth_user_context: user_context,
|
347
|
+
authentication_method: "oauth"
|
348
|
+
)
|
349
|
+
end
|
350
|
+
|
351
|
+
# Retrieve OAuth token information
|
352
|
+
def oauth_token_info
|
353
|
+
return nil unless oauth_access_token
|
354
|
+
|
355
|
+
{
|
356
|
+
access_token: oauth_access_token,
|
357
|
+
refresh_token: oauth_refresh_token,
|
358
|
+
expires_at: oauth_token_expires_at,
|
359
|
+
user_context: oauth_user_context || {},
|
360
|
+
authentication_method: authentication_method
|
361
|
+
}
|
362
|
+
end
|
363
|
+
|
364
|
+
# Check if OAuth token is valid and not expired
|
365
|
+
def oauth_token_valid?
|
366
|
+
return false unless oauth_access_token
|
367
|
+
return true unless oauth_token_expires_at
|
368
|
+
|
369
|
+
oauth_token_expires_at > Time.current
|
370
|
+
end
|
371
|
+
|
372
|
+
# Clear OAuth token data
|
373
|
+
def clear_oauth_token!
|
374
|
+
update!(
|
375
|
+
oauth_access_token: nil,
|
376
|
+
oauth_refresh_token: nil,
|
377
|
+
oauth_token_expires_at: nil,
|
378
|
+
oauth_user_context: nil,
|
379
|
+
authentication_method: "none"
|
380
|
+
)
|
381
|
+
end
|
382
|
+
|
383
|
+
# Update OAuth token (for refresh flow)
|
384
|
+
def update_oauth_token(access_token:, refresh_token: nil, expires_at:)
|
385
|
+
update!(
|
386
|
+
oauth_access_token: access_token,
|
387
|
+
oauth_refresh_token: refresh_token,
|
388
|
+
oauth_token_expires_at: expires_at
|
389
|
+
)
|
390
|
+
end
|
391
|
+
|
392
|
+
# Get user information from OAuth context
|
393
|
+
def oauth_user
|
394
|
+
return nil unless oauth_user_context.is_a?(Hash)
|
395
|
+
|
396
|
+
OpenStruct.new(oauth_user_context)
|
397
|
+
end
|
398
|
+
|
399
|
+
# Check if session is authenticated via OAuth
|
400
|
+
def oauth_authenticated?
|
401
|
+
authentication_method == "oauth" && oauth_token_valid?
|
402
|
+
end
|
403
|
+
|
404
|
+
# Find session by OAuth access token (class method)
|
405
|
+
def self.find_by_oauth_token(access_token)
|
406
|
+
find_by(oauth_access_token: access_token)
|
407
|
+
end
|
408
|
+
|
409
|
+
# Find sessions with expired OAuth tokens (class method)
|
410
|
+
def self.with_expired_oauth_tokens
|
411
|
+
where("oauth_token_expires_at IS NOT NULL AND oauth_token_expires_at < ?", Time.current)
|
412
|
+
end
|
413
|
+
|
414
|
+
# Cleanup expired OAuth tokens (class method)
|
415
|
+
def self.cleanup_expired_oauth_tokens
|
416
|
+
with_expired_oauth_tokens.update_all(
|
417
|
+
oauth_access_token: nil,
|
418
|
+
oauth_refresh_token: nil,
|
419
|
+
oauth_token_expires_at: nil,
|
420
|
+
oauth_user_context: nil,
|
421
|
+
authentication_method: "none"
|
422
|
+
)
|
423
|
+
end
|
424
|
+
|
326
425
|
private
|
327
426
|
|
328
427
|
# if this session is from a server, the writer is the client
|
data/config/routes.rb
CHANGED
@@ -3,6 +3,16 @@
|
|
3
3
|
ActionMCP::Engine.routes.draw do
|
4
4
|
get "/up", to: "/rails/health#show", as: :action_mcp_health_check
|
5
5
|
|
6
|
+
# OAuth 2.1 metadata endpoints
|
7
|
+
get "/.well-known/oauth-authorization-server", to: "oauth/metadata#authorization_server", as: :oauth_authorization_server_metadata
|
8
|
+
get "/.well-known/oauth-protected-resource", to: "oauth/metadata#protected_resource", as: :oauth_protected_resource_metadata
|
9
|
+
|
10
|
+
# OAuth 2.1 endpoints
|
11
|
+
get "/oauth/authorize", to: "oauth/endpoints#authorize", as: :oauth_authorize
|
12
|
+
post "/oauth/token", to: "oauth/endpoints#token", as: :oauth_token
|
13
|
+
post "/oauth/introspect", to: "oauth/endpoints#introspect", as: :oauth_introspect
|
14
|
+
post "/oauth/revoke", to: "oauth/endpoints#revoke", as: :oauth_revoke
|
15
|
+
|
6
16
|
# MCP 2025-03-26 Spec routes
|
7
17
|
get "/", to: "application#show", as: :mcp_get
|
8
18
|
post "/", to: "application#create", as: :mcp_post
|