actionmcp 0.32.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54b807ad23c23796ef5495fb91afffb9ee10d998516a72d20897c519bed56c4c
4
- data.tar.gz: e5ec996f8e8dcb73383de34fe5f851382961a74a27e3b28c26271aadb343c30f
3
+ metadata.gz: 49bbf32664efb30dff032e7ab8ff3e76d972016cc7cf1a1a966c61cc507d7680
4
+ data.tar.gz: 61182b5e6367ecbbe7db4446a8da8a7ae756dd164e6b302a99f5583be64b3c8c
5
5
  SHA512:
6
- metadata.gz: 55938823b5df5d13b8ffe2adbb9e581331e60fabb2f7111807fa10b119cd898f2ab3dd695cbda8924af0b9be79cf9eb0767b18324a1042ac46fb4facb6562abe
7
- data.tar.gz: 76c6c5a553e55f06b61165844a3d55e45854dffe515879f14eaaf5b1399aa8f3273563b3c78cf028bab54547cc3165947cd5e2606e19f96be55285c845c641ef
6
+ metadata.gz: c2eac5bcb5a430ae67223000f3741f15acf4d6e1fbdd9d9f240640768d4da6e9b62d2ca0ec303be327ae818f1af86ad458b22e30fed6312cb710d02710532b8c
7
+ data.tar.gz: a39eb66c2dd002fd821c2db65b3f848c2a68e5d219d46efb0736d5501572235021be2d188cffd6783a14d8e0704737f313f9f225c2542c2d5ca047af08b9193e
data/README.md CHANGED
@@ -221,9 +221,136 @@ end
221
221
 
222
222
  For dynamic versioning, consider adding the `rails_app_version` gem.
223
223
 
