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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -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 +25 -6
  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 +243 -14
  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/json_rpc_handler.rb +35 -9
  23. data/lib/action_mcp/server/notifications.rb +14 -5
  24. data/lib/action_mcp/server/prompts.rb +18 -5
  25. data/lib/action_mcp/server/registry_management.rb +32 -0
  26. data/lib/action_mcp/server/resources.rb +3 -2
  27. data/lib/action_mcp/server/tools.rb +50 -6
  28. data/lib/action_mcp/sse_listener.rb +3 -2
  29. data/lib/action_mcp/tagged_stream_logging.rb +47 -0
  30. data/lib/action_mcp/test_helper.rb +57 -34
  31. data/lib/action_mcp/tool.rb +45 -9
  32. data/lib/action_mcp/version.rb +1 -1
  33. data/lib/action_mcp.rb +4 -4
  34. 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
- return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'protocolVersion'")
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
- # Basic check, could be more specific based on spec requirements
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
- return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'clientInfo'")
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
- return send_jsonrpc_error(request_id, :invalid_params, "Missing or invalid 'capabilities'")
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
- # 3. Store Info and Initialize Session
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) # Store the agreed-upon version
45
+ session.set_protocol_version(client_protocol_version)
44
46
 
45
- # Attempt to initialize (this saves the session if new)
47
+ # Initialize the session
46
48
  unless session.initialize!
47
- # Handle potential initialization failure (e.g., validation error on save)
48
- return send_jsonrpc_error(request_id, :internal_error, "Failed to initialize session")
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
- # 4. Return Success Response Payload
52
- send_jsonrpc_response(request_id, result: session.server_capabilities_payload)
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" # [SERVER] Client initializing the connection
36
+ when "initialize"
15
37
  transport.send_capabilities(id, params)
16
- when %r{^prompts/} # Prompt-related requests
38
+ when %r{^prompts/}
17
39
  process_prompts(rpc_method, id, params)
18
- when %r{^resources/} # Resource-related requests
40
+ when %r{^resources/}
19
41
  process_resources(rpc_method, id, params)
20
- when %r{^tools/} # Tool-related requests
42
+ when %r{^tools/}
21
43
  process_tools(rpc_method, id, params)
22
- when "completion/complete" # Completion requests
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
- # Send progress notification for an asynchronous operation
38
- def send_progress_notification(token:, value:, message: nil)
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
- token: token,
41
- value: value
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("$/progress", params)
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
- prompts = format_registry_items(PromptsRegistry.non_abstract)
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
- result = PromptsRegistry.prompt_call(prompt_name.to_s, params)
13
- if result.is_error
14
- send_jsonrpc_response(request_id, error: result)
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
- send_jsonrpc_response(request_id, result:)
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
- 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
@@ -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 = ->(raw_message) {
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
- # 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
@@ -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 # Create a new response for each invocation
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
- perform # Invoke the subclass-specific logic if valid
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
- # Handle validation failure
148
- @response.mark_as_error!(:invalid_request, message: "Invalid input", data: errors.full_messages)
178
+ @response.mark_as_error!(:invalid_request,
179
+ message: "Invalid input",
180
+ data: errors.full_messages)
149
181
  end
150
182
 
151
- @response # Return the response with collected content
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.31.0"
5
+ VERSION = "0.32.1"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
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
- attr_accessor :server
41
-
41
+ delegate :server, to: "ActionMCP::Server"
42
42
  # Returns the configuration instance.
43
43
  #
44
44
  # @return [Configuration] the configuration instance