actionmcp 0.50.1 → 0.50.3

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.
@@ -4,37 +4,30 @@ module ActionMCP
4
4
  module Server
5
5
  class JsonRpcHandler < JsonRpcHandlerBase
6
6
  # Handle server-specific methods
7
- # @param rpc_method [String]
8
- # @param id [String, Integer]
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
7
+ # @param request [JSON_RPC::Request, JSON_RPC::Notification, JSON_RPC::Response]
8
+ def call(request)
9
+ read(request.to_h)
10
+ case request
11
+ when JSON_RPC::Request
12
+ handle_request(request)
13
+ when JSON_RPC::Notification
14
+ handle_notification(request)
15
+ when JSON_RPC::Response
16
+ handle_response(request)
23
17
  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
18
  end
30
19
 
31
- def handle_method(rpc_method, id, params)
32
- # Ensure we have the current request ID
33
- @current_request_id = id
20
+ private
21
+
22
+ def handle_request(request)
23
+ @current_request_id = id = request.id
24
+ rpc_method = request.method
25
+ params = request.params
34
26
 
35
27
  case rpc_method
36
28
  when "initialize"
37
- transport.send_capabilities(id, params)
29
+ message = transport.send_capabilities(id, params)
30
+ extract_message_payload(message, id)
38
31
  when %r{^prompts/}
39
32
  process_prompts(rpc_method, id, params)
40
33
  when %r{^resources/}
@@ -44,104 +37,178 @@ module ActionMCP
44
37
  when "completion/complete"
45
38
  process_completion_complete(id, params)
46
39
  else
47
- transport.send_jsonrpc_error(id, :method_not_found, "Method not found #{rpc_method}")
40
+ error_response(id, :method_not_found, "Method not found: #{rpc_method}")
48
41
  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}")
52
42
  end
53
43
 
44
+ def handle_notification(notification)
45
+ @current_request_id = nil
54
46
 
55
- # Server methods (client → server)
56
-
57
- # @param id [String]
58
- # @param params [Hash]
59
- # @example {
60
- # "ref": {
61
- # "type": "ref/prompt",
62
- # "name": "code_review"
63
- # },
64
- # "argument": {
65
- # "name": "language",
66
- # "value": "py"
67
- # }
68
- # }
69
- # @return [Hash]
70
- # @example {
71
- # "completion": {
72
- # "values": ["python", "pytorch", "pyside"],
73
- # "total": 10,
74
- # "hasMore": true
75
- # }
76
- # }
77
- def process_completion_complete(id, params)
78
- # TODO: Not Implemented, but to remove the error message in the inspector
79
- transport.send_jsonrpc_response(id, result: { completion: { values: [], total: 0, hasMore: false } })
80
- case params["ref"]["type"]
81
- when "ref/prompt"
82
- # TODO: Implement completion
83
- when "ref/resource"
84
- # TODO: Implement completion
47
+ begin
48
+ method_name = notification.method.to_s
49
+ params = notification.params || {}
50
+
51
+ process_notifications(method_name, params)
52
+ { type: :notifications_only }
53
+ rescue StandardError => e
54
+ Rails.logger.error("Error handling notification #{notification.method}: #{e.message}")
55
+ Rails.logger.error(e.backtrace.join("\n"))
56
+ { type: :notifications_only }
85
57
  end
86
58
  end
87
59
 
88
- # @param rpc_method [String]
89
- # @param id [String]
90
- # @param params [Hash]
60
+ def handle_response(response)
61
+ Rails.logger.debug("Received response: #{response.inspect}")
62
+ {
63
+ type: :responses,
64
+ request_id: response.id,
65
+ payload: {
66
+ jsonrpc: "2.0",
67
+ id: response.id,
68
+ result: response.result
69
+ }
70
+ }
71
+ end
72
+
91
73
  def process_prompts(rpc_method, id, params)
74
+ params ||= {}
75
+
92
76
  case rpc_method
93
- when "prompts/get" # Get specific prompt
94
- transport.send_prompts_get(id, params["name"], params["arguments"])
95
- when "prompts/list" # List available prompts
96
- transport.send_prompts_list(id)
77
+ when "prompts/get"
78
+ name = params["name"] || params[:name]
79
+ arguments = params["arguments"] || params[:arguments] || {}
80
+ message = transport.send_prompts_get(id, name, arguments)
81
+ extract_message_payload(message, id)
82
+ when "prompts/list"
83
+ message = transport.send_prompts_list(id)
84
+ extract_message_payload(message, id)
97
85
  else
