actionmcp 0.50.2 → 0.50.4
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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b2ba13241313978eedd0619b256d320728391aa9afaa899b6299e65e54867358
|
4
|
+
data.tar.gz: 6bfc9173a29184139f7b9f0ceb543a56da3a557d3c633abae09e8bdc4c8465ee
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 77740b75ca3ec92fdb1eff6ecbfe1020113093899652b5cee0a56fbfbbe0c40c93e26f82803df732f33464e5cf532018901118cc6092d3e204001c490966b36f
|
7
|
+
data.tar.gz: c4ddcadeac684c84116e7f6fe04dff67b6f9749425bd4d5901471affacc2bfb1bb644e6a3d955718bd0ae6a788af0fd7981df621a2f15f4e372510b809190710
|
@@ -4,39 +4,54 @@ module ActionMCP
|
|
4
4
|
# Implements the MCP endpoints according to the 2025-03-26 specification.
|
5
5
|
# Supports GET for server-initiated SSE streams, POST for client messages
|
6
6
|
# (responding with JSON or SSE), and optionally DELETE for session termination.
|
7
|
-
class
|
7
|
+
class ApplicationController < ActionController::Metal
|
8
8
|
REQUIRED_PROTOCOL_VERSION = "2025-03-26"
|
9
|
+
MCP_SESSION_ID_HEADER = "Mcp-Session-Id"
|
9
10
|
|
11
|
+
ActionController::API.without_modules(:StrongParameters, :ParamsWrapper).each do |left|
|
12
|
+
include left
|
13
|
+
end
|
14
|
+
include Engine.routes.url_helpers
|
10
15
|
include JSONRPC_Rails::ControllerHelpers
|
11
16
|
include ActionController::Live
|
12
17
|
|
18
|
+
# Provides the ActionMCP::Session for the current request.
|
19
|
+
# Handles finding existing sessions via header/param or initializing a new one.
|
20
|
+
# Specific controllers/handlers might need to enforce session ID presence based on context.
|
21
|
+
# @return [ActionMCP::Session] The session object (might be unsaved if new)
|
22
|
+
def mcp_session
|
23
|
+
@mcp_session ||= find_or_initialize_session
|
24
|
+
end
|
25
|
+
|
26
|
+
# Provides a unique key for caching or pub/sub based on the session ID.
|
27
|
+
# Ensures mcp_session is called first to establish the session ID.
|
28
|
+
# @return [String] The session key string.
|
29
|
+
def session_key
|
30
|
+
@session_key ||= "action_mcp-sessions-#{mcp_session.id}"
|
31
|
+
end
|
32
|
+
|
13
33
|
# Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
|
14
|
-
# @route GET /
|
34
|
+
# @route GET /
|
15
35
|
def show
|
16
|
-
# 1. Check Accept Header
|
17
36
|
unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
18
37
|
return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
|
19
38
|
end
|
20
39
|
|
21
|
-
# 2. Check Session (Must exist and be initialized)
|
22
40
|
session_id_from_header = extract_session_id
|
23
41
|
return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
|
24
42
|
|
25
|
-
session = mcp_session
|
43
|
+
session = mcp_session
|
26
44
|
if session.nil? || session.new_record?
|
27
45
|
return render_not_found("Session not found.")
|
28
46
|
elsif !session.initialized?
|
29
|
-
# Spec doesn't explicitly forbid GET before initialized, but it seems logical
|
30
47
|
return render_bad_request("Session is not fully initialized.")
|
31
48
|
elsif session.status == "closed"
|
32
49
|
return render_not_found("Session has been terminated.")
|
33
50
|
end
|
34
51
|
|
35
|
-
# Check for Last-Event-ID header for resumability
|
36
52
|
last_event_id = request.headers["Last-Event-ID"].presence
|
37
53
|
Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
|
38
54
|
|
39
|
-
# 3. Set SSE Headers
|
40
55
|
response.headers["Content-Type"] = "text/event-stream"
|
41
56
|
response.headers["X-Accel-Buffering"] = "no"
|
42
57
|
response.headers["Cache-Control"] = "no-cache"
|
@@ -44,7 +59,6 @@ module ActionMCP
|
|
44
59
|
|
45
60
|
Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
|
46
61
|
|
47
|
-
# 4. Setup Stream, Listener, and Heartbeat
|
48
62
|
sse = SSE.new(response.stream)
|
49
63
|
listener = SSEListener.new(session)
|
50
64
|
connection_active = Concurrent::AtomicBoolean.new
|
@@ -53,31 +67,23 @@ module ActionMCP
|
|
53
67
|
heartbeat_active.make_true
|
54
68
|
heartbeat_task = nil
|
55
69
|
|
56
|
-
# Start listener
|
57
70
|
listener_started = listener.start do |message|
|
58
|
-
# Write message using helper to include event ID
|
59
71
|
write_sse_event(sse, session, message)
|
60
72
|
end
|
61
73
|
|
62
74
|
unless listener_started
|
63
75
|
Rails.logger.error "Unified SSE (GET): Listener failed to activate for session: #{session.id}"
|
64
|
-
# Don't write error to stream as per spec for GET, just close
|
65
76
|
connection_active.make_false
|
66
|
-
return
|
77
|
+
return
|
67
78
|
end
|
68
79
|
|
69
|
-
# Handle resumability by sending missed events if Last-Event-ID is provided
|
70
80
|
if last_event_id.present? && last_event_id.to_i.positive?
|
71
81
|
begin
|
72
|
-
# Fetch events that occurred after the Last-Event-ID
|
73
82
|
missed_events = session.get_sse_events_after(last_event_id.to_i)
|
74
|
-
|
75
83
|
if missed_events.any?
|
76
84
|
Rails.logger.info "Unified SSE (GET): Sending #{missed_events.size} missed events for session: #{session.id}"
|
77
|
-
|
78
|
-
# Send each missed event to the client
|
79
85
|
missed_events.each do |event|
|
80
|
-
sse.
|
86
|
+
sse.write(event.to_sse)
|
81
87
|
end
|
82
88
|
else
|
83
89
|
Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
|
@@ -87,14 +93,12 @@ module ActionMCP
|
|
87
93
|
end
|
88
94
|
end
|
89
95
|
|
90
|
-
# Heartbeat sender proc
|
91
96
|
heartbeat_interval = ActionMCP.configuration.sse_heartbeat_interval || 15.seconds
|
92
97
|
heartbeat_sender = lambda do
|
93
98
|
if connection_active.true? && !response.stream.closed?
|
94
99
|
begin
|
95
|
-
# Use helper to send ping with event ID
|
96
100
|
future = Concurrent::Promises.future { write_sse_event(sse, session, { type: "ping" }) }
|
97
|
-
future.value!(5)
|
101
|
+
future.value!(5)
|
98
102
|
if heartbeat_active.true?
|
99
103
|
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
100
104
|
end
|
@@ -110,26 +114,18 @@ module ActionMCP
|
|
110
114
|
end
|
111
115
|
end
|
112
116
|
|
113
|
-
# Start first heartbeat
|
114
117
|
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
115
|
-
|
116
|
-
# Keep connection alive while active
|
117
118
|
sleep 0.1 while connection_active.true? && !response.stream.closed?
|
118
119
|
rescue ActionController::Live::ClientDisconnected, IOError => e
|
119
120
|
Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
|
120
121
|
rescue StandardError => e
|
121
122
|
Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
122
123
|
ensure
|
123
|
-
# Cleanup
|
124
124
|
Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
|
125
125
|
heartbeat_active&.make_false
|
126
126
|
heartbeat_task&.cancel
|
127
127
|
listener&.stop
|
128
|
-
|
129
|
-
# Clean up old SSE events if resumability is enabled
|
130
128
|
cleanup_old_sse_events(session) if session
|
131
|
-
|
132
|
-
# Don't close the session itself here, it might be used by other connections/requests
|
133
129
|
sse&.close
|
134
130
|
begin
|
135
131
|
response.stream&.close
|
@@ -141,22 +137,18 @@ module ActionMCP
|
|
141
137
|
# Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
|
142
138
|
# @route POST /mcp
|
143
139
|
def create
|
144
|
-
|
145
|
-
|
146
|
-
return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
|
140
|
+
unless post_accept_headers_valid?
|
141
|
+
return render_not_acceptable(post_accept_headers_error_message)
|
147
142
|
end
|
148
143
|
|
149
|
-
# 2. Determine if this is an initialize request (before session check)
|
150
144
|
is_initialize_request = check_if_initialize_request(jsonrpc_params)
|
151
|
-
|
152
|
-
# 3. Check Session (unless it's an initialize request)
|
153
145
|
session_initially_missing = extract_session_id.nil?
|
154
|
-
session = mcp_session
|
146
|
+
session = mcp_session
|
155
147
|
|
156
148
|
unless is_initialize_request
|
157
149
|
if session_initially_missing
|
158
150
|
return render_bad_request("Mcp-Session-Id header is required for this request.")
|
159
|
-
elsif session.nil? || session.new_record?
|
151
|
+
elsif session.nil? || session.new_record?
|
160
152
|
return render_not_found("Session not found.")
|
161
153
|
elsif session.status == "closed"
|
162
154
|
return render_not_found("Session has been terminated.")
|
@@ -168,17 +160,11 @@ module ActionMCP
|
|
168
160
|
response.headers[MCP_SESSION_ID_HEADER] = session.id
|
169
161
|
end
|
170
162
|
|
171
|
-
# 4. Instantiate Handlers
|
172
163
|
transport_handler = Server::TransportHandler.new(session)
|
173
164
|
json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
|
174
|
-
|
175
|
-
# 5. Call Handler
|
176
165
|
handler_results = json_rpc_handler.call(jsonrpc_params)
|
177
|
-
|
178
|
-
# 6. Process Results
|
179
166
|
process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
|
180
167
|
rescue ActionController::Live::ClientDisconnected, IOError => e
|
181
|
-
# Ensure stream is closed if SSE response was attempted and client disconnected
|
182
168
|
Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
|
183
169
|
begin
|
184
170
|
response.stream&.close
|
@@ -191,25 +177,20 @@ module ActionMCP
|
|
191
177
|
end
|
192
178
|
|
193
179
|
# Handles DELETE requests for session termination (2025-03-26 spec).
|
194
|
-
# @route DELETE /
|
180
|
+
# @route DELETE /
|
195
181
|
def destroy
|
196
|
-
# 1. Check Session Header
|
197
182
|
session_id_from_header = extract_session_id
|
198
183
|
return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
|
199
184
|
|
200
|
-
# 2. Find Session
|
201
185
|
session = Session.find_by(id: session_id_from_header)
|
202
|
-
|
203
186
|
if session.nil?
|
204
187
|
return render_not_found("Session not found.")
|
205
188
|
elsif session.status == "closed"
|
206
|
-
# Session already closed, treat as success (idempotent)
|
207
189
|
return head :no_content
|
208
190
|
end
|
209
191
|
|
210
|
-
# 3. Terminate Session
|
211
192
|
begin
|
212
|
-
session.close!
|
193
|
+
session.close!
|
213
194
|
Rails.logger.info "Unified DELETE: Terminated session: #{session.id}"
|
214
195
|
head :no_content
|
215
196
|
rescue StandardError => e
|
@@ -220,6 +201,21 @@ module ActionMCP
|
|
220
201
|
|
221
202
|
private
|
222
203
|
|
204
|
+
# Finds an existing session based on header or param, or initializes a new one.
|
205
|
+
# Note: This doesn't save the new session; that happens upon first use or explicitly.
|
206
|
+
def find_or_initialize_session
|
207
|
+
session_id = extract_session_id
|
208
|
+
if session_id
|
209
|
+
session = Session.find_by(id: session_id)
|
210
|
+
if session && session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
|
211
|
+
session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
212
|
+
end
|
213
|
+
session
|
214
|
+
else
|
215
|
+
Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
223
219
|
# @return [String, nil] The extracted session ID or nil if not found.
|
224
220
|
def extract_session_id
|
225
221
|
request.headers[MCP_SESSION_ID_HEADER].presence
|
@@ -231,66 +227,64 @@ module ActionMCP
|
|
231
227
|
request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
232
228
|
end
|
233
229
|
|
230
|
+
# Checks if the Accept headers for POST are valid according to server preference.
|
231
|
+
def post_accept_headers_valid?
|
232
|
+
if ActionMCP.configuration.post_response_preference == :sse
|
233
|
+
accepts_valid_content_types?
|
234
|
+
else
|
235
|
+
request.accepts.any? { |type| type.to_s == "application/json" }
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
# Returns the appropriate error message for POST Accept header validation.
|
240
|
+
def post_accept_headers_error_message
|
241
|
+
if ActionMCP.configuration.post_response_preference == :sse
|
242
|
+
"Client must accept 'application/json' and 'text/event-stream'"
|
243
|
+
else
|
244
|
+
"Client must accept 'application/json'"
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
234
248
|
# Checks if the parsed body represents an 'initialize' request.
|
235
249
|
def check_if_initialize_request(payload)
|
236
250
|
return false unless payload.is_a?(JSON_RPC::Request) && !jsonrpc_params_batch?
|
237
|
-
|
238
251
|
payload.method == "initialize"
|
239
252
|
end
|
240
253
|
|
241
254
|
# Processes the results from the JsonRpcHandler.
|
242
255
|
def process_handler_results(results, session, session_initially_missing, is_initialize_request)
|
243
|
-
# Make sure we always have a results hash
|
244
256
|
results ||= {}
|
245
|
-
|
246
|
-
# Check if this is a notification request
|
247
257
|
is_notification = jsonrpc_params.is_a?(JSON_RPC::Notification)
|
248
|
-
|
249
|
-
# Extract request ID from results
|
250
258
|
request_id = nil
|
251
259
|
if results.is_a?(Hash)
|
252
260
|
request_id = results[:request_id] || results[:id]
|
253
|
-
# If we have a payload that's a response, extract ID from there as well
|
254
261
|
request_id ||= results[:payload][:id] if results[:payload].is_a?(Hash) && results[:payload][:id]
|
255
262
|
end
|
256
|
-
|
257
|
-
# Default to empty hash for response payload if nil
|
258
263
|
result_type = results[:type]
|
259
264
|
result_payload = results[:payload] || {}
|
260
|
-
|
261
|
-
# Ensure payload has the correct ID if it's a hash
|
262
265
|
result_payload[:id] = request_id if result_payload.is_a?(Hash) && request_id && !result_payload.key?(:id)
|
263
266
|
|
264
267
|
case result_type
|
265
268
|
when :error
|
266
|
-
# Ensure error responses preserve the ID
|
267
269
|
error_payload = result_payload
|
268
270
|
error_payload[:id] = request_id if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
|
269
271
|
render json: error_payload, status: results.fetch(:status, :bad_request)
|
270
|
-
|
271
272
|
when :notifications_only
|
272
273
|
head :accepted
|
273
|
-
|
274
274
|
when :responses
|
275
275
|
server_preference = ActionMCP.configuration.post_response_preference
|
276
276
|
use_sse = (server_preference == :sse)
|
277
277
|
add_session_header = is_initialize_request && session_initially_missing && session.persisted?
|
278
|
-
|
279
278
|
if use_sse
|
280
279
|
render_sse_response(result_payload, session, add_session_header)
|
281
280
|
else
|
282
281
|
render_json_response(result_payload, session, add_session_header)
|
283
282
|
end
|
284
|
-
|
285
283
|
else
|
286
|
-
# Handle unknown result types
|
287
284
|
Rails.logger.error "Unknown handler result type: #{result_type.inspect}"
|
288
|
-
|
289
|
-
# If the original request was a notification, don't send back a response with an ID
|
290
285
|
if is_notification
|
291
286
|
head :accepted
|
292
287
|
else
|
293
|
-
# For regular requests, return a proper JSON-RPC response
|
294
288
|
render json: {
|
295
289
|
jsonrpc: "2.0",
|
296
290
|
id: request_id,
|
@@ -314,11 +308,9 @@ module ActionMCP
|
|
314
308
|
response.headers["X-Accel-Buffering"] = "no"
|
315
309
|
response.headers["Cache-Control"] = "no-cache"
|
316
310
|
response.headers["Connection"] = "keep-alive"
|
317
|
-
|
318
311
|
sse = SSE.new(response.stream)
|
319
312
|
write_sse_event(sse, session, payload)
|
320
313
|
ensure
|
321
|
-
# Close the stream after sending the response(s)
|
322
314
|
sse&.close
|
323
315
|
begin
|
324
316
|
response.stream&.close
|
@@ -328,53 +320,68 @@ module ActionMCP
|
|
328
320
|
Rails.logger.debug "Unified SSE (POST): Response stream closed."
|
329
321
|
end
|
330
322
|
|
331
|
-
#
|
332
|
-
|
333
|
-
|
334
|
-
|
323
|
+
# Helper to write a JSON payload as an SSE event with a unique ID.
|
324
|
+
# Also stores the event for potential resumability.
|
325
|
+
def write_sse_event(sse, session, payload)
|
326
|
+
event_id = session.increment_sse_counter!
|
327
|
+
data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
|
328
|
+
sse_event = "id: #{event_id}\ndata: #{data}\n\n"
|
329
|
+
sse.write(sse_event)
|
330
|
+
return unless ActionMCP.configuration.enable_sse_resumability
|
331
|
+
begin
|
332
|
+
session.store_sse_event(event_id, payload, session.max_stored_sse_events)
|
333
|
+
rescue StandardError => e
|
334
|
+
Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
|
335
|
+
end
|
335
336
|
end
|
336
337
|
|
337
338
|
# Helper to clean up old SSE events for a session
|
338
339
|
def cleanup_old_sse_events(session)
|
339
340
|
return unless ActionMCP.configuration.enable_sse_resumability
|
340
|
-
|
341
341
|
begin
|
342
|
-
# Get retention period from configuration
|
343
342
|
retention_period = session.sse_event_retention_period
|
344
343
|
count = session.cleanup_old_sse_events(retention_period)
|
345
|
-
|
346
344
|
Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive?
|
347
345
|
rescue StandardError => e
|
348
346
|
Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
|
349
347
|
end
|
350
348
|
end
|
351
349
|
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
350
|
+
def format_tools_list(tools, session)
|
351
|
+
protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
|
352
|
+
tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
|
353
|
+
end
|
356
354
|
|
357
|
-
|
358
|
-
data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
|
359
|
-
sse_event = "id: #{event_id}\ndata: #{data}\n\n"
|
355
|
+
# --- Error Rendering Methods ---
|
360
356
|
|
361
|
-
|
362
|
-
|
357
|
+
# Renders a 400 Bad Request response with a JSON-RPC-like error structure.
|
358
|
+
def render_bad_request(message = "Bad Request")
|
359
|
+
render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
360
|
+
end
|
363
361
|
|
364
|
-
|
365
|
-
|
362
|
+
# Renders a 404 Not Found response with a JSON-RPC-like error structure.
|
363
|
+
def render_not_found(message = "Not Found")
|
364
|
+
render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
|
365
|
+
end
|
366
366
|
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
|
371
|
-
end
|
367
|
+
# Renders a 405 Method Not Allowed response.
|
368
|
+
def render_method_not_allowed(message = "Method Not Allowed")
|
369
|
+
render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
|
372
370
|
end
|
373
371
|
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
372
|
+
# Renders a 406 Not Acceptable response.
|
373
|
+
def render_not_acceptable(message = "Not Acceptable")
|
374
|
+
render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
|
375
|
+
end
|
376
|
+
|
377
|
+
# Renders a 501 Not Implemented response.
|
378
|
+
def render_not_implemented(message = "Not Implemented")
|
379
|
+
render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
|
380
|
+
end
|
381
|
+
|
382
|
+
# Renders a 500 Internal Server Error response.
|
383
|
+
def render_internal_server_error(message = "Internal Server Error", id = nil)
|
384
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
|
378
385
|
end
|
379
386
|
end
|
380
387
|
end
|
data/config/routes.rb
CHANGED
@@ -4,7 +4,7 @@ ActionMCP::Engine.routes.draw do
|
|
4
4
|
get "/up", to: "/rails/health#show", as: :action_mcp_health_check
|
5
5
|
|
6
6
|
# MCP 2025-03-26 Spec routes
|
7
|
-
get "/", to: "
|
8
|
-
post "/", to: "
|
9
|
-
delete "/", to: "
|
7
|
+
get "/", to: "application#show", as: :mcp_get
|
8
|
+
post "/", to: "application#create", as: :mcp_post
|
9
|
+
delete "/", to: "application#destroy", as: :mcp_delete
|
10
10
|
end
|
data/lib/action_mcp/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: actionmcp
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.50.
|
4
|
+
version: 0.50.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Abdelkader Boudih
|
8
|
-
autorequire:
|
9
8
|
bindir: exe
|
10
9
|
cert_chain: []
|
11
|
-
date:
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: activerecord
|
@@ -106,8 +105,7 @@ files:
|
|
106
105
|
- MIT-LICENSE
|
107
106
|
- README.md
|
108
107
|
- Rakefile
|
109
|
-
- app/controllers/action_mcp/
|
110
|
-
- app/controllers/action_mcp/unified_controller.rb
|
108
|
+
- app/controllers/action_mcp/application_controller.rb
|
111
109
|
- app/models/action_mcp.rb
|
112
110
|
- app/models/action_mcp/application_record.rb
|
113
111
|
- app/models/action_mcp/session.rb
|
@@ -215,7 +213,6 @@ metadata:
|
|
215
213
|
source_code_uri: https://github.com/seuros/action_mcp
|
216
214
|
changelog_uri: https://github.com/seuros/action_mcp/blob/master/CHANGELOG.md
|
217
215
|
rubygems_mfa_required: 'true'
|
218
|
-
post_install_message:
|
219
216
|
rdoc_options: []
|
220
217
|
require_paths:
|
221
218
|
- lib
|
@@ -230,8 +227,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
230
227
|
- !ruby/object:Gem::Version
|
231
228
|
version: '0'
|
232
229
|
requirements: []
|
233
|
-
rubygems_version: 3.
|
234
|
-
signing_key:
|
230
|
+
rubygems_version: 3.6.7
|
235
231
|
specification_version: 4
|
236
232
|
summary: Provides essential tooling for building Model Context Protocol (MCP) capable
|
237
233
|
servers
|
@@ -1,79 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module ActionMCP
|
4
|
-
class MCPController < ActionController::Metal
|
5
|
-
abstract!
|
6
|
-
ActionController::API.without_modules(:StrongParameters, :ParamsWrapper).each do |left|
|
7
|
-
include left
|
8
|
-
end
|
9
|
-
include Engine.routes.url_helpers
|
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.
|
25
|
-
def 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
|
-
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
|
42
|
-
else
|
43
|
-
# Create new session with 2025 protocol
|
44
|
-
Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
45
|
-
end
|
46
|
-
end
|
47
|
-
|
48
|
-
# Renders a 400 Bad Request response with a JSON-RPC-like error structure.
|
49
|
-
def render_bad_request(message = "Bad Request")
|
50
|
-
# Using -32600 for Invalid Request based on JSON-RPC spec
|
51
|
-
render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
52
|
-
end
|
53
|
-
|
54
|
-
# Renders a 404 Not Found response with a JSON-RPC-like error structure.
|
55
|
-
def render_not_found(message = "Not Found")
|
56
|
-
# Using a custom code or a generic server error range code might be appropriate.
|
57
|
-
# Let's use -32001 for a generic server error.
|
58
|
-
render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
|
59
|
-
end
|
60
|
-
|
61
|
-
# Renders a 405 Method Not Allowed response.
|
62
|
-
def render_method_not_allowed(message = "Method Not Allowed")
|
63
|
-
# Using -32601 Method not found from JSON-RPC spec seems applicable
|
64
|
-
render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
|
65
|
-
end
|
66
|
-
|
67
|
-
# Renders a 406 Not Acceptable response.
|
68
|
-
def render_not_acceptable(message = "Not Acceptable")
|
69
|
-
# No direct JSON-RPC equivalent, using a generic server error code.
|
70
|
-
render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
|
71
|
-
end
|
72
|
-
|
73
|
-
# Renders a 501 Not Implemented response.
|
74
|
-
def render_not_implemented(message = "Not Implemented")
|
75
|
-
# No direct JSON-RPC equivalent, using a generic server error code.
|
76
|
-
render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
|
77
|
-
end
|
78
|
-
end
|
79
|
-
end
|