actionmcp 0.32.1 → 0.50.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +138 -4
  3. data/app/controllers/action_mcp/unified_controller.rb +1 -1
  4. data/config/routes.rb +4 -9
  5. data/db/migrate/20250512154359_consolidated_migration.rb +146 -0
  6. data/exe/actionmcp_cli +8 -1
  7. data/lib/action_mcp/client.rb +3 -9
  8. data/lib/action_mcp/configuration.rb +2 -4
  9. data/lib/action_mcp/engine.rb +1 -1
  10. data/lib/action_mcp/server/configuration.rb +63 -0
  11. data/lib/action_mcp/server/simple_pub_sub.rb +145 -0
  12. data/lib/action_mcp/server/solid_cable_adapter.rb +222 -0
  13. data/lib/action_mcp/server.rb +84 -2
  14. data/lib/action_mcp/sse_listener.rb +3 -3
  15. data/lib/action_mcp/tool.rb +3 -7
  16. data/lib/action_mcp/version.rb +1 -1
  17. data/lib/action_mcp.rb +3 -3
  18. data/lib/generators/action_mcp/config/config_generator.rb +29 -0
  19. data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
  20. metadata +21 -26
  21. data/app/controllers/action_mcp/messages_controller.rb +0 -44
  22. data/app/controllers/action_mcp/sse_controller.rb +0 -179
  23. data/db/migrate/20250308122801_create_action_mcp_sessions.rb +0 -32
  24. data/db/migrate/20250314230152_add_is_ping_to_session_message.rb +0 -8
  25. data/db/migrate/20250316005021_create_action_mcp_session_subscriptions.rb +0 -16
  26. data/db/migrate/20250316005649_create_action_mcp_session_resources.rb +0 -25
  27. data/db/migrate/20250324203409_remove_session_message_text.rb +0 -7
  28. data/db/migrate/20250327124131_add_sse_event_counter_to_action_mcp_sessions.rb +0 -7
  29. data/db/migrate/20250329120300_add_registries_to_sessions.rb +0 -9
  30. data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +0 -16
  31. data/lib/action_mcp/client/stdio_client.rb +0 -115
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54b807ad23c23796ef5495fb91afffb9ee10d998516a72d20897c519bed56c4c
4
- data.tar.gz: e5ec996f8e8dcb73383de34fe5f851382961a74a27e3b28c26271aadb343c30f
3
+ metadata.gz: 845438ee87dd1b7c0453604f5716981ab1ac345a3413b135237aa82d6329cdd3
4
+ data.tar.gz: 0a8666f43de743705899d20a784de5796d9be421619f736db067d3143f38a495
5
5
  SHA512:
6
- metadata.gz: 55938823b5df5d13b8ffe2adbb9e581331e60fabb2f7111807fa10b119cd898f2ab3dd695cbda8924af0b9be79cf9eb0767b18324a1042ac46fb4facb6562abe
7
- data.tar.gz: 76c6c5a553e55f06b61165844a3d55e45854dffe515879f14eaaf5b1399aa8f3273563b3c78cf028bab54547cc3165947cd5e2606e19f96be55285c845c641ef
6
+ metadata.gz: fdeb2ef3b774d957a812cf1a82ee5135ddacb352746c83ef45aacd3bcf715b900467d7d6154b83b0a2024aa611671102addf4ab97884d43b928c89d7c78f68ac
7
+ data.tar.gz: 8ad56c0ff5a39e299d4133e39b5eae5acffaf24c6f07bd49c5f33a40afe71d6e158bf3d071ee28a4926ecf1f189e4b91d60bd76bf8d91088c9f68be4b4eeb93a
data/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # ActionMCP
2
2
 