98
86
  Rails.logger.warn("Unknown prompts method: #{rpc_method}")
87
+ error_response(id, :method_not_found, "Unknown prompts method: #{rpc_method}")
99
88
  end
100
89
  end
101
90
 
102
- # @param rpc_method [String]
103
- # @param id [String]
104
- # @param params [Hash]
105
91
  def process_tools(rpc_method, id, params)
92
+ params ||= {}
93
+
106
94
  case rpc_method
107
- when "tools/list" # List available tools
108
- transport.send_tools_list(id, params)
109
- when "tools/call" # Call a tool
110
- transport.send_tools_call(id, params["name"], params["arguments"])
95
+ when "tools/list"
96
+ message = transport.send_tools_list(id, params)
97
+ extract_message_payload(message, id)
98
+ when "tools/call"
99
+ name = params["name"] || params[:name]
100
+ arguments = params["arguments"] || params[:arguments] || {}
101
+
102
+ return error_response(id, :invalid_params, "Tool name is required") if name.nil?
103
+
104
+ message = transport.send_tools_call(id, name, arguments)
105
+ extract_message_payload(message, id)
111
106
  else
112
107
  Rails.logger.warn("Unknown tools method: #{rpc_method}")
108
+ error_response(id, :method_not_found, "Unknown tools method: #{rpc_method}")
113
109
  end
114
110
  end
115
111
 
116
- # @param rpc_method [String]
117
- # @param id [String]
118
- # @param params [Hash]
119
112
  def process_resources(rpc_method, id, params)
113
+ params ||= {}
114
+
120
115
  case rpc_method
121
- when "resources/list" # List available resources
122
- transport.send_resources_list(id)
123
- when "resources/templates/list" # List resource templates
124
- transport.send_resource_templates_list(id)
125
- when "resources/read" # Read resource content
126
- transport.send_resource_read(id, params)
127
- when "resources/subscribe" # Subscribe to resource updates
128
- transport.send_resource_subscribe(id, params["uri"])
129
- when "resources/unsubscribe" # Unsubscribe from resource updates
130
- transport.send_resource_unsubscribe(id, params["uri"])
116
+ when "resources/list"
117
+ message = transport.send_resources_list(id)
118
+ extract_message_payload(message, id)
119
+ when "resources/templates/list"
120
+ message = transport.send_resource_templates_list(id)
121
+ extract_message_payload(message, id)
122
+ when "resources/read"
123
+ return error_response(id, :invalid_params, "Resource URI is required") if params.nil? || params.empty?
124
+
125
+ message = transport.send_resource_read(id, params)
126
+ extract_message_payload(message, id)
127
+ when "resources/subscribe"
128
+ uri = params["uri"] || params[:uri]
129
+ return error_response(id, :invalid_params, "Resource URI is required") if uri.nil?
130
+
131
+ message = transport.send_resource_subscribe(id, uri)
132
+ extract_message_payload(message, id)
133
+ when "resources/unsubscribe"
134
+ uri = params["uri"] || params[:uri]
135
+ return error_response(id, :invalid_params, "Resource URI is required") if uri.nil?
136
+
137
+ message = transport.send_resource_unsubscribe(id, uri)
138
+ extract_message_payload(message, id)
131
139
  else
132
140
  Rails.logger.warn("Unknown resources method: #{rpc_method}")
141
+ error_response(id, :method_not_found, "Unknown resources method: #{rpc_method}")
142
+ end
143
+ end
144
+
145
+ def process_completion_complete(id, params)
146
+ params ||= {}
147
+
148
+ result = transport.send_jsonrpc_response(id, result: {
149
+ completion: { values: [], total: 0, hasMore: false }
150
+ })
151
+
152
+ if result.is_a?(ActionMCP::Session::Message)
153
+ extract_message_payload(result, id)
154
+ else
155
+ wrap_transport_result(result, id)
133
156
  end
134
157
  end
135
158
 
136
159
  def process_notifications(rpc_method, params)
137
160
  case rpc_method
