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.
- checksums.yaml +4 -4
- data/README.md +138 -4
- data/app/controllers/action_mcp/unified_controller.rb +1 -1
- data/config/routes.rb +4 -9
- data/db/migrate/20250512154359_consolidated_migration.rb +146 -0
- data/exe/actionmcp_cli +8 -1
- data/lib/action_mcp/client.rb +3 -9
- data/lib/action_mcp/configuration.rb +2 -4
- data/lib/action_mcp/engine.rb +1 -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/tool.rb +3 -7
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +3 -3
- data/lib/generators/action_mcp/config/config_generator.rb +29 -0
- data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
- metadata +21 -26
- data/app/controllers/action_mcp/messages_controller.rb +0 -44
- data/app/controllers/action_mcp/sse_controller.rb +0 -179
- data/db/migrate/20250308122801_create_action_mcp_sessions.rb +0 -32
- data/db/migrate/20250314230152_add_is_ping_to_session_message.rb +0 -8
- data/db/migrate/20250316005021_create_action_mcp_session_subscriptions.rb +0 -16
- data/db/migrate/20250316005649_create_action_mcp_session_resources.rb +0 -25
- data/db/migrate/20250324203409_remove_session_message_text.rb +0 -7
- data/db/migrate/20250327124131_add_sse_event_counter_to_action_mcp_sessions.rb +0 -7
- data/db/migrate/20250329120300_add_registries_to_sessions.rb +0 -9
- data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +0 -16
- 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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 845438ee87dd1b7c0453604f5716981ab1ac345a3413b135237aa82d6329cdd3
|
4
|
+
data.tar.gz: 0a8666f43de743705899d20a784de5796d9be421619f736db067d3143f38a495
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
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
|
-
|
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
|
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
|
-
#
|
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
|
-
#
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
data/lib/action_mcp/client.rb
CHANGED
@@ -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
|
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
|
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
|
-
|
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
|
-
# ---
|
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 = "
|
44
|
+
@protocol_version = "2025-03-26"
|
47
45
|
|
48
46
|
# Resumability defaults
|
49
47
|
@enable_sse_resumability = true
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -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, [
|
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
|