actionmcp 0.31.1 → 0.33.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +143 -5
  3. data/app/controllers/action_mcp/mcp_controller.rb +13 -17
  4. data/app/controllers/action_mcp/messages_controller.rb +3 -1
  5. data/app/controllers/action_mcp/sse_controller.rb +22 -4
  6. data/app/controllers/action_mcp/unified_controller.rb +147 -52
  7. data/app/models/action_mcp/session/message.rb +1 -0
  8. data/app/models/action_mcp/session/sse_event.rb +55 -0
  9. data/app/models/action_mcp/session.rb +235 -12
  10. data/app/models/concerns/mcp_console_helpers.rb +68 -0
  11. data/app/models/concerns/mcp_message_inspect.rb +73 -0
  12. data/config/routes.rb +4 -2
  13. data/db/migrate/20250329120300_add_registries_to_sessions.rb +9 -0
  14. data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +16 -0
  15. data/lib/action_mcp/capability.rb +16 -0
  16. data/lib/action_mcp/configuration.rb +16 -4
  17. data/lib/action_mcp/console_detector.rb +12 -0
  18. data/lib/action_mcp/engine.rb +3 -0
  19. data/lib/action_mcp/json_rpc_handler_base.rb +1 -1
  20. data/lib/action_mcp/resource_template.rb +11 -0
  21. data/lib/action_mcp/server/capabilities.rb +28 -22
  22. data/lib/action_mcp/server/configuration.rb +63 -0
  23. data/lib/action_mcp/server/json_rpc_handler.rb +35 -9
  24. data/lib/action_mcp/server/notifications.rb +14 -5
  25. data/lib/action_mcp/server/prompts.rb +18 -5
  26. data/lib/action_mcp/server/registry_management.rb +32 -0
  27. data/lib/action_mcp/server/resources.rb +3 -2
  28. data/lib/action_mcp/server/simple_pub_sub.rb +145 -0
  29. data/lib/action_mcp/server/solid_cable_adapter.rb +222 -0
  30. data/lib/action_mcp/server/tools.rb +50 -6
  31. data/lib/action_mcp/server.rb +84 -2
  32. data/lib/action_mcp/sse_listener.rb +6 -5
  33. data/lib/action_mcp/tagged_stream_logging.rb +47 -0
  34. data/lib/action_mcp/test_helper.rb +57 -34
  35. data/lib/action_mcp/tool.rb +45 -9
  36. data/lib/action_mcp/version.rb +1 -1
  37. data/lib/action_mcp.rb +4 -4
  38. data/lib/generators/action_mcp/config/config_generator.rb +29 -0
  39. data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
  40. metadata +23 -13
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "concurrent/map"
5
+ require "concurrent/array"
6
+ require "concurrent/executor/thread_pool_executor"
7
+
8
+ module ActionMCP
9
+ module Server
10
+ # Mock SolidCable::PubSub for testing
11
+ class MockSolidCablePubSub
12
+ attr_reader :subscriptions, :messages
13
+
14
+ def initialize(options = {})
15
+ @options = options
16
+ @subscriptions = Concurrent::Map.new
17
+ @messages = Concurrent::Array.new
18
+ end
19
+
20
+ def subscribe(channel, &block)
21
+ @subscriptions[channel] ||= Concurrent::Array.new
22
+ @subscriptions[channel] << block
23
+ end
24
+
25
+ def unsubscribe(channel)
26
+ @subscriptions.delete(channel)
27
+ end
28
+
29
+ def broadcast(channel, message)
30
+ @messages << { channel: channel, message: message }
31
+ callbacks = @subscriptions[channel] || []
32
+ callbacks.each { |callback| callback.call(message) }
33
+ end
34
+ end
35
+
36
+ # Adapter for SolidCable PubSub
37
+ class SolidCableAdapter
38
+ # Thread pool configuration
39
+ DEFAULT_MIN_THREADS = 5
40
+ DEFAULT_MAX_THREADS = 10
41
+ DEFAULT_MAX_QUEUE = 100
42
+ DEFAULT_THREAD_TIMEOUT = 60 # seconds
43
+
44
+ def initialize(options = {})
45
+ @options = options
46
+ @subscriptions = Concurrent::Map.new
47
+ @channels = Concurrent::Map.new
48
+ @channel_subscribed = Concurrent::Map.new # Track channel subscription status
49
+
50
+ # Initialize thread pool for callbacks
51
+ pool_options = {
52
+ min_threads: options["min_threads"] || DEFAULT_MIN_THREADS,
53
+ max_threads: options["max_threads"] || DEFAULT_MAX_THREADS,
54
+ max_queue: options["max_queue"] || DEFAULT_MAX_QUEUE,
55
+ fallback_policy: :caller_runs, # Execute in the caller's thread if queue is full
56
+ idletime: DEFAULT_THREAD_TIMEOUT
57
+ }
58
+ @thread_pool = Concurrent::ThreadPoolExecutor.new(pool_options)
59
+
60
+ # Configure SolidCable with options from mcp.yml
61
+ # The main option we care about is polling_interval
62
+ pubsub_options = {}
63
+
64
+ if @options["polling_interval"]
65
+ # Convert from ActiveSupport::Duration if needed (e.g., "0.1.seconds")
66
+ interval = @options["polling_interval"]
67
+ interval = interval.to_f if interval.respond_to?(:to_f)
68
+ pubsub_options[:polling_interval] = interval
69
+ end
70
+
71
+ # If there's a connects_to option, pass it along
72
+ if @options["connects_to"]
73
+ pubsub_options[:connects_to] = @options["connects_to"]
74
+ end
75
+
76
+ # Use mock version for testing or real version in production
77
+ if defined?(SolidCable) && !testing?
78
+ @solid_cable_pubsub = SolidCable::PubSub.new(pubsub_options)
79
+ else
80
+ @solid_cable_pubsub = MockSolidCablePubSub.new(pubsub_options)
81
+ end
82
+ end
83
+
84
+ # Subscribe to a channel
85
+ # @param channel [String] The channel name
86
+ # @param message_callback [Proc] Callback for received messages
87
+ # @param success_callback [Proc] Callback for successful subscription
88
+ # @return [String] Subscription ID
89
+ def subscribe(channel, message_callback, success_callback = nil)
90
+ subscription_id = SecureRandom.uuid
91
+
92
+ @subscriptions[subscription_id] = {
93
+ channel: channel,
94
+ message_callback: message_callback
95
+ }
96
+
97
+ @channels[channel] ||= Concurrent::Array.new
98
+ @channels[channel] << subscription_id
99
+
100
+ # Subscribe to SolidCable only if we haven't already subscribed to this channel
101
+ unless subscribed_to_solid_cable?(channel)
102
+ @solid_cable_pubsub.subscribe(channel) do |message|
103
+ dispatch_message(channel, message)
104
+ end
105
+ @channel_subscribed[channel] = true
106
+ end
107
+
108
+ log_subscription_event(channel, "Subscribed", subscription_id)
109
+ success_callback&.call
110
+
111
+ subscription_id
112
+ end
113
+
114
+ # Unsubscribe from a channel
115
+ # @param channel [String] The channel name
116
+ # @param callback [Proc] Optional callback for unsubscribe completion
117
+ def unsubscribe(channel, callback = nil)
118
+ # Remove our subscriptions
119
+ subscription_ids = @channels[channel] || []
120
+ subscription_ids.each do |subscription_id|
121
+ @subscriptions.delete(subscription_id)
122
+ end
123
+
124
+ @channels.delete(channel)
125
+
126
+ # Only unsubscribe from SolidCable if we're actually subscribed
127
+ if subscribed_to_solid_cable?(channel)
128
+ @solid_cable_pubsub.unsubscribe(channel)
129
+ @channel_subscribed.delete(channel)
130
+ end
131
+
132
+ log_subscription_event(channel, "Unsubscribed")
133
+ callback&.call
134
+ end
135
+
136
+ # Broadcast a message to a channel
137
+ # @param channel [String] The channel name
138
+ # @param message [String] The message to broadcast
139
+ def broadcast(channel, message)
140
+ @solid_cable_pubsub.broadcast(channel, message)
141
+ log_broadcast_event(channel, message)
142
+ end
143
+
144
+ # Check if a channel has subscribers
145
+ # @param channel [String] The channel name
146
+ # @return [Boolean] True if channel has subscribers
147
+ def has_subscribers?(channel)
148
+ subscribers = @channels[channel]
149
+ return false unless subscribers
150
+ !subscribers.empty?
151
+ end
152
+
153
+ # Check if we're already subscribed to a channel
154
+ # @param channel [String] The channel name
155
+ # @return [Boolean] True if we're already subscribed
156
+ def subscribed_to?(channel)
157
+ channel_subs = @channels[channel]
158
+ return false if channel_subs.nil?
159
+ !channel_subs.empty?
160
+ end
161
+
162
+ # Shut down the thread pool gracefully
163
+ def shutdown
164
+ @thread_pool.shutdown
165
+ @thread_pool.wait_for_termination(5) # Wait up to 5 seconds for tasks to complete
166
+ end
167
+
168
+ private
169
+
170
+ # Check if we're in a testing environment
171
+ def testing?
172
+ defined?(Minitest) || ENV["RAILS_ENV"] == "test"
173
+ end
174
+
175
+ # Check if we're already subscribed to this channel in SolidCable
176
+ def subscribed_to_solid_cable?(channel)
177
+ @channel_subscribed[channel] == true
178
+ end
179
+
180
+ def dispatch_message(channel, message)
181
+ subscription_ids = @channels[channel] || []
182
+
183
+ subscription_ids.each do |subscription_id|
184
+ subscription = @subscriptions[subscription_id]
185
+ next unless subscription && subscription[:message_callback]
186
+
187
+ @thread_pool.post do
188
+ begin
189
+ subscription[:message_callback].call(message)
190
+ rescue StandardError => e
191
+ log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
192
+ end
193
+ end
194
+ end
195
+ end
196
+
197
+ def log_subscription_event(channel, action, subscription_id = nil)
198
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
199
+
200
+ message = "SolidCableAdapter: #{action} channel=#{channel}"
201
+ message += " subscription_id=#{subscription_id}" if subscription_id
202
+
203
+ Rails.logger.debug(message)
204
+ end
205
+
206
+ def log_broadcast_event(channel, message)
207
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
208
+
209
+ # Truncate the message for logging
210
+ truncated_message = message.to_s[0..100]
211
+ truncated_message += "..." if message.to_s.length > 100
212
+
213
+ Rails.logger.debug("SolidCableAdapter: Broadcasting to channel=#{channel} message=#{truncated_message}")
214
+ end
215
+
216
+ def log_error(message)
217
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
218
+ Rails.logger.error("SolidCableAdapter: #{message}")
219
+ end
220
+ end
221
+ end
222
+ end
@@ -3,19 +3,63 @@
3
3
  module ActionMCP
