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.
- checksums.yaml +4 -4
- data/README.md +143 -5
- data/app/controllers/action_mcp/mcp_controller.rb +13 -17
- data/app/controllers/action_mcp/messages_controller.rb +3 -1
- data/app/controllers/action_mcp/sse_controller.rb +22 -4
- data/app/controllers/action_mcp/unified_controller.rb +147 -52
- data/app/models/action_mcp/session/message.rb +1 -0
- data/app/models/action_mcp/session/sse_event.rb +55 -0
- data/app/models/action_mcp/session.rb +235 -12
- data/app/models/concerns/mcp_console_helpers.rb +68 -0
- data/app/models/concerns/mcp_message_inspect.rb +73 -0
- data/config/routes.rb +4 -2
- data/db/migrate/20250329120300_add_registries_to_sessions.rb +9 -0
- data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +16 -0
- data/lib/action_mcp/capability.rb +16 -0
- data/lib/action_mcp/configuration.rb +16 -4
- data/lib/action_mcp/console_detector.rb +12 -0
- data/lib/action_mcp/engine.rb +3 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +1 -1
- data/lib/action_mcp/resource_template.rb +11 -0
- data/lib/action_mcp/server/capabilities.rb +28 -22
- data/lib/action_mcp/server/configuration.rb +63 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +35 -9
- data/lib/action_mcp/server/notifications.rb +14 -5
- data/lib/action_mcp/server/prompts.rb +18 -5
- data/lib/action_mcp/server/registry_management.rb +32 -0
- data/lib/action_mcp/server/resources.rb +3 -2
- data/lib/action_mcp/server/simple_pub_sub.rb +145 -0
- data/lib/action_mcp/server/solid_cable_adapter.rb +222 -0
- data/lib/action_mcp/server/tools.rb +50 -6
- data/lib/action_mcp/server.rb +84 -2
- data/lib/action_mcp/sse_listener.rb +6 -5
- data/lib/action_mcp/tagged_stream_logging.rb +47 -0
- data/lib/action_mcp/test_helper.rb +57 -34
- data/lib/action_mcp/tool.rb +45 -9
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +4 -4
- data/lib/generators/action_mcp/config/config_generator.rb +29 -0
- data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
- 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
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
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
|
-
|
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
|
data/lib/action_mcp/server.rb
CHANGED
@@ -1,13 +1,95 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
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 ||=
|
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
|
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
|
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 =
|
29
|
+
message_callback = lambda { |raw_message|
|
30
30
|
process_message(raw_message, callback)
|
31
31
|
}
|
32
32
|
|
33
|
-
# Subscribe using the
|
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
|
-
#
|
10
|
-
|
11
|
-
|
12
|
-
|
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
|
-
|
16
|
-
|
17
|
-
|
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
|
-
#
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
#
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
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
|
-
#
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|