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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +192 -0
  3. data/app/controllers/action_mcp/application_controller.rb +12 -6
  4. data/lib/action_mcp/client/active_record_session_store.rb +57 -0
  5. data/lib/action_mcp/client/session_store.rb +2 -103
  6. data/lib/action_mcp/client/session_store_factory.rb +36 -0
  7. data/lib/action_mcp/client/test_session_store.rb +84 -0
  8. data/lib/action_mcp/client/volatile_session_store.rb +38 -0
  9. data/lib/action_mcp/configuration.rb +16 -1
  10. data/lib/action_mcp/current.rb +19 -0
  11. data/lib/action_mcp/current_helpers.rb +19 -0
  12. data/lib/action_mcp/gateway.rb +85 -0
  13. data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
  14. data/lib/action_mcp/jwt_decoder.rb +26 -0
  15. data/lib/action_mcp/prompt.rb +1 -0
  16. data/lib/action_mcp/resource_template.rb +1 -0
  17. data/lib/action_mcp/server/base_messaging.rb +14 -0
  18. data/lib/action_mcp/server/error_aware.rb +8 -1
  19. data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
  20. data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
  21. data/lib/action_mcp/server/messaging.rb +12 -1
  22. data/lib/action_mcp/server/registry_management.rb +0 -1
  23. data/lib/action_mcp/server/response_collector.rb +40 -0
  24. data/lib/action_mcp/server/session_store.rb +762 -0
  25. data/lib/action_mcp/server/tools.rb +14 -3
  26. data/lib/action_mcp/server/transport_handler.rb +9 -5
  27. data/lib/action_mcp/server.rb +7 -0
  28. data/lib/action_mcp/tagged_stream_logging.rb +0 -4
  29. data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
  30. data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
  31. data/lib/action_mcp/test_helper.rb +4 -0
  32. data/lib/action_mcp/tool.rb +1 -0
  33. data/lib/action_mcp/version.rb +1 -1
  34. data/lib/action_mcp.rb +0 -1
  35. data/lib/generators/action_mcp/install/install_generator.rb +4 -0
  36. data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
  37. 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 [Boolean] true if handled, false otherwise
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
@@ -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
  # ---------------------------------------------------
@@ -10,6 +10,7 @@ module ActionMCP
10
10
  include ResourceCallbacks
11
11
  include Logging
12
12
  include UriAmbiguityChecker
13
+ include CurrentHelpers
13
14
 
14
15
  # Track all registered templates
15
16
  @registered_templates = []
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Server
5
+ # Base messaging functionality
6
+ module BaseMessaging
7
+ private
8
+
9
+ def write_message(data)
10
+ session.write(data)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -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
- error_response(request_id, e)
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
- transport.send_tools_call(id, name, arguments)
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
- common_method = handle_common_methods(rpc_method, id, params)
36
- return common_method if common_method
37
- route_to_handler(rpc_method, id, params)
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
- write_message(message)
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
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/action_mcp/server/registry_management.rb
4
3
  module ActionMCP
5
4
  module Server
6
5
  module RegistryManagement
@@ -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