4
4
  module Server
5
5
  module Tools
6
- def send_tools_list(request_id)
7
- tools = format_registry_items(ToolsRegistry.non_abstract)
6
+ def send_tools_list(request_id, params = {})
7
+ protocol_version = session.protocol_version
8
+ # Extract progress token from _meta if provided
9
+ progress_token = params.dig("_meta", "progressToken")
10
+
11
+ # Send initial progress notification if token is provided
12
+ if progress_token
13
+ session.send_progress_notification(
14
+ progressToken: progress_token,
15
+ progress: 0,
16
+ message: "Starting tools list retrieval"
17
+ )
18
+ end
19
+
20
+ # Use session's registered tools instead of global registry
21
+ tools = session.registered_tools.map { |tool_class|
22
+ tool_class.to_h(protocol_version: protocol_version)
23
+ }
24
+
25
+ # Send completion progress notification if token is provided
26
+ if progress_token
27
+ session.send_progress_notification(
28
+ progressToken: progress_token,
29
+ progress: 100,
30
+ message: "Tools list retrieval complete"
31
+ )
32
+ end
33
+
8
34
  send_jsonrpc_response(request_id, result: { tools: tools })
9
35
  end
10
36
 
11
37
  def send_tools_call(request_id, tool_name, arguments, _meta = {})