224
+ ### PubSub Configuration
225
+
226
+ ActionMCP uses a pub/sub system for real-time communication. You can choose between several adapters:
227
+
228
+ 1. **SolidCable** - Database-backed pub/sub (no Redis required)
229
+ 2. **Simple** - In-memory pub/sub for development and testing
230
+ 3. **Redis** - Redis-backed pub/sub (if you prefer Redis)
231
+
232
+ #### Migrating from ActionCable
233
+
234
+ If you were previously using ActionCable with ActionMCP, you will need to migrate to the new PubSub system. Here's how:
235
+
236
+ 1. Remove the ActionCable dependency from your Gemfile (if you don't need it for other purposes)
237
+ 2. Install one of the PubSub adapters (SolidCable recommended)
238
+ 3. Create a configuration file at `config/mcp.yml` (you can use the generator: `bin/rails g action_mcp:config`)
239
+ 4. Run your tests to ensure everything works correctly
240
+
241
+ The new PubSub system maintains the same API as the previous ActionCable-based implementation, so your existing code should continue to work without changes.
242
+
243
+ Configure your adapter in `config/mcp.yml`:
244
+
245
+ ```yaml
246
+ development:
247
+ adapter: solid_cable
248
+ polling_interval: 0.1.seconds
249
+ # Thread pool configuration (optional)
250
+ # min_threads: 5 # Minimum number of threads in the pool
251
+ # max_threads: 10 # Maximum number of threads in the pool
252
+ # max_queue: 100 # Maximum number of tasks that can be queued
253
+
254
+ test:
255
+ adapter: test # Uses the simple in-memory adapter
256
+
257
+ production:
258
+ adapter: solid_cable
259
+ polling_interval: 0.5.seconds
260
+ # Optional: connects_to: cable # If using a separate database
261
+
262
+ # Thread pool configuration for high-traffic environments
263
+ min_threads: 10 # Minimum number of threads in the pool
264
+ max_threads: 20 # Maximum number of threads in the pool
265
+ max_queue: 500 # Maximum number of tasks that can be queued
266
+ ```
267
+
268
+ #### SolidCable (Database-backed, Recommended)
269
+
270
+ For SolidCable, add it to your Gemfile:
271
+
272
+ ```ruby
273
+ gem "solid_cable" # Database-backed adapter (no Redis needed)
274
+ ```
275
+
276
+ Then install it:
277
+
278
+ ```bash
279
+ bundle install
280
+ bin/rails solid_cable:install
281
+ ```
282
+
283
+ The installer will create the necessary database migration. You'll need to configure it in your `config/mcp.yml`. You can create this file with `bin/rails g action_mcp:config`.
284
+
285
+ #### Redis Adapter
286
+
287
+ If you prefer Redis, add it to your Gemfile:
288
+
289
+ ```ruby
290
+ gem "redis", "~> 5.0"
291
+ ```
292
+
293
+ Then configure the Redis adapter in your `config/mcp.yml`:
294
+
295
+ ```yaml
296
+ production:
297
+ adapter: redis
298
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
299
+ channel_prefix: your_app_production
300
+
301
+ # Thread pool configuration for high-traffic environments
302
+ min_threads: 10 # Minimum number of threads in the pool
303
+ max_threads: 20 # Maximum number of threads in the pool
304
+ max_queue: 500 # Maximum number of tasks that can be queued
305
+ ```
306
+
307
+ ## Thread Pool Management
308
+
309
+ ActionMCP uses thread pools to efficiently handle message callbacks. This prevents the system from being overwhelmed by too many threads under high load.
310
+
311
+ ### Thread Pool Configuration
312
+
313
+ You can configure the thread pool in your `config/mcp.yml`:
314
+
315
+ ```yaml
316
+ production:
317
+ adapter: solid_cable
318
+ # Thread pool configuration
319
+ min_threads: 10 # Minimum number of threads to keep in the pool
320
+ max_threads: 20 # Maximum number of threads the pool can grow to
321
+ max_queue: 500 # Maximum number of tasks that can be queued
322
+ ```
323
+
324
+ The thread pool will automatically:
325
+ - Start with `min_threads` threads
326
+ - Scale up to `max_threads` as needed
327
+ - Queue tasks up to `max_queue` limit
328
+ - Use caller's thread if queue is full (fallback policy)
329
+
330
+ ### Graceful Shutdown
331
+
332
+ When your application is shutting down, you should call:
333
+
334
+ ```ruby
335
+ ActionMCP::Server.shutdown
336
+ ```
337
+
338
+ This ensures all thread pools are properly terminated and tasks are completed.
339
+
224
340
  ## Engine and Mounting
225
341
 
226
- **ActionMCP** runs as a standalone Rack application, similar to **ActionCable**. It is **not** mounted in `routes.rb`.
342
+ **ActionMCP** runs as a standalone Rack application. It is **not** mounted in `routes.rb`.
343
+
344
+ ### Installing the Configuration Generator
345
+
346
+ ActionMCP includes a generator to help you create the configuration file:
347
+
348
+ ```bash
349
+ # Generate the mcp.yml configuration file
350
+ bin/rails generate action_mcp:config
351
+ ```
352
+
353
+ This will create `config/mcp.yml` with example configurations for all environments.
227
354
 
228
355
  > **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
229
356
 
@@ -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
@@ -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
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "concurrent/map"
5
+ require "concurrent/array"
6
+ require "concurrent/executor/thread_pool_executor"
7
+
8
+ module ActionMCP
9
+ module Server
10
+ # Mock SolidCable::PubSub for testing
11
+ class MockSolidCablePubSub
12
+ attr_reader :subscriptions, :messages
13
+
14
+ def initialize(options = {})
15
+ @options = options
16
+ @subscriptions = Concurrent::Map.new
17
+ @messages = Concurrent::Array.new
18
+ end
19
+
20
+ def subscribe(channel, &block)
21
+ @subscriptions[channel] ||= Concurrent::Array.new
22
+ @subscriptions[channel] << block
23
+ end
24
+
25
+ def unsubscribe(channel)
26
+ @subscriptions.delete(channel)
27
+ end
28
+
29
+ def broadcast(channel, message)
30
+ @messages << { channel: channel, message: message }
31
+ callbacks = @subscriptions[channel] || []
32
+ callbacks.each { |callback| callback.call(message) }
33
+ end
34
+ end
35
+
36
+ # Adapter for SolidCable PubSub
37
+ class SolidCableAdapter
38
+ # Thread pool configuration
39
+ DEFAULT_MIN_THREADS = 5
40
+ DEFAULT_MAX_THREADS = 10
41
+ DEFAULT_MAX_QUEUE = 100
42
+ DEFAULT_THREAD_TIMEOUT = 60 # seconds
43
+
44
+ def initialize(options = {})
45
+ @options = options
46
+ @subscriptions = Concurrent::Map.new
47
+ @channels = Concurrent::Map.new
48
+ @channel_subscribed = Concurrent::Map.new # Track channel subscription status
49
+
50
+ # Initialize thread pool for callbacks
51
+ pool_options = {
52
+ min_threads: options["min_threads"] || DEFAULT_MIN_THREADS,
53
+ max_threads: options["max_threads"] || DEFAULT_MAX_THREADS,
54
+ max_queue: options["max_queue"] || DEFAULT_MAX_QUEUE,
55
+ fallback_policy: :caller_runs, # Execute in the caller's thread if queue is full
56
+ idletime: DEFAULT_THREAD_TIMEOUT
57
+ }
58
+ @thread_pool = Concurrent::ThreadPoolExecutor.new(pool_options)
59
+
60
+ # Configure SolidCable with options from mcp.yml
61
+ # The main option we care about is polling_interval
62
+ pubsub_options = {}
63
+
64
+ if @options["polling_interval"]
65
+ # Convert from ActiveSupport::Duration if needed (e.g., "0.1.seconds")
66
+ interval = @options["polling_interval"]
67
+ interval = interval.to_f if interval.respond_to?(:to_f)
68
+ pubsub_options[:polling_interval] = interval
69
+ end
70
+
71
+ # If there's a connects_to option, pass it along
72
+ if @options["connects_to"]
73
+ pubsub_options[:connects_to] = @options["connects_to"]
74
+ end
75
+
76
+ # Use mock version for testing or real version in production
77
+ if defined?(SolidCable) && !testing?
78
+ @solid_cable_pubsub = SolidCable::PubSub.new(pubsub_options)
79
+ else
80
+ @solid_cable_pubsub = MockSolidCablePubSub.new(pubsub_options)
81
+ end
82
+ end
83
+
84
+ # Subscribe to a channel
85
+ # @param channel [String] The channel name
86
+ # @param message_callback [Proc] Callback for received messages
87
+ # @param success_callback [Proc] Callback for successful subscription
88
+ # @return [String] Subscription ID
89
+ def subscribe(channel, message_callback, success_callback = nil)
90
+ subscription_id = SecureRandom.uuid
91
+
92
+ @subscriptions[subscription_id] = {
93
+ channel: channel,
94
+ message_callback: message_callback
95
+ }
96
+
97
+ @channels[channel] ||= Concurrent::Array.new
98
+ @channels[channel] << subscription_id
99
+
100
+ # Subscribe to SolidCable only if we haven't already subscribed to this channel
101
+ unless subscribed_to_solid_cable?(channel)
102
+ @solid_cable_pubsub.subscribe(channel) do |message|
103
+ dispatch_message(channel, message)
104
+ end
105
+ @channel_subscribed[channel] = true
106
+ end
107
+
108
+ log_subscription_event(channel, "Subscribed", subscription_id)
109
+ success_callback&.call
110
+
111
+ subscription_id
112
+ end
113
+
114
+ # Unsubscribe from a channel
115
+ # @param channel [String] The channel name
116
+ # @param callback [Proc] Optional callback for unsubscribe completion
117
+ def unsubscribe(channel, callback = nil)
118
+ # Remove our subscriptions
119
+ subscription_ids = @channels[channel] || []
120
+ subscription_ids.each do |subscription_id|
121
+ @subscriptions.delete(subscription_id)
122
+ end
123
+
124
+ @channels.delete(channel)
125
+
126
+ # Only unsubscribe from SolidCable if we're actually subscribed
127
+ if subscribed_to_solid_cable?(channel)
128
+ @solid_cable_pubsub.unsubscribe(channel)
129
+ @channel_subscribed.delete(channel)
130
+ end
131
+
132
+ log_subscription_event(channel, "Unsubscribed")
133
+ callback&.call
134
+ end
135
+
136
+ # Broadcast a message to a channel
137
+ # @param channel [String] The channel name
138
+ # @param message [String] The message to broadcast
139
+ def broadcast(channel, message)
140
+ @solid_cable_pubsub.broadcast(channel, message)
141
+ log_broadcast_event(channel, message)
142
+ end
143
+
144
+ # Check if a channel has subscribers
145
+ # @param channel [String] The channel name
146
+ # @return [Boolean] True if channel has subscribers
147
+ def has_subscribers?(channel)
148
+ subscribers = @channels[channel]
149
+ return false unless subscribers
150
+ !subscribers.empty?
151
+ end
152
+
153
+ # Check if we're already subscribed to a channel
154
+ # @param channel [String] The channel name
155
+ # @return [Boolean] True if we're already subscribed
156
+ def subscribed_to?(channel)
157
+ channel_subs = @channels[channel]
158
+ return false if channel_subs.nil?
159
+ !channel_subs.empty?
160
+ end
161
+
162
+ # Shut down the thread pool gracefully
163
+ def shutdown
164
+ @thread_pool.shutdown
165
+ @thread_pool.wait_for_termination(5) # Wait up to 5 seconds for tasks to complete
166
+ end
167
+
168
+ private
169
+
170
+ # Check if we're in a testing environment
171
+ def testing?
172
+ defined?(Minitest) || ENV["RAILS_ENV"] == "test"
173
+ end
174
+
175
+ # Check if we're already subscribed to this channel in SolidCable
176
+ def subscribed_to_solid_cable?(channel)
177
+ @channel_subscribed[channel] == true
178
+ end
179
+
180
+ def dispatch_message(channel, message)
181
+ subscription_ids = @channels[channel] || []
182
+
183
+ subscription_ids.each do |subscription_id|
184
+ subscription = @subscriptions[subscription_id]
185
+ next unless subscription && subscription[:message_callback]
186
+
187
+ @thread_pool.post do
188
+ begin
189
+ subscription[:message_callback].call(message)
190
+ rescue StandardError => e
191
+ log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
192
+ end
193
+ end
194
+ end
195
+ end
196
+
197
+ def log_subscription_event(channel, action, subscription_id = nil)
198
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
199
+
200
+ message = "SolidCableAdapter: #{action} channel=#{channel}"
201
+ message += " subscription_id=#{subscription_id}" if subscription_id
202
+
203
+ Rails.logger.debug(message)
204
+ end
205
+
206
+ def log_broadcast_event(channel, message)
207
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
208
+
209
+ # Truncate the message for logging
210
+ truncated_message = message.to_s[0..100]
211
+ truncated_message += "..." if message.to_s.length > 100
212
+
213
+ Rails.logger.debug("SolidCableAdapter: Broadcasting to channel=#{channel} message=#{truncated_message}")
214
+ end
215
+
216
+ def log_error(message)
217
+ return unless defined?(Rails) && Rails.respond_to?(:logger)
218
+ Rails.logger.error("SolidCableAdapter: #{message}")
219
+ end
220
+ end
221
+ end
222
+ end
@@ -1,13 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO: move all server related code here before version 1.0.0
3
+ require_relative "server/simple_pub_sub"
4
+ require_relative "server/configuration"
5
+
6
+ # Conditionally load adapters based on available gems
7
+ begin
8
+ require "solid_cable/pubsub"
9
+ require_relative "server/solid_cable_adapter"
10
+ rescue LoadError
11
+ # SolidCable not available
12
+ end
13
+
4
14
  module ActionMCP
5
15
  # Module for server-related functionality.
6
16
  module Server
7
17
  module_function
8
18
 
9
19
  def server
10
- @server ||= ActionCable::Server::Base.new
20
+ @server ||= ServerBase.new
21
+ end
22
+
23
+ # Shut down the server and clean up resources
24
+ def shutdown
25
+ return unless @server
26
+ @server.shutdown
27
+ @server = nil
28
+ end
29
+
30
+ # Available pubsub adapter types
31
+ ADAPTERS = {
32
+ "test" => "SimplePubSub",
33
+ "simple" => "SimplePubSub",
34
+ "solid_cable" => "SolidCableAdapter" # Will use mock version in tests
35
+ }.compact.freeze
36
+
37
+ # Custom server base class for PubSub functionality
38
+ class ServerBase
39
+ def initialize(config_path = nil)
40
+ @configuration = Configuration.new(config_path)
41
+ end
42
+
43
+ def pubsub
44
+ @pubsub ||= create_pubsub
45
+ end
46
+
47
+ # Allow manual override of the configuration
48
+ # @param config_path [String] Path to a cable.yml configuration file
49
+ def configure(config_path)
50
+ shutdown_pubsub if @pubsub
51
+ @configuration = Configuration.new(config_path)
52
+ @pubsub = nil # Reset pubsub so it will be recreated with new config
53
+ end
54
+
55
+ # Gracefully shut down the server and its resources
56
+ def shutdown
57
+ shutdown_pubsub
58
+ end
59
+
60
+ private
61
+
62
+ # Shut down the pubsub adapter gracefully
63
+ def shutdown_pubsub
64
+ return unless @pubsub && @pubsub.respond_to?(:shutdown)
65
+
66
+ begin
67
+ @pubsub.shutdown
68
+ rescue => e
69
+ message = "Error shutting down pubsub adapter: #{e.message}"
70
+ Rails.logger.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
71
+ ensure
72
+ @pubsub = nil
73
+ end
74
+ end
75
+
76
+ def create_pubsub
77
+ adapter_name = @configuration.adapter_name
78
+ adapter_options = @configuration.adapter_options
79
+
80
+ # Default to simple if adapter not found
81
+ adapter_class_name = ADAPTERS[adapter_name] || "SimplePubSub"
82
+
83
+ begin
84
+ # Create an instance of the adapter class with the configuration options
85
+ adapter_class = ActionMCP::Server.const_get(adapter_class_name)
86
+ adapter_class.new(adapter_options)
87
+ rescue NameError, LoadError => e
88
+ message = "Error creating adapter #{adapter_name}: #{e.message}"
89
+ Rails.logger.error(message) if defined?(Rails) && Rails.respond_to?(:logger)
90
+ SimplePubSub.new # Fallback to simple pubsub
91
+ end
92
+ end
11
93
  end
12
94
  end
13
95
  end
@@ -4,7 +4,7 @@ require "concurrent/atomic/atomic_boolean"
4
4
  require "concurrent/promise"
5
5
 
6
6
  module ActionMCP
7
- # Listener class to subscribe to session messages via Action Cable adapter.
7
+ # Listener class to subscribe to session messages via PubSub adapter.
8
8
  class SSEListener
9
9
  delegate :session_key, :adapter, to: :@session
10
10
 
@@ -15,7 +15,7 @@ module ActionMCP
15
15
  @subscription_active = Concurrent::AtomicBoolean.new
16
16
  end
17
17
 
18
- # Start listening using ActionCable's adapter
18
+ # Start listening using PubSub adapter
19
19
  # @yield [Hash] Yields parsed message received from the pub/sub channel
20
20
  # @return [Boolean] True if subscription was successful within timeout, false otherwise.
21
21
  def start(&callback)
@@ -30,7 +30,7 @@ module ActionMCP
30
30
  process_message(raw_message, callback)
31
31
  }
