actionmcp 0.50.2 → 0.50.3
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: 7c5d576f3bc8c02f3682c056b8145b9af5ad85ad581a131b4bb7dccdd14b0531
|
4
|
+
data.tar.gz: 8233276b69f4033c8b62a015f3960ef8503f16344f3be0c17106441994146d9d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5c20d55384bd4b62831f6613c0aecd1e32ffdd4fe2918f315b512fd43ec7065c9ecd8c3a5d62a5c578c868a6c3de8a4989ae54dd9563d58c0b9570affad1833b
|
7
|
+
data.tar.gz: da098c411863f6f033ed88aa31cf8745548bd32ec7b9074c9db789e46c065bdf3243e4a596a05459626c484c7a44a3913f6591e90e15c90d7eea51d3609e5482
|
@@ -4,39 +4,58 @@ 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
|
+
|
33
|
+
# --- MCP UnifiedController actions ---
|
34
|
+
|
13
35
|
# Handles GET requests for establishing server-initiated SSE streams (2025-03-26 spec).
|
14
|
-
# @route GET /
|
36
|
+
# @route GET /
|
15
37
|
def show
|
16
|
-
|
17
|
-
|
18
|
-
|
38
|
+
if ActionMCP.configuration.post_response_preference == :sse
|
39
|
+
unless request.accepts.any? { |type| type.to_s == "text/event-stream" }
|
40
|
+
return render_not_acceptable("Client must accept 'text/event-stream' for GET requests.")
|
41
|
+
end
|
19
42
|
end
|
20
43
|
|
21
|
-
# 2. Check Session (Must exist and be initialized)
|
22
44
|
session_id_from_header = extract_session_id
|
23
45
|
return render_bad_request("Mcp-Session-Id header is required for GET requests.") unless session_id_from_header
|
24
46
|
|
25
|
-
session = mcp_session
|
47
|
+
session = mcp_session
|
26
48
|
if session.nil? || session.new_record?
|
27
49
|
return render_not_found("Session not found.")
|
28
50
|
elsif !session.initialized?
|
29
|
-
# Spec doesn't explicitly forbid GET before initialized, but it seems logical
|
30
51
|
return render_bad_request("Session is not fully initialized.")
|
31
52
|
elsif session.status == "closed"
|
32
53
|
return render_not_found("Session has been terminated.")
|
33
54
|
end
|
34
55
|
|
35
|
-
# Check for Last-Event-ID header for resumability
|
36
56
|
last_event_id = request.headers["Last-Event-ID"].presence
|
37
57
|
Rails.logger.info "Unified SSE (GET): Resuming from Last-Event-ID: #{last_event_id}" if last_event_id
|
38
58
|
|
39
|
-
# 3. Set SSE Headers
|
40
59
|
response.headers["Content-Type"] = "text/event-stream"
|
41
60
|
response.headers["X-Accel-Buffering"] = "no"
|
42
61
|
response.headers["Cache-Control"] = "no-cache"
|
@@ -44,7 +63,6 @@ module ActionMCP
|
|
44
63
|
|
45
64
|
Rails.logger.info "Unified SSE (GET): Starting stream for session: #{session.id}"
|
46
65
|
|
47
|
-
# 4. Setup Stream, Listener, and Heartbeat
|
48
66
|
sse = SSE.new(response.stream)
|
49
67
|
listener = SSEListener.new(session)
|
50
68
|
connection_active = Concurrent::AtomicBoolean.new
|
@@ -53,31 +71,23 @@ module ActionMCP
|
|
53
71
|
heartbeat_active.make_true
|
54
72
|
heartbeat_task = nil
|
55
73
|
|
56
|
-
# Start listener
|
57
74
|
listener_started = listener.start do |message|
|
58
|
-
# Write message using helper to include event ID
|
59
75
|
write_sse_event(sse, session, message)
|
60
76
|
end
|
61
77
|
|
62
78
|
unless listener_started
|
63
79
|
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
80
|
connection_active.make_false
|
66
|
-
return
|
81
|
+
return
|
67
82
|
end
|
68
83
|
|
69
|
-
# Handle resumability by sending missed events if Last-Event-ID is provided
|
70
84
|
if last_event_id.present? && last_event_id.to_i.positive?
|
71
85
|
begin
|
72
|
-
# Fetch events that occurred after the Last-Event-ID
|
73
86
|
missed_events = session.get_sse_events_after(last_event_id.to_i)
|
74
|
-
|
75
87
|
if missed_events.any?
|
76
88
|
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
89
|
missed_events.each do |event|
|
80
|
-
sse.
|
90
|
+
sse.write(event.to_sse)
|
81
91
|
end
|
82
92
|
else
|
83
93
|
Rails.logger.info "Unified SSE (GET): No missed events to send for session: #{session.id}"
|
@@ -87,14 +97,12 @@ module ActionMCP
|
|
87
97
|
end
|
88
98
|
end
|
89
99
|
|
90
|
-
# Heartbeat sender proc
|
91
100
|
heartbeat_interval = ActionMCP.configuration.sse_heartbeat_interval || 15.seconds
|
92
101
|
heartbeat_sender = lambda do
|
93
102
|
if connection_active.true? && !response.stream.closed?
|
94
103
|
begin
|
95
|
-
# Use helper to send ping with event ID
|
96
104
|
future = Concurrent::Promises.future { write_sse_event(sse, session, { type: "ping" }) }
|
97
|
-
future.value!(5)
|
105
|
+
future.value!(5)
|
98
106
|
if heartbeat_active.true?
|
99
107
|
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
100
108
|
end
|
@@ -110,26 +118,18 @@ module ActionMCP
|
|
110
118
|
end
|
111
119
|
end
|
112
120
|
|
113
|
-
# Start first heartbeat
|
114
121
|
heartbeat_task = Concurrent::ScheduledTask.execute(heartbeat_interval, &heartbeat_sender)
|
115
|
-
|
116
|
-
# Keep connection alive while active
|
117
122
|
sleep 0.1 while connection_active.true? && !response.stream.closed?
|
118
123
|
rescue ActionController::Live::ClientDisconnected, IOError => e
|
119
124
|
Rails.logger.debug "Unified SSE (GET): Client disconnected for session: #{session&.id}: #{e.message}"
|
120
125
|
rescue StandardError => e
|
121
126
|
Rails.logger.error "Unified SSE (GET): Unexpected error for session: #{session&.id}: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
|
122
127
|
ensure
|
123
|
-
# Cleanup
|
124
128
|
Rails.logger.debug "Unified SSE (GET): Cleaning up connection for session: #{session&.id}"
|
125
129
|
heartbeat_active&.make_false
|
126
130
|
heartbeat_task&.cancel
|
127
131
|
listener&.stop
|
128
|
-
|
129
|
-
# Clean up old SSE events if resumability is enabled
|
130
132
|
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
133
|
sse&.close
|
134
134
|
begin
|
135
135
|
response.stream&.close
|
@@ -141,22 +141,18 @@ module ActionMCP
|
|
141
141
|
# Handles POST requests containing client JSON-RPC messages according to 2025-03-26 spec.
|
142
142
|
# @route POST /mcp
|
143
143
|
def create
|
144
|
-
# 1. Check Accept Header
|
145
144
|
unless accepts_valid_content_types?
|
146
145
|
return render_not_acceptable("Client must accept 'application/json' and 'text/event-stream'")
|
147
146
|
end
|
148
147
|
|
149
|
-
# 2. Determine if this is an initialize request (before session check)
|
150
148
|
is_initialize_request = check_if_initialize_request(jsonrpc_params)
|
151
|
-
|
152
|
-
# 3. Check Session (unless it's an initialize request)
|
153
149
|
session_initially_missing = extract_session_id.nil?
|
154
|
-
session = mcp_session
|
150
|
+
session = mcp_session
|
155
151
|
|
156
152
|
unless is_initialize_request
|
157
153
|
if session_initially_missing
|
158
154
|
return render_bad_request("Mcp-Session-Id header is required for this request.")
|
159
|
-
elsif session.nil? || session.new_record?
|
155
|
+
elsif session.nil? || session.new_record?
|
160
156
|
return render_not_found("Session not found.")
|
161
157
|
elsif session.status == "closed"
|
162
158
|
return render_not_found("Session has been terminated.")
|
@@ -168,17 +164,11 @@ module ActionMCP
|
|
168
164
|
response.headers[MCP_SESSION_ID_HEADER] = session.id
|
169
165
|
end
|
170
166
|
|
171
|
-
# 4. Instantiate Handlers
|
172
167
|
transport_handler = Server::TransportHandler.new(session)
|
173
168
|
json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
|
174
|
-
|
175
|
-
# 5. Call Handler
|
176
169
|
handler_results = json_rpc_handler.call(jsonrpc_params)
|
177
|
-
|
178
|
-
# 6. Process Results
|
179
170
|
process_handler_results(handler_results, session, session_initially_missing, is_initialize_request)
|
180
171
|
rescue ActionController::Live::ClientDisconnected, IOError => e
|
181
|
-
# Ensure stream is closed if SSE response was attempted and client disconnected
|
182
172
|
Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
|
183
173
|
begin
|
184
174
|
response.stream&.close
|
@@ -191,25 +181,20 @@ module ActionMCP
|
|
191
181
|
end
|
192
182
|
|
193
183
|
# Handles DELETE requests for session termination (2025-03-26 spec).
|
194
|
-
# @route DELETE /
|
184
|
+
# @route DELETE /
|
195
185
|
def destroy
|
196
|
-
# 1. Check Session Header
|
197
186
|
session_id_from_header = extract_session_id
|
198
187
|
return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
|
199
188
|
|
200
|
-
# 2. Find Session
|
201
189
|
session = Session.find_by(id: session_id_from_header)
|
202
|
-
|
203
190
|
if session.nil?
|
204
191
|
return render_not_found("Session not found.")
|
205
192
|
elsif session.status == "closed"
|
206
|
-
# Session already closed, treat as success (idempotent)
|
207
193
|
return head :no_content
|
208
194
|
end
|
209
195
|
|
210
|
-
# 3. Terminate Session
|
211
196
|
begin
|
212
|
-
session.close!
|
197
|
+
session.close!
|
213
198
|
Rails.logger.info "Unified DELETE: Terminated session: #{session.id}"
|
214
199
|
head :no_content
|
215
200
|
rescue StandardError => e
|
@@ -220,6 +205,21 @@ module ActionMCP
|
|
220
205
|
|
221
206
|
private
|
222
207
|
|
208
|
+
# Finds an existing session based on header or param, or initializes a new one.
|
209
|
+
# Note: This doesn't save the new session; that happens upon first use or explicitly.
|
210
|
+
def find_or_initialize_session
|
211
|
+
session_id = extract_session_id
|
212
|
+
if session_id
|
213
|
+
session = Session.find_by(id: session_id)
|
214
|
+
if session && session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
|
215
|
+
session.update!(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
216
|
+
end
|
217
|
+
session
|
218
|
+
else
|
219
|
+
Session.new(protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
223
|
# @return [String, nil] The extracted session ID or nil if not found.
|
224
224
|
def extract_session_id
|
225
225
|
request.headers[MCP_SESSION_ID_HEADER].presence
|
@@ -234,63 +234,43 @@ module ActionMCP
|
|
234
234
|
# Checks if the parsed body represents an 'initialize' request.
|
235
235
|
def check_if_initialize_request(payload)
|
236
236
|
return false unless payload.is_a?(JSON_RPC::Request) && !jsonrpc_params_batch?
|
237
|
-
|
238
237
|
payload.method == "initialize"
|
239
238
|
end
|
240
239
|
|
241
240
|
# Processes the results from the JsonRpcHandler.
|
242
241
|
def process_handler_results(results, session, session_initially_missing, is_initialize_request)
|
243
|
-
# Make sure we always have a results hash
|
244
242
|
results ||= {}
|
245
|
-
|
246
|
-
# Check if this is a notification request
|
247
243
|
is_notification = jsonrpc_params.is_a?(JSON_RPC::Notification)
|
248
|
-
|
249
|
-
# Extract request ID from results
|
250
244
|
request_id = nil
|
251
245
|
if results.is_a?(Hash)
|
252
246
|
request_id = results[:request_id] || results[:id]
|
253
|
-
# If we have a payload that's a response, extract ID from there as well
|
254
247
|
request_id ||= results[:payload][:id] if results[:payload].is_a?(Hash) && results[:payload][:id]
|
255
248
|
end
|
256
|
-
|
257
|
-
# Default to empty hash for response payload if nil
|
258
249
|
result_type = results[:type]
|
259
250
|
result_payload = results[:payload] || {}
|
260
|
-
|
261
|
-
# Ensure payload has the correct ID if it's a hash
|
262
251
|
result_payload[:id] = request_id if result_payload.is_a?(Hash) && request_id && !result_payload.key?(:id)
|
263
252
|
|
264
253
|
case result_type
|
265
254
|
when :error
|
266
|
-
# Ensure error responses preserve the ID
|
267
255
|
error_payload = result_payload
|
268
256
|
error_payload[:id] = request_id if error_payload.is_a?(Hash) && !error_payload.key?(:id) && request_id
|
269
257
|
render json: error_payload, status: results.fetch(:status, :bad_request)
|
270
|
-
|
271
258
|
when :notifications_only
|
272
259
|
head :accepted
|
273
|
-
|
274
260
|
when :responses
|
275
261
|
server_preference = ActionMCP.configuration.post_response_preference
|
276
262
|
use_sse = (server_preference == :sse)
|
277
263
|
add_session_header = is_initialize_request && session_initially_missing && session.persisted?
|
278
|
-
|
279
264
|
if use_sse
|
280
265
|
render_sse_response(result_payload, session, add_session_header)
|
281
266
|
else
|
282
267
|
render_json_response(result_payload, session, add_session_header)
|
283
268
|
end
|
284
|
-
|
285
269
|
else
|
286
|
-
# Handle unknown result types
|
287
270
|
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
271
|
if is_notification
|
291
272
|
head :accepted
|
292
273
|
else
|
293
|
-
# For regular requests, return a proper JSON-RPC response
|
294
274
|
render json: {
|
295
275
|
jsonrpc: "2.0",
|
296
276
|
id: request_id,
|
@@ -314,11 +294,9 @@ module ActionMCP
|
|
314
294
|
response.headers["X-Accel-Buffering"] = "no"
|
315
295
|
response.headers["Cache-Control"] = "no-cache"
|
316
296
|
response.headers["Connection"] = "keep-alive"
|
317
|
-
|
318
297
|
sse = SSE.new(response.stream)
|
319
298
|
write_sse_event(sse, session, payload)
|
320
299
|
ensure
|
321
|
-
# Close the stream after sending the response(s)
|
322
300
|
sse&.close
|
323
301
|
begin
|
324
302
|
response.stream&.close
|
@@ -328,53 +306,68 @@ module ActionMCP
|
|
328
306
|
Rails.logger.debug "Unified SSE (POST): Response stream closed."
|
329
307
|
end
|
330
308
|
|
331
|
-
#
|
332
|
-
|
333
|
-
|
334
|
-
|
309
|
+
# Helper to write a JSON payload as an SSE event with a unique ID.
|
310
|
+
# Also stores the event for potential resumability.
|
311
|
+
def write_sse_event(sse, session, payload)
|
312
|
+
event_id = session.increment_sse_counter!
|
313
|
+
data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
|
314
|
+
sse_event = "id: #{event_id}\ndata: #{data}\n\n"
|
315
|
+
sse.write(sse_event)
|
316
|
+
return unless ActionMCP.configuration.enable_sse_resumability
|
317
|
+
begin
|
318
|
+
session.store_sse_event(event_id, payload, session.max_stored_sse_events)
|
319
|
+
rescue StandardError => e
|
320
|
+
Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
|
321
|
+
end
|
335
322
|
end
|
336
323
|
|
337
324
|
# Helper to clean up old SSE events for a session
|
338
325
|
def cleanup_old_sse_events(session)
|
339
326
|
return unless ActionMCP.configuration.enable_sse_resumability
|
340
|
-
|
341
327
|
begin
|
342
|
-
# Get retention period from configuration
|
343
328
|
retention_period = session.sse_event_retention_period
|
344
329
|
count = session.cleanup_old_sse_events(retention_period)
|
345
|
-
|
346
330
|
Rails.logger.debug "Cleaned up #{count} old SSE events for session: #{session.id}" if count.positive?
|
347
331
|
rescue StandardError => e
|
348
332
|
Rails.logger.error "Error cleaning up old SSE events: #{e.message}"
|
349
333
|
end
|
350
334
|
end
|
351
335
|
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
336
|
+
def format_tools_list(tools, session)
|
337
|
+
protocol_version = session.protocol_version || ActionMCP.configuration.protocol_version
|
338
|
+
tools.map { |tool| tool.klass.to_h(protocol_version: protocol_version) }
|
339
|
+
end
|
356
340
|
|
357
|
-
|
358
|
-
data = payload.is_a?(String) ? payload : MultiJson.dump(payload)
|
359
|
-
sse_event = "id: #{event_id}\ndata: #{data}\n\n"
|
341
|
+
# --- Error Rendering Methods ---
|
360
342
|
|
361
|
-
|
362
|
-
|
343
|
+
# Renders a 400 Bad Request response with a JSON-RPC-like error structure.
|
344
|
+
def render_bad_request(message = "Bad Request")
|
345
|
+
render json: { jsonrpc: "2.0", error: { code: -32_600, message: message } }
|
346
|
+
end
|
363
347
|
|
364
|
-
|
365
|
-
|
348
|
+
# Renders a 404 Not Found response with a JSON-RPC-like error structure.
|
349
|
+
def render_not_found(message = "Not Found")
|
350
|
+
render json: { jsonrpc: "2.0", error: { code: -32_001, message: message } }
|
351
|
+
end
|
366
352
|
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
Rails.logger.error "Failed to store SSE event for resumability: #{e.message}"
|
371
|
-
end
|
353
|
+
# Renders a 405 Method Not Allowed response.
|
354
|
+
def render_method_not_allowed(message = "Method Not Allowed")
|
355
|
+
render json: { jsonrpc: "2.0", error: { code: -32_601, message: message } }
|
372
356
|
end
|
373
357
|
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
358
|
+
# Renders a 406 Not Acceptable response.
|
359
|
+
def render_not_acceptable(message = "Not Acceptable")
|
360
|
+
render json: { jsonrpc: "2.0", error: { code: -32_002, message: message } }
|
361
|
+
end
|
362
|
+
|
363
|
+
# Renders a 501 Not Implemented response.
|
364
|
+
def render_not_implemented(message = "Not Implemented")
|
365
|
+
render json: { jsonrpc: "2.0", error: { code: -32_003, message: message } }
|
366
|
+
end
|
367
|
+
|
368
|
+
# Renders a 500 Internal Server Error response.
|
369
|
+
def render_internal_server_error(message = "Internal Server Error", id = nil)
|
370
|
+
render json: { jsonrpc: "2.0", id: id, error: { code: -32_000, message: message } }
|
378
371
|
end
|
379
372
|
end
|
380
373
|
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.3
|
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
|