3
- **ActionMCP** is a Ruby gem that provides essential tooling for building Model Context Protocol (MCP) capable servers in Ruby on Rails applications.
3
+ **ActionMCP** is a Ruby gem focused on providing Model Context Protocol (MCP) capability to Ruby on Rails applications, specifically as a server.
4
+
5
+ ActionMCP is designed for production Rails environments and does **not** support STDIO transport. STDIO is not included because it is not production-ready and is only suitable for desktop or script-based use cases. Instead, ActionMCP is built for robust, network-based deployments.
6
+
7
+ The client functionality in ActionMCP is intended to connect to remote MCP servers, not to local processes via STDIO.
4
8
 
5
9
  It offers base classes and helpers for creating MCP applications, making it easier to integrate your Ruby/Rails application with the MCP standard.
6
10
 
@@ -16,8 +20,9 @@ MCP allows AI systems to plug into various resources in a consistent, secure way
16
20
 
17
21
  This means an AI (like an LLM) can request information or actions from your application through a well-defined protocol, and your app can provide context or perform tasks for the AI in return.
18
22
 
19
- **ActionMCP** is targeted at developers building MCP-enabled applications.
20
- It simplifies the process of integrating Ruby and Rails apps with the MCP standard by providing a set of base classes and an easy-to-use server interface.
23
+ **ActionMCP** is targeted at developers building MCP-enabled Rails applications. It simplifies the process of integrating Ruby and Rails apps with the MCP standard by providing a set of base classes and an easy-to-use server interface.
24
+
25
+ > **Note:** STDIO transport is not supported in ActionMCP. This gem is focused on production-ready, network-based deployments. STDIO is only suitable for desktop or script-based experimentation and is intentionally excluded.
21
26
 
22
27
  Instead of implementing MCP support from scratch, you can subclass and configure the provided **Prompt**, **Tool**, and **ResourceTemplate** classes to expose your app's functionality to LLMs.
23
28
 
@@ -25,6 +30,8 @@ ActionMCP handles the underlying MCP message format and routing, so you can adhe
25
30
 
26
31
  In short, ActionMCP helps you build an MCP server (the component that exposes capabilities to AI) more quickly and with fewer mistakes.
27
32
 
33
+ > **Client connections:** The client part of ActionMCP is meant to connect to remote MCP servers only. Connecting to local processes (such as via STDIO) is not supported.
34
+
28
35
  ## Installation
29
36
 
30
37
  To start using ActionMCP, add it to your project:
@@ -221,9 +228,136 @@ end
221
228
 
222
229
  For dynamic versioning, consider adding the `rails_app_version` gem.
223
230
 
