actionmcp 0.20.0 → 0.24.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/action_mcp/messages_controller.rb +2 -2
  3. data/app/models/action_mcp/session/message.rb +21 -5
  4. data/app/models/action_mcp/session.rb +8 -4
  5. data/lib/action_mcp/capability.rb +2 -3
  6. data/lib/action_mcp/client/base.rb +222 -0
  7. data/lib/action_mcp/client/blueprint.rb +161 -0
  8. data/lib/action_mcp/client/catalog.rb +160 -0
  9. data/lib/action_mcp/client/collection.rb +93 -0
  10. data/lib/action_mcp/client/json_rpc_handler.rb +114 -0
  11. data/lib/action_mcp/client/logging.rb +20 -0
  12. data/lib/action_mcp/{transport → client}/messaging.rb +1 -1
  13. data/lib/action_mcp/client/prompt_book.rb +117 -0
  14. data/lib/action_mcp/client/prompts.rb +39 -0
  15. data/lib/action_mcp/client/request_timeouts.rb +76 -0
  16. data/lib/action_mcp/client/resources.rb +85 -0
  17. data/lib/action_mcp/client/roots.rb +13 -0
  18. data/lib/action_mcp/client/server.rb +60 -0
  19. data/lib/action_mcp/{transport → client}/sse_client.rb +70 -111
  20. data/lib/action_mcp/{transport → client}/stdio_client.rb +38 -38
  21. data/lib/action_mcp/client/toolbox.rb +194 -0
  22. data/lib/action_mcp/client/tools.rb +39 -0
  23. data/lib/action_mcp/client.rb +20 -231
  24. data/lib/action_mcp/engine.rb +1 -3
  25. data/lib/action_mcp/instrumentation/controller_runtime.rb +1 -1
  26. data/lib/action_mcp/instrumentation/instrumentation.rb +2 -0
  27. data/lib/action_mcp/instrumentation/resource_instrumentation.rb +1 -0
  28. data/lib/action_mcp/json_rpc_handler_base.rb +106 -0
  29. data/lib/action_mcp/log_subscriber.rb +2 -0
  30. data/lib/action_mcp/logging.rb +1 -1
  31. data/lib/action_mcp/{transport → server}/capabilities.rb +2 -2
  32. data/lib/action_mcp/server/json_rpc_handler.rb +121 -0
  33. data/lib/action_mcp/server/messaging.rb +28 -0
  34. data/lib/action_mcp/{transport → server}/notifications.rb +1 -1
  35. data/lib/action_mcp/{transport → server}/prompts.rb +1 -1
  36. data/lib/action_mcp/{transport → server}/resources.rb +1 -18
  37. data/lib/action_mcp/{transport → server}/roots.rb +1 -1
  38. data/lib/action_mcp/{transport → server}/sampling.rb +1 -1
  39. data/lib/action_mcp/server/sampling_request.rb +115 -0
  40. data/lib/action_mcp/{transport → server}/tools.rb +1 -1
  41. data/lib/action_mcp/server/transport_handler.rb +41 -0
  42. data/lib/action_mcp/uri_ambiguity_checker.rb +6 -10
  43. data/lib/action_mcp/version.rb +1 -1
  44. data/lib/action_mcp.rb +2 -1
  45. metadata +31 -33
  46. data/lib/action_mcp/base_json_rpc_handler.rb +0 -97
  47. data/lib/action_mcp/client_json_rpc_handler.rb +0 -69
  48. data/lib/action_mcp/json_rpc_handler.rb +0 -229
  49. data/lib/action_mcp/sampling_request.rb +0 -113
  50. data/lib/action_mcp/server_json_rpc_handler.rb +0 -90
  51. data/lib/action_mcp/transport/transport_base.rb +0 -126
  52. data/lib/action_mcp/transport_handler.rb +0 -39
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # Base collection class for MCP client collections
6
+ class Collection
7
+ include RequestTimeouts
8
+
9
+ attr_reader :client, :loaded
10
+
11
+ def initialize(items, client, silence_sql: true)
12
+ @collection_data = items || []
13
+ @client = client
14
+ @loaded = !(@collection_data.empty?)
15
+ @silence_sql = silence_sql
16
+ end
17
+
18
+ def all
19
+ silence_logs { load_items unless @loaded }
20
+ @collection_data
21
+ end
22
+
23
+ def all!(timeout: DEFAULT_TIMEOUT)
24
+ silence_logs { load_items(force: true, timeout: timeout) }
25
+ @collection_data
26
+ end
27
+
28
+ # Filter items based on a given block
29
+ #
30
+ # @yield [item] Block that determines whether to include an item
31
+ # @yieldparam item [Object] An item from the collection
32
+ # @yieldreturn [Boolean] true to include the item, false to exclude it
33
+ # @return [Array<Object>] Items that match the filter criteria
34
+ def filter(&block)
35
+ all.select(&block)
36
+ end
37
+
38
+ # Number of items in the collection
39
+ #
40
+ # @return [Integer] The number of items
41
+ def size
42
+ all.size
43
+ end
44
+
45
+ # Implements enumerable functionality
46
+ include Enumerable
47
+
48
+ def each(&block)
49
+ all.each(&block)
50
+ end
51
+
52
+ alias loaded? loaded
53
+
54
+ protected
55
+
56
+ def load_items(force: false, timeout: DEFAULT_TIMEOUT)
57
+ return if @loaded && !force
58
+
59
+ # Make sure @load_method is defined in the subclass
60
+ raise NotImplementedError, "Subclass must define @load_method" unless defined?(@load_method)
61
+
62
+ # Use the RequestTimeouts module to handle the request
63
+ load_with_timeout(@load_method, force: force, timeout: timeout)
64
+ end
65
+
66
+ private
67
+
68
+ def silence_logs
69
+ return yield unless @silence_sql
70
+
71
+ original_log_level = Session.logger&.level
72
+ begin
73
+ # Temporarily increase log level to suppress SQL queries
74
+ Session.logger.level = Logger::WARN if Session.logger
75
+ yield
76
+ ensure
77
+ # Restore original log level
78
+ Session.logger.level = original_log_level if Session.logger
79
+ end
80
+ end
81
+
82
+ def log_error(message)
83
+ # Safely handle logging - don't assume Rails.logger exists
84
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
85
+ Rails.logger.error("[#{self.class.name}] #{message}")
86
+ else
87
+ # Fall back to puts if Rails.logger is not available
88
+ puts "[ERROR] [#{self.class.name}] #{message}"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ class JsonRpcHandler < JsonRpcHandlerBase
6
+ attr_reader :client
7
+ def initialize(transport, client)
8
+ super(transport)
9
+ @client = client
10
+ end
11
+
12
+ protected
13
+
14
+ # Handle client-specific methods
15
+ # @param rpc_method [String]
16
+ # @param id [String, Integer]
17
+ # @param params [Hash]
18
+ def handle_method(rpc_method, id, params)
19
+ puts "\e[31mUnknown server method: #{rpc_method} #{id} #{params}\e[0m"
20
+ end
21
+
22
+ # @param rpc_method [String]
23
+ # @param id [String]
24
+ def process_roots(rpc_method, id)
25
+ case rpc_method
26
+ when "roots/list" # List available roots
27
+ transport.send_roots_list(id)
28
+ else
29
+ Rails.logger.warn("Unknown roots method: #{rpc_method}")
30
+ end
31
+ end
32
+
33
+ # @param rpc_method [String]
34
+ # @param id [String]
35
+ # @param params [Hash]
36
+ def process_sampling(rpc_method, id, params)
37
+ case rpc_method
38
+ when "sampling/createMessage" # Create a message using AI
39
+ # @param id [String]
40
+ # @param params [SamplingRequest]
41
+ transport.send_sampling_create_message(id, params)
42
+ else
43
+ Rails.logger.warn("Unknown sampling method: #{rpc_method}")
44
+ end
45
+ end
46
+
47
+ # @param rpc_method [String]
48
+ def process_notifications(rpc_method, params)
49
+ case rpc_method
50
+ when "notifications/resources/updated" # Resource update notification
51
+ puts "\e[31m Resource #{params['uri']} was updated\e[0m"
52
+ # Handle resource update notification
53
+ # TODO: fetch updated resource or mark it as stale
54
+ when "notifications/tools/list_changed" # Tool list change notification
55
+ puts "\e[31m Tool list has changed\e[0m"
56
+ # Handle tool list change notification
57
+ # TODO: fetch new tools or mark them as stale
58
+ when "notifications/prompts/list_changed" # Prompt list change notification
59
+ puts "\e[31m Prompt list has changed\e[0m"
60
+ # Handle prompt list change notification
61
+ # TODO: fetch new prompts or mark them as stale
62
+ when "notifications/resources/list_changed" # Resource list change notification
63
+ puts "\e[31m Resource list has changed\e[0m"
64
+ # Handle resource list change notification
65
+ # TODO: fetch new resources or mark them as stale
66
+ else
67
+ super
68
+ end
69
+ end
70
+
71
+ def process_response(id, result)
72
+ if transport.id == id
73
+ ## This initializes the transport
74
+ client.server = Client::Server.new(result)
75
+ return send_initialized_notification
76
+ end
77
+
78
+ request = transport.messages.requests.find_by(jsonrpc_id: id)
79
+ return unless request
80
+
81
+ # Mark the request as acknowledged
82
+ request.update(request_acknowledged: true)
83
+
84
+ case request.rpc_method
85
+ when "tools/list"
86
+ client.toolbox.tools = result["tools"]
87
+ return true
88
+ when "prompts/list"
89
+ client.prompt_book.prompts = result["prompts"]
90
+ return true
91
+ when "resources/list"
92
+ client.catalog.resources = result["resources"]
93
+ return true
94
+ when "resources/templates/list"
95
+ client.blueprint.templates = result["resourceTemplates"]
96
+ return true
97
+ end
98
+
99
+ puts "\e[31mUnknown response: #{id} #{result}\e[0m"
100
+ end
101
+
102
+ def process_error(id, error)
103
+ # Do something ?
104
+ puts "\e[31mUnknown error: #{id} #{error}\e[0m"
105
+ end
106
+
107
+
108
+ def send_initialized_notification
109
+ transport.initialize!
110
+ client.send_jsonrpc_notification("notifications/initialized")
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ module Logging
6
+ # Set the client's logging level
7
+ # @param level [String] Logging level (debug, info, warning, error, etc.)
8
+ # @return [Boolean] Success status
9
+ def set_logging_level(level)
10
+ request_id = SecureRandom.uuid_v7
11
+
12
+ # Send request
13
+ send_jsonrpc_request("client/setLoggingLevel",
14
+ params: { level: level },
15
+ id: request_id
16
+ )
17
+ end
18
+ end
19
+ end
20
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
- module Transport
4
+ module Client
5
5
  module Messaging
