actionmcp 0.31.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 +143 -5
- data/app/controllers/action_mcp/mcp_controller.rb +13 -17
- data/app/controllers/action_mcp/messages_controller.rb +3 -1
- data/app/controllers/action_mcp/sse_controller.rb +22 -4
- data/app/controllers/action_mcp/unified_controller.rb +147 -52
- data/app/models/action_mcp/session/message.rb +1 -0
- data/app/models/action_mcp/session/sse_event.rb +55 -0
- data/app/models/action_mcp/session.rb +235 -12
- data/app/models/concerns/mcp_console_helpers.rb +68 -0
- data/app/models/concerns/mcp_message_inspect.rb +73 -0
- data/config/routes.rb +4 -2
- data/db/migrate/20250329120300_add_registries_to_sessions.rb +9 -0
- data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +16 -0
- data/lib/action_mcp/capability.rb +16 -0
- data/lib/action_mcp/configuration.rb +16 -4
- data/lib/action_mcp/console_detector.rb +12 -0
- data/lib/action_mcp/engine.rb +3 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +1 -1
- data/lib/action_mcp/resource_template.rb +11 -0
- data/lib/action_mcp/server/capabilities.rb +28 -22
- data/lib/action_mcp/server/configuration.rb +63 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +35 -9
- data/lib/action_mcp/server/notifications.rb +14 -5
- data/lib/action_mcp/server/prompts.rb +18 -5
- data/lib/action_mcp/server/registry_management.rb +32 -0
- data/lib/action_mcp/server/resources.rb +3 -2
- 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/tools.rb +50 -6
- data/lib/action_mcp/server.rb +84 -2
- data/lib/action_mcp/sse_listener.rb +6 -5
- data/lib/action_mcp/tagged_stream_logging.rb +47 -0
- data/lib/action_mcp/test_helper.rb +57 -34
- data/lib/action_mcp/tool.rb +45 -9
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +4 -4
- data/lib/generators/action_mcp/config/config_generator.rb +29 -0
- data/lib/generators/action_mcp/config/templates/mcp.yml +36 -0
- metadata +23 -13
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
@@ -169,6 +169,7 @@ class ProductResourceTemplate < ApplicationMCPResTemplate
|
|
169
169
|
)
|
170
170
|
end
|
171
171
|
end
|
172
|
+
```
|
172
173
|
|
173
174
|
# Example of callbacks:
|
174
175
|
|
@@ -220,17 +221,154 @@ end
|
|
220
221
|
|
221
222
|
For dynamic versioning, consider adding the `rails_app_version` gem.
|
222
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
|
+
|
223
340
|
## Engine and Mounting
|
224
341
|
|
225
|
-
ActionMCP
|
226
|
-
|
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.
|
356
|
+
|
357
|
+
### 1. Create `mcp.ru`
|
229
358
|
|
230
359
|
```ruby
|
231
|
-
Rails.
|
232
|
-
|
360
|
+
# Load the full Rails environment to access models, DB, Redis, etc.
|
361
|
+
require_relative "config/environment"
|
362
|
+
|
363
|
+
ActionMCP.configure do |config|
|
364
|
+
config.mcp_endpoint_path = "/mcp"
|
233
365
|
end
|
366
|
+
|
367
|
+
run ActionMCP::Engine
|
368
|
+
```
|
369
|
+
### 2. Start the server
|
370
|
+
```bash
|
371
|
+
bin/rails s -c mcp.ru -p 6277 -P tmp/pids/mcp.pid
|
234
372
|
```
|
235
373
|
|
236
374
|
## Generators
|
@@ -33,51 +33,47 @@ module ActionMCP
|
|
33
33
|
def find_or_initialize_session
|
34
34
|
session_id = extract_session_id
|
35
35
|
if session_id
|
36
|
-
|
37
|
-
|
38
|
-
|
36
|
+
session = Session.find_by(id: session_id)
|
37
|
+
if session && session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
|
38
|
+
# Update existing session to use 2025 protocol
|
39
|
+
session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
40
|
+
end
|
41
|
+
session
|
39
42
|
else
|
40
|
-
#
|
41
|
-
Session.new
|
43
|
+
# Create new session with 2025 protocol
|
44
|
+
Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
42
45
|
end
|
43
46
|
end
|
44
47
|
|
45
|
-
# Extracts the session ID from the request header or parameters.
|
46
|
-
# Prefers the Mcp-Session-Id header (new spec) over the param (old spec).
|
47
|
-
# @return [String, nil] The extracted session ID or nil if not found.
|
48
|
-
def extract_session_id
|
49
|
-
request.headers[MCP_SESSION_ID_HEADER].presence || params[:session_id].presence
|
50
|
-
end
|
51
|
-
|
52
48
|
# Renders a 400 Bad Request response with a JSON-RPC-like error structure.
|
53
49
|
def render_bad_request(message = "Bad Request")
|
54
50
|
# Using -32600 for Invalid Request based on JSON-RPC spec
|
55
|
-
render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
51
|
+
render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
56
52
|
end
|
57
53
|
|
58
54
|
# Renders a 404 Not Found response with a JSON-RPC-like error structure.
|
59
55
|
def render_not_found(message = "Not Found")
|
60
56
|
# Using a custom code or a generic server error range code might be appropriate.
|
61
57
|
# Let's use -32001 for a generic server error.
|
62
|
-
render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
|
58
|
+
render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
|
63
59
|
end
|
64
60
|
|
65
61
|
# Renders a 405 Method Not Allowed response.
|
66
62
|
def render_method_not_allowed(message = "Method Not Allowed")
|
67
63
|
# Using -32601 Method not found from JSON-RPC spec seems applicable
|
68
|
-
render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
|
64
|
+
render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
|
69
65
|
end
|
70
66
|
|
71
67
|
# Renders a 406 Not Acceptable response.
|
72
68
|
def render_not_acceptable(message = "Not Acceptable")
|
73
69
|
# No direct JSON-RPC equivalent, using a generic server error code.
|
74
|
-
render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
|
70
|
+
render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
|
75
71
|
end
|
76
72
|
|
77
73
|
# Renders a 501 Not Implemented response.
|
78
74
|
def render_not_implemented(message = "Not Implemented")
|
79
75
|
# No direct JSON-RPC equivalent, using a generic server error code.
|
80
|
-
render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
|
76
|
+
render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
|
81
77
|
end
|
82
78
|
end
|
83
79
|
end
|
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
module ActionMCP
|
4
4
|
class MessagesController < MCPController
|
5
|
+
REQUIRED_PROTOCOL_VERSION = "2024-11-05"
|
6
|
+
|
5
7
|
include Instrumentation::ControllerRuntime
|
6
8
|
|
7
9
|
# @route POST / (sse_in)
|
@@ -34,7 +36,7 @@ module ActionMCP
|
|
34
36
|
|
35
37
|
def filter_jsonrpc_params(params)
|
36
38
|
# Valid JSON-RPC keys (both request and response)
|
37
|
-
valid_keys = [
|
39
|
+
valid_keys = %w[jsonrpc method params id result error]
|
38
40
|
|
39
41
|
params.to_h.slice(*valid_keys)
|
40
42
|
end
|
@@ -2,6 +2,8 @@
|
|
2
2
|
|
3
3
|
module ActionMCP
|
4
4
|
class SSEController < MCPController
|
5
|
+
REQUIRED_PROTOCOL_VERSION = "2024-11-05"
|
6
|
+
|
5
7
|
HEARTBEAT_INTERVAL = 30 # in seconds
|
6
8
|
INITIAL_CONNECTION_TIMEOUT = 5 # in seconds
|
7
9
|
include ActionController::Live
|
@@ -35,8 +37,16 @@ module ActionMCP
|
|
35
37
|
error = build_timeout_error
|
36
38
|
# Safely write error and close the stream
|
37
39
|
Concurrent::Promise.execute do
|
38
|
-
|
39
|
-
|
40
|
+
begin
|
41
|
+
sse.write(error)
|
42
|
+
rescue StandardError
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
begin
|
46
|
+
response.stream.close
|
47
|
+
rescue StandardError
|
48
|
+
nil
|
49
|
+
end
|
40
50
|
connection_active.make_false
|
41
51
|
end
|
42
52
|
end
|
@@ -108,8 +118,16 @@ module ActionMCP
|
|
108
118
|
heartbeat_active&.make_false # Signal to stop scheduling new heartbeats
|
109
119
|
heartbeat_task&.cancel # Cancel any pending heartbeat task
|
110
120
|
listener&.stop
|
111
|
-
|
112
|
-
|
121
|
+
begin
|
122
|
+
mcp_session.close!
|
123
|
+
rescue StandardError
|
124
|
+
nil
|
125
|
+
end
|
126
|
+
begin
|
127
|
+
response.stream.close
|
128
|
+
rescue StandardError
|
129
|
+
nil
|
130
|
+
end
|
113
131
|
|
114
132
|
Rails.logger.debug "SSE: Connection cleaned up for session: #{session_id}"
|
115
133
|
end
|
@@ -5,12 +5,15 @@ module ActionMCP
|
|
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
|
8
|
+
REQUIRED_PROTOCOL_VERSION = "2025-03-26"
|
9
|
+
|
10
|
+
include JSONRPC_Rails::ControllerHelpers
|
8
11
|
include ActionController::Live
|
9
12
|
# TODO: Include Instrumentation::ControllerRuntime if needed for metrics
|
10
13
|
|
11
14
|
# Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
|
12
15
|
# @route GET /mcp
|
13
|
-
def
|
16
|
+
def show
|
14
17
|
# 1. Check Accept Header
|
15
18
|
unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
16
19
|
return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
|
@@ -30,7 +33,9 @@ module ActionMCP
|
|
30
33
|
return render_not_found("Session has been terminated.")
|
31
34
|
end
|
32
35
|
|
33
|
-
#
|
36
|
+
# Check for Last-Event-ID header for resumability
|
37
|
+
last_event_id = request.headers["Last-Event-ID"].presence
|
38
|
+
Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
|
34
39
|
|
35
40
|
# 3. Set SSE Headers
|
36
41
|
response.headers["Content-Type"] = "text/event-stream"
|
@@ -43,8 +48,10 @@ module ActionMCP
|
|
43
48
|
# 4. Setup Stream, Listener, and Heartbeat
|
44
49
|
sse = SSE.new(response.stream)
|
45
50
|
listener = SSEListener.new(session) # Use the listener class (defined below or moved)
|
46
|
-
connection_active = Concurrent::AtomicBoolean.new
|
47
|
-
|
51
|
+
connection_active = Concurrent::AtomicBoolean.new
|
52
|
+
connection_active.make_true
|
53
|
+
heartbeat_active = Concurrent::AtomicBoolean.new
|
54
|
+
heartbeat_active.make_true
|
48
55
|
heartbeat_task = nil
|
49
56
|
|
50
57
|
# Start listener
|
@@ -60,6 +67,27 @@ module ActionMCP
|
|
60
67
|
return # Error logged, connection will close in ensure block
|
61
68
|
end
|
62
69
|
|
70
|
+
# Handle resumability by sending missed events if Last-Event-ID is provided
|
71
|
+
if last_event_id.present? && last_event_id.to_i > 0
|
72
|
+
begin
|
73
|
+
# Fetch events that occurred after the Last-Event-ID
|
74
|
+
missed_events = session.get_sse_events_after(last_event_id.to_i)
|
75
|
+
|
76
|
+
if missed_events.any?
|
77
|
+
Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
|
78
|
+
|
79
|
+
# Send each missed event to the client
|
80
|
+
missed_events.each do |event|
|
81
|
+
sse.stream.write(event.to_sse)
|
82
|
+
end
|
83
|
+
else
|
84
|
+
Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
|
85
|
+
end
|
86
|
+
rescue => e
|
87
|
+
Rails.logger.error "Unified SSE (GET): Error sending missed events: #{e.message}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
63
91
|
# Heartbeat sender proc
|
64
92
|
heartbeat_sender = lambda do
|
65
93
|
if connection_active.true? && !response.stream.closed?
|
@@ -98,6 +126,10 @@ module ActionMCP
|
|
98
126
|
heartbeat_active&.make_false
|
99
127
|
heartbeat_task&.cancel
|
100
128
|
listener&.stop
|
129
|
+
|
130
|
+
# Clean up old SSE events if resumability is enabled
|
131
|
+
cleanup_old_sse_events(session) if session
|
132
|
+
|
101
133
|
# Don't close the session itself here, it might be used by other connections/requests
|
102
134
|
sse&.close
|
103
135
|
begin
|
@@ -109,18 +141,14 @@ module ActionMCP
|
|
109
141
|
|
110
142
|
# Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
|
111
143
|
# @route POST /mcp
|
112
|
-
def
|
144
|
+
def create
|
113
145
|
# 1. Check Accept Header
|
114
146
|
unless accepts_valid_content_types?
|
115
147
|
return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
|
116
148
|
end
|
117
149
|
|
118
|
-
# 2. Parse Request Body
|
119
|
-
parsed_body = parse_request_body
|
120
|
-
return unless parsed_body # Error rendered in parse_request_body
|
121
|
-
|
122
150
|
# Determine if this is an initialize request (before session check)
|
123
|
-
is_initialize_request = check_if_initialize_request(
|
151
|
+
is_initialize_request = check_if_initialize_request(jsonrpc_params)
|
124
152
|
|
125
153
|
# 3. Check Session (unless it's an initialize request)
|
126
154
|
session_initially_missing = extract_session_id.nil?
|
@@ -134,13 +162,16 @@ module ActionMCP
|
|
134
162
|
return render_not_found("Session has been terminated.")
|
135
163
|
end
|
136
164
|
end
|
137
|
-
|
165
|
+
if session.new_record?
|
166
|
+
session.save!
|
167
|
+
response.headers[MCP_SESSION_ID_HEADER] = session.id
|
168
|
+
end
|
138
169
|
# 4. Instantiate Handlers
|
139
170
|
transport_handler = Server::TransportHandler.new(session)
|
140
171
|
json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
|
141
172
|
|
142
173
|
# 5. Call Handler
|
143
|
-
handler_results = json_rpc_handler.call(
|
174
|
+
handler_results = json_rpc_handler.call(jsonrpc_params.to_h)
|
144
175
|
|
145
176
|
# 6. Process Results
|
146
177
|
process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
|
@@ -160,13 +191,7 @@ module ActionMCP
|
|
160
191
|
|
161
192
|
# Handles DELETE requests for session termination (2025-03-26 spec).
|
162
193
|
# @route DELETE /mcp
|
163
|
-
def
|
164
|
-
allow_termination = ActionMCP.configuration.allow_client_session_termination
|
165
|
-
|
166
|
-
unless allow_termination
|
167
|
-
return render_method_not_allowed("Session termination via DELETE is not supported by this server.")
|
168
|
-
end
|
169
|
-
|
194
|
+
def destroy
|
170
195
|
# 1. Check Session Header
|
171
196
|
session_id_from_header = extract_session_id
|
172
197
|
return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
|
@@ -195,58 +220,91 @@ module ActionMCP
|
|
195
220
|
|
196
221
|
private
|
197
222
|
|
223
|
+
# @return [String, nil] The extracted session ID or nil if not found.
|
224
|
+
def extract_session_id
|
225
|
+
request.headers[MCP_SESSION_ID_HEADER].presence
|
226
|
+
end
|
227
|
+
|
198
228
|
# Checks if the client's Accept header includes the required types.
|
199
229
|
def accepts_valid_content_types?
|
200
230
|
request.accepts.any? { |type| type.to_s == "application/json" } &&
|
201
231
|
request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
202
232
|
end
|
203
233
|
|
204
|
-
# Parses the JSON request body. Renders error if invalid.
|
205
|
-
def parse_request_body
|
206
|
-
body = request.body.read
|
207
|
-
MultiJson.load(body)
|
208
|
-
rescue MultiJson::ParseError => e
|
209
|
-
render_bad_request("Invalid JSON in request body: #{e.message}")
|
210
|
-
nil # Indicate failure
|
211
|
-
end
|
212
|
-
|
213
234
|
# Checks if the parsed body represents an 'initialize' request.
|
214
|
-
def check_if_initialize_request(
|
215
|
-
|
216
|
-
|
217
|
-
elsif parsed_body.is_a?(Array) # Cannot be in a batch
|
218
|
-
false
|
219
|
-
else
|
220
|
-
false
|
221
|
-
end
|
235
|
+
def check_if_initialize_request(payload)
|
236
|
+
return false unless payload.is_a?(JSON_RPC::Request) && !jsonrpc_params_batch?
|
237
|
+
payload.method == "initialize"
|
222
238
|
end
|
223
239
|
|
224
240
|
# Processes the results from the JsonRpcHandler.
|
225
241
|
def process_handler_results(results, session, session_initially_missing, is_initialize_request)
|
226
|
-
|
242
|
+
# Make sure we always have a results hash
|
243
|
+
results ||= {}
|
244
|
+
|
245
|
+
# Check if this is a notification request
|
246
|
+
is_notification = jsonrpc_params.is_a?(Hash) &&
|
247
|
+
jsonrpc_params["method"].to_s.start_with?("notifications/") &&
|
248
|
+
!jsonrpc_params.key?("id")
|
249
|
+
|
250
|
+
# Extract request ID from results
|
251
|
+
request_id = nil
|
252
|
+
if results.is_a?(Hash)
|
253
|
+
request_id = results[:request_id] || results[:id]
|
254
|
+
|
255
|
+
# If we have a payload that's a response, extract ID from there as well
|
256
|
+
if results[:payload].is_a?(Hash) && results[:payload][:id]
|
257
|
+
request_id ||= results[:payload][:id]
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
# Default to empty hash for response payload if nil
|
262
|
+
result_type = results[:type]
|
263
|
+
result_payload = results[:payload] || {}
|
264
|
+
|
265
|
+
# Ensure payload has the correct ID if it's a hash
|
266
|
+
if result_payload.is_a?(Hash) && request_id
|
267
|
+
result_payload[:id] = request_id unless result_payload.key?(:id)
|
268
|
+
end
|
269
|
+
|
270
|
+
# Check if the payload is a notification
|
271
|
+
is_notification_payload = result_payload.is_a?(Hash) &&
|
272
|
+
result_payload[:method]&.to_s&.start_with?("notifications/") &&
|
273
|
+
!result_payload.key?(:id)
|
274
|
+
|
275
|
+
case result_type
|
227
276
|
when :error
|
228
|
-
#
|
229
|
-
|
277
|
+
# Ensure error responses preserve the ID
|
278
|
+
error_payload = result_payload
|
279
|
+
if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
|
280
|
+
error_payload[:id] = request_id
|
281
|
+
end
|
282
|
+
render json: error_payload, status: results.fetch(:status, :bad_request)
|
230
283
|
when :notifications_only
|
231
|
-
# No response needed, just accept
|
232
284
|
head :accepted
|
233
285
|
when :responses
|
234
|
-
|
235
|
-
# Client MUST accept both 'application/json' and 'text/event-stream' (checked earlier).
|
236
|
-
server_preference = ActionMCP.configuration.post_response_preference # :json or :sse
|
286
|
+
server_preference = ActionMCP.configuration.post_response_preference
|
237
287
|
use_sse = (server_preference == :sse)
|
238
288
|
|
239
|
-
# Add session ID header if this was a successful initialize request that created the session
|
240
289
|
add_session_header = is_initialize_request && session_initially_missing && session.persisted?
|
241
290
|
|
242
291
|
if use_sse
|
243
|
-
render_sse_response(
|
292
|
+
render_sse_response(result_payload, session, add_session_header)
|
244
293
|
else
|
245
|
-
render_json_response(
|
294
|
+
render_json_response(result_payload, session, add_session_header)
|
246
295
|
end
|
247
296
|
else
|
248
|
-
#
|
249
|
-
|
297
|
+
# This was causing the "Unknown handler result type: " error
|
298
|
+
Rails.logger.error "Unknown handler result type: #{result_type.inspect}"
|
299
|
+
|
300
|
+
# Return a proper JSON-RPC response with the preserved ID
|
301
|
+
# Default to a response with JSON-RPC message format
|
302
|
+
status = is_notification ? :accepted : :ok
|
303
|
+
render json: {
|
304
|
+
jsonrpc: "2.0",
|
305
|
+
id: request_id,
|
306
|
+
result: {}
|
307
|
+
}, status: status
|
250
308
|
end
|
251
309
|
end
|
252
310
|
|
@@ -254,11 +312,13 @@ module ActionMCP
|
|
254
312
|
def render_json_response(payload, session, add_session_header)
|
255
313
|
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
256
314
|
response.headers["Content-Type"] = "application/json"
|
315
|
+
|
257
316
|
render json: payload, status: :ok
|
258
317
|
end
|
259
318
|
|
260
319
|
# Renders the JSON-RPC response(s) as an SSE stream.
|
261
320
|
def render_sse_response(payload, session, add_session_header)
|
321
|
+
# This is not recommended with puma
|
262
322
|
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
263
323
|
response.headers["Content-Type"] = "text/event-stream"
|
264
324
|
response.headers["X-Accel-Buffering"] = "no"
|
@@ -285,17 +345,52 @@ module ActionMCP
|
|
285
345
|
end
|
286
346
|
|
287
347
|
# Renders a 500 Internal Server Error response.
|
288
|
-
def render_internal_server_error(message = "Internal Server Error")
|
348
|
+
def render_internal_server_error(message = "Internal Server Error", id = nil)
|
289
349
|
# Using -32000 for generic server error
|
290
|
-
render json: { jsonrpc: "2.0", error: { code: -32_000, message: message } }
|
350
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
|
351
|
+
end
|
352
|
+
|
353
|
+
# Helper to clean up old SSE events for a session
|
354
|
+
def cleanup_old_sse_events(session)
|
355
|
+
return unless ActionMCP.configuration.enable_sse_resumability
|
356
|
+
|
357
|
+
begin
|
358
|
+
# Get retention period from configuration
|
359
|
+
retention_period = session.sse_event_retention_period
|
360
|
+
count = session.cleanup_old_sse_events(retention_period)
|
361
|
+
|
362
|
+
Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count > 0
|
363
|
+
rescue => e
|
364
|
+
Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
|
365
|
+
end
|
291
366
|
end
|
292
367
|
|
293
368
|
# Helper to write a JSON payload as an SSE event with a unique ID.
|
369
|
+
# Also stores the event for potential resumability.
|
294
370
|
def write_sse_event(sse, session, payload)
|
295
371
|
event_id = session.increment_sse_counter!
|
372
|
+
|
296
373
|
# Manually format the SSE event string including the ID
|
297
|
-
data = MultiJson.dump(payload)
|
298
|
-
|
374
|
+
data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
|
375
|
+
sse_event = "id: #{event_id}\ndata: #{data}\n\n"
|
376
|
+
|
377
|
+
# Write to the stream
|
378
|
+
sse.stream.write(sse_event)
|
379
|
+
|
380
|
+
# Store the event for potential resumption if resumability is enabled
|
381
|
+
if ActionMCP.configuration.enable_sse_resumability
|
382
|
+
begin
|
383
|
+
session.store_sse_event(event_id, payload, session.max_stored_sse_events)
|
384
|
+
rescue => e
|
385
|
+
Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
def format_tools_list(tools, session)
|
391
|
+
# Pass the session's protocol version when formatting tools
|
392
|
+
protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
|
393
|
+
tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
|
299
394
|
end
|
300
395
|
|
301
396
|
# TODO: Add methods for handle_get (SSE setup, listener, heartbeat) - Partially Done
|
@@ -32,6 +32,7 @@ module ActionMCP
|
|
32
32
|
# including the direction (client or server), message type (request, response, notification),
|
33
33
|
# and any associated JSON-RPC ID.
|
34
34
|
class Message < ApplicationRecord
|
35
|
+
include MCPMessageInspect
|
35
36
|
belongs_to :session,
|
36
37
|
class_name: "ActionMCP::Session",
|
37
38
|
inverse_of: :messages,
|