12
- result = ToolsRegistry.tool_call(tool_name, arguments, _meta)
13
- if result.is_error
14
- send_jsonrpc_response(request_id, error: result)
38
+ # Find tool in session's registry
39
+ tool_class = session.registered_tools.find { |t| t.tool_name == tool_name }
40
+
41
+ if tool_class
42
+ # Create tool and set execution context
43
+ tool = tool_class.new(arguments)
44
+ tool.with_context({ session: session })
45
+
46
+ result = tool.call
47
+
48
+ if result.is_error
49
+ send_jsonrpc_response(request_id, error: result)
50
+ else
51
+ send_jsonrpc_response(request_id, result: result)
52
+ end
15
53
  else
16
- send_jsonrpc_response(request_id, result:)
54
+ send_jsonrpc_error(request_id, :method_not_found, "Tool '#{tool_name}' not available in this session")
17
55
  end
18
56
  end
57
+
58
+ private
59
+
60
+ def format_registry_items(registry, protocol_version = nil)
61
+ registry.map { |item| item.klass.to_h(protocol_version: protocol_version) }
62
+ end
19
63
  end
20
64
  end
21
65
  end
@@ -1,13 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO: move all server related code here before version 1.0.0
3
+ require_relative "server/simple_pub_sub"
4
+ require_relative "server/configuration"
5
+
6
+ # Conditionally load adapters based on available gems
7
+ begin
8
+ require "solid_cable/pubsub"
9
+ require_relative "server/solid_cable_adapter"
10
+ rescue LoadError
11
+ # SolidCable not available
12
+ end
13
+
4
14
  module ActionMCP