231
+ ### PubSub Configuration
232
+
233
+ ActionMCP uses a pub/sub system for real-time communication. You can choose between several adapters:
234
+
235
+ 1. **SolidCable** - Database-backed pub/sub (no Redis required)
236
+ 2. **Simple** - In-memory pub/sub for development and testing
237
+ 3. **Redis** - Redis-backed pub/sub (if you prefer Redis)
238
+
239
+ #### Migrating from ActionCable
240
+
241
+ If you were previously using ActionCable with ActionMCP, you will need to migrate to the new PubSub system. Here's how:
242
+
243
+ 1. Remove the ActionCable dependency from your Gemfile (if you don't need it for other purposes)
244
+ 2. Install one of the PubSub adapters (SolidCable recommended)
245
+ 3. Create a configuration file at `config/mcp.yml` (you can use the generator: `bin/rails g action_mcp:config`)
246
+ 4. Run your tests to ensure everything works correctly
247
+
248
+ The new PubSub system maintains the same API as the previous ActionCable-based implementation, so your existing code should continue to work without changes.
249
+
250
+ Configure your adapter in `config/mcp.yml`:
251
+
252
+ ```yaml
253
+ development:
254
+ adapter: solid_cable
255
+ polling_interval: 0.1.seconds
256
+ # Thread pool configuration (optional)
257
+ # min_threads: 5 # Minimum number of threads in the pool
258
+ # max_threads: 10 # Maximum number of threads in the pool
259
+ # max_queue: 100 # Maximum number of tasks that can be queued
260
+
261
+ test:
262
+ adapter: test # Uses the simple in-memory adapter
263
+
264
+ production:
265
+ adapter: solid_cable
266
+ polling_interval: 0.5.seconds
267
+ # Optional: connects_to: cable # If using a separate database
268
+
269
+ # Thread pool configuration for high-traffic environments
270
+ min_threads: 10 # Minimum number of threads in the pool
271
+ max_threads: 20 # Maximum number of threads in the pool
272
+ max_queue: 500 # Maximum number of tasks that can be queued
273
+ ```
274
+
275
+ #### SolidCable (Database-backed, Recommended)
276
+
277
+ For SolidCable, add it to your Gemfile:
278
+
279
+ ```ruby
280
+ gem "solid_cable" # Database-backed adapter (no Redis needed)
281
+ ```
282
+
283
+ Then install it:
284
+
285
+ ```bash
286
+ bundle install
287
+ bin/rails solid_cable:install
288
+ ```
289
+
290
+ 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`.
291
+
292
+ #### Redis Adapter
293
+
294
+ If you prefer Redis, add it to your Gemfile:
295
+
296
+ ```ruby
297
+ gem "redis", "~> 5.0"
298
+ ```
299
+
300
+ Then configure the Redis adapter in your `config/mcp.yml`:
301
+
302
+ ```yaml
303
+ production:
304
+ adapter: redis
305
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
306
+ channel_prefix: your_app_production
307
+
308
+ # Thread pool configuration for high-traffic environments
309
+ min_threads: 10 # Minimum number of threads in the pool
310
+ max_threads: 20 # Maximum number of threads in the pool
311
+ max_queue: 500 # Maximum number of tasks that can be queued
312
+ ```
313
+
314
+ ## Thread Pool Management
315
+
316
+ ActionMCP uses thread pools to efficiently handle message callbacks. This prevents the system from being overwhelmed by too many threads under high load.
317
+
318
+ ### Thread Pool Configuration
319
+
320
+ You can configure the thread pool in your `config/mcp.yml`:
321
+
322
+ ```yaml
323
+ production:
324
+ adapter: solid_cable
325
+ # Thread pool configuration
326
+ min_threads: 10 # Minimum number of threads to keep in the pool
327
+ max_threads: 20 # Maximum number of threads the pool can grow to
328
+ max_queue: 500 # Maximum number of tasks that can be queued
329
+ ```
330
+
331
+ The thread pool will automatically:
332
+ - Start with `min_threads` threads
333
+ - Scale up to `max_threads` as needed
334
+ - Queue tasks up to `max_queue` limit
335
+ - Use caller's thread if queue is full (fallback policy)
336
+
337
+ ### Graceful Shutdown
338
+
339
+ When your application is shutting down, you should call:
340
+
341
+ ```ruby
342
+ ActionMCP::Server.shutdown
343
+ ```
344
+
345
+ This ensures all thread pools are properly terminated and tasks are completed.
346
+
224
347
  ## Engine and Mounting
225
348
 
226
- **ActionMCP** runs as a standalone Rack application, similar to **ActionCable**. It is **not** mounted in `routes.rb`.
349
+ **ActionMCP** runs as a standalone Rack application. It is **not** mounted in `routes.rb`.
350
+
351
+ ### Installing the Configuration Generator
352
+
353
+ ActionMCP includes a generator to help you create the configuration file:
354
+
355
+ ```bash
356
+ # Generate the mcp.yml configuration file
357
+ bin/rails generate action_mcp:config
358
+ ```
359
+
360
+ This will create `config/mcp.yml` with example configurations for all environments.
227
361
 
228
362
  > **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
229
363
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
- # Handles the unified MCP endpoint for the 2025-03-26 specification.
4
+ # Implements the MCP endpoints according to the 2025-03-26 specification.
5
5
  # Supports GET for server-initiated SSE streams, POST for client messages
6
6
  # (responding with JSON or SSE), and optionally DELETE for session termination.
7
7
  class UnifiedController < MCPController
data/config/routes.rb CHANGED
@@ -2,14 +2,9 @@
2
2
 
3
3
  ActionMCP::Engine.routes.draw do
4
4
  get "/up", to: "/rails/health#show", as: :action_mcp_health_check
5
- # --- Routes for 2024-11-05 Spec (HTTP+SSE) ---
6
- # Kept for backward compatibility
7
- get "/", to: "sse#events", as: :sse_out
8
- post "/", to: "messages#create", as: :sse_in, defaults: { format: "json" }
9
5
 
10
- # --- Routes for 2025-03-26 Spec (Streamable HTTP) ---
11
- mcp_endpoint = ActionMCP.configuration.mcp_endpoint_path
12
- get mcp_endpoint, to: "unified#show", as: :mcp_get
13
- post mcp_endpoint, to: "unified#create", as: :mcp_post
14
- delete mcp_endpoint, to: "unified#destroy", as: :mcp_delete
6
+ # MCP 2025-03-26 Spec routes
7
+ get "/", to: "unified#show", as: :mcp_get
8
+ post "/", to: "unified#create", as: :mcp_post
9
+ delete "/", to: "unified#destroy", as: :mcp_delete
15
10
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ConsolidatedMigration < ActiveRecord::Migration[8.0]
4
+ def change
5
+ # Only create tables if they don't exist to avoid deleting existing data
6
+
7
+ # Create sessions table
8
+ unless table_exists?(:action_mcp_sessions)
9
+ create_table :action_mcp_sessions, id: :string do |t|
10
+ t.string :role, null: false, default: 'server', comment: 'The role of the session'
11
+ t.string :status, null: false, default: 'pre_initialize'
12
+ t.datetime :ended_at, comment: 'The time the session ended'
13
+ t.string :protocol_version
14
+ t.jsonb :server_capabilities, comment: 'The capabilities of the server'
15
+ t.jsonb :client_capabilities, comment: 'The capabilities of the client'
16
+ t.jsonb :server_info, comment: 'The information about the server'
17
+ t.jsonb :client_info, comment: 'The information about the client'
18
+ t.boolean :initialized, null: false, default: false
19
+ t.integer :messages_count, null: false, default: 0
20
+ t.integer :sse_event_counter, default: 0, null: false
21
+ t.jsonb :tool_registry, default: []
22
+ t.jsonb :prompt_registry, default: []
23
+ t.jsonb :resource_registry, default: []
24
+ t.timestamps
25
+ end
26
+ end
27
+
28
+ # Create session messages table
29
+ unless table_exists?(:action_mcp_session_messages)
30
+ create_table :action_mcp_session_messages do |t|
31
+ t.references :session, null: false,
32
+ foreign_key: { to_table: :action_mcp_sessions,
33
+ on_delete: :cascade,
34
+ on_update: :cascade,
35
+ name: 'fk_action_mcp_session_messages_session_id' }, type: :string
36
+ t.string :direction, null: false, comment: 'The message recipient', default: 'client'
37
+ t.string :message_type, null: false, comment: 'The type of the message'
38
+ t.string :jsonrpc_id
39
+ t.jsonb :message_json
40
+ t.boolean :is_ping, default: false, null: false, comment: 'Whether the message is a ping'
41
+ t.boolean :request_acknowledged, default: false, null: false
42
+ t.boolean :request_cancelled, null: false, default: false
43
+ t.timestamps
44
+ end
45
+ end
46
+
47
+ # Create session subscriptions table
48
+ unless table_exists?(:action_mcp_session_subscriptions)
49
+ create_table :action_mcp_session_subscriptions do |t|
50
+ t.references :session,
51
+ null: false,
52
+ foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
53
+ type: :string
54
+ t.string :uri, null: false
55
+ t.datetime :last_notification_at
56
+ t.timestamps
57
+ end
58
+ end
59
+
60
+ # Create session resources table
61
+ unless table_exists?(:action_mcp_session_resources)
62
+ create_table :action_mcp_session_resources do |t|
63
+ t.references :session,
64
+ null: false,
65
+ foreign_key: { to_table: :action_mcp_sessions, on_delete: :cascade },
66
+ type: :string
67
+ t.string :uri, null: false
68
+ t.string :name
69
+ t.text :description
70
+ t.string :mime_type, null: false
71
+ t.boolean :created_by_tool, default: false
72
+ t.datetime :last_accessed_at
73
+ t.json :metadata
74
+ t.timestamps
75
+ end
76
+ end
77
+
78
+ # Create SSE events table
79
+ unless table_exists?(:action_mcp_sse_events)
80
+ create_table :action_mcp_sse_events do |t|
81
+ t.references :session, null: false, foreign_key: { to_table: :action_mcp_sessions }, index: true, type: :string
82
+ t.integer :event_id, null: false
83
+ t.text :data, null: false
84
+ t.timestamps
85
+
86
+ # Index for efficiently retrieving events after a given ID for a specific session
87
+ t.index [ :session_id, :event_id ], unique: true
88
+ t.index :created_at # For cleanup of old events
89
+ end
90
+ end
91
+
92
+ # Add missing columns to existing tables if they exist
93
+
94
+ # For action_mcp_sessions
95
+ if table_exists?(:action_mcp_sessions)
96
+ unless column_exists?(:action_mcp_sessions, :sse_event_counter)
97
+ add_column :action_mcp_sessions, :sse_event_counter, :integer, default: 0, null: false
98
+ end
99
+
100
+ unless column_exists?(:action_mcp_sessions, :tool_registry)
101
+ add_column :action_mcp_sessions, :tool_registry, :jsonb, default: []
102
+ end
103
+
104
+ unless column_exists?(:action_mcp_sessions, :prompt_registry)
105
+ add_column :action_mcp_sessions, :prompt_registry, :jsonb, default: []
106
+ end
107
+
108
+ unless column_exists?(:action_mcp_sessions, :resource_registry)
109
+ add_column :action_mcp_sessions, :resource_registry, :jsonb, default: []
110
+ end
111
+ end
112
+
113
+ # For action_mcp_session_messages
114
+ if table_exists?(:action_mcp_session_messages)
115
+ unless column_exists?(:action_mcp_session_messages, :is_ping)
116
+ add_column :action_mcp_session_messages, :is_ping, :boolean, default: false, null: false, comment: 'Whether the message is a ping'
117
+ end
118
+
119
+ unless column_exists?(:action_mcp_session_messages, :request_acknowledged)
120
+ add_column :action_mcp_session_messages, :request_acknowledged, :boolean, default: false, null: false
121
+ end
122
+
123
+ unless column_exists?(:action_mcp_session_messages, :request_cancelled)
124
+ add_column :action_mcp_session_messages, :request_cancelled, :boolean, null: false, default: false
125
+ end
126
+
127
+ if column_exists?(:action_mcp_session_messages, :message_text)
128
+ remove_column :action_mcp_session_messages, :message_text
129
+ end
130
+
131
+ if column_exists?(:action_mcp_session_messages, :direction)
132
+ change_column_comment :action_mcp_session_messages, :direction, 'The message recipient'
133
+ end
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def table_exists?(table_name)
140
+ ActionMCP::ApplicationRecord.connection.table_exists?(table_name)
141
+ end
142
+
143
+ def column_exists?(table_name, column_name)
144
+ ActionMCP::ApplicationRecord.connection.column_exists?(table_name, column_name)
145
+ end
146
+ end
data/exe/actionmcp_cli CHANGED
@@ -24,6 +24,8 @@ end
24
24
  # Parse command-line arguments
25
25
  parser = OptionParser.new do |opts|
26
26
  opts.banner = 'Usage: mcp_client ENDPOINT [options]'
27
+ opts.separator ''
28
+ opts.separator 'ENDPOINT must be an HTTP(S) URL (e.g., http://localhost:3000/action_mcp)'
27
29
  opts.on('-l', '--log-level LEVEL', 'Set log level (DEBUG, INFO, WARN, ERROR)') do |l|
28
30
  options[:logging_level] = l.upcase
29
31
  logger.level = begin
@@ -53,6 +55,11 @@ if endpoint.nil?
53
55
  exit 1
54
56
  end
55
57
 
58
+ unless endpoint =~ %r{\Ahttps?://}
59
+ puts "Error: Only HTTP(S) endpoints are supported. STDIO/command endpoints are not allowed."
60
+ exit 1
61
+ end
62
+
56
63
  # Function to generate a unique request ID
57
64
  def generate_request_id
58
65
  SecureRandom.uuid
@@ -129,7 +136,7 @@ def print_help
129
136
  puts 'Otherwise, enter a raw JSON-RPC request to send directly'
130
137
  end
131
138
 
132
- # Initialize and start the client
139
+ # Initialize and start the client (only HTTP(S) endpoints are supported)
133
140
  client = ActionMCP.create_client(endpoint, logger: logger)
134
141
 
135
142
  # Start the transport
@@ -3,27 +3,21 @@
3
3
  module ActionMCP
4
4
  # Creates a client appropriate for the given endpoint.
5
5
  #
6
- # @param endpoint [String] The endpoint to connect to (URL or command).
6
+ # @param endpoint [String] The endpoint to connect to (URL).
7
7
  # @param logger [Logger] The logger to use. Default is Logger.new($stdout).
8
8
  # @param options [Hash] Additional options to pass to the client constructor.
9
9
  #
10
- # @return [Client::SSEClient, Client::StdioClient] An instance of either SSEClient or StdioClient
11
- # depending on the format of the endpoint.
10
+ # @return [Client::SSEClient] An instance of SSEClient for HTTP(S) endpoints.
12
11
  #
13
12
  # @example
14
13
  # client = ActionMCP.create_client("http://127.0.0.1:3001/action_mcp")
15
14
  # client.connect
16
- #
17
- # @example
18
- # client = ActionMCP.create_client("some_command")
19
- # client.execute
20
15
  def self.create_client(endpoint, logger: Logger.new($stdout), **options)
21
16
  if endpoint =~ %r{\Ahttps?://}
22
17
  logger.info("Creating SSE client for endpoint: #{endpoint}")
23
18
  Client::SSEClient.new(endpoint, logger: logger, **options)
24
19
  else
25
- logger.info("Creating STDIO client for command: #{endpoint}")
26
- Client::StdioClient.new(endpoint, logger: logger, **options)
20
+ raise ArgumentError, "Only HTTP(S) endpoints are supported. STDIO and other transports are not supported."
27
21
  end
28
22
  end
29
23
 
@@ -22,8 +22,7 @@ module ActionMCP
22
22
  :logging_level,
23
23
  :active_profile,
24
24
  :profiles,
25
- # --- New Transport Options ---
26
- :mcp_endpoint_path,
25
+ # --- Transport Options ---
27
26
  :sse_heartbeat_interval,
28
27
  :post_response_preference, # :json or :sse
29
28
  :protocol_version,
@@ -40,10 +39,9 @@ module ActionMCP
40
39
  @active_profile = :primary
41
40
  @profiles = default_profiles
42
41
 
43
- @mcp_endpoint_path = "/mcp"
44
42
  @sse_heartbeat_interval = 30
45
43
  @post_response_preference = :json
46
- @protocol_version = "2024-11-05"
44
+ @protocol_version = "2025-03-26"
47
45
 
48
46
  # Resumability defaults
49
47
  @enable_sse_resumability = true
@@ -21,7 +21,7 @@ module ActionMCP
21
21
  ActionMCP::ResourceTemplate.registered_templates.clear
22
22
  end
23
23
 
24
- config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.mcp_endpoint_path ]
24
+ config.middleware.use JSONRPC_Rails::Middleware::Validator, [ "/" ]
25
25
 
26
26
  # Load MCP profiles during initialization
27
27
  initializer "action_mcp.load_profiles" do
@@ -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