actionmcp 0.29.0 → 0.30.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/app/controllers/action_mcp/mcp_controller.rb +69 -1
- data/app/controllers/action_mcp/sse_controller.rb +13 -78
- data/app/controllers/action_mcp/unified_controller.rb +304 -0
- data/app/models/action_mcp/session.rb +13 -2
- data/config/routes.rb +7 -0
- data/db/migrate/20250327124131_add_sse_event_counter_to_action_mcp_sessions.rb +7 -0
- data/exe/actionmcp_cli +5 -5
- data/lib/action_mcp/base_response.rb +1 -1
- data/lib/action_mcp/client/messaging.rb +5 -5
- data/lib/action_mcp/configuration.rb +18 -16
- data/lib/action_mcp/server/capabilities.rb +45 -10
- data/lib/action_mcp/server/messaging.rb +4 -4
- data/lib/action_mcp/sse_listener.rb +83 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -4
- data/lib/tasks/action_mcp_tasks.rake +7 -7
- metadata +20 -11
- data/lib/action_mcp/json_rpc/json_rpc_error.rb +0 -91
- data/lib/action_mcp/json_rpc/notification.rb +0 -27
- data/lib/action_mcp/json_rpc/request.rb +0 -46
- data/lib/action_mcp/json_rpc/response.rb +0 -80
- data/lib/action_mcp/json_rpc.rb +0 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4812289fae53550ca8bdf3912945b3176d3b2e12b4d7c025399b5ba7ae09f5de
|
4
|
+
data.tar.gz: 2dc3ca3cdfb2ca303724534b31904772be67db652ca437af559fcf5f1e39ea5c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b0dc0aa934e8b3fcc7f359f04f601de09898037820cc302dae0c67542fe74d1a8a1a43623d1156b6d0d7e680e8da5ecac8c64d72a38e97b364b7a6ed1e58ad7e
|
7
|
+
data.tar.gz: b16f4cbbddea8dff1ad52de97f7f0a6510bf0ba0fa65c19ca7ed3f0e017e3053a80d19c30ee600b1172552324acc7590ddaf39c5a161709f4387abd5aed5f4dd
|
@@ -8,8 +8,76 @@ module ActionMCP
|
|
8
8
|
end
|
9
9
|
include Engine.routes.url_helpers
|
10
10
|
|
11
|
+
# Header name for MCP Session ID (as per 2025-03-26 spec)
|
12
|
+
MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
|
13
|
+
|
14
|
+
# Provides the ActionMCP::Session for the current request.
|
15
|
+
# Handles finding existing sessions via header/param or initializing a new one.
|
16
|
+
# Specific controllers/handlers might need to enforce session ID presence based on context.
|
17
|
+
# @return [ActionMCP::Session] The session object (might be unsaved if new)
|
18
|
+
def mcp_session
|
19
|
+
@mcp_session ||= find_or_initialize_session
|
20
|
+
end
|
21
|
+
|
22
|
+
# Provides a unique key for caching or pub/sub based on the session ID.
|
23
|
+
# Ensures mcp_session is called first to establish the session ID.
|
24
|
+
# @return [String] The session key string.
|
11
25
|
def session_key
|
12
|
-
@session_key
|
26
|
+
@session_key ||= "action_mcp-sessions-#{mcp_session.id}"
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
# Finds an existing session based on header or param, or initializes a new one.
|
32
|
+
# Note: This doesn't save the new session; that happens upon first use or explicitly.
|
33
|
+
def find_or_initialize_session
|
34
|
+
session_id = extract_session_id
|
35
|
+
if session_id
|
36
|
+
# Attempt to find the session by ID. Return nil if not found.
|
37
|
+
# Controllers should handle the nil case (e.g., return 404).
|
38
|
+
Session.find_by(id: session_id)
|
39
|
+
else
|
40
|
+
# No session ID provided, initialize a new one (likely for 'initialize' request).
|
41
|
+
Session.new
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
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
|
+
# Renders a 400 Bad Request response with a JSON-RPC-like error structure.
|
53
|
+
def render_bad_request(message = "Bad Request")
|
54
|
+
# Using -32600 for Invalid Request based on JSON-RPC spec
|
55
|
+
render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }, status: :bad_request
|
56
|
+
end
|
57
|
+
|
58
|
+
# Renders a 404 Not Found response with a JSON-RPC-like error structure.
|
59
|
+
def render_not_found(message = "Not Found")
|
60
|
+
# Using a custom code or a generic server error range code might be appropriate.
|
61
|
+
# Let's use -32001 for a generic server error.
|
62
|
+
render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }, status: :not_found
|
63
|
+
end
|
64
|
+
|
65
|
+
# Renders a 405 Method Not Allowed response.
|
66
|
+
def render_method_not_allowed(message = "Method Not Allowed")
|
67
|
+
# Using -32601 Method not found from JSON-RPC spec seems applicable
|
68
|
+
render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }, status: :method_not_allowed
|
69
|
+
end
|
70
|
+
|
71
|
+
# Renders a 406 Not Acceptable response.
|
72
|
+
def render_not_acceptable(message = "Not Acceptable")
|
73
|
+
# No direct JSON-RPC equivalent, using a generic server error code.
|
74
|
+
render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }, status: :not_acceptable
|
75
|
+
end
|
76
|
+
|
77
|
+
# Renders a 501 Not Implemented response.
|
78
|
+
def render_not_implemented(message = "Not Implemented")
|
79
|
+
# No direct JSON-RPC equivalent, using a generic server error code.
|
80
|
+
render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }, status: :not_implemented
|
13
81
|
end
|
14
82
|
end
|
15
83
|
end
|
@@ -61,16 +61,20 @@ module ActionMCP
|
|
61
61
|
|
62
62
|
# Setup recurring heartbeat using ScheduledTask with proper cancellation
|
63
63
|
heartbeat_task = nil
|
64
|
-
heartbeat_sender =
|
64
|
+
heartbeat_sender = lambda do
|
65
65
|
if connection_active.true? && !response.stream.closed?
|
66
66
|
begin
|
67
67
|
# Try to send heartbeat with a controlled execution time
|
68
68
|
future = Concurrent::Promises.future do
|
69
|
-
|
69
|
+
ping_request = JSON_RPC::Request.new(
|
70
|
+
id: SecureRandom.uuid_v7, # Generate a unique ID for each ping
|
71
|
+
method: "ping"
|
72
|
+
).to_h
|
73
|
+
sse.write(ping_request)
|
70
74
|
end
|
71
75
|
|
72
76
|
# Wait for the heartbeat with timeout
|
73
|
-
|
77
|
+
future.value(5) # 5 second timeout
|
74
78
|
|
75
79
|
# Schedule the next heartbeat if this one succeeded
|
76
80
|
if heartbeat_active.true?
|
@@ -92,9 +96,7 @@ module ActionMCP
|
|
92
96
|
heartbeat_task = Concurrent::ScheduledTask.execute(HEARTBEAT_INTERVAL, &heartbeat_sender)
|
93
97
|
|
94
98
|
# Wait for connection to be closed or cancelled
|
95
|
-
while connection_active.true? && !response.stream.closed?
|
96
|
-
sleep 0.1
|
97
|
-
end
|
99
|
+
sleep 0.1 while connection_active.true? && !response.stream.closed?
|
98
100
|
rescue ActionController::Live::ClientDisconnected, IOError => e
|
99
101
|
Rails.logger.debug "SSE: Client disconnected: #{e.message}"
|
100
102
|
rescue StandardError => e
|
@@ -115,9 +117,9 @@ module ActionMCP
|
|
115
117
|
private
|
116
118
|
|
117
119
|
def build_timeout_error
|
118
|
-
|
120
|
+
JSON_RPC::Response.new(
|
119
121
|
id: SecureRandom.uuid_v7,
|
120
|
-
error:
|
122
|
+
error: JSON_RPC::JsonRpcError.new(
|
121
123
|
:server_error,
|
122
124
|
message: "No message received within initial connection timeout"
|
123
125
|
).to_h
|
@@ -125,9 +127,9 @@ module ActionMCP
|
|
125
127
|
end
|
126
128
|
|
127
129
|
def build_listener_error
|
128
|
-
|
130
|
+
JSON_RPC::Response.new(
|
129
131
|
id: SecureRandom.uuid_v7,
|
130
|
-
error:
|
132
|
+
error: JSON_RPC::JsonRpcError.new(
|
131
133
|
:server_error,
|
132
134
|
message: "Failed to establish server connection"
|
133
135
|
).to_h
|
@@ -155,71 +157,4 @@ module ActionMCP
|
|
155
157
|
mcp_session.session_key
|
156
158
|
end
|
157
159
|
end
|
158
|
-
|
159
|
-
class SSEListener
|
160
|
-
attr_reader :session_key, :adapter
|
161
|
-
|
162
|
-
delegate :session_key, :adapter, to: :@session
|
163
|
-
|
164
|
-
# @param session [ActionMCP::Session]
|
165
|
-
def initialize(session)
|
166
|
-
@session = session
|
167
|
-
@stopped = Concurrent::AtomicBoolean.new(false)
|
168
|
-
@subscription_active = Concurrent::AtomicBoolean.new(false)
|
169
|
-
end
|
170
|
-
|
171
|
-
# Start listening using ActionCable's adapter
|
172
|
-
def start(&callback)
|
173
|
-
Rails.logger.debug "Starting listener for channel: #{session_key}"
|
174
|
-
|
175
|
-
success_callback = lambda {
|
176
|
-
Rails.logger.info "Successfully subscribed to channel: #{session_key}"
|
177
|
-
@subscription_active.make_true
|
178
|
-
}
|
179
|
-
|
180
|
-
# Set up message callback
|
181
|
-
message_callback = lambda { |raw_message|
|
182
|
-
return if @stopped.true?
|
183
|
-
|
184
|
-
begin
|
185
|
-
# Try to parse the message if it's JSON
|
186
|
-
message = MultiJson.load(raw_message)
|
187
|
-
# Send the message to the callback
|
188
|
-
callback.call(message) if callback
|
189
|
-
rescue StandardError => e
|
190
|
-
Rails.logger.error "Error processing message: #{e.message}"
|
191
|
-
# Still try to send the raw message as a fallback
|
192
|
-
callback.call(raw_message) if callback
|
193
|
-
end
|
194
|
-
}
|
195
|
-
|
196
|
-
# Subscribe using the ActionCable adapter
|
197
|
-
adapter.subscribe(session_key, message_callback, success_callback)
|
198
|
-
|
199
|
-
# Use a future with timeout to check subscription status
|
200
|
-
subscription_future = Concurrent::Promises.future do
|
201
|
-
while !@subscription_active.true? && !@stopped.true?
|
202
|
-
sleep 0.1
|
203
|
-
end
|
204
|
-
@subscription_active.true?
|
205
|
-
end
|
206
|
-
|
207
|
-
# Wait up to 1 second for subscription to be established
|
208
|
-
begin
|
209
|
-
subscription_result = subscription_future.value(1)
|
210
|
-
subscription_result || @subscription_active.true?
|
211
|
-
rescue Concurrent::TimeoutError
|
212
|
-
Rails.logger.warn "Timed out waiting for subscription activation"
|
213
|
-
false
|
214
|
-
end
|
215
|
-
end
|
216
|
-
|
217
|
-
def stop
|
218
|
-
@stopped.make_true
|
219
|
-
if (mcp_session = Session.find_by(id: session_key))
|
220
|
-
mcp_session.close
|
221
|
-
end
|
222
|
-
Rails.logger.debug "Unsubscribed from: #{session_key}"
|
223
|
-
end
|
224
|
-
end
|
225
|
-
end
|
160
|
+
end
|
@@ -0,0 +1,304 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
# Handles the unified MCP endpoint for the 2025-03-26 specification.
|
5
|
+
# Supports GET for server-initiated SSE streams, POST for client messages
|
6
|
+
# (responding with JSON or SSE), and optionally DELETE for session termination.
|
7
|
+
class UnifiedController < MCPController
|
8
|
+
include ActionController::Live
|
9
|
+
# TODO: Include Instrumentation::ControllerRuntime if needed for metrics
|
10
|
+
|
11
|
+
# Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
|
12
|
+
# @route GET /mcp
|
13
|
+
def handle_get
|
14
|
+
# 1. Check Accept Header
|
15
|
+
unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
16
|
+
return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
|
17
|
+
end
|
18
|
+
|
19
|
+
# 2. Check Session (Must exist and be initialized)
|
20
|
+
session_id_from_header = extract_session_id
|
21
|
+
return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
|
22
|
+
|
23
|
+
session = mcp_session # Finds based on header
|
24
|
+
if session.nil? || session.new_record?
|
25
|
+
return render_not_found("Session not found.")
|
26
|
+
elsif !session.initialized?
|
27
|
+
# Spec doesn't explicitly forbid GET before initialized, but it seems logical
|
28
|
+
return render_bad_request("Session is not fully initialized.")
|
29
|
+
elsif session.status == "closed"
|
30
|
+
return render_not_found("Session has been terminated.")
|
31
|
+
end
|
32
|
+
|
33
|
+
# TODO: Handle Last-Event-ID header for stream resumption
|
34
|
+
|
35
|
+
# 3. Set SSE Headers
|
36
|
+
response.headers["Content-Type"] = "text/event-stream"
|
37
|
+
response.headers["X-Accel-Buffering"] = "no"
|
38
|
+
response.headers["Cache-Control"] = "no-cache"
|
39
|
+
response.headers["Connection"] = "keep-alive"
|
40
|
+
|
41
|
+
Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
|
42
|
+
|
43
|
+
# 4. Setup Stream, Listener, and Heartbeat
|
44
|
+
sse = SSE.new(response.stream)
|
45
|
+
listener = SSEListener.new(session) # Use the listener class (defined below or moved)
|
46
|
+
connection_active = Concurrent::AtomicBoolean.new(true)
|
47
|
+
heartbeat_active = Concurrent::AtomicBoolean.new(true)
|
48
|
+
heartbeat_task = nil
|
49
|
+
|
50
|
+
# Start listener
|
51
|
+
listener_started = listener.start do |message|
|
52
|
+
# Write message using helper to include event ID
|
53
|
+
write_sse_event(sse, session, message)
|
54
|
+
end
|
55
|
+
|
56
|
+
unless listener_started
|
57
|
+
Rails.logger.error "Unified SSE (GET): Listener failed to activate for session: #{session.id}"
|
58
|
+
# Don't write error to stream as per spec for GET, just close
|
59
|
+
connection_active.make_false
|
60
|
+
return # Error logged, connection will close in ensure block
|
61
|
+
end
|
62
|
+
|
63
|
+
# Heartbeat sender proc
|
64
|
+
heartbeat_sender = lambda do
|
65
|
+
if connection_active.true? && !response.stream.closed?
|
66
|
+
begin
|
67
|
+
# Use helper to send ping with event ID
|
68
|
+
future = Concurrent::Promises.future { write_sse_event(sse, session, { type: "ping" }) }
|
69
|
+
future.value!(5) # 5 second timeout for write
|
70
|
+
if heartbeat_active.true?
|
71
|
+
heartbeat_task = Concurrent::ScheduledTask.execute(ActionMCP.configuration.sse_heartbeat_interval,
|
72
|
+
&heartbeat_sender)
|
73
|
+
end
|
74
|
+
rescue Concurrent::TimeoutError
|
75
|
+
Rails.logger.warn "Unified SSE (GET): Heartbeat timed out for session: #{session.id}, closing."
|
76
|
+
connection_active.make_false
|
77
|
+
rescue StandardError => e
|
78
|
+
Rails.logger.debug "Unified SSE (GET): Heartbeat error for session: #{session.id}: #{e.message}"
|
79
|
+
connection_active.make_false
|
80
|
+
end
|
81
|
+
else
|
82
|
+
heartbeat_active.make_false
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Start first heartbeat
|
87
|
+
heartbeat_task = Concurrent::ScheduledTask.execute(HEARTBEAT_INTERVAL, &heartbeat_sender)
|
88
|
+
|
89
|
+
# Keep connection alive while active
|
90
|
+
sleep 0.1 while connection_active.true? && !response.stream.closed?
|
91
|
+
rescue ActionController::Live::ClientDisconnected, IOError => e
|
92
|
+
Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
|
93
|
+
rescue StandardError => e
|
94
|
+
Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
95
|
+
ensure
|
96
|
+
# Cleanup
|
97
|
+
Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
|
98
|
+
heartbeat_active&.make_false
|
99
|
+
heartbeat_task&.cancel
|
100
|
+
listener&.stop
|
101
|
+
# Don't close the session itself here, it might be used by other connections/requests
|
102
|
+
sse&.close
|
103
|
+
begin
|
104
|
+
response.stream&.close
|
105
|
+
rescue StandardError
|
106
|
+
nil
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
# Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
|
111
|
+
# @route POST /mcp
|
112
|
+
def handle_post
|
113
|
+
# 1. Check Accept Header
|
114
|
+
unless accepts_valid_content_types?
|
115
|
+
return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
|
116
|
+
end
|
117
|
+
|
118
|
+
# 2. Parse Request Body
|
119
|
+
parsed_body = parse_request_body
|
120
|
+
return unless parsed_body # Error rendered in parse_request_body
|
121
|
+
|
122
|
+
# Determine if this is an initialize request (before session check)
|
123
|
+
is_initialize_request = check_if_initialize_request(parsed_body)
|
124
|
+
|
125
|
+
# 3. Check Session (unless it's an initialize request)
|
126
|
+
session_initially_missing = extract_session_id.nil?
|
127
|
+
session = mcp_session # This finds or initializes
|
128
|
+
unless is_initialize_request
|
129
|
+
if session_initially_missing
|
130
|
+
return render_bad_request("Mcp-Session-Id header is required for this request.")
|
131
|
+
elsif session.nil? || session.new_record? # Should be found if ID was provided
|
132
|
+
return render_not_found("Session not found.")
|
133
|
+
elsif session.status == "closed"
|
134
|
+
return render_not_found("Session has been terminated.")
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
# 4. Instantiate Handlers
|
139
|
+
transport_handler = Server::TransportHandler.new(session)
|
140
|
+
json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
|
141
|
+
|
142
|
+
# 5. Call Handler
|
143
|
+
handler_results = json_rpc_handler.call(parsed_body)
|
144
|
+
|
145
|
+
# 6. Process Results
|
146
|
+
process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
|
147
|
+
rescue ActionController::Live::ClientDisconnected, IOError => e
|
148
|
+
# Ensure stream is closed if SSE response was attempted and client disconnected
|
149
|
+
Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
|
150
|
+
# Ensure stream is closed, cleanup might happen in ensure block if needed
|
151
|
+
begin
|
152
|
+
response.stream&.close
|
153
|
+
rescue StandardError
|
154
|
+
nil
|
155
|
+
end
|
156
|
+
rescue StandardError => e
|
157
|
+
Rails.logger.error "Unified POST Error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
158
|
+
render_internal_server_error("An unexpected error occurred.") unless performed?
|
159
|
+
end
|
160
|
+
|
161
|
+
# Handles DELETE requests for session termination (2025-03-26 spec).
|
162
|
+
# @route DELETE /mcp
|
163
|
+
def handle_delete
|
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
|
+
|
170
|
+
# 1. Check Session Header
|
171
|
+
session_id_from_header = extract_session_id
|
172
|
+
return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
|
173
|
+
|
174
|
+
# 2. Find Session
|
175
|
+
# Note: mcp_session helper finds based on header, but doesn't raise error if not found
|
176
|
+
session = Session.find_by(id: session_id_from_header)
|
177
|
+
|
178
|
+
if session.nil?
|
179
|
+
return render_not_found("Session not found.")
|
180
|
+
elsif session.status == "closed"
|
181
|
+
# Session already closed, treat as success (idempotent)
|
182
|
+
return head :no_content
|
183
|
+
end
|
184
|
+
|
185
|
+
# 3. Terminate Session
|
186
|
+
begin
|
187
|
+
session.close! # This should handle cleanup like unsubscribing etc.
|
188
|
+
Rails.logger.info "Unified DELETE: Terminated session: #{session.id}"
|
189
|
+
head :no_content
|
190
|
+
rescue StandardError => e
|
191
|
+
Rails.logger.error "Unified DELETE: Error terminating session #{session.id}: #{e.class} - #{e.message}"
|
192
|
+
render_internal_server_error("Failed to terminate session.")
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
private
|
197
|
+
|
198
|
+
# Checks if the client's Accept header includes the required types.
|
199
|
+
def accepts_valid_content_types?
|
200
|
+
request.accepts.any? { |type| type.to_s == "application/json" } &&
|
201
|
+
request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
202
|
+
end
|
203
|
+
|
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
|
+
# Checks if the parsed body represents an 'initialize' request.
|
214
|
+
def check_if_initialize_request(parsed_body)
|
215
|
+
if parsed_body.is_a?(Hash) && parsed_body["method"] == "initialize"
|
216
|
+
true
|
217
|
+
elsif parsed_body.is_a?(Array) # Cannot be in a batch
|
218
|
+
false
|
219
|
+
else
|
220
|
+
false
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
# Processes the results from the JsonRpcHandler.
|
225
|
+
def process_handler_results(results, session, session_initially_missing, is_initialize_request)
|
226
|
+
case results[:type]
|
227
|
+
when :error
|
228
|
+
# Handle handler-level errors (e.g., batch parse error)
|
229
|
+
render json: results[:payload], status: results.fetch(:status, :bad_request)
|
230
|
+
when :notifications_only
|
231
|
+
# No response needed, just accept
|
232
|
+
head :accepted
|
233
|
+
when :responses
|
234
|
+
# Determine response format based on server preference and client acceptance.
|
235
|
+
# Client MUST accept both 'application/json' and 'text/event-stream' (checked earlier).
|
236
|
+
server_preference = ActionMCP.configuration.post_response_preference # :json or :sse
|
237
|
+
use_sse = (server_preference == :sse)
|
238
|
+
|
239
|
+
# Add session ID header if this was a successful initialize request that created the session
|
240
|
+
add_session_header = is_initialize_request && session_initially_missing && session.persisted?
|
241
|
+
|
242
|
+
if use_sse
|
243
|
+
render_sse_response(results[:payload], session, add_session_header)
|
244
|
+
else
|
245
|
+
render_json_response(results[:payload], session, add_session_header)
|
246
|
+
end
|
247
|
+
else
|
248
|
+
# Should not happen
|
249
|
+
render_internal_server_error("Unknown handler result type: #{results[:type]}")
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
# Renders the JSON-RPC response(s) as a direct JSON HTTP response.
|
254
|
+
def render_json_response(payload, session, add_session_header)
|
255
|
+
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
256
|
+
response.headers["Content-Type"] = "application/json"
|
257
|
+
render json: payload, status: :ok
|
258
|
+
end
|
259
|
+
|
260
|
+
# Renders the JSON-RPC response(s) as an SSE stream.
|
261
|
+
def render_sse_response(payload, session, add_session_header)
|
262
|
+
response.headers[MCP_SESSION_ID_HEADER] = session.id if add_session_header
|
263
|
+
response.headers["Content-Type"] = "text/event-stream"
|
264
|
+
response.headers["X-Accel-Buffering"] = "no"
|
265
|
+
response.headers["Cache-Control"] = "no-cache"
|
266
|
+
response.headers["Connection"] = "keep-alive"
|
267
|
+
|
268
|
+
sse = SSE.new(response.stream)
|
269
|
+
# TODO: Add logic for sending related server requests/notifications before/after response?
|
270
|
+
|
271
|
+
if payload.is_a?(Array)
|
272
|
+
# Send batched responses as separate events or one event? Spec allows batching.
|
273
|
+
# Let's send as one event for now, using one ID for the batch.
|
274
|
+
end
|
275
|
+
write_sse_event(sse, session, payload)
|
276
|
+
ensure
|
277
|
+
# Close the stream after sending the response(s)
|
278
|
+
sse&.close
|
279
|
+
begin
|
280
|
+
response.stream&.close
|
281
|
+
rescue StandardError
|
282
|
+
nil
|
283
|
+
end
|
284
|
+
Rails.logger.debug "Unified SSE (POST): Response stream closed."
|
285
|
+
end
|
286
|
+
|
287
|
+
# Renders a 500 Internal Server Error response.
|
288
|
+
def render_internal_server_error(message = "Internal Server Error")
|
289
|
+
# Using -32000 for generic server error
|
290
|
+
render json: { jsonrpc: "2.0", error: { code: -32_000, message: message } }, status: :internal_server_error
|
291
|
+
end
|
292
|
+
|
293
|
+
# Helper to write a JSON payload as an SSE event with a unique ID.
|
294
|
+
def write_sse_event(sse, session, payload)
|
295
|
+
event_id = session.increment_sse_counter!
|
296
|
+
# Manually format the SSE event string including the ID
|
297
|
+
data = MultiJson.dump(payload)
|
298
|
+
sse.stream.write("id: #{event_id}\ndata: #{data}\n\n")
|
299
|
+
end
|
300
|
+
|
301
|
+
# TODO: Add methods for handle_get (SSE setup, listener, heartbeat) - Partially Done
|
302
|
+
# TODO: Add method for handle_delete (session termination) - DONE (Basic)
|
303
|
+
end
|
304
|
+
end
|
@@ -14,6 +14,7 @@
|
|
14
14
|
# role(The role of the session) :string default("server"), not null
|
15
15
|
# server_capabilities(The capabilities of the server) :jsonb
|
16
16
|
# server_info(The information about the server) :jsonb
|
17
|
+
# sse_event_counter :integer default(0), not null
|
17
18
|
# status :string default("pre_initialize"), not null
|
18
19
|
# created_at :datetime not null
|
19
20
|
# updated_at :datetime not null
|
@@ -63,7 +64,7 @@ module ActionMCP
|
|
63
64
|
|
64
65
|
# MESSAGING dispatch
|
65
66
|
def write(data)
|
66
|
-
if data.is_a?(
|
67
|
+
if data.is_a?(JSON_RPC::Request) || data.is_a?(JSON_RPC::Response) || data.is_a?(JSON_RPC::Notification)
|
67
68
|
data = data.to_json
|
68
69
|
end
|
69
70
|
data = MultiJson.dump(data) if data.is_a?(Hash)
|
@@ -124,7 +125,7 @@ module ActionMCP
|
|
124
125
|
|
125
126
|
def send_ping!
|
126
127
|
Session.logger.silence do
|
127
|
-
write(
|
128
|
+
write(JSON_RPC::Request.new(id: Time.now.to_i, method: "ping"))
|
128
129
|
end
|
129
130
|
end
|
130
131
|
|
@@ -136,6 +137,16 @@ module ActionMCP
|
|
136
137
|
subscriptions.find_by(uri: uri)&.destroy
|
137
138
|
end
|
138
139
|
|
140
|
+
# Atomically increments the SSE event counter and returns the new value.
|
141
|
+
# This ensures unique, sequential IDs for SSE events within the session.
|
142
|
+
# @return [Integer] The new value of the counter.
|
143
|
+
def increment_sse_counter!
|
144
|
+
# Use update_counters for an atomic increment operation
|
145
|
+
self.class.update_counters(id, sse_event_counter: 1)
|
146
|
+
# Reload to get the updated value (update_counters doesn't update the instance)
|
147
|
+
reload.sse_event_counter
|
148
|
+
end
|
149
|
+
|
139
150
|
private
|
140
151
|
|
141
152
|
# if this session is from a server, the writer is the client
|
data/config/routes.rb
CHANGED
@@ -1,6 +1,13 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
ActionMCP::Engine.routes.draw do
|
4
|
+
# --- Routes for 2024-11-05 Spec (HTTP+SSE) ---
|
5
|
+
# Kept for backward compatibility
|
4
6
|
get "/", to: "sse#events", as: :sse_out
|
5
7
|
post "/", to: "messages#create", as: :sse_in, defaults: { format: "json" }
|
8
|
+
|
9
|
+
# --- Routes for 2025-03-26 Spec (Streamable HTTP) ---
|
10
|
+
mcp_endpoint = ActionMCP.configuration.mcp_endpoint_path
|
11
|
+
get mcp_endpoint, to: "unified#handle_get", as: :mcp_get
|
12
|
+
post mcp_endpoint, to: "unified#handle_post", as: :mcp_post
|
6
13
|
end
|
data/exe/actionmcp_cli
CHANGED
@@ -92,7 +92,7 @@ def parse_command(input)
|
|
92
92
|
arguments[key] = parsed_value
|
93
93
|
end
|
94
94
|
|
95
|
-
|
95
|
+
JSON_RPC::Request.new(
|
96
96
|
id: generate_request_id,
|
97
97
|
method: 'tools/get',
|
98
98
|
params: {
|
@@ -101,12 +101,12 @@ def parse_command(input)
|
|
101
101
|
}
|
102
102
|
)
|
103
103
|
when 'list_tools'
|
104
|
-
|
104
|
+
JSON_RPC::Request.new(
|
105
105
|
id: generate_request_id,
|
106
106
|
method: 'tools/list'
|
107
107
|
)
|
108
108
|
when 'list_prompts'
|
109
|
-
|
109
|
+
JSON_RPC::Request.new(
|
110
110
|
id: generate_request_id,
|
111
111
|
method: 'prompts/list'
|
112
112
|
)
|
@@ -188,7 +188,7 @@ loop do
|
|
188
188
|
json = MultiJson.load(input)
|
189
189
|
# Validate that the parsed JSON has the required fields
|
190
190
|
if json['method']
|
191
|
-
request =
|
191
|
+
request = JSON_RPC::Request.new(
|
192
192
|
id: json['id'] || generate_request_id,
|
193
193
|
method: json['method'],
|
194
194
|
params: json['params']
|
@@ -200,7 +200,7 @@ loop do
|
|
200
200
|
rescue MultiJson::ParseError => e
|
201
201
|
puts "Invalid input: not a valid command or JSON. #{e.message}"
|
202
202
|
next
|
203
|
-
rescue
|
203
|
+
rescue JSON_RPC::JsonRpcError => e
|
204
204
|
puts "Invalid JSON-RPC request: #{e.message}"
|
205
205
|
next
|
206
206
|
end
|
@@ -21,7 +21,7 @@ module ActionMCP
|
|
21
21
|
# Convert to hash format expected by MCP protocol
|
22
22
|
def to_h
|
23
23
|
if @is_error
|
24
|
-
|
24
|
+
JSON_RPC::JsonRpcError.new(@symbol, message: @error_message, data: @error_data).to_h
|
25
25
|
else
|
26
26
|
build_success_hash
|
27
27
|
end
|
@@ -4,23 +4,23 @@ module ActionMCP
|
|
4
4
|
module Client
|
5
5
|
module Messaging
|
6
6
|
def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
|
7
|
-
request =
|
7
|
+
request = JSON_RPC::Request.new(id: id, method: method, params: params)
|
8
8
|
write_message(request)
|
9
9
|
end
|
10
10
|
|
11
11
|
def send_jsonrpc_response(request_id, result: nil, error: nil)
|
12
|
-
response =
|
12
|
+
response = JSON_RPC::Response.new(id: request_id, result: result, error: error)
|
13
13
|
write_message(response)
|
14
14
|
end
|
15
15
|
|
16
16
|
def send_jsonrpc_notification(method, params = nil)
|
17
|
-
notification =
|
17
|
+
notification = JSON_RPC::Notification.new(method: method, params: params)
|
18
18
|
write_message(notification)
|
19
19
|
end
|
20
20
|
|
21
21
|
def send_jsonrpc_error(request_id, symbol, message, data = nil)
|
22
|
-
error =
|
23
|
-
response =
|
22
|
+
error = JSON_RPC::JsonRpcError.new(symbol, message:, data:)
|
23
|
+
response = JSON_RPC::Response.new(id: request_id, error:)
|
24
24
|
write_message(response)
|
25
25
|
end
|
26
26
|
end
|