5
15
  # Module for server-related functionality.
6
16
  module Server
7
17
  module_function
8
18
 
9
19
  def server
10
- @server ||= ActionCable::Server::Base.new
20
+ @server ||= ServerBase.new
21
+ end
22
+
23
+ # Shut down the server and clean up resources
24
+ def shutdown
25
+ return unless @server
26
+ @server.shutdown
27
+ @server = nil
28
+ end
29
+
30
+ # Available pubsub adapter types
31
+ ADAPTERS = {
32
+ "test" => "SimplePubSub",
33
+ "simple" => "SimplePubSub",
34
+ "solid_cable" => "SolidCableAdapter" # Will use mock version in tests
35
+ }.compact.freeze
36
+
37
+ # Custom server base class for PubSub functionality
38
+ class ServerBase
39
+ def initialize(config_path = nil)
40
+ @configuration = Configuration.new(config_path)
41
+ end
42
+
43
+ def pubsub
44
+ @pubsub ||= create_pubsub
45
+ end
46
+
47
+ # Allow manual override of the configuration
48
+ # @param config_path [String] Path to a cable.yml configuration file
49
+ def configure(config_path)
50
+ shutdown_pubsub if @pubsub
51
+ @configuration = Configuration.new(config_path)
52
+ @pubsub = nil # Reset pubsub so it will be recreated with new config
53
+ end
54
+
55
+ # Gracefully shut down the server and its resources
56
+ def shutdown
57
+ shutdown_pubsub
58
+ end
59
+
60
+ private
61
+
62
+ # Shut down the pubsub adapter gracefully
63
+ def shutdown_pubsub
64
+ return unless @pubsub && @pubsub.respond_to?(:shutdown)
65
+
66
+ begin
67
+ @pubsub.shutdown
68
+ rescue => e
69
+ message = "Error shutting down pubsub adapter: #{e.message}"
70
+ Rails.logger.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
71
+ ensure
72
+ @pubsub = nil
73
+ end
74
+ end
75
+
76
+ def create_pubsub
77
+ adapter_name = @configuration.adapter_name
78
+ adapter_options = @configuration.adapter_options
79
+
80
+ # Default to simple if adapter not found
81
+ adapter_class_name = ADAPTERS[adapter_name] || "SimplePubSub"
82
+
83
+ begin
84
+ # Create an instance of the adapter class with the configuration options
85
+ adapter_class = ActionMCP::Server.const_get(adapter_class_name)
86
+ adapter_class.new(adapter_options)
87
+ rescue NameError, LoadError => e
88
+ message = "Error creating adapter #{adapter_name}: #{e.message}"
89
+ Rails.logger.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
90
+ SimplePubSub.new # Fallback to simple pubsub
91
+ end
92
+ end
11
93
  end
12
94
  end
13
95
  end
@@ -4,7 +4,7 @@ require "concurrent/atomic/atomic_boolean"
4
4
  require "concurrent/promise"
5
5
 
6
6
  module ActionMCP
7
- # Listener class to subscribe to session messages via Action Cable adapter.
7
+ # Listener class to subscribe to session messages via PubSub adapter.
8
8
  class SSEListener
9
9
  delegate :session_key, :adapter, to: :@session
10
10
 
@@ -15,22 +15,22 @@ module ActionMCP
15
15
  @subscription_active = Concurrent::AtomicBoolean.new
16
16
  end
17
17
 
18
- # Start listening using ActionCable's adapter
18
+ # Start listening using PubSub adapter
19
19
  # @yield [Hash] Yields parsed message received from the pub/sub channel
20
20
  # @return [Boolean] True if subscription was successful within timeout, false otherwise.
21
21
  def start(&callback)
22
22
  Rails.logger.debug "SSEListener: Starting for channel: #{session_key}"
23
23
 
