actionmcp 0.51.0 → 0.52.1
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 +192 -0
- data/app/controllers/action_mcp/application_controller.rb +12 -6
- data/lib/action_mcp/client/active_record_session_store.rb +57 -0
- data/lib/action_mcp/client/session_store.rb +2 -103
- data/lib/action_mcp/client/session_store_factory.rb +36 -0
- data/lib/action_mcp/client/test_session_store.rb +84 -0
- data/lib/action_mcp/client/volatile_session_store.rb +38 -0
- data/lib/action_mcp/configuration.rb +16 -1
- data/lib/action_mcp/current.rb +19 -0
- data/lib/action_mcp/current_helpers.rb +19 -0
- data/lib/action_mcp/gateway.rb +85 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
- data/lib/action_mcp/jwt_decoder.rb +26 -0
- data/lib/action_mcp/prompt.rb +1 -0
- data/lib/action_mcp/resource_template.rb +1 -0
- data/lib/action_mcp/server/base_messaging.rb +14 -0
- data/lib/action_mcp/server/error_aware.rb +8 -1
- data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
- data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
- data/lib/action_mcp/server/messaging.rb +12 -1
- data/lib/action_mcp/server/registry_management.rb +0 -1
- data/lib/action_mcp/server/response_collector.rb +40 -0
- data/lib/action_mcp/server/session_store.rb +762 -0
- data/lib/action_mcp/server/tools.rb +14 -3
- data/lib/action_mcp/server/transport_handler.rb +9 -5
- data/lib/action_mcp/server.rb +7 -0
- data/lib/action_mcp/tagged_stream_logging.rb +0 -4
- data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
- data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
- data/lib/action_mcp/test_helper.rb +4 -0
- data/lib/action_mcp/tool.rb +1 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +0 -1
- data/lib/generators/action_mcp/install/install_generator.rb +4 -0
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
- metadata +29 -1
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class UnauthorizedError < StandardError; end
|
5
|
+
|
6
|
+
class Gateway
|
7
|
+
class << self
|
8
|
+
def identified_by(*attrs)
|
9
|
+
@identifiers ||= []
|
10
|
+
@identifiers.concat(attrs.map(&:to_sym)).uniq!
|
11
|
+
attr_accessor(*attrs)
|
12
|
+
end
|
13
|
+
|
14
|
+
def identifiers
|
15
|
+
@identifiers ||= []
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
identified_by :user
|
20
|
+
|
21
|
+
attr_reader :request
|
22
|
+
|
23
|
+
def call(request)
|
24
|
+
@request = request
|
25
|
+
connect
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def connect
|
30
|
+
identities = authenticate!
|
31
|
+
reject_unauthorized_connection unless identities.is_a?(Hash)
|
32
|
+
|
33
|
+
# Assign all identities (e.g., :user, :account)
|
34
|
+
self.class.identifiers.each do |id|
|
35
|
+
value = identities[id]
|
36
|
+
reject_unauthorized_connection unless value
|
37
|
+
|
38
|
+
public_send("#{id}=", value)
|
39
|
+
|
40
|
+
# Set to ActionMCP::Current
|
41
|
+
ActionMCP::Current.public_send("#{id}=", value)
|
42
|
+
end
|
43
|
+
|
44
|
+
# Also set the gateway instance itself
|
45
|
+
ActionMCP::Current.gateway = self
|
46
|
+
end
|
47
|
+
|
48
|
+
|
49
|
+
protected
|
50
|
+
|
51
|
+
def authenticate!
|
52
|
+
token = extract_bearer_token
|
53
|
+
raise UnauthorizedError, "Missing token" unless token
|
54
|
+
|
55
|
+
payload = ActionMCP::JwtDecoder.decode(token)
|
56
|
+
resolve_user(payload)
|
57
|
+
rescue ActionMCP::JwtDecoder::DecodeError => e
|
58
|
+
raise UnauthorizedError, e.message
|
59
|
+
end
|
60
|
+
|
61
|
+
def extract_bearer_token
|
62
|
+
header = request.headers["Authorization"] || request.headers["authorization"]
|
63
|
+
return nil unless header&.start_with?("Bearer ")
|
64
|
+
header.split(" ", 2).last
|
65
|
+
end
|
66
|
+
|
67
|
+
def resolve_user(payload)
|
68
|
+
return nil unless payload.is_a?(Hash)
|
69
|
+
user_id = payload["user_id"] || payload["sub"]
|
70
|
+
return nil unless user_id
|
71
|
+
user = User.find_by(id: user_id)
|
72
|
+
return nil unless user
|
73
|
+
|
74
|
+
# Return a hash with all identified_by attributes
|
75
|
+
self.class.identifiers.each_with_object({}) do |identifier, hash|
|
76
|
+
hash[identifier] = user if identifier == :user
|
77
|
+
# Add support for other identifiers as needed
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def reject_unauthorized_connection
|
82
|
+
raise UnauthorizedError, "Unauthorized"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -51,13 +51,18 @@ module ActionMCP
|
|
51
51
|
# @param rpc_method [String]
|
52
52
|
# @param id [String, Integer]
|
53
53
|
# @param params [Hash]
|
54
|
-
# @return [
|
54
|
+
# @return [JSON_RPC::Response, nil] Response if handled, nil otherwise
|
55
55
|
def handle_common_methods(rpc_method, id, params)
|
56
56
|
case rpc_method
|
57
57
|
when Methods::PING
|
58
58
|
transport.send_pong(id)
|
59
|
+
# In return mode, get the response that was just created
|
60
|
+
transport.messaging_mode == :return ? transport.get_last_response : true
|
59
61
|
when %r{^notifications/}
|
60
62
|
process_notifications(rpc_method, params)
|
63
|
+
true
|
64
|
+
else
|
65
|
+
nil
|
61
66
|
end
|
62
67
|
end
|
63
68
|
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require "jwt"
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class JwtDecoder
|
5
|
+
class DecodeError < StandardError; end
|
6
|
+
|
7
|
+
# Configurable defaults
|
8
|
+
class << self
|
9
|
+
attr_accessor :secret, :algorithm
|
10
|
+
|
11
|
+
def decode(token)
|
12
|
+
payload, _header = JWT.decode(token, secret, true, { algorithm: algorithm })
|
13
|
+
payload
|
14
|
+
rescue JWT::ExpiredSignature
|
15
|
+
raise DecodeError, "Token has expired"
|
16
|
+
rescue JWT::DecodeError => e
|
17
|
+
# Simplify the error message for invalid tokens
|
18
|
+
raise DecodeError, "Invalid token"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Defaults (can be overridden in an initializer)
|
23
|
+
self.secret = ENV.fetch("ACTION_MCP_JWT_SECRET") { "change-me" }
|
24
|
+
self.algorithm = "HS256"
|
25
|
+
end
|
26
|
+
end
|
data/lib/action_mcp/prompt.rb
CHANGED
@@ -4,6 +4,7 @@ module ActionMCP
|
|
4
4
|
# Abstract base class for Prompts
|
5
5
|
class Prompt < Capability
|
6
6
|
include ActionMCP::Callbacks
|
7
|
+
include ActionMCP::CurrentHelpers
|
7
8
|
class_attribute :_argument_definitions, instance_accessor: false, default: []
|
8
9
|
|
9
10
|
# ---------------------------------------------------
|
@@ -24,7 +24,14 @@ module ActionMCP
|
|
24
24
|
def with_error_handling(request_id)
|
25
25
|
yield
|
26
26
|
rescue JSON_RPC::JsonRpcError => e
|
27
|
-
|
27
|
+
if transport.messaging_mode == :return
|
28
|
+
response = error_response(request_id, e)
|
29
|
+
transport.write_message(response)
|
30
|
+
response
|
31
|
+
else
|
32
|
+
transport.send_jsonrpc_response(request_id, error: e)
|
33
|
+
nil
|
34
|
+
end
|
28
35
|
end
|
29
36
|
end
|
30
37
|
end
|
@@ -36,7 +36,8 @@ module ActionMCP
|
|
36
36
|
def handle_tools_call(id, params)
|
37
37
|
name = validate_required_param(params, "name", "Tool name is required")
|
38
38
|
arguments = extract_arguments(params)
|
39
|
-
|
39
|
+
_meta = params["_meta"] || params[:_meta] || {}
|
40
|
+
transport.send_tools_call(id, name, arguments, _meta)
|
40
41
|
end
|
41
42
|
|
42
43
|
def extract_arguments(params)
|
@@ -31,11 +31,18 @@ module ActionMCP
|
|
31
31
|
rpc_method = request.method
|
32
32
|
params = request.params
|
33
33
|
|
34
|
-
with_error_handling(id) do
|
35
|
-
|
36
|
-
|
37
|
-
|
34
|
+
result = with_error_handling(id) do
|
35
|
+
common_result = handle_common_methods(rpc_method, id, params)
|
36
|
+
if common_result
|
37
|
+
common_result
|
38
|
+
else
|
39
|
+
route_to_handler(rpc_method, id, params)
|
40
|
+
# In return mode, get the last response that was collected
|
41
|
+
transport.messaging_mode == :return ? transport.get_last_response : nil
|
42
|
+
end
|
38
43
|
end
|
44
|
+
|
45
|
+
result
|
39
46
|
end
|
40
47
|
|
41
48
|
def route_to_handler(rpc_method, id, params)
|
@@ -64,6 +71,7 @@ module ActionMCP
|
|
64
71
|
params = notification.params || {}
|
65
72
|
|
66
73
|
process_notifications(method_name, params)
|
74
|
+
# Notifications don't expect a response
|
67
75
|
nil
|
68
76
|
end
|
69
77
|
|
@@ -3,6 +3,11 @@
|
|
3
3
|
module ActionMCP
|
4
4
|
module Server
|
5
5
|
module Messaging
|
6
|
+
# Operation mode for the messaging module
|
7
|
+
# :write - writes messages directly (default, for SSE)
|
8
|
+
# :return - returns messages without writing (for JSON responses)
|
9
|
+
attr_accessor :messaging_mode
|
10
|
+
|
6
11
|
def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
|
7
12
|
send_message(:request, method: method, params: params, id: id)
|
8
13
|
end
|
@@ -44,7 +49,13 @@ module ActionMCP
|
|
44
49
|
)
|
45
50
|
end
|
46
51
|
|
47
|
-
|
52
|
+
if messaging_mode == :return
|
53
|
+
write_message(message) # This will be intercepted by ResponseCollector
|
54
|
+
message
|
55
|
+
else
|
56
|
+
write_message(message)
|
57
|
+
nil
|
58
|
+
end
|
48
59
|
end
|
49
60
|
end
|
50
61
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Server
|
5
|
+
# Module to collect responses when operating in :return mode
|
6
|
+
module ResponseCollector
|
7
|
+
attr_reader :collected_responses
|
8
|
+
|
9
|
+
def initialize_response_collector
|
10
|
+
@collected_responses = []
|
11
|
+
end
|
12
|
+
|
13
|
+
# Override write_message to collect responses instead of writing them
|
14
|
+
def write_message(message)
|
15
|
+
if messaging_mode == :return
|
16
|
+
@collected_responses ||= []
|
17
|
+
@collected_responses << message
|
18
|
+
message
|
19
|
+
else
|
20
|
+
super
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Get all collected responses
|
25
|
+
def get_collected_responses
|
26
|
+
@collected_responses || []
|
27
|
+
end
|
28
|
+
|
29
|
+
# Get the last response (useful for single request/response scenarios)
|
30
|
+
def get_last_response
|
31
|
+
@collected_responses&.last
|
32
|
+
end
|
33
|
+
|
34
|
+
# Clear collected responses
|
35
|
+
def clear_collected_responses
|
36
|
+
@collected_responses = []
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|