32
32
 
33
- # Subscribe using the ActionCable adapter
33
+ # Subscribe using the PubSub adapter
34
34
  adapter.subscribe(session_key, message_callback, success_callback)
35
35
 
36
36
  wait_for_subscription
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.32.1"
5
+ VERSION = "0.33.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Generators
5
+ class ConfigGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ desc "Creates ActionMCP configuration file (config/mcp.yml)"
9
+
10
+ def create_mcp_yml
11
+ template "mcp.yml", "config/mcp.yml"
12
+ end
13
+
14
+ def show_instructions
15
+ say "ActionMCP configuration file created at config/mcp.yml"
16
+ say "You can customize your PubSub adapters and other settings in this file."
17
+ say ""
18
+ say "Available adapters:"
19
+ say " - simple : In-memory adapter for development"
20
+ say " - test : Test adapter"
21
+ say " - solid_cable : Database-backed adapter (requires solid_cable gem)"
22
+ say " - redis : Redis-backed adapter (requires redis gem)"
23
+ say ""
24
+ say "Example usage:"
25
+ say " rails g action_mcp:install # Main generator"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,36 @@
1
+ # ActionMCP Configuration
2
+ # This file contains configuration for the ActionMCP pub/sub system.
3
+ # Different environments can use different adapters.
4
+
5
+ development:
6
+ # In-memory adapter for development
7
+ adapter: simple
8
+ # Thread pool configuration (optional)
9
+ # min_threads: 5 # Minimum number of threads in the pool
10
+ # max_threads: 10 # Maximum number of threads in the pool
11
+ # max_queue: 100 # Maximum number of tasks that can be queued
12
+
13
+ test:
14
+ # Test adapter for testing
15
+ adapter: test
16
+
17
+ production:
18
+ # Choose one of the following adapters:
19
+
20
+ # 1. Database-backed adapter (recommended)
21
+ adapter: solid_cable
22
+ polling_interval: 0.5.seconds
23
+ # connects_to: cable # Optional: specify a different database connection
24
+
25
+ # Thread pool configuration (optional)
26
+ min_threads: 10 # Minimum number of threads in the pool
27
+ max_threads: 20 # Maximum number of threads in the pool
28
+ max_queue: 500 # Maximum number of tasks that can be queued
29
+
30
+ # 2. Redis-backed adapter (alternative)
31
+ # adapter: redis
32
+ # url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
33
+ # channel_prefix: <%= Rails.application.class.module_parent_name.underscore %>_production
34
+ # min_threads: 10 # Minimum number of threads in the pool
35
+ # max_threads: 20 # Maximum number of threads in the pool
36
+ # max_queue: 500 # Maximum number of tasks that can be queued
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.32.1
4
+ version: 0.33.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -9,20 +9,6 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: actioncable
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: 8.0.1
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: 8.0.1
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: activerecord
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +79,20 @@ dependencies:
93
79
  - - "~>"
