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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e4c587dbf08153dbc1b3c04a9a65be8accf56959e5f3c1e336c84950ca32936
4
- data.tar.gz: c6fd2f8e495eaf00e30cd88a9022ab31cde81a24c90dda056cfc1ba9bc0e6526
3
+ metadata.gz: 4812289fae53550ca8bdf3912945b3176d3b2e12b4d7c025399b5ba7ae09f5de
4
+ data.tar.gz: 2dc3ca3cdfb2ca303724534b31904772be67db652ca437af559fcf5f1e39ea5c
5
5
  SHA512:
6
- metadata.gz: d52f4037a16226d74929dc4bff5a6d5b3dbf5b9f4774b5521dc1dc2e107689e4de6271d3ba327ba124f114b7631f29f8d357f534954b55f996b31f281ae6bfbd
7
- data.tar.gz: a08bda7ee781728e75477c1aa943728440ced72b42d71123d596fe6ff13f84893bbec8862071dde69b8452918735329adb4b97a95a55271f458fc578320ebd23
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 = "action_mcp-sessions-#{session_id}"
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 = -> do
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
- sse.write({ ping: true })
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
- result = future.value(5) # 5 second timeout
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
- JsonRpc::Response.new(
120
+ JSON_RPC::Response.new(
119
121
  id: SecureRandom.uuid_v7,
120
- error: JsonRpc::JsonRpcError.new(
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
- JsonRpc::Response.new(
130
+ JSON_RPC::Response.new(
129
131
  id: SecureRandom.uuid_v7,
130
- error: JsonRpc::JsonRpcError.new(
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?(JsonRpc::Request) || data.is_a?(JsonRpc::Response) || data.is_a?(JsonRpc::Notification)
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(JsonRpc::Request.new(id: Time.now.to_i, method: "ping"))
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddSSEEventCounterToActionMCPSessions < ActiveRecord::Migration[8.0]
4
+ def change
5
+ add_column :action_mcp_sessions, :sse_event_counter, :integer, default: 0, null: false
6
+ end
7
+ 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
- ActionMCP::JsonRpc::Request.new(
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
- ActionMCP::JsonRpc::Request.new(
104
+ JSON_RPC::Request.new(
105
105
  id: generate_request_id,
106
106
  method: 'tools/list'
107
107
  )
108
108
  when 'list_prompts'
109
- ActionMCP::JsonRpc::Request.new(
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 = ActionMCP::JsonRpc::Request.new(
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 ActionMCP::JsonRpc::JsonRpcError => e
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
- JsonRpc::JsonRpcError.new(@symbol, message: @error_message, data: @error_data).to_h
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 = JsonRpc::Request.new(id: id, method: method, params: params)
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 = JsonRpc::Response.new(id: request_id, result: result, error: error)
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 = JsonRpc::Notification.new(method: method, params: params)
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 = JsonRpc::JsonRpcError.new(symbol, message:, data:)
23
- response = JsonRpc::Response.new(id: request_id, error:)
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