actionmcp 0.31.0 → 0.32.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.
- checksums.yaml +4 -4
- data/README.md +16 -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 +25 -6
- 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 +243 -14
- 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/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/tools.rb +50 -6
- data/lib/action_mcp/sse_listener.rb +3 -2
- 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
- metadata +25 -20
@@ -10,46 +10,52 @@ module ActionMCP
|
|
10
10
|
# @param params [Hash] The JSON-RPC parameters.
|
11
11
|
# @return [Hash] A hash representing the JSON-RPC response (success or error).
|
12
12
|
def send_capabilities(request_id, params = {})
|
13
|
-
# 1. Validate Parameters
|
14
13
|
client_protocol_version = params["protocolVersion"]
|
15
14
|
client_info = params["clientInfo"]
|
16
15
|
client_capabilities = params["capabilities"]
|
17
16
|
|
18
17
|
unless client_protocol_version.is_a?(String) && client_protocol_version.present?
|
19
|
-
|
18
|
+
send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'protocolVersion'")
|
19
|
+
return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Missing or invalid 'protocolVersion'" } } }
|
20
20
|
end
|
21
|
-
#
|
21
|
+
# Check if the protocol version is supported
|
22
|
+
unless ActionMCP::SUPPORTED_VERSIONS.include?(client_protocol_version)
|
23
|
+
error_data = {
|
24
|
+
supported: ActionMCP::SUPPORTED_VERSIONS,
|
25
|
+
requested: client_protocol_version
|
26
|
+
}
|
27
|
+
send_jsonrpc_error(request_id, :invalid_params, "Unsupported protocol version", error_data)
|
28
|
+
return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Unsupported protocol version", data: error_data } } }
|
29
|
+
end
|
30
|
+
|
22
31
|
unless client_info.is_a?(Hash)
|
23
|
-
|
32
|
+
send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'clientInfo'")
|
33
|
+
return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Missing or invalid 'clientInfo'" } } }
|
24
34
|
end
|
25
35
|
unless client_capabilities.is_a?(Hash)
|
26
|
-
|
36
|
+
send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'capabilities'")
|
37
|
+
return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32602, message: "Missing or invalid 'capabilities'" } } }
|
27
38
|
end
|
28
39
|
|
29
|
-
# 2. Check Protocol Version
|
30
|
-
server_protocol_version = ActionMCP::PROTOCOL_VERSION
|
31
|
-
unless client_protocol_version == server_protocol_version
|
32
|
-
error_data = {
|
33
|
-
supported: [ server_protocol_version ],
|
34
|
-
requested: client_protocol_version
|
35
|
-
}
|
36
|
-
# Using -32602 Invalid Params code as per spec example for version mismatch
|
37
|
-
return send_jsonrpc_error(request_id, :invalid_params, "Unsupported protocol version", error_data)
|
38
|
-
end
|
39
40
|
|
40
|
-
|
41
|
+
|
42
|
+
# Store client information
|
41
43
|
session.store_client_info(client_info)
|
42
44
|
session.store_client_capabilities(client_capabilities)
|
43
|
-
session.set_protocol_version(client_protocol_version)
|
45
|
+
session.set_protocol_version(client_protocol_version)
|
44
46
|
|
45
|
-
#
|
47
|
+
# Initialize the session
|
46
48
|
unless session.initialize!
|
47
|
-
|
48
|
-
return
|
49
|
+
send_jsonrpc_error(request_id, :internal_error, "Failed to initialize session")
|
50
|
+
return { type: :error, id: request_id, payload: { jsonrpc: "2.0", id: request_id, error: { code: -32603, message: "Failed to initialize session" } } }
|
49
51
|
end
|
50
52
|
|
51
|
-
#
|
52
|
-
|
53
|
+
# Send the successful response with the protocol version the client requested
|
54
|
+
capabilities_payload = session.server_capabilities_payload
|
55
|
+
capabilities_payload[:protocolVersion] = client_protocol_version # Use the client's requested version
|
56
|
+
|
57
|
+
send_jsonrpc_response(request_id, result: capabilities_payload)
|
58
|
+
{ type: :responses, id: request_id, payload: { jsonrpc: "2.0", id: request_id, result: capabilities_payload } }
|
53
59
|
end
|
54
60
|
end
|
55
61
|
end
|
@@ -3,29 +3,55 @@
|
|
3
3
|
module ActionMCP
|
4
4
|
module Server
|
5
5
|
class JsonRpcHandler < JsonRpcHandlerBase
|
6
|
-
protected
|
7
|
-
|
8
6
|
# Handle server-specific methods
|
9
7
|
# @param rpc_method [String]
|
10
8
|
# @param id [String, Integer]
|
11
9
|
# @param params [Hash]
|
10
|
+
def call(line)
|
11
|
+
request = if line.is_a?(String)
|
12
|
+
line.strip!
|
13
|
+
return if line.empty?
|
14
|
+
|
15
|
+
begin
|
16
|
+
MultiJson.load(line)
|
17
|
+
rescue MultiJson::ParseError => e
|
18
|
+
Rails.logger.error("Failed to parse JSON: #{e.message}")
|
19
|
+
return
|
20
|
+
end
|
21
|
+
else
|
22
|
+
line
|
23
|
+
end
|
24
|
+
|
25
|
+
# Store the request ID for error responses
|
26
|
+
@current_request_id = request["id"] if request.is_a?(Hash)
|
27
|
+
|
28
|
+
process_request(request)
|
29
|
+
end
|
30
|
+
|
12
31
|
def handle_method(rpc_method, id, params)
|
32
|
+
# Ensure we have the current request ID
|
33
|
+
@current_request_id = id
|
34
|
+
|
13
35
|
case rpc_method
|
14
|
-
when "initialize"
|
36
|
+
when "initialize"
|
15
37
|
transport.send_capabilities(id, params)
|
16
|
-
when %r{^prompts/}
|
38
|
+
when %r{^prompts/}
|
17
39
|
process_prompts(rpc_method, id, params)
|
18
|
-
when %r{^resources/}
|
40
|
+
when %r{^resources/}
|
19
41
|
process_resources(rpc_method, id, params)
|
20
|
-
when %r{^tools/}
|
42
|
+
when %r{^tools/}
|
21
43
|
process_tools(rpc_method, id, params)
|
22
|
-
when "completion/complete"
|
44
|
+
when "completion/complete"
|
23
45
|
process_completion_complete(id, params)
|
24
46
|
else
|
25
|
-
transport.send_jsonrpc_error(id, :method_not_found, "Method not found")
|
47
|
+
transport.send_jsonrpc_error(id, :method_not_found, "Method not found #{rpc_method}")
|
26
48
|
end
|
49
|
+
rescue StandardError => e
|
50
|
+
Rails.logger.error("Error handling method #{rpc_method}: #{e.message}")
|
51
|
+
transport.send_jsonrpc_error(id, :internal_error, "Internal error: #{e.message}")
|
27
52
|
end
|
28
53
|
|
54
|
+
|
29
55
|
# Server methods (client → server)
|
30
56
|
|
31
57
|
# @param id [String]
|
@@ -79,7 +105,7 @@ module ActionMCP
|
|
79
105
|
def process_tools(rpc_method, id, params)
|
80
106
|
case rpc_method
|
81
107
|
when "tools/list" # List available tools
|
82
|
-
transport.send_tools_list(id)
|
108
|
+
transport.send_tools_list(id, params)
|
83
109
|
when "tools/call" # Call a tool
|
84
110
|
transport.send_tools_call(id, params["name"], params["arguments"])
|
85
111
|
else
|
@@ -34,15 +34,24 @@ module ActionMCP
|
|
34
34
|
send_jsonrpc_notification("notifications/logging/message", params)
|
35
35
|
end
|
36
36
|
|
37
|
-
#
|
38
|
-
def send_progress_notification(
|
37
|
+
# Updated to match MCP 2025-03-26 specification
|
38
|
+
def send_progress_notification(progressToken:, progress:, total: nil, message: nil, **options)
|
39
39
|
params = {
|
40
|
-
|
41
|
-
|
40
|
+
progressToken: progressToken,
|
41
|
+
progress: progress
|
42
42
|
}
|
43
|
+
# Only include total and message if they are present (not nil)
|
44
|
+
params[:total] = total unless total.nil?
|
43
45
|
params[:message] = message if message.present?
|
46
|
+
params.merge!(options) if options.any?
|
44
47
|
|
45
|
-
send_jsonrpc_notification("
|
48
|
+
send_jsonrpc_notification("notifications/progress", params)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Backward compatibility method for old API
|
52
|
+
def send_progress_notification_legacy(token:, value:, message: nil)
|
53
|
+
Rails.logger.warn("DEPRECATION: send_progress_notification with token/value is deprecated. Use progressToken/progress instead.")
|
54
|
+
send_progress_notification(progressToken: token, progress: value, message: message)
|
46
55
|
end
|
47
56
|
end
|
48
57
|
end
|
@@ -4,16 +4,29 @@ module ActionMCP
|
|
4
4
|
module Server
|
5
5
|
module Prompts
|
6
6
|
def send_prompts_list(request_id)
|
7
|
-
|
7
|
+
# Use session's registered prompts
|
8
|
+
prompts = session.registered_prompts.map(&:to_h)
|
8
9
|
send_jsonrpc_response(request_id, result: { prompts: prompts })
|
9
10
|
end
|
10
11
|
|
11
12
|
def send_prompts_get(request_id, prompt_name, params)
|
12
|
-
|
13
|
-
|
14
|
-
|
13
|
+
# Find prompt in session's registry
|
14
|
+
prompt_class = session.registered_prompts.find { |p| p.prompt_name == prompt_name }
|
15
|
+
|
16
|
+
if prompt_class
|
17
|
+
# Create prompt and set execution context
|
18
|
+
prompt = prompt_class.new(params)
|
19
|
+
prompt.with_context({ session: session })
|
20
|
+
|
21
|
+
result = prompt.call
|
22
|
+
|
23
|
+
if result.is_error
|
24
|
+
send_jsonrpc_response(request_id, error: result)
|
25
|
+
else
|
26
|
+
send_jsonrpc_response(request_id, result: result)
|
27
|
+
end
|
15
28
|
else
|
16
|
-
|
29
|
+
send_jsonrpc_error(request_id, :method_not_found, "Prompt '#{prompt_name}' not available in this session")
|
17
30
|
end
|
18
31
|
end
|
19
32
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# lib/action_mcp/server/registry_management.rb
|
2
|
+
module ActionMCP
|
3
|
+
module Server
|
4
|
+
module RegistryManagement
|
5
|
+
def send_registry_add_tool(request_id, tool_name)
|
6
|
+
tool_class = ActionMCP::ToolsRegistry.find(tool_name)
|
7
|
+
|
8
|
+
if tool_class
|
9
|
+
session.register_tool(tool_class)
|
10
|
+
send_jsonrpc_response(request_id, result: { success: true })
|
11
|
+
else
|
12
|
+
send_jsonrpc_error(request_id, :invalid_params, "Tool '#{tool_name}' not found")
|
13
|
+
end
|
14
|
+
rescue ActionMCP::RegistryBase::NotFound
|
15
|
+
send_jsonrpc_error(request_id, :invalid_params, "Tool '#{tool_name}' not found")
|
16
|
+
end
|
17
|
+
|
18
|
+
def send_registry_remove_tool(request_id, tool_name)
|
19
|
+
tool_class = session.available_tools.find { |t| t.tool_name == tool_name }
|
20
|
+
|
21
|
+
if tool_class
|
22
|
+
session.unregister_tool(tool_class)
|
23
|
+
send_jsonrpc_response(request_id, result: { success: true })
|
24
|
+
else
|
25
|
+
send_jsonrpc_error(request_id, :invalid_params, "Tool '#{tool_name}' not in session")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Similar methods for prompts and resources
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -46,10 +46,11 @@ module ActionMCP
|
|
46
46
|
# # Sends: {"jsonrpc":"2.0","id":"req-789","result":{"contents":[{"uri":"file:///example.txt","text":"Example content"}]}}
|
47
47
|
def send_resource_read(id, params)
|
48
48
|
if (template = ResourceTemplatesRegistry.find_template_for_uri(params[:uri]))
|
49
|
+
# Create template instance and set execution context
|
49
50
|
record = template.process(params[:uri])
|
51
|
+
record.with_context({ session: session })
|
52
|
+
|
50
53
|
if (resource = record.call)
|
51
|
-
# if resource is a array or a collection, return each item then it ok
|
52
|
-
# else wrap it in a array
|
53
54
|
resource = [ resource ] unless resource.respond_to?(:each)
|
54
55
|
content = resource.map(&:to_h)
|
55
56
|
send_jsonrpc_response(id, result: { contents: content })
|
@@ -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
|
@@ -21,12 +21,12 @@ module ActionMCP
|
|
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
|
|
@@ -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
|
data/lib/action_mcp/tool.rb
CHANGED
@@ -16,6 +16,7 @@ module ActionMCP
|
|
16
16
|
# @return [Array<String>] The required properties of the tool.
|
17
17
|
class_attribute :_schema_properties, instance_accessor: false, default: {}
|
18
18
|
class_attribute :_required_properties, instance_accessor: false, default: []
|
19
|
+
class_attribute :_annotations, instance_accessor: false, default: {}
|
19
20
|
|
20
21
|
# --------------------------------------------------------------------------
|
21
22
|
# Tool Name and Description DSL
|
@@ -45,6 +46,29 @@ module ActionMCP
|
|
45
46
|
def type
|
46
47
|
:tool
|
47
48
|
end
|
49
|
+
|
50
|
+
def annotate(key, value)
|
51
|
+
self._annotations = _annotations.merge(key.to_s => value)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Convenience methods for common annotations
|
55
|
+
def destructive(enabled = true)
|
56
|
+
annotate(:destructive, enabled)
|
57
|
+
end
|
58
|
+
|
59
|
+
def read_only(enabled = true)
|
60
|
+
annotate(:readOnly, enabled)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Return annotations based on protocol version
|
64
|
+
def annotations_for_protocol(protocol_version = nil)
|
65
|
+
# Only include annotations for 2025+ protocols
|
66
|
+
if protocol_version.nil? || protocol_version == "2024-11-05"
|
67
|
+
{}
|
68
|
+
else
|
69
|
+
_annotations
|
70
|
+
end
|
71
|
+
end
|
48
72
|
end
|
49
73
|
|
50
74
|
# --------------------------------------------------------------------------
|
@@ -114,14 +138,21 @@ module ActionMCP
|
|
114
138
|
# Returns a hash representation of the tool definition including its JSON Schema.
|
115
139
|
#
|
116
140
|
# @return [Hash] The tool definition.
|
117
|
-
def self.to_h
|
141
|
+
def self.to_h(protocol_version: nil)
|
118
142
|
schema = { type: "object", properties: _schema_properties }
|
119
143
|
schema[:required] = _required_properties if _required_properties.any?
|
120
|
-
|
144
|
+
|
145
|
+
result = {
|
121
146
|
name: tool_name,
|
122
147
|
description: description.presence,
|
123
148
|
inputSchema: schema
|
124
149
|
}.compact
|
150
|
+
|
151
|
+
# Add annotations if protocol supports them
|
152
|
+
annotations = annotations_for_protocol(protocol_version)
|
153
|
+
result[:annotations] = annotations if annotations.any?
|
154
|
+
|
155
|
+
result
|
125
156
|
end
|
126
157
|
|
127
158
|
# --------------------------------------------------------------------------
|
@@ -131,24 +162,29 @@ module ActionMCP
|
|
131
162
|
# Public entry point for executing the tool
|
132
163
|
# Returns an array of Content objects collected from render calls
|
133
164
|
def call
|
134
|
-
@response = ToolResponse.new
|
165
|
+
@response = ToolResponse.new
|
166
|
+
performed = false # ← track execution
|
135
167
|
|
136
|
-
# Check validations before proceeding
|
137
168
|
if valid?
|
138
169
|
begin
|
139
170
|
run_callbacks :perform do
|
140
|
-
|
171
|
+
performed = true # ← set if we reach the block
|
172
|
+
perform
|
141
173
|
end
|
142
174
|
rescue StandardError => e
|
143
|
-
# Handle exceptions during execution
|
144
175
|
@response.mark_as_error!(:internal_error, message: e.message)
|
145
176
|
end
|
146
177
|
else
|
147
|
-
|
148
|
-
|
178
|
+
@response.mark_as_error!(:invalid_request,
|
179
|
+
message: "Invalid input",
|
180
|
+
data: errors.full_messages)
|
149
181
|
end
|
150
182
|
|
151
|
-
|
183
|
+
# If callbacks halted execution (`performed` still false) and
|
184
|
+
# nothing else marked an error, surface it as invalid_request.
|
185
|
+
@response.mark_as_error!(:invalid_request, message: "Aborted by callback") if !performed && !@response.error?
|
186
|
+
|
187
|
+
@response
|
152
188
|
end
|
153
189
|
|
154
190
|
def inspect
|
data/lib/action_mcp/version.rb
CHANGED
data/lib/action_mcp.rb
CHANGED
@@ -34,11 +34,11 @@ module ActionMCP
|
|
34
34
|
require_relative "action_mcp/version"
|
35
35
|
require_relative "action_mcp/client"
|
36
36
|
include Logging
|
37
|
-
PROTOCOL_VERSION = "2024-11-05"
|
38
|
-
|
37
|
+
PROTOCOL_VERSION = "2024-11-05" # Default version
|
38
|
+
CURRENT_VERSION = "2025-03-26" # Current version for the /mcp endpoint
|
39
|
+
SUPPORTED_VERSIONS = %w[2024-11-05 2025-03-26].freeze
|
39
40
|
class << self
|
40
|
-
|
41
|
-
|
41
|
+
delegate :server, to: "ActionMCP::Server"
|
42
42
|
# Returns the configuration instance.
|
43
43
|
#
|
44
44
|
# @return [Configuration] the configuration instance
|