94
80
  - !ruby/object:Gem::Version
95
81
  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:
@@ -177,6 +177,7 @@ files:
177
177
  - lib/action_mcp/resource_templates_registry.rb
178
178
  - lib/action_mcp/server.rb
179
179
  - lib/action_mcp/server/capabilities.rb
180
+ - lib/action_mcp/server/configuration.rb
180
181
  - lib/action_mcp/server/json_rpc_handler.rb
181
182
  - lib/action_mcp/server/messaging.rb
182
183
  - lib/action_mcp/server/notifications.rb
@@ -186,6 +187,8 @@ files:
186
187
  - lib/action_mcp/server/roots.rb
187
188
  - lib/action_mcp/server/sampling.rb
188
189
  - lib/action_mcp/server/sampling_request.rb
190
+ - lib/action_mcp/server/simple_pub_sub.rb
191
+ - lib/action_mcp/server/solid_cable_adapter.rb
189
192
  - lib/action_mcp/server/tools.rb
190
193
  - lib/action_mcp/server/transport_handler.rb
191
194
  - lib/action_mcp/sse_listener.rb
@@ -199,6 +202,8 @@ files:
199
202
  - lib/action_mcp/uri_ambiguity_checker.rb
200
203
  - lib/action_mcp/version.rb
201
204
  - lib/actionmcp.rb
205
+ - lib/generators/action_mcp/config/config_generator.rb
206
+ - lib/generators/action_mcp/config/templates/mcp.yml
202
207
  - lib/generators/action_mcp/install/install_generator.rb
203
208
  - lib/generators/action_mcp/install/templates/application_mcp_prompt.rb
204
209
  - lib/generators/action_mcp/install/templates/application_mcp_res_template.rb