138
- when "notifications/initialized" # Client initialization complete
139
- puts "\e[31mInitialized\e[0m"
161
+ when "notifications/initialized"
162
+ Rails.logger.info "Client notified initialization complete"
140
163
  transport.initialize!
141
164
  else
142
165
  super
143
166
  end
144
167
  end
168
+
169
+ def extract_message_payload(message, id)
170
+ if message.is_a?(ActionMCP::Session::Message)
171
+ {
172
+ type: :responses,
173
+ request_id: id,
174
+ payload: message.message_json
175
+ }
176
+ else
177
+ message
178
+ end
179
+ end
180
+
181
+ def wrap_transport_result(transport_result, id)
182
+ if transport_result.is_a?(Hash) && transport_result[:type]
183
+ transport_result
184
+ else
185
+ {
186
+ type: :responses,
187
+ request_id: id,
188
+ payload: transport_result
189
+ }
190
+ end
191
+ end
192
+
193
+ def error_response(id, code, message)
194
+ error_code = case code
195
+ when :method_not_found then -32_601
196
+ when :invalid_params then -32_602
197
+ when :internal_error then -32_603
198
+ else -32_000
199
+ end
200
+
201
+ {
202
+ type: :error,
203
+ request_id: id,
204
+ payload: {
205
+ jsonrpc: "2.0",
206
+ id: id,
207
+ error: { code: error_code, message: message }
208
+ },
209
+ status: :bad_request
210
+ }
211
+ end
145
212
  end
146
213
  end
147
214
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # lib/action_mcp/server/registry_management.rb
2
4
  module ActionMCP
3
5
  module Server
@@ -13,7 +13,7 @@ module ActionMCP
13
13
  DEFAULT_MIN_THREADS = 5
14
14
  DEFAULT_MAX_THREADS = 10
15
15
  DEFAULT_MAX_QUEUE = 100
16
- DEFAULT_THREAD_TIMEOUT = 60 # seconds
16
+ DEFAULT_THREAD_TIMEOUT = 60 # seconds
17
17
 
18
18
  def initialize(options = {})
19
19
  @subscriptions = Concurrent::Map.new
@@ -58,6 +58,7 @@ module ActionMCP
58
58
  def subscribed_to?(channel)
59
59
  channel_subs = @channels[channel]
60
60
  return false if channel_subs.nil?
61
+
61
62
  !channel_subs.empty?
62
63
  end
63
64
 
@@ -91,11 +92,9 @@ module ActionMCP
91
92
  next unless subscription && subscription[:message_callback]
92
93
 
93
94
  @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
95
+ subscription[:message_callback].call(message)
96
+ rescue StandardError => e
97
+ log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
99
98
  end
100
99
  end
101
100
  end
@@ -106,6 +105,7 @@ module ActionMCP
106
105
  def has_subscribers?(channel)
107
106
  subscribers = @channels[channel]
108
107
  return false unless subscribers
108
+
109
109
  !subscribers.empty?
110
110
  end
111
111
 
@@ -138,6 +138,7 @@ module ActionMCP
138
138
 
139
139
  def log_error(message)
140
140
  return unless defined?(Rails) && Rails.respond_to?(:logger)
141
+
141
142
  Rails.logger.error("SimplePubSub: #{message}")
142
143
  end
143
144
  end
@@ -39,13 +39,13 @@ module ActionMCP
39
39
  DEFAULT_MIN_THREADS = 5
40
40
  DEFAULT_MAX_THREADS = 10
41
41
  DEFAULT_MAX_QUEUE = 100
42
- DEFAULT_THREAD_TIMEOUT = 60 # seconds
42
+ DEFAULT_THREAD_TIMEOUT = 60 # seconds
43
43
 
44
44
  def initialize(options = {})
45
45
  @options = options
46
46
  @subscriptions = Concurrent::Map.new
47
47
  @channels = Concurrent::Map.new
48
- @channel_subscribed = Concurrent::Map.new # Track channel subscription status
48
+ @channel_subscribed = Concurrent::Map.new # Track channel subscription status
49
49
 
50
50
  # Initialize thread pool for callbacks