6
6
  def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
7
7
  request = JsonRpc::Request.new(id: id, method: method, params: params)
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ # PromptBook
6
+ #
7
+ # A collection that manages and provides access to prompt templates from the MCP server.
8
+ # The class stores prompt definitions along with their arguments and provides methods
9
+ # for retrieving, filtering, and accessing prompts. It supports lazy loading of prompts
10
+ # when initialized with a client.
11
+ #
12
+ # Example usage:
13
+ # # Eager loading
14
+ # prompts_data = client.list_prompts # Returns array of prompt definitions
15
+ # book = PromptBook.new(prompts_data)
16
+ #
17
+ # # Lazy loading
18
+ # book = PromptBook.new([], client)
19
+ # prompts = book.all # Prompts are loaded here
20
+ #
21
+ # # Access a specific prompt by name
22
+ # summary_prompt = book.find("summarize_text")
23
+ #
24
+ # # Get all prompts matching a criteria
25
+ # text_prompts = book.filter { |p| p.name.include?("text") }
26
+ #
27
+ class PromptBook < Collection
28
+ # Initialize a new PromptBook with prompt definitions
29
+ #
30
+ # @param prompts [Array<Hash>] Array of prompt definition hashes, each containing
31
+ # name, description, and arguments keys
32
+ # @param client [Object, nil] Optional client for lazy loading of prompts
33
+ def initialize(prompts, client)
34
+ super(prompts, client)
35
+ self.prompts = @collection_data
36
+ @load_method = :list_prompts
37
+ end
38
+
39
+ # Find a prompt by name
40
+ #
41
+ # @param name [String] Name of the prompt to find
42
+ # @return [Prompt, nil] The prompt with the given name, or nil if not found
43
+ def find(name)
44
+ all.find { |prompt| prompt.name == name }
45
+ end
46
+
47
+ # Get a list of all prompt names
48
+ #
49
+ # @return [Array<String>] Names of all prompts in the collection
50
+ def names
51
+ all.map(&:name)
52
+ end
53
+
54
+ # Check if the collection contains a prompt with the given name
55
+ #
56
+ # @param name [String] The prompt name to check for
57
+ # @return [Boolean] true if a prompt with the name exists
58
+ def contains?(name)
59
+ all.any? { |prompt| prompt.name == name }
60
+ end
61
+
62
+ # Convert raw prompt data into Prompt objects
63
+ #
64
+ # @param prompts [Array<Hash>] Array of prompt definition hashes
65
+ def prompts=(prompts)
66
+ @collection_data = prompts.map { |data| Prompt.new(data) }
67
+ end
68
+
69
+ # Internal Prompt class to represent individual prompts
70
+ class Prompt
71
+ attr_reader :name, :description, :arguments
72
+
73
+ # Initialize a new Prompt instance
74
+ #
75
+ # @param data [Hash] Prompt definition hash containing name, description, and arguments
76
+ def initialize(data)
77
+ @name = data["name"]
78
+ @description = data["description"]
79
+ @arguments = data["arguments"] || []
80
+ end
81
+
82
+ # Get all required arguments for this prompt
83
+ #
84
+ # @return [Array<Hash>] Array of argument hashes that are required
85
+ def required_arguments
86
+ @arguments.select { |arg| arg["required"] }
87
+ end
88
+
89
+ # Get all optional arguments for this prompt
90
+ #
91
+ # @return [Array<Hash>] Array of argument hashes that are optional
92
+ def optional_arguments
93
+ @arguments.reject { |arg| arg["required"] }
94
+ end
95
+
96
+ # Check if the prompt has a specific argument
97
+ #
98
+ # @param name [String] Name of the argument to check for
99
+ # @return [Boolean] true if the argument exists
100
+ def has_argument?(name)
101
+ @arguments.any? { |arg| arg["name"] == name }
102
+ end
103
+
104
+ # Generate a hash representation of the prompt
105
+ #
106
+ # @return [Hash] Hash containing prompt details
107
+ def to_h
108
+ {
109
+ "name" => @name,
110
+ "description" => @description,
111
+ "arguments" => @arguments
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ module Prompts
6
+ # List all available prompts from the server
7
+ # @return [String] Request ID for tracking the request
8
+ def list_prompts
9
+ request_id = SecureRandom.uuid_v7
10
+
11
+ # Send request
12
+ send_jsonrpc_request("prompts/list", id: request_id)
13
+
14
+ # Return request ID for tracking the request
15
+ request_id
16
+ end
17
+
18
+ # Get a specific prompt with arguments
19
+ # @param name [String] Name of the prompt to get
20
+ # @param arguments [Hash] Arguments to pass to the prompt
21
+ # @return [String] Request ID for tracking the request
22
+ def get_prompt(name, arguments = {})
23
+ request_id = SecureRandom.uuid_v7
24
+
25
+ # Send request
26
+ send_jsonrpc_request("prompts/get",
27
+ params: {
28
+ name: name,
29
+ arguments: arguments
30
+ },
31
+ id: request_id
32
+ )
33
+
34
+ # Return request ID for tracking the request
35
+ request_id
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ module RequestTimeouts
6
+ # Default timeout in seconds
7
+ DEFAULT_TIMEOUT = 1.0
8
+
9
+ # Load resources with timeout support - blocking until response or timeout
10
+ # @param method_name [Symbol] The method to call for loading (e.g., :list_resources)
11
+ # @param force [Boolean] Whether to force reload even if already loaded
12
+ # @param timeout [Float] Timeout in seconds
13
+ # @return [Boolean] Success status
14
+ def load_with_timeout(method_name, force: false, timeout: DEFAULT_TIMEOUT)
15
+ return true if @loaded && !force
16
+
17
+ # Make the request and store its ID
18
+ request_id = client.send(method_name)
19
+
20
+ start_time = Time.now
21
+
22
+ # Wait until either:
23
+ # 1. The collection is loaded (@loaded becomes true from JsonRpcHandler)
24
+ # 2. The timeout is reached
25
+ while !@loaded && (Time.now - start_time) < timeout
26
+ sleep(0.1)
27
+ end
28
+
29
+ # If we timed out
30
+ unless @loaded
31
+ request = client.session.messages.requests.find_by(jsonrpc_id: request_id)
32
+
33
+ if request && !request.request_acknowledged?
34
+ # Send cancel notification
35
+ client.send_jsonrpc_notification("notifications/cancelled", {
36
+ requestId: request_id,
37
+ reason: "Request timed out after #{timeout} seconds"
38
+ })
39
+
40
+ # Mark as cancelled in the database
41
+ request.update(request_cancelled: true)
42
+
43
+ log_error("Request #{method_name} timed out after #{timeout} seconds")
44
+ end
45
+
46
+ # Mark as loaded even though we timed out
47
+ @loaded = true
48
+ return false
49
+ end
50
+
51
+ # Collection was successfully loaded
52
+ true
53
+ end
54
+
55
+ private
56
+
57
+ def handle_timeout(request_id, method_name, timeout)
58
+ # Find the request
59
+ request = client.session.messages.requests.find_by(jsonrpc_id: request_id)
60
+
61
+ if request && !request.request_acknowledged?
62
+ # Send cancel notification
63
+ client.send_jsonrpc_notification("notifications/cancelled", {
64
+ requestId: request_id,
65
+ reason: "Request timed out after #{timeout} seconds"
66
+ })
67
+
68
+ # Mark as cancelled in the database
69
+ request.update(request_cancelled: true)
70
+
71
+ log_error("Request #{method_name} timed out after #{timeout} seconds")
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ module Resources
6
+ # List all available resources from the server
7
+ # @return [String] Request ID for tracking the request
8
+ def list_resources
9
+ request_id = SecureRandom.uuid_v7
10
+
11
+ # Send request
12
+ send_jsonrpc_request("resources/list", id: request_id)
13
+
14
+ # Return request ID for tracking the request
15
+ request_id
16
+ end
17
+
18
+ # List resource templates from the server
19
+ # @return [String] Request ID for tracking the request
20
+ def list_resource_templates
21
+ request_id = SecureRandom.uuid_v7
22
+
23
+ # Send request
24
+ send_jsonrpc_request("resources/templates/list", id: request_id)
25
+
26
+ # Return request ID for tracking the request
27
+ request_id
28
+ end
29
+
30
+ # Read a specific resource
31
+ # @param uri [String] URI of the resource to read
32
+ # @return [String] Request ID for tracking the request
33
+ def read_resource(uri)
34
+ request_id = SecureRandom.uuid_v7
35
+
36
+ # Send request
37
+ send_jsonrpc_request("resources/read",
38
+ params: { uri: uri },
39
+ id: request_id
40
+ )
41
+
42
+ # Return request ID for tracking the request
43
+ request_id
44
+ end
45
+
46
+ # Subscribe to updates for a specific resource
47
+ # @param uri [String] URI of the resource to subscribe to
48
+ # @param update_callback [Proc] Callback for resource updates
49
+ # @return [String] Request ID for tracking the request
50
+ def subscribe_resource(uri, update_callback)
51
+ @resource_subscriptions ||= {}
52
+ @resource_subscriptions[uri] = update_callback
53
+
54
+ request_id = SecureRandom.uuid_v7
55
+
56
+ # Send request
57
+ send_jsonrpc_request("resources/subscribe",
58
+ params: { uri: uri },
59
+ id: request_id
60
+ )
61
+
62
+ # Return request ID for tracking the request
63
+ request_id
64
+ end
65
+
66
+ # Unsubscribe from updates for a specific resource
67
+ # @param uri [String] URI of the resource to unsubscribe from
68
+ # @return [String] Request ID for tracking the request
69
+ def unsubscribe_resource(uri)
70
+ @resource_subscriptions&.delete(uri)
71
+
72
+ request_id = SecureRandom.uuid_v7
73
+
74
+ # Send request
75
+ send_jsonrpc_request("resources/unsubscribe",
76
+ params: { uri: uri },
77
+ id: request_id
78
+ )
79
+
80
+ # Return request ID for tracking the request
81
+ request_id
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Client
5
+ module Roots
6
+ # Notify the server that the roots list has changed
7
+ def roots_list_changed_notification
8
+ send_jsonrpc_notification("notifications/roots/list_changed")
9
+ true
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,60 @@
1
+ module ActionMCP
2
+ module Client
3
+ class Server
4
+ attr_reader :name, :version
5
+
6
+ attr_reader :server_info, :capabilities
7
+
8
+ def initialize(data)
9
+ # Store protocol version if needed for later use
10
+ @protocol_version = data["protocolVersion"]
11
+
12
+ # Extract server information
13
+ @server_info = data["serverInfo"] || {}
14
+ @name = server_info["name"]
15
+ @version = server_info["version"]
16
+
17
+ # Store capabilities for dynamic checking
18
+ @capabilities = data["capabilities"] || {}
19
+ end
20
+
21
+ # Check if 'tools' capability is present
22
+ def tools?
23
+ @capabilities.key?("tools")
24
+ end
25
+
26
+ # Check if 'prompts' capability is present
27
+ def prompts?
28
+ @capabilities.key?("prompts")
29
+ end
30
+
31
+ # Check if tools have a dynamic state based on listChanged flag
32
+ def dynamic_tools?
33
+ tool_cap = @capabilities["tools"] || {}
34
+ tool_cap["listChanged"] == true
35
+ end
36
+
37
+ # Check if logging capability exists
38
+ def logging?
39
+ @capabilities.key?("logging")
40
+ end
41
+
42
+ # Check if resources capability exists
43
+ def resources?
44
+ @capabilities.key?("resources")
45
+ end
46
+
47
+ # Check if resources have a dynamic state based on listChanged flag
48
+ def dynamic_resources?
49
+ resources_cap = @capabilities["resources"] || {}
50
+ resources_cap["listChanged"] == true
51
+ end
52
+
53
+ def inspect
54
+ "#<#{self.class.name} name: #{name}, version: #{version} with resources: #{resources?}, tools: #{tools?}, prompts: #{prompts?}, logging: #{logging?}>"
55
+ end
56
+
57
+ alias to_s inspect
58
+ end
59
+ end
60
+ end