actionmcp 0.50.13 → 0.52.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 +83 -0
- data/app/controllers/action_mcp/application_controller.rb +12 -6
- data/app/models/action_mcp/session.rb +5 -1
- data/lib/action_mcp/client/base.rb +88 -82
- data/lib/action_mcp/client/json_rpc_handler.rb +42 -5
- data/lib/action_mcp/client/session_store.rb +231 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +291 -0
- data/lib/action_mcp/client/transport.rb +107 -0
- data/lib/action_mcp/client.rb +45 -5
- 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/capabilities.rb +23 -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 +30 -6
@@ -1,5 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "gateway"
|
4
|
+
require "active_support/core_ext/integer/time"
|
5
|
+
|
3
6
|
module ActionMCP
|
4
7
|
# Configuration class to hold settings for the ActionMCP server.
|
5
8
|
class Configuration
|
@@ -30,7 +33,12 @@ module ActionMCP
|
|
30
33
|
:vibed_ignore_version,
|
31
34
|
# --- SSE Resumability Options ---
|
32
35
|
:sse_event_retention_period,
|
33
|
-
:max_stored_sse_events
|
36
|
+
:max_stored_sse_events,
|
37
|
+
# --- Gateway Options ---
|
38
|
+
:gateway_class,
|
39
|
+
:current_class,
|
40
|
+
# --- Session Store Options ---
|
41
|
+
:session_store_type
|
34
42
|
|
35
43
|
def initialize
|
36
44
|
@logging_enabled = true
|
@@ -47,6 +55,13 @@ module ActionMCP
|
|
47
55
|
# Resumability defaults
|
48
56
|
@sse_event_retention_period = 15.minutes
|
49
57
|
@max_stored_sse_events = 100
|
58
|
+
|
59
|
+
# Gateway - default to ApplicationGateway if it exists, otherwise ActionMCP::Gateway
|
60
|
+
@gateway_class = defined?(::ApplicationGateway) ? ::ApplicationGateway : ActionMCP::Gateway
|
61
|
+
@current_class = nil
|
62
|
+
|
63
|
+
# Session Store
|
64
|
+
@session_store_type = Rails.env.production? ? :active_record : :volatile
|
50
65
|
end
|
51
66
|
|
52
67
|
def name
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class Current < ActiveSupport::CurrentAttributes
|
5
|
+
attribute :user
|
6
|
+
attribute :gateway
|
7
|
+
|
8
|
+
def user=(user)
|
9
|
+
super
|
10
|
+
set_user_time_zone if user.respond_to?(:time_zone)
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def set_user_time_zone
|
16
|
+
Time.zone = user.time_zone
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module CurrentHelpers
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
protected
|
8
|
+
|
9
|
+
# Access the current user from ActionMCP::Current
|
10
|
+
def current_user
|
11
|
+
ActionMCP::Current.user
|
12
|
+
end
|
13
|
+
|
14
|
+
# Access the current gateway from ActionMCP::Current
|
15
|
+
def current_gateway
|
16
|
+
ActionMCP::Current.gateway
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -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
|
# ---------------------------------------------------
|
@@ -13,6 +13,7 @@ module ActionMCP
|
|
13
13
|
client_protocol_version = params["protocolVersion"]
|
14
14
|
client_info = params["clientInfo"]
|
15
15
|
client_capabilities = params["capabilities"]
|
16
|
+
session_id = params["sessionId"]
|
16
17
|
|
17
18
|
unless client_protocol_version.is_a?(String) && client_protocol_version.present?
|
18
19
|
return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'protocolVersion'")
|
@@ -32,6 +33,28 @@ module ActionMCP
|
|
32
33
|
return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'capabilities'")
|
33
34
|
end
|
34
35
|
|
36
|
+
# Handle session resumption if sessionId provided
|
37
|
+
if session_id
|
38
|
+
existing_session = ActionMCP::Session.find_by(id: session_id)
|
39
|
+
if existing_session && existing_session.initialized?
|
40
|
+
# Resume existing session - update transport reference
|
41
|
+
transport.instance_variable_set(:@session, existing_session)
|
42
|
+
Rails.logger.info("Resuming existing session: #{session_id}")
|
43
|
+
|
44
|
+
# Return existing session info
|
45
|
+
capabilities_payload = existing_session.server_capabilities_payload
|
46
|
+
capabilities_payload[:protocolVersion] = if ActionMCP.configuration.vibed_ignore_version
|
47
|
+
PROTOCOL_VERSION
|
48
|
+
else
|
49
|
+
client_protocol_version
|
50
|
+
end
|
51
|
+
return send_jsonrpc_response(request_id, result: capabilities_payload)
|
52
|
+
else
|
53
|
+
Rails.logger.warn("Session #{session_id} not found or not initialized, creating new session")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# Create new session if not resuming
|
35
58
|
session.store_client_info(client_info)
|
36
59
|
session.store_client_capabilities(client_capabilities)
|
37
60
|
session.set_protocol_version(client_protocol_version)
|
@@ -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
|