actionmcp 0.60.2 → 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 -59
- data/app/controllers/action_mcp/application_controller.rb +95 -28
- 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 +68 -43
- 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/capability.rb +2 -0
- data/lib/action_mcp/client/json_rpc_handler.rb +9 -9
- data/lib/action_mcp/client/jwt_client_provider.rb +134 -0
- data/lib/action_mcp/configuration.rb +90 -11
- data/lib/action_mcp/engine.rb +26 -1
- 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/prompt.rb +14 -0
- data/lib/action_mcp/registry_base.rb +25 -4
- data/lib/action_mcp/resource_response.rb +110 -0
- data/lib/action_mcp/resource_template.rb +30 -2
- data/lib/action_mcp/server/capabilities.rb +3 -14
- data/lib/action_mcp/server/memory_session.rb +0 -1
- data/lib/action_mcp/server/prompts.rb +8 -1
- data/lib/action_mcp/server/resources.rb +9 -6
- data/lib/action_mcp/server/tools.rb +41 -20
- data/lib/action_mcp/server.rb +6 -3
- data/lib/action_mcp/sse_listener.rb +0 -7
- data/lib/action_mcp/test_helper.rb +5 -0
- data/lib/action_mcp/tool.rb +108 -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
- data/lib/tasks/action_mcp_tasks.rake +238 -0
- metadata +11 -1
@@ -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"]
|
@@ -79,7 +79,7 @@ module ActionMCP
|
|
79
79
|
|
80
80
|
# Generate refresh token if enabled
|
81
81
|
refresh_token = nil
|
82
|
-
if oauth_config[
|
82
|
+
if oauth_config[:enable_refresh_tokens]
|
83
83
|
refresh_token = generate_refresh_token(
|
84
84
|
client_id: client_id,
|
85
85
|
scope: code_data[:scope],
|
@@ -206,13 +206,29 @@ module ActionMCP
|
|
206
206
|
revoked
|
207
207
|
end
|
208
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
|
+
|
209
225
|
# Client Credentials Grant (for server-to-server)
|
210
226
|
# @param client_id [String] OAuth client identifier
|
211
227
|
# @param client_secret [String] OAuth client secret
|
212
228
|
# @param scope [String] Requested scope
|
213
229
|
# @return [Hash] Token response
|
214
230
|
def client_credentials_grant(client_id:, client_secret:, scope: nil)
|
215
|
-
unless oauth_config[
|
231
|
+
unless oauth_config[:enable_client_credentials]
|
216
232
|
raise UnsupportedGrantTypeError, "Client credentials grant not supported"
|
217
233
|
end
|
218
234
|
|
@@ -244,19 +260,37 @@ module ActionMCP
|
|
244
260
|
private
|
245
261
|
|
246
262
|
def oauth_config
|
247
|
-
@oauth_config ||= ActionMCP.configuration.oauth_config || {}
|
263
|
+
@oauth_config ||= HashWithIndifferentAccess.new(ActionMCP.configuration.oauth_config || {})
|
248
264
|
end
|
249
265
|
|
250
266
|
def validate_client(client_id, client_secret, require_secret: false)
|
251
|
-
#
|
252
|
-
|
253
|
-
|
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]
|
254
283
|
if provider_class && provider_class.respond_to?(:validate_client)
|
255
284
|
provider_class.validate_client(client_id, client_secret)
|
256
285
|
elsif require_secret && client_secret.nil?
|
257
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"
|
258
293
|
end
|
259
|
-
# Default: allow any client for development
|
260
294
|
end
|
261
295
|
|
262
296
|
def validate_pkce(code_challenge, method, code_verifier)
|
@@ -271,7 +305,7 @@ module ActionMCP
|
|
271
305
|
raise InvalidGrantError, "Invalid code verifier"
|
272
306
|
end
|
273
307
|
when "plain"
|
274
|
-
unless oauth_config[
|
308
|
+
unless oauth_config[:allow_plain_pkce]
|
275
309
|
raise InvalidGrantError, "Plain PKCE not allowed"
|
276
310
|
end
|
277
311
|
unless code_challenge == code_verifier
|
@@ -283,7 +317,7 @@ module ActionMCP
|
|
283
317
|
end
|
284
318
|
|
285
319
|
def validate_scope(scope)
|
286
|
-
supported_scopes = oauth_config
|
320
|
+
supported_scopes = oauth_config.fetch(:scopes_supported, [ "mcp:tools", "mcp:resources", "mcp:prompts" ])
|
287
321
|
requested_scopes = scope.split(" ")
|
288
322
|
unsupported = requested_scopes - supported_scopes
|
289
323
|
if unsupported.any?
|
@@ -292,7 +326,7 @@ module ActionMCP
|
|
292
326
|
end
|
293
327
|
|
294
328
|
def default_scope
|
295
|
-
oauth_config
|
329
|
+
oauth_config.fetch(:default_scope, "mcp:tools mcp:resources mcp:prompts")
|
296
330
|
end
|
297
331
|
|
298
332
|
def generate_access_token(client_id:, scope:, user_id:)
|
@@ -325,17 +359,19 @@ module ActionMCP
|
|
325
359
|
end
|
326
360
|
|
327
361
|
def token_expires_in
|
328
|
-
oauth_config
|
362
|
+
oauth_config.fetch(:access_token_expires_in, 3600) # 1 hour
|
329
363
|
end
|
330
364
|
|
331
365
|
def refresh_token_expires_in
|
332
|
-
oauth_config
|
366
|
+
oauth_config.fetch(:refresh_token_expires_in, 7.days.to_i) # 1 week
|
333
367
|
end
|
334
368
|
|
335
369
|
# Storage methods - these delegate to a configurable storage backend
|
336
370
|
def storage
|
337
371
|
@storage ||= begin
|
338
|
-
|
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)
|
339
375
|
storage_class = storage_class.constantize if storage_class.is_a?(String)
|
340
376
|
storage_class.new
|
341
377
|
end
|
@@ -0,0 +1,12 @@
|
|
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
|
data/lib/action_mcp/prompt.rb
CHANGED
@@ -27,6 +27,7 @@ module ActionMCP
|
|
27
27
|
#
|
28
28
|
# @return [String] The default prompt name.
|
29
29
|
def self.default_prompt_name
|
30
|
+
return "" if name.nil?
|
30
31
|
name.demodulize.underscore.sub(/_prompt$/, "")
|
31
32
|
end
|
32
33
|
|
@@ -37,6 +38,19 @@ module ActionMCP
|
|
37
38
|
:prompt
|
38
39
|
end
|
39
40
|
|
41
|
+
def unregister_from_registry
|
42
|
+
ActionMCP::PromptsRegistry.unregister(self) if ActionMCP::PromptsRegistry.items.values.include?(self)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Hook called when a class inherits from Prompt
|
46
|
+
def inherited(subclass)
|
47
|
+
super
|
48
|
+
# Run the ActiveSupport load hook when a prompt is defined
|
49
|
+
subclass.class_eval do
|
50
|
+
ActiveSupport.run_load_hooks(:action_mcp_prompt, subclass)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
40
54
|
# Sets or retrieves the _meta field
|
41
55
|
def meta(data = nil)
|
42
56
|
if data
|
@@ -11,11 +11,25 @@ module ActionMCP
|
|
11
11
|
#
|
12
12
|
# @return [Hash] A hash of registered items.
|
13
13
|
def items
|
14
|
-
@items
|
15
|
-
|
14
|
+
@items ||= {}
|
15
|
+
end
|
16
|
+
|
17
|
+
# Register an item explicitly
|
18
|
+
#
|
19
|
+
# @param klass [Class] The class to register
|
20
|
+
# @return [void]
|
21
|
+
def register(klass)
|
22
|
+
return if klass.abstract?
|
23
|
+
|
24
|
+
items[klass.capability_name] = klass
|
25
|
+
end
|
16
26
|
|
17
|
-
|
18
|
-
|
27
|
+
# Unregister an item
|
28
|
+
#
|
29
|
+
# @param klass [Class] The class to unregister
|
30
|
+
# @return [void]
|
31
|
+
def unregister(klass)
|
32
|
+
items.delete(klass.capability_name)
|
19
33
|
end
|
20
34
|
|
21
35
|
# Retrieve an item by name.
|
@@ -44,6 +58,13 @@ module ActionMCP
|
|
44
58
|
RegistryScope.new(items)
|
45
59
|
end
|
46
60
|
|
61
|
+
# Clears the registry cache.
|
62
|
+
#
|
63
|
+
# @return [void]
|
64
|
+
def clear!
|
65
|
+
@items = nil
|
66
|
+
end
|
67
|
+
|
47
68
|
private
|
48
69
|
|
49
70
|
# Helper to determine if an item is abstract.
|
@@ -0,0 +1,110 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
# Manages resource resolution results and errors for ResourceTemplate operations.
|
5
|
+
# Unlike ToolResponse, ResourceResponse only uses JSON-RPC protocol errors per MCP spec.
|
6
|
+
class ResourceResponse < BaseResponse
|
7
|
+
attr_reader :contents
|
8
|
+
|
9
|
+
delegate :empty?, :size, :each, :find, :map, to: :contents
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
super
|
13
|
+
@contents = []
|
14
|
+
end
|
15
|
+
|
16
|
+
# Add a resource content item to the response
|
17
|
+
def add_content(content)
|
18
|
+
@contents << content
|
19
|
+
content # Return the content for chaining
|
20
|
+
end
|
21
|
+
|
22
|
+
# Add multiple content items
|
23
|
+
def add_contents(contents_array)
|
24
|
+
@contents.concat(contents_array)
|
25
|
+
self
|
26
|
+
end
|
27
|
+
|
28
|
+
# Mark as error with ResourceTemplate-specific error types
|
29
|
+
def mark_as_template_not_found!(uri)
|
30
|
+
mark_as_error!(
|
31
|
+
:invalid_params,
|
32
|
+
message: "Resource template not found for URI: #{uri}",
|
33
|
+
data: { uri: uri, error_type: "TEMPLATE_NOT_FOUND" }
|
34
|
+
)
|
35
|
+
end
|
36
|
+
|
37
|
+
def mark_as_parameter_validation_failed!(missing_params, uri)
|
38
|
+
mark_as_error!(
|
39
|
+
:invalid_params,
|
40
|
+
message: "Required parameters missing: #{missing_params.join(', ')}",
|
41
|
+
data: {
|
42
|
+
uri: uri,
|
43
|
+
missing_parameters: missing_params,
|
44
|
+
error_type: "PARAMETER_VALIDATION_FAILED"
|
45
|
+
}
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
def mark_as_resolution_failed!(uri, reason = nil)
|
50
|
+
message = "Resource resolution failed for URI: #{uri}"
|
51
|
+
message += " - #{reason}" if reason
|
52
|
+
|
53
|
+
mark_as_error!(
|
54
|
+
:internal_error,
|
55
|
+
message: message,
|
56
|
+
data: {
|
57
|
+
uri: uri,
|
58
|
+
reason: reason,
|
59
|
+
error_type: "RESOLUTION_FAILED"
|
60
|
+
}
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
def mark_as_callback_aborted!(uri)
|
65
|
+
mark_as_error!(
|
66
|
+
:internal_error,
|
67
|
+
message: "Resource resolution was aborted by callback chain",
|
68
|
+
data: {
|
69
|
+
uri: uri,
|
70
|
+
error_type: "CALLBACK_ABORTED"
|
71
|
+
}
|
72
|
+
)
|
73
|
+
end
|
74
|
+
|
75
|
+
def mark_as_not_found!(uri)
|
76
|
+
# Use method_not_found for resource not found (closest standard JSON-RPC error)
|
77
|
+
mark_as_error!(
|
78
|
+
:method_not_found, # -32601 - closest standard error for "not found"
|
79
|
+
message: "Resource not found",
|
80
|
+
data: { uri: uri }
|
81
|
+
)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Implementation of build_success_hash for ResourceResponse
|
85
|
+
def build_success_hash
|
86
|
+
{
|
87
|
+
contents: @contents.map(&:to_h)
|
88
|
+
}
|
89
|
+
end
|
90
|
+
|
91
|
+
# Implementation of compare_with_same_class for ResourceResponse
|
92
|
+
def compare_with_same_class(other)
|
93
|
+
contents == other.contents && is_error == other.is_error
|
94
|
+
end
|
95
|
+
|
96
|
+
# Implementation of hash_components for ResourceResponse
|
97
|
+
def hash_components
|
98
|
+
[ contents, is_error ]
|
99
|
+
end
|
100
|
+
|
101
|
+
# Pretty print for better debugging
|
102
|
+
def inspect
|
103
|
+
if is_error
|
104
|
+
"#<#{self.class.name} error: #{@error_message}>"
|
105
|
+
else
|
106
|
+
"#<#{self.class.name} contents: #{contents.size} items>"
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|