actionmcp 0.31.1 → 0.32.1
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 +16 -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/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/tools.rb +50 -6
- data/lib/action_mcp/sse_listener.rb +3 -2
- 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
- metadata +25 -20
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 54b807ad23c23796ef5495fb91afffb9ee10d998516a72d20897c519bed56c4c
|
4
|
+
data.tar.gz: e5ec996f8e8dcb73383de34fe5f851382961a74a27e3b28c26271aadb343c30f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 55938823b5df5d13b8ffe2adbb9e581331e60fabb2f7111807fa10b119cd898f2ab3dd695cbda8924af0b9be79cf9eb0767b18324a1042ac46fb4facb6562abe
|
7
|
+
data.tar.gz: 76c6c5a553e55f06b61165844a3d55e45854dffe515879f14eaaf5b1399aa8f3273563b3c78cf028bab54547cc3165947cd5e2606e19f96be55285c845c641ef
|
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
|
|
@@ -222,15 +223,25 @@ For dynamic versioning, consider adding the `rails_app_version` gem.
|
|
222
223
|
|
223
224
|
## Engine and Mounting
|
224
225
|
|
225
|
-
ActionMCP
|
226
|
-
|
226
|
+
**ActionMCP** runs as a standalone Rack application, similar to **ActionCable**. It is **not** mounted in `routes.rb`.
|
227
|
+
|
228
|
+
> **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
|
227
229
|
|
228
|
-
|
230
|
+
### 1. Create `mcp.ru`
|
229
231
|
|
230
232
|
```ruby
|
231
|
-
Rails.
|
232
|
-
|
233
|
+
# Load the full Rails environment to access models, DB, Redis, etc.
|
234
|
+
require_relative "config/environment"
|
235
|
+
|
236
|
+
ActionMCP.configure do |config|
|
237
|
+
config.mcp_endpoint_path = "/mcp"
|
233
238
|
end
|
239
|
+
|
240
|
+
run ActionMCP::Engine
|
241
|
+
```
|
242
|
+
### 2. Start the server
|
243
|
+
```bash
|
244
|
+
bin/rails s -c mcp.ru -p 6277 -P tmp/pids/mcp.pid
|
234
245
|
```
|
235
246
|
|
236
247
|
## 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,
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# == Schema Information
|
4
|
+
#
|
5
|
+
# Table name: action_mcp_sse_events
|
6
|
+
#
|
7
|
+
# id :bigint not null, primary key
|
8
|
+
# data :text not null
|
9
|
+
# created_at :datetime not null
|
10
|
+
# updated_at :datetime not null
|
11
|
+
# event_id :integer not null
|
12
|
+
# session_id :string not null
|
13
|
+
#
|
14
|
+
# Indexes
|
15
|
+
#
|
16
|
+
# index_action_mcp_sse_events_on_created_at (created_at)
|
17
|
+
# index_action_mcp_sse_events_on_session_id (session_id)
|
18
|
+
# index_action_mcp_sse_events_on_session_id_and_event_id (session_id,event_id) UNIQUE
|
19
|
+
#
|
20
|
+
# Foreign Keys
|
21
|
+
#
|
22
|
+
# fk_rails_... (session_id => action_mcp_sessions.id)
|
23
|
+
#
|
24
|
+
module ActionMCP
|
25
|
+
class Session
|
26
|
+
# Represents a Server-Sent Event (SSE) in an MCP session
|
27
|
+
# These events are stored for potential resumption when a client reconnects
|
28
|
+
class SSEEvent < ApplicationRecord
|
29
|
+
self.table_name = "action_mcp_sse_events"
|
30
|
+
|
31
|
+
belongs_to :session, class_name: "ActionMCP::Session"
|
32
|
+
|
33
|
+
# Validations
|
34
|
+
validates :event_id, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
35
|
+
validates :data, presence: true
|
36
|
+
|
37
|
+
# Scopes
|
38
|
+
scope :recent, -> { order(event_id: :desc) }
|
39
|
+
scope :after_id, ->(id) { where("event_id > ?", id) }
|
40
|
+
scope :before, ->(time) { where("created_at < ?", time) }
|
41
|
+
|
42
|
+
# Serializes the data as JSON if it's not already a string
|
43
|
+
def data_for_stream
|
44
|
+
return data if data.is_a?(String)
|
45
|
+
data.is_a?(Hash) ? data.to_json : data.to_s
|
46
|
+
end
|
47
|
+
|
48
|
+
# Generates the SSE formatted event string
|
49
|
+
# @return [String] The formatted SSE event
|
50
|
+
def to_sse
|
51
|
+
"id: #{event_id}\ndata: #{data_for_stream}\n\n"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|