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 +4 -4
- data/README.md +128 -1
- data/lib/action_mcp/server/configuration.rb +63 -0
- data/lib/action_mcp/server/simple_pub_sub.rb +145 -0
- data/lib/action_mcp/server/solid_cable_adapter.rb +222 -0
- data/lib/action_mcp/server.rb +84 -2
- data/lib/action_mcp/sse_listener.rb +3 -3
- data/lib/action_mcp/version.rb +1 -1
- data/lib/generators/action_mcp/config/config_generator.rb +29 -0
- data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
- metadata +20 -15
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 49bbf32664efb30dff032e7ab8ff3e76d972016cc7cf1a1a966c61cc507d7680
|
4
|
+
data.tar.gz: 61182b5e6367ecbbe7db4446a8da8a7ae756dd164e6b302a99f5583be64b3c8c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
data/lib/action_mcp/server.rb
CHANGED
@@ -1,13 +1,95 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
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 ||=
|
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
|
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
|
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
|
33
|
+
# Subscribe using the PubSub adapter
|
34
34
|
adapter.subscribe(session_key, message_callback, success_callback)
|
35
35
|
|
36
36
|
wait_for_subscription
|
data/lib/action_mcp/version.rb
CHANGED
@@ -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.
|
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
|