24
- success_callback = -> {
24
+ success_callback = lambda {
25
25
  Rails.logger.info "SSEListener: Successfully subscribed to channel: #{session_key}"
26
26
  @subscription_active.make_true
27
27
  }
28
28
 
29
- message_callback = ->(raw_message) {
29
+ message_callback = lambda { |raw_message|
30
30
  process_message(raw_message, callback)
31
31
  }
32
32
 
33
- # Subscribe using the ActionCable adapter
33
+ # Subscribe using the PubSub adapter
34
34
  adapter.subscribe(session_key, message_callback, success_callback)
35
35
 
36
36
  wait_for_subscription
@@ -81,6 +81,7 @@ module ActionMCP
81
81
 
82
82
  def valid_json_format?(string)
83
83
  return false if string.blank?
84
+
84
85
  string = string.strip
85
86
  (string.start_with?("{") && string.end_with?("}")) ||
86
87
  (string.start_with?("[") && string.end_with?("]"))
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # frozen_string_literal: true
4
+
5
+ # lib/action_mcp/tagged_io_logging.rb
6
+
7
+ module ActionMCP
8
+ module TaggedStreamLogging
9
+ # ──────────── ANSI COLOURS ────────────
10
+ CLR = "\e[0m"
11
+ BLUE_TX = "\e[34m" # outgoing JSON‑RPC (TX)
12
+ GREEN_RX = "\e[32m" # incoming JSON‑RPC (RX)
13
+ YELLOW_ERR = "\e[33m" # decode / validation warnings
14
+ RED_FATAL = "\e[31m" # unexpected exceptions
15
+
16
+ # ——— Outbound: any frame we ‘write’ to the wire ———
17
+ def write_message(data)
18
+ pretty = json_normalise(data)
19
+ ActionMCP.logger.tagged("MCP", "TX") { ActionMCP.logger.debug("#{BLUE_TX}#{pretty}#{CLR}") }
20
+ super
21
+ rescue StandardError => e
22
+ ActionMCP.logger.tagged("MCP", "TX") { ActionMCP.logger.error("#{RED_FATAL}#{e.message}#{CLR}") }
23
+ raise
24
+ end
25
+
26
+ # ——— Inbound: every raw line handed to the JSON‑RPC handler ———
27
+ def read(line)
28
+ pretty = json_normalise(line)
29
+ ActionMCP.logger.tagged("MCP", "RX") { ActionMCP.logger.debug("#{GREEN_RX}#{pretty}#{CLR}") }
30
+ super
31
+ rescue MultiJson::ParseError => e
32
+ ActionMCP.logger.tagged("MCP", "RX") { ActionMCP.logger.warn("#{YELLOW_ERR}Bad JSON → #{e.message}#{CLR}") }
33
+ raise
34
+ rescue StandardError => e
35
+ ActionMCP.logger.tagged("MCP", "RX") { ActionMCP.logger.error("#{RED_FATAL}#{e.message}#{CLR}") }
36
+ raise
37
+ end
38
+
39
+ private
40
+
41
+ # Accepts String, Hash, or any #to_json‑able object.
42
+ def json_normalise(obj)
43
+ str = obj.is_a?(String) ? obj.strip : MultiJson.dump(obj)
44
+ str.empty? ? "<empty frame>" : str
45
+ end
46
+ end
47
+ end
@@ -3,53 +3,76 @@
3
3
  require "active_support/testing/assertions"
4
4
 
5
5
  module ActionMCP
6
+ #---------------------------------------------------------------------------
7
+ # ActionMCP::TestHelper
8
+ #
9
+ # Include in any `ActiveSupport::TestCase`:
10
+ #
11
+ # include ActionMCP::TestHelper
12
+ #
13
+ # and you get assert_mcp_tool_findable,
14
+ # assert_mcp_prompt_findable,
15
+ # execute_mcp_tool,
16
+ # execute_mcp_prompt,
17
+ # assert_mcp_error_code,
18
+ # assert_mcp_tool_output,
19
+ # assert_mcp_prompt_output.
20
+ #
21
+ # Short alias names (without the prefix) remain for this gem’s own suite but
22
+ # are *not* documented for public use.
23
+ #---------------------------------------------------------------------------
6
24
  module TestHelper
7
25
  include ActiveSupport::Testing::Assertions
8
26
 
9
- # Asserts that a tool is findable in the ToolsRegistry.
10
- # @param [String] tool_name
11
- def assert_tool_findable(tool_name)
12
- assert ActionMCP::ToolsRegistry.tools.key?(tool_name), "Tool #{tool_name} not found in registry"
27
+ # ──── Registry assertions ────────────────────────────────────────────────
28
+ def assert_mcp_tool_findable(name, msg = nil)
29
+ assert ActionMCP::ToolsRegistry.tools.key?(name),
30
+ msg || "Tool #{name.inspect} not found in ToolsRegistry"
13
31
  end
32
+ alias assert_tool_findable assert_mcp_tool_findable
14
33
 
15
- # Asserts that a prompt is findable in the PromptsRegistry.
16
- # @param [String] prompt_name
17
- def assert_prompt_findable(prompt_name)
18
- assert ActionMCP::PromptsRegistry.prompts.key?(prompt_name), "Prompt #{prompt_name} not found in registry"
34
+ def assert_mcp_prompt_findable(name, msg = nil)
35
+ assert ActionMCP::PromptsRegistry.prompts.key?(name),
36
+ msg || "Prompt #{name.inspect} not found in PromptsRegistry"
19
37
  end
38
+ alias assert_prompt_findable assert_mcp_prompt_findable
20
39
 
21
- # Executes a tool with the given name and arguments.
22
- # @param [String] tool_name
23
- # @param [Hash] args
24
- def execute_tool(tool_name, args = {})
25
- result = ActionMCP::ToolsRegistry.tool_call(tool_name, args)
26
- assert_not result.is_error, "Tool #{tool_name} returned an error: #{result.to_h[:message]}"
27
- result
40
+ # ──── Execution helpers (happy‑path only) ────────────────────────────────
41
+ def execute_mcp_tool(name, args = {})
42
+ resp = ActionMCP::ToolsRegistry.tool_call(name, args)
43
+ assert !resp.is_error, "Tool #{name.inspect} returned error: #{resp.to_h[:message]}"
44
+ resp
28
45
  end
46
+ alias execute_tool execute_mcp_tool
29
47
 
30
- # Executes a prompt with the given name and arguments.
31
- # @param [String] prompt_name
32
- # @param [Hash] args
33
- def execute_prompt(prompt_name, args = {})
34
- result = ActionMCP::PromptsRegistry.prompt_call(prompt_name, args)
35
- assert_not result.is_error, "Prompt #{prompt_name} returned an error: #{result.to_h[:message]}"
36
- result
48
+ def execute_mcp_prompt(name, args = {})
49
+ resp = ActionMCP::PromptsRegistry.prompt_call(name, args)
50
+ assert !resp.is_error, "Prompt #{name.inspect} returned error: #{resp.to_h[:message]}"
51
+ resp
37
52
  end
53
+ alias execute_prompt execute_mcp_prompt
38
54
 
39
- # Asserts that the output of a tool is equal to the expected output.
40
- # @param [Hash] expected_output
41
- # @param [ActionMCP::ToolResponse] result
42
- def assert_tool_output(expected_output, result)
43
- assert_equal expected_output, result.to_h[:content],
44
- "Tool output did not match expected output #{expected_output} != #{result.to_h[:content]}"
55
+ # ──── Negative‑path helper ───────────────────────────────────────────────
56
+ def assert_mcp_error_code(code, response, msg = nil)
57
+ assert response.error?, msg || "Expected response to be an error"
58
+ assert_equal code, response.to_h[:code],
59
+ msg || "Expected error code #{code}, got #{response.to_h[:code]}"
45
60
  end
61
+ alias assert_error_code assert_mcp_error_code
46
62
 
47
- # Asserts that the output of a prompt is equal to the expected output.
48
- # @param [Hash] expected_output
49
- # @param [ActionMCP::PromptResponse] result
50
- def assert_prompt_output(expected_output, result)
51
- assert_equal expected_output, result.to_h[:messages],
52
- "Prompt output did not match expected output #{expected_output} != #{result.to_h[:messages]}"
63
+ # ──── Output assertions ─────────────────────────────────────────────────
64
+ def assert_mcp_tool_output(expected, response, msg = nil)
65
+ assert response.success?, msg || "Expected a successful tool response"
66
+ assert_equal expected, response.contents.map(&:to_h),
67
+ msg || "Tool output did not match expected"
53
68
  end
69
+ alias assert_tool_output assert_mcp_tool_output
70
+
71
+ def assert_mcp_prompt_output(expected, response, msg = nil)
72
+ assert response.success?, msg || "Expected a successful prompt response"
73
+ assert_equal expected, response.messages,
74
+ msg || "Prompt output did not match expected"
75
+ end
76
+ alias assert_prompt_output assert_mcp_prompt_output
54
77
  end
55
78
  end