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
@@ -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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "erb"
5
+
6
+ module ActionMCP
7
+ module Server
8
+ # Configuration loader for ActionMCP server
9
+ class Configuration
10
+ attr_reader :config
11
+
12
+ def initialize(config_path = nil)
13
+ @config_path = config_path || default_config_path
14
+ @config = load_config
15
+ end
16
+
17
+ # Get the configuration for the current environment
18
+ def for_env(env = nil)
19
+ environment = env || (defined?(Rails) ? Rails.env : "development")
20
+ config[environment] || config["development"] || {}
21
+ end
22
+
23
+ # Get the adapter name for the current environment
24
+ def adapter_name(env = nil)
25
+ env_config = for_env(env)
26
+ env_config["adapter"]
27
+ end
28
+
29
+ # Get the adapter options for the current environment
30
+ def adapter_options(env = nil)
31
+ env_config = for_env(env)
32
+ env_config.except("adapter")
33
+ end
34
+
35
+ private
36
+
37
+ def load_config
38
+ return {} unless File.exist?(@config_path.to_s)
39
+
40
+ yaml = ERB.new(File.read(@config_path)).result
41
+ YAML.safe_load(yaml, aliases: true) || {}
42
+ rescue => e
43
+ Rails.logger.error("Error loading ActionMCP config: #{e.message}") if defined?(Rails) && Rails.respond_to?(:logger)
44
+ {}
45
+ end
46
+
47
+ def default_config_path
48
+ return Rails.root.join("config", "mcp.yml") if defined?(Rails) && Rails.respond_to?(:root)
49
+
50
+ # Fallback to looking for a mcp.yml in the current directory or parent directories
51
+ path = Dir.pwd
52
+ while path != "/"
53
+ config_path = File.join(path, "config", "mcp.yml")
54
+ return config_path if File.exist?(config_path)
55
+ path = File.dirname(path)
56
+ end
57
+
58
+ # Default to an empty config if no mcp.yml found
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ 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 })
@@ -0,0 +1,145 @@
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
+ # Simple in-memory PubSub implementation for testing and development
11
+ class SimplePubSub
12
+ # Thread pool configuration
13
+ DEFAULT_MIN_THREADS = 5
14
+ DEFAULT_MAX_THREADS = 10
15
+ DEFAULT_MAX_QUEUE = 100
16
+ DEFAULT_THREAD_TIMEOUT = 60 # seconds
17
+
18
+ def initialize(options = {})
19
+ @subscriptions = Concurrent::Map.new
20
+ @channels = Concurrent::Map.new
21
+
22
+ # Initialize thread pool for callbacks
23
+ pool_options = {
24
+ min_threads: options["min_threads"] || DEFAULT_MIN_THREADS,
25
+ max_threads: options["max_threads"] || DEFAULT_MAX_THREADS,
26
+ max_queue: options["max_queue"] || DEFAULT_MAX_QUEUE,
27
+ fallback_policy: :caller_runs, # Execute in the caller's thread if queue is full
28
+ idletime: DEFAULT_THREAD_TIMEOUT
29
+ }
30
+ @thread_pool = Concurrent::ThreadPoolExecutor.new(pool_options)
31
+ end
32
+
33
+ # Subscribe to a channel
34
+ # @param channel [String] The channel name
35
+ # @param message_callback [Proc] Callback for received messages
36
+ # @param success_callback [Proc] Callback for successful subscription
37
+ # @return [String] Subscription ID
38
+ def subscribe(channel, message_callback, success_callback = nil)
39
+ subscription_id = SecureRandom.uuid
40
+
41
+ @subscriptions[subscription_id] = {
42
+ channel: channel,
43
+ message_callback: message_callback
44
+ }
45
+
46
+ @channels[channel] ||= Concurrent::Array.new
47
+ @channels[channel] << subscription_id
48
+
49
+ log_subscription_event(channel, "Subscribed", subscription_id)
50
+ success_callback&.call
51
+
52
+ subscription_id
53
+ end
54
+
55
+ # Check if we're already subscribed to a channel
56
+ # @param channel [String] The channel name
57
+ # @return [Boolean] True if we're already subscribed
58
+ def subscribed_to?(channel)
59
+ channel_subs = @channels[channel]
60
+ return false if channel_subs.nil?
61
+ !channel_subs.empty?
62
+ end
63
+
64
+ # Unsubscribe from a channel
65
+ # @param channel [String] The channel name
66
+ # @param callback [Proc] Optional callback for unsubscribe completion
67
+ def unsubscribe(channel, callback = nil)
68
+ # Remove our subscriptions
69
+ subscription_ids = @channels[channel] || []
70
+ subscription_ids.each do |subscription_id|
71
+ @subscriptions.delete(subscription_id)
72
+ end
73
+
74
+ @channels.delete(channel)
75
+
76
+ log_subscription_event(channel, "Unsubscribed")
77
+ callback&.call
78
+ end
79
+
80
+ # Broadcast a message to a channel
81
+ # @param channel [String] The channel name
82
+ # @param message [String] The message to broadcast
83
+ def broadcast(channel, message)
84
+ subscription_ids = @channels[channel] || []
85
+ return if subscription_ids.empty?
86
+
87
+ log_broadcast_event(channel, message)
88
+
89
+ subscription_ids.each do |subscription_id|
90
+ subscription = @subscriptions[subscription_id]
91
+ next unless subscription && subscription[:message_callback]
92
+
93
+ @thread_pool.post do
94
+ begin
95
+ subscription[:message_callback].call(message)
96
+ rescue StandardError => e
97
+ log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ # Check if a channel has subscribers
104
+ # @param channel [String] The channel name
105
+ # @return [Boolean] True if channel has subscribers
106
+ def has_subscribers?(channel)
107
+ subscribers = @channels[channel]
108
+ return false unless subscribers
109
+ !subscribers.empty?
110
+ end
111
+
112
+ # Shut down the thread pool gracefully
113
+ def shutdown
114
+ @thread_pool.shutdown
115
+ @thread_pool.wait_for_termination(5) # Wait up to 5 seconds for tasks to complete
116
+ end
117
+
118
+ private
119
+
120
+ def log_subscription_event(channel, action, subscription_id = nil)
121
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
122
+
123
+ message = "SimplePubSub: #{action} channel=#{channel}"
124
+ message += " subscription_id=#{subscription_id}" if subscription_id
125
+
126
+ Rails.logger.debug(message)
127
+ end
128
+
129
+ def log_broadcast_event(channel, message)
130
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
131
+
132
+ # Truncate the message for logging
133
+ truncated_message = message.to_s[0..100]
134
+ truncated_message += "..." if message.to_s.length > 100
135
+
136
+ Rails.logger.debug("SimplePubSub: Broadcasting to channel=#{channel} message=#{truncated_message}")
137
+ end
138
+
139
+ def log_error(message)
140
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
141
+ Rails.logger.error("SimplePubSub: #{message}")
142
+ end
143
+ end
144
+ end
145
+ end