51
51
  pool_options = {
@@ -69,15 +69,13 @@ module ActionMCP
69
69
  end
70
70
 
71
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
72
+ pubsub_options[:connects_to] = @options["connects_to"] if @options["connects_to"]
75
73
 
76
74
  # Use mock version for testing or real version in production
77
- if defined?(SolidCable) && !testing?
78
- @solid_cable_pubsub = SolidCable::PubSub.new(pubsub_options)
75
+ @solid_cable_pubsub = if defined?(SolidCable) && !testing?
76
+ SolidCable::PubSub.new(pubsub_options)
79
77
  else
80
- @solid_cable_pubsub = MockSolidCablePubSub.new(pubsub_options)
78
+ MockSolidCablePubSub.new(pubsub_options)
81
79
  end
82
80
  end
83
81
 
@@ -147,6 +145,7 @@ module ActionMCP
147
145
  def has_subscribers?(channel)
148
146
  subscribers = @channels[channel]
149
147
  return false unless subscribers
148
+
150
149
  !subscribers.empty?
151
150
  end
152
151
 
@@ -156,6 +155,7 @@ module ActionMCP
156
155
  def subscribed_to?(channel)
157
156
  channel_subs = @channels[channel]
158
157
  return false if channel_subs.nil?
158
+
159
159
  !channel_subs.empty?
160
160
  end
161
161
 
@@ -185,11 +185,9 @@ module ActionMCP
185
185
  next unless subscription && subscription[:message_callback]
186
186
 
187
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
188
+ subscription[:message_callback].call(message)
189
+ rescue StandardError => e
190
+ log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
193
191
  end
194
192
  end
195
193
  end
@@ -215,6 +213,7 @@ module ActionMCP
215
213
 
216
214
  def log_error(message)
217
215
  return unless defined?(Rails) && Rails.respond_to?(:logger)
216
+
218
217
  Rails.logger.error("SolidCableAdapter: #{message}")
219
218
  end
220
219
  end
@@ -18,9 +18,9 @@ module ActionMCP
18
18
  end
19
19
 
20
20
  # Use session's registered tools instead of global registry
21
- tools = session.registered_tools.map { |tool_class|
21
+ tools = session.registered_tools.map do |tool_class|
22
22
  tool_class.to_h(protocol_version: protocol_version)
23
- }
23
+ end
24
24
 
25
25
  # Send completion progress notification if token is provided
26
26
  if progress_token
@@ -23,6 +23,7 @@ module ActionMCP
23
23
  # Shut down the server and clean up resources
24
24
  def shutdown
25
25
  return unless @server
26
+
26
27
  @server.shutdown
27
28
  @server = nil
28
29
  end
@@ -49,7 +50,7 @@ module ActionMCP
49
50
  def configure(config_path)
50
51
  shutdown_pubsub if @pubsub
51
52
  @configuration = Configuration.new(config_path)
52
- @pubsub = nil # Reset pubsub so it will be recreated with new config
53
+ @pubsub = nil # Reset pubsub so it will be recreated with new config
53
54
  end
54
55
 
55
56
  # Gracefully shut down the server and its resources
@@ -61,11 +62,11 @@ module ActionMCP
61
62
 
62
63
  # Shut down the pubsub adapter gracefully
63
64
  def shutdown_pubsub
64
- return unless @pubsub && @pubsub.respond_to?(:shutdown)
65
+ return unless @pubsub.respond_to?(:shutdown)
65
66
 
66
67
  begin
67
68
  @pubsub.shutdown
68
- rescue => e
69
+ rescue StandardError => e
69
70
  message = "Error shutting down pubsub adapter: #{e.message}"
70
71
  Rails.logger.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
71
72
  ensure
@@ -87,7 +88,7 @@ module ActionMCP
87
88
  rescue NameError, LoadError => e
88
89
  message = "Error creating adapter #{adapter_name}: #{e.message}"
89
90
  Rails.logger.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
90
- SimplePubSub.new # Fallback to simple pubsub
91
+ SimplePubSub.new # Fallback to simple pubsub
91
92
  end
92
93
  end
93
94
  end
@@ -61,7 +61,7 @@ module ActionMCP
61
61
  end
62
62
 
63
63
  # Return annotations for the tool
64
- def annotations_for_protocol(protocol_version = nil)
64
+ def annotations_for_protocol(_protocol_version = nil)
65
65
  # Always include annotations now that we only support 2025+
66
66
  _annotations
67
67
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.50.1"
5
+ VERSION = "0.50.3"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.50.1
4
+ version: 0.50.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: 8.0.1
26
+ - !ruby/object:Gem::Dependency
27
+ name: concurrent-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 1.3.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 1.3.1
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: jsonrpc-rails
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -79,20 +93,6 @@ dependencies:
79
93
  - - "~>"
80
94
  - !ruby/object:Gem::Version
81
95
  version: '2.6'
82
- - !ruby/object:Gem::Dependency
83
- name: concurrent-ruby
84
- requirement: !ruby/object:Gem::Requirement
85
- requirements:
86
- - - ">="
87
- - !ruby/object:Gem::Version
88
- version: 1.3.1
89
- type: :runtime
90
- prerelease: false
91
- version_requirements: !ruby/object:Gem::Requirement
92
- requirements:
93
- - - ">="
94
- - !ruby/object:Gem::Version
95
- version: 1.3.1
96
96
  description: It offers base classes and helpers for creating MCP applications, making
97
97
  it easier to integrate your Ruby/Rails application with the MCP standard
98
98
  email:
@@ -105,8 +105,7 @@ files:
105
105
  - MIT-LICENSE
106
106
  - README.md
107
107
  - Rakefile
108
- - app/controllers/action_mcp/mcp_controller.rb
109
- - app/controllers/action_mcp/unified_controller.rb
108
+ - app/controllers/action_mcp/application_controller.rb
110
109
  - app/models/action_mcp.rb
111
110
  - app/models/action_mcp/application_record.rb
112
111
  - app/models/action_mcp/session.rb
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- class MCPController < ActionController::Metal
5
- abstract!
6
- ActionController::API.without_modules(:StrongParameters, :ParamsWrapper).each do |left|
7
- include left
8
- end
9
- include Engine.routes.url_helpers
10
-
11
- # Header name for MCP Session ID (as per 2025-03-26 spec)
12
- MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
13
-
14
- # Provides the ActionMCP::Session for the current request.
15
- # Handles finding existing sessions via header/param or initializing a new one.
16
- # Specific controllers/handlers might need to enforce session ID presence based on context.
17
- # @return [ActionMCP::Session] The session object (might be unsaved if new)
18
- def mcp_session
19
- @mcp_session ||= find_or_initialize_session
20
- end
21
-
22
- # Provides a unique key for caching or pub/sub based on the session ID.
23
- # Ensures mcp_session is called first to establish the session ID.
24
- # @return [String] The session key string.
25
- def session_key
26
- @session_key ||= "action_mcp-sessions-#{mcp_session.id}"
27
- end
28
-
29
- private
30
-
31
- # Finds an existing session based on header or param, or initializes a new one.
32
- # Note: This doesn't save the new session; that happens upon first use or explicitly.
33
- def find_or_initialize_session
34
- session_id = extract_session_id
35
- if session_id
36
- session = Session.find_by(id: session_id)
37
- if session && session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
38
- # Update existing session to use 2025 protocol
39
- session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
40
- end
41
- session
42
- else
43
- # Create new session with 2025 protocol
44
- Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
45
- end
46
- end
47
-
48
- # Renders a 400 Bad Request response with a JSON-RPC-like error structure.
49
- def render_bad_request(message = "Bad Request")
50
- # Using -32600 for Invalid Request based on JSON-RPC spec
51
- render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
52
- end
53
-
54
- # Renders a 404 Not Found response with a JSON-RPC-like error structure.
55
- def render_not_found(message = "Not Found")
56
- # Using a custom code or a generic server error range code might be appropriate.
57
- # Let's use -32001 for a generic server error.
58
- render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
59
- end
60
-
61
- # Renders a 405 Method Not Allowed response.
62
- def render_method_not_allowed(message = "Method Not Allowed")
63
- # Using -32601 Method not found from JSON-RPC spec seems applicable
64
- render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
65
- end
66
-
67
- # Renders a 406 Not Acceptable response.
68
- def render_not_acceptable(message = "Not Acceptable")
69
- # No direct JSON-RPC equivalent, using a generic server error code.
70
- render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
71
- end
72
-
73
- # Renders a 501 Not Implemented response.
74
- def render_not_implemented(message = "Not Implemented")
75
- # No direct JSON-RPC equivalent, using a generic server error code.
76
- render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
77
- end
78
- end
79
- end