actionmcp 0.31.1 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +16 -5
- data/app/controllers/action_mcp/mcp_controller.rb +13 -17
- data/app/controllers/action_mcp/messages_controller.rb +3 -1
- data/app/controllers/action_mcp/sse_controller.rb +22 -4
- data/app/controllers/action_mcp/unified_controller.rb +147 -52
- data/app/models/action_mcp/session/message.rb +1 -0
- data/app/models/action_mcp/session/sse_event.rb +55 -0
- data/app/models/action_mcp/session.rb +235 -12
- data/app/models/concerns/mcp_console_helpers.rb +68 -0
- data/app/models/concerns/mcp_message_inspect.rb +73 -0
- data/config/routes.rb +4 -2
- data/db/migrate/20250329120300_add_registries_to_sessions.rb +9 -0
- data/db/migrate/20250329150312_create_action_mcp_sse_events.rb +16 -0
- data/lib/action_mcp/capability.rb +16 -0
- data/lib/action_mcp/configuration.rb +16 -4
- data/lib/action_mcp/console_detector.rb +12 -0
- data/lib/action_mcp/engine.rb +3 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +1 -1
- data/lib/action_mcp/resource_template.rb +11 -0
- data/lib/action_mcp/server/capabilities.rb +28 -22
- data/lib/action_mcp/server/json_rpc_handler.rb +35 -9
- data/lib/action_mcp/server/notifications.rb +14 -5
- data/lib/action_mcp/server/prompts.rb +18 -5
- data/lib/action_mcp/server/registry_management.rb +32 -0
- data/lib/action_mcp/server/resources.rb +3 -2
- data/lib/action_mcp/server/tools.rb +50 -6
- data/lib/action_mcp/sse_listener.rb +3 -2
- data/lib/action_mcp/tagged_stream_logging.rb +47 -0
- data/lib/action_mcp/test_helper.rb +57 -34
- data/lib/action_mcp/tool.rb +45 -9
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +4 -4
- metadata +25 -20
@@ -10,12 +10,15 @@
|
|
10
10
|
# ended_at(The time the session ended) :datetime
|
11
11
|
# initialized :boolean default(FALSE), not null
|
12
12
|
# messages_count :integer default(0), not null
|
13
|
+
# prompt_registry :jsonb
|
13
14
|
# protocol_version :string
|
15
|
+
# resource_registry :jsonb
|
14
16
|
# role(The role of the session) :string default("server"), not null
|
15
17
|
# server_capabilities(The capabilities of the server) :jsonb
|
16
18
|
# server_info(The information about the server) :jsonb
|
17
19
|
# sse_event_counter :integer default(0), not null
|
18
20
|
# status :string default("pre_initialize"), not null
|
21
|
+
# tool_registry :jsonb
|
19
22
|
# created_at :datetime not null
|
20
23
|
# updated_at :datetime not null
|
21
24
|
#
|
@@ -26,6 +29,7 @@ module ActionMCP
|
|
26
29
|
# such as client and server capabilities, protocol version, and session status.
|
27
30
|
# It also manages the association with messages and subscriptions related to the session.
|
28
31
|
class Session < ApplicationRecord
|
32
|
+
include MCPConsoleHelpers
|
29
33
|
attribute :id, :string, default: -> { SecureRandom.hex(6) }
|
30
34
|
has_many :messages,
|
31
35
|
class_name: "ActionMCP::Session::Message",
|
@@ -43,17 +47,24 @@ module ActionMCP
|
|
43
47
|
dependent: :delete_all,
|
44
48
|
inverse_of: :session
|
45
49
|
|
50
|
+
has_many :sse_events,
|
51
|
+
class_name: "ActionMCP::Session::SSEEvent",
|
52
|
+
foreign_key: "session_id",
|
53
|
+
dependent: :delete_all,
|
54
|
+
inverse_of: :session
|
55
|
+
|
46
56
|
scope :pre_initialize, -> { where(status: "pre_initialize") }
|
47
57
|
scope :closed, -> { where(status: "closed") }
|
48
58
|
scope :without_messages, -> { includes(:messages).where(action_mcp_session_messages: { id: nil }) }
|
49
59
|
|
50
60
|
scope :from_server, -> { where(role: "server") }
|
51
61
|
scope :from_client, -> { where(role: "client") }
|
52
|
-
|
62
|
+
# Initialize with default registries
|
63
|
+
before_create :initialize_registries
|
53
64
|
before_create :set_server_info, if: -> { role == "server" }
|
54
65
|
before_create :set_server_capabilities, if: -> { role == "server" }
|
55
66
|
|
56
|
-
validates :protocol_version, inclusion: { in:
|
67
|
+
validates :protocol_version, inclusion: { in: SUPPORTED_VERSIONS }, allow_nil: true
|
57
68
|
|
58
69
|
def close!
|
59
70
|
dummy_callback = ->(*) { } # this callback seem broken
|
@@ -119,16 +130,6 @@ module ActionMCP
|
|
119
130
|
save
|
120
131
|
end
|
121
132
|
|
122
|
-
def message_flow
|
123
|
-
messages.without_pings.order(created_at: :asc).map do |message|
|
124
|
-
{
|
125
|
-
direction: message.direction,
|
126
|
-
data: message.data,
|
127
|
-
type: message.message_type
|
128
|
-
}
|
129
|
-
end
|
130
|
-
end
|
131
|
-
|
132
133
|
def send_ping!
|
133
134
|
Session.logger.silence do
|
134
135
|
write(JSON_RPC::Request.new(id: Time.now.to_i, method: "ping"))
|
@@ -153,6 +154,163 @@ module ActionMCP
|
|
153
154
|
reload.sse_event_counter
|
154
155
|
end
|
155
156
|
|
157
|
+
# Stores an SSE event for potential resumption
|
158
|
+
# @param event_id [Integer] The event ID
|
159
|
+
# @param data [Hash, String] The event data
|
160
|
+
# @param max_events [Integer] Maximum number of events to store (oldest events are removed when exceeded)
|
161
|
+
# @return [ActionMCP::Session::SSEEvent] The created event
|
162
|
+
def store_sse_event(event_id, data, max_events = 100)
|
163
|
+
# Create the SSE event record
|
164
|
+
event = sse_events.create!(
|
165
|
+
event_id: event_id,
|
166
|
+
data: data
|
167
|
+
)
|
168
|
+
|
169
|
+
# Maintain cache limit by removing oldest events if needed
|
170
|
+
if sse_events.count > max_events
|
171
|
+
sse_events.order(event_id: :asc).limit(sse_events.count - max_events).destroy_all
|
172
|
+
end
|
173
|
+
|
174
|
+
event
|
175
|
+
end
|
176
|
+
|
177
|
+
# Retrieves SSE events after a given ID
|
178
|
+
# @param last_event_id [Integer] The ID to retrieve events after
|
179
|
+
# @param limit [Integer] Maximum number of events to return
|
180
|
+
# @return [Array<ActionMCP::Session::SSEEvent>] The events
|
181
|
+
def get_sse_events_after(last_event_id, limit = 50)
|
182
|
+
sse_events.where("event_id > ?", last_event_id)
|
183
|
+
.order(event_id: :asc)
|
184
|
+
.limit(limit)
|
185
|
+
end
|
186
|
+
|
187
|
+
# Cleans up old SSE events
|
188
|
+
# @param max_age [ActiveSupport::Duration] Maximum age of events to keep
|
189
|
+
# @return [Integer] Number of events removed
|
190
|
+
def cleanup_old_sse_events(max_age = 15.minutes)
|
191
|
+
cutoff_time = Time.current - max_age
|
192
|
+
events_to_delete = sse_events.where("created_at < ?", cutoff_time)
|
193
|
+
count = events_to_delete.count
|
194
|
+
events_to_delete.destroy_all
|
195
|
+
count
|
196
|
+
end
|
197
|
+
|
198
|
+
def send_progress_notification(progressToken:, progress:, total: nil, message: nil)
|
199
|
+
# Create a transport handler to send the notification
|
200
|
+
handler = ActionMCP::Server::TransportHandler.new(self)
|
201
|
+
handler.send_progress_notification(
|
202
|
+
progressToken: progressToken,
|
203
|
+
progress: progress,
|
204
|
+
total: total,
|
205
|
+
message: message
|
206
|
+
)
|
207
|
+
end
|
208
|
+
|
209
|
+
# Calculates the retention period for SSE events based on configuration
|
210
|
+
# @return [ActiveSupport::Duration] The retention period
|
211
|
+
def sse_event_retention_period
|
212
|
+
ActionMCP.configuration.sse_event_retention_period || 15.minutes
|
213
|
+
end
|
214
|
+
|
215
|
+
# Calculates the maximum number of SSE events to store based on configuration
|
216
|
+
# @return [Integer] The maximum number of events
|
217
|
+
def max_stored_sse_events
|
218
|
+
ActionMCP.configuration.max_stored_sse_events || 100
|
219
|
+
end
|
220
|
+
|
221
|
+
def send_progress_notification_legacy(token:, value:, message: nil)
|
222
|
+
send_progress_notification(progressToken: token, progress: value, message: message)
|
223
|
+
end
|
224
|
+
|
225
|
+
# Registry management methods
|
226
|
+
def register_tool(tool_class_or_name)
|
227
|
+
tool_name = normalize_name(tool_class_or_name, :tool)
|
228
|
+
return false unless tool_exists?(tool_name)
|
229
|
+
|
230
|
+
self.tool_registry ||= []
|
231
|
+
unless self.tool_registry.include?(tool_name)
|
232
|
+
self.tool_registry << tool_name
|
233
|
+
save!
|
234
|
+
send_tools_list_changed_notification
|
235
|
+
end
|
236
|
+
true
|
237
|
+
end
|
238
|
+
|
239
|
+
def unregister_tool(tool_class_or_name)
|
240
|
+
tool_name = normalize_name(tool_class_or_name, :tool)
|
241
|
+
self.tool_registry ||= []
|
242
|
+
|
243
|
+
if self.tool_registry.delete(tool_name)
|
244
|
+
save!
|
245
|
+
send_tools_list_changed_notification
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
def register_prompt(prompt_class_or_name)
|
250
|
+
prompt_name = normalize_name(prompt_class_or_name, :prompt)
|
251
|
+
return false unless prompt_exists?(prompt_name)
|
252
|
+
|
253
|
+
self.prompt_registry ||= []
|
254
|
+
unless self.prompt_registry.include?(prompt_name)
|
255
|
+
self.prompt_registry << prompt_name
|
256
|
+
save!
|
257
|
+
send_prompts_list_changed_notification
|
258
|
+
end
|
259
|
+
true
|
260
|
+
end
|
261
|
+
|
262
|
+
def unregister_prompt(prompt_class_or_name)
|
263
|
+
prompt_name = normalize_name(prompt_class_or_name, :prompt)
|
264
|
+
self.prompt_registry ||= []
|
265
|
+
|
266
|
+
if self.prompt_registry.delete(prompt_name)
|
267
|
+
save!
|
268
|
+
send_prompts_list_changed_notification
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
def register_resource_template(template_class_or_name)
|
273
|
+
template_name = normalize_name(template_class_or_name, :resource_template)
|
274
|
+
return false unless resource_template_exists?(template_name)
|
275
|
+
|
276
|
+
self.resource_registry ||= []
|
277
|
+
unless self.resource_registry.include?(template_name)
|
278
|
+
self.resource_registry << template_name
|
279
|
+
save!
|
280
|
+
send_resources_list_changed_notification
|
281
|
+
end
|
282
|
+
true
|
283
|
+
end
|
284
|
+
|
285
|
+
def unregister_resource_template(template_class_or_name)
|
286
|
+
template_name = normalize_name(template_class_or_name, :resource_template)
|
287
|
+
self.resource_registry ||= []
|
288
|
+
|
289
|
+
if self.resource_registry.delete(template_name)
|
290
|
+
save!
|
291
|
+
send_resources_list_changed_notification
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
# Get registered items for this session
|
296
|
+
def registered_tools
|
297
|
+
(self.tool_registry || []).filter_map do |tool_name|
|
298
|
+
ActionMCP::ToolsRegistry.find(tool_name) rescue nil
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
def registered_prompts
|
303
|
+
(self.prompt_registry || []).filter_map do |prompt_name|
|
304
|
+
ActionMCP::PromptsRegistry.find(prompt_name) rescue nil
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
def registered_resource_templates
|
309
|
+
(self.resource_registry || []).filter_map do |template_name|
|
310
|
+
ActionMCP::ResourceTemplatesRegistry.find(template_name) rescue nil
|
311
|
+
end
|
312
|
+
end
|
313
|
+
|
156
314
|
private
|
157
315
|
|
158
316
|
# if this session is from a server, the writer is the client
|
@@ -172,5 +330,70 @@ module ActionMCP
|
|
172
330
|
def set_server_capabilities
|
173
331
|
self.server_capabilities ||= ActionMCP.configuration.capabilities
|
174
332
|
end
|
333
|
+
|
334
|
+
def initialize_registries
|
335
|
+
# Start with default registries from configuration
|
336
|
+
self.tool_registry = ActionMCP.configuration.filtered_tools.map(&:name)
|
337
|
+
self.prompt_registry = ActionMCP.configuration.filtered_prompts.map(&:name)
|
338
|
+
self.resource_registry = ActionMCP.configuration.filtered_resources.map(&:name)
|
339
|
+
end
|
340
|
+
|
341
|
+
def normalize_name(class_or_name, type)
|
342
|
+
case class_or_name
|
343
|
+
when String
|
344
|
+
class_or_name
|
345
|
+
when Class
|
346
|
+
case type
|
347
|
+
when :tool
|
348
|
+
class_or_name.tool_name
|
349
|
+
when :prompt
|
350
|
+
class_or_name.prompt_name
|
351
|
+
when :resource_template
|
352
|
+
class_or_name.capability_name
|
353
|
+
end
|
354
|
+
else
|
355
|
+
raise ArgumentError, "Expected String or Class, got #{class_or_name.class}"
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
def tool_exists?(tool_name)
|
360
|
+
ActionMCP::ToolsRegistry.find(tool_name)
|
361
|
+
true
|
362
|
+
rescue ActionMCP::RegistryBase::NotFound
|
363
|
+
false
|
364
|
+
end
|
365
|
+
|
366
|
+
def prompt_exists?(prompt_name)
|
367
|
+
ActionMCP::PromptsRegistry.find(prompt_name)
|
368
|
+
true
|
369
|
+
rescue ActionMCP::RegistryBase::NotFound
|
370
|
+
false
|
371
|
+
end
|
372
|
+
|
373
|
+
def resource_template_exists?(template_name)
|
374
|
+
ActionMCP::ResourceTemplatesRegistry.find(template_name)
|
375
|
+
true
|
376
|
+
rescue ActionMCP::RegistryBase::NotFound
|
377
|
+
false
|
378
|
+
end
|
379
|
+
|
380
|
+
def send_tools_list_changed_notification
|
381
|
+
# Only send if server capabilities allow it
|
382
|
+
if server_capabilities.dig("tools", "listChanged")
|
383
|
+
write(JSON_RPC::Notification.new(method: "notifications/tools/list_changed"))
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
def send_prompts_list_changed_notification
|
388
|
+
if server_capabilities.dig("prompts", "listChanged")
|
389
|
+
write(JSON_RPC::Notification.new(method: "notifications/prompts/list_changed"))
|
390
|
+
end
|
391
|
+
end
|
392
|
+
|
393
|
+
def send_resources_list_changed_notification
|
394
|
+
if server_capabilities.dig("resources", "listChanged")
|
395
|
+
write(JSON_RPC::Notification.new(method: "notifications/resources/list_changed"))
|
396
|
+
end
|
397
|
+
end
|
175
398
|
end
|
176
399
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# app/models/concerns/mcp_console_helpers.rb
|
2
|
+
module MCPConsoleHelpers
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
class_methods do
|
6
|
+
def pretty_messages(session_or_messages, limit: 10)
|
7
|
+
messages = if session_or_messages.respond_to?(:messages)
|
8
|
+
session_or_messages.messages.order(:created_at).last(limit)
|
9
|
+
else
|
10
|
+
session_or_messages.last(limit)
|
11
|
+
end
|
12
|
+
|
13
|
+
messages.each do |msg|
|
14
|
+
puts msg.inspect
|
15
|
+
if msg.data&.dig("method")
|
16
|
+
puts " └─ #{msg.data['method']}"
|
17
|
+
end
|
18
|
+
puts
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def message_flow(session, limit: 50)
|
23
|
+
puts "\nMCP Message Flow:"
|
24
|
+
puts "Session ID: #{session.id}"
|
25
|
+
puts "Protocol: #{session.protocol_version}"
|
26
|
+
puts "─" * 70
|
27
|
+
|
28
|
+
session.messages.order(:created_at).last(limit).each do |msg|
|
29
|
+
time = msg.created_at.strftime("%H:%M:%S.%3N")
|
30
|
+
arrow = msg.direction == "client" ? "→" : "←"
|
31
|
+
direction_label = msg.direction == "client" ? "CLIENT" : "SERVER"
|
32
|
+
|
33
|
+
if ActionMCP::ConsoleDetector.in_console?
|
34
|
+
color_code = case msg.message_type
|
35
|
+
when "request" then "\e[34m"
|
36
|
+
when "response" then "\e[32m"
|
37
|
+
when "error" then "\e[31m"
|
38
|
+
when "notification" then "\e[33m"
|
39
|
+
else "\e[90m"
|
40
|
+
end
|
41
|
+
puts "#{time} #{color_code}#{direction_label}\e[0m #{arrow} #{msg.inspect}"
|
42
|
+
else
|
43
|
+
puts "#{time} #{direction_label} #{arrow} #{msg.inspect}"
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
puts "─" * 70
|
48
|
+
end
|
49
|
+
|
50
|
+
def message_stats(session)
|
51
|
+
stats = session.messages.group(:message_type, :direction).count
|
52
|
+
|
53
|
+
puts "\nMessage Statistics:"
|
54
|
+
puts "─" * 40
|
55
|
+
|
56
|
+
stats.each do |(type, direction), count|
|
57
|
+
puts "#{type.ljust(15)} #{direction.ljust(10)} #{count}"
|
58
|
+
end
|
59
|
+
|
60
|
+
puts "─" * 40
|
61
|
+
puts "Total: #{session.messages.count}"
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def message_flow(limit: 50)
|
66
|
+
self.class.message_flow(self, limit: limit)
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# app/models/concerns/mcp_message_inspect.rb
|
2
|
+
module MCPMessageInspect
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
def inspect(show_data: false)
|
6
|
+
if show_data
|
7
|
+
super() # Rails default inspect
|
8
|
+
else
|
9
|
+
build_summary_inspect
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def build_summary_inspect
|
16
|
+
case message_type
|
17
|
+
when "request"
|
18
|
+
format_request_summary
|
19
|
+
when "response", "error"
|
20
|
+
format_response_summary
|
21
|
+
when "notification"
|
22
|
+
format_notification_summary
|
23
|
+
else
|
24
|
+
format_default_summary
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def format_request_summary
|
29
|
+
method = data&.dig("method")
|
30
|
+
formatted = "#<Message #{id}: REQUEST #{jsonrpc_id} -> #{method}>"
|
31
|
+
console? ? colorize(formatted, :blue) : formatted
|
32
|
+
end
|
33
|
+
|
34
|
+
def format_response_summary
|
35
|
+
formatted = "#<Message #{id}: #{message_type.upcase} #{jsonrpc_id}>"
|
36
|
+
if console?
|
37
|
+
color = message_type == "error" ? :red : :green
|
38
|
+
colorize(formatted, color)
|
39
|
+
else
|
40
|
+
formatted
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def format_notification_summary
|
45
|
+
method = data&.dig("method")
|
46
|
+
formatted = "#<Message #{id}: NOTIFICATION -> #{method}>"
|
47
|
+
console? ? colorize(formatted, :yellow) : formatted
|
48
|
+
end
|
49
|
+
|
50
|
+
def format_default_summary
|
51
|
+
formatted = "#<Message #{id}: #{message_type.upcase}>"
|
52
|
+
console? ? colorize(formatted, :gray) : formatted
|
53
|
+
end
|
54
|
+
|
55
|
+
def console?
|
56
|
+
# Check if we're in a Rails console environment
|
57
|
+
defined?(Rails::Console) ||
|
58
|
+
defined?(::Rails.application) && Rails.application.console? ||
|
59
|
+
(defined?(IRB) && IRB.CurrentContext.kind_of?(IRB::ExtendCommandBundle))
|
60
|
+
end
|
61
|
+
|
62
|
+
def colorize(text, color)
|
63
|
+
colors = {
|
64
|
+
blue: "\e[34m",
|
65
|
+
green: "\e[32m",
|
66
|
+
red: "\e[31m",
|
67
|
+
yellow: "\e[33m",
|
68
|
+
gray: "\e[90m"
|
69
|
+
}
|
70
|
+
|
71
|
+
"#{colors[color]}#{text}\e[0m"
|
72
|
+
end
|
73
|
+
end
|
data/config/routes.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
ActionMCP::Engine.routes.draw do
|
4
|
+
get "/up", to: "/rails/health#show", as: :action_mcp_health_check
|
4
5
|
# --- Routes for 2024-11-05 Spec (HTTP+SSE) ---
|
5
6
|
# Kept for backward compatibility
|
6
7
|
get "/", to: "sse#events", as: :sse_out
|
@@ -8,6 +9,7 @@ ActionMCP::Engine.routes.draw do
|
|
8
9
|
|
9
10
|
# --- Routes for 2025-03-26 Spec (Streamable HTTP) ---
|
10
11
|
mcp_endpoint = ActionMCP.configuration.mcp_endpoint_path
|
11
|
-
get mcp_endpoint, to: "unified#
|
12
|
-
post mcp_endpoint, to: "unified#
|
12
|
+
get mcp_endpoint, to: "unified#show", as: :mcp_get
|
13
|
+
post mcp_endpoint, to: "unified#create", as: :mcp_post
|
14
|
+
delete mcp_endpoint, to: "unified#destroy", as: :mcp_delete
|
13
15
|
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class AddRegistriesToSessions < ActiveRecord::Migration[8.0]
|
4
|
+
def change
|
5
|
+
add_column :action_mcp_sessions, :tool_registry, :jsonb, default: []
|
6
|
+
add_column :action_mcp_sessions, :prompt_registry, :jsonb, default: []
|
7
|
+
add_column :action_mcp_sessions, :resource_registry, :jsonb, default: []
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateActionMCPSSEEvents < ActiveRecord::Migration[8.0]
|
4
|
+
def change
|
5
|
+
create_table :action_mcp_sse_events do |t|
|
6
|
+
t.references :session, null: false, foreign_key: { to_table: :action_mcp_sessions }, index: true, type: :string
|
7
|
+
t.integer :event_id, null: false
|
8
|
+
t.text :data, null: false
|
9
|
+
t.timestamps
|
10
|
+
|
11
|
+
# Index for efficiently retrieving events after a given ID for a specific session
|
12
|
+
t.index [ :session_id, :event_id ], unique: true
|
13
|
+
t.index :created_at # For cleanup of old events
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -14,6 +14,22 @@ module ActionMCP
|
|
14
14
|
class_attribute :_capability_name, instance_accessor: false
|
15
15
|
class_attribute :_description, instance_accessor: false, default: ""
|
16
16
|
|
17
|
+
attr_reader :execution_context
|
18
|
+
|
19
|
+
def initialize(*)
|
20
|
+
super
|
21
|
+
@execution_context = {}
|
22
|
+
end
|
23
|
+
|
24
|
+
def with_context(context)
|
25
|
+
@execution_context = context
|
26
|
+
self
|
27
|
+
end
|
28
|
+
|
29
|
+
def session
|
30
|
+
execution_context[:session]
|
31
|
+
end
|
32
|
+
|
17
33
|
# use _capability_name or default_capability_name
|
18
34
|
def self.capability_name
|
19
35
|
_capability_name || default_capability_name
|
@@ -23,10 +23,14 @@ module ActionMCP
|
|
23
23
|
:active_profile,
|
24
24
|
:profiles,
|
25
25
|
# --- New Transport Options ---
|
26
|
-
:allow_client_session_termination,
|
27
26
|
:mcp_endpoint_path,
|
28
27
|
:sse_heartbeat_interval,
|
29
|
-
:post_response_preference # :json or :sse
|
28
|
+
:post_response_preference, # :json or :sse
|
29
|
+
:protocol_version,
|
30
|
+
# --- SSE Resumability Options ---
|
31
|
+
:enable_sse_resumability,
|
32
|
+
:sse_event_retention_period,
|
33
|
+
:max_stored_sse_events
|
30
34
|
|
31
35
|
def initialize
|
32
36
|
@logging_enabled = true
|
@@ -36,10 +40,15 @@ module ActionMCP
|
|
36
40
|
@active_profile = :primary
|
37
41
|
@profiles = default_profiles
|
38
42
|
|
39
|
-
@allow_client_session_termination = true
|
40
43
|
@mcp_endpoint_path = "/mcp"
|
41
44
|
@sse_heartbeat_interval = 30
|
42
45
|
@post_response_preference = :json
|
46
|
+
@protocol_version = "2024-11-05"
|
47
|
+
|
48
|
+
# Resumability defaults
|
49
|
+
@enable_sse_resumability = true
|
50
|
+
@sse_event_retention_period = 15.minutes
|
51
|
+
@max_stored_sse_events = 100
|
43
52
|
end
|
44
53
|
|
45
54
|
def name
|
@@ -132,6 +141,9 @@ module ActionMCP
|
|
132
141
|
|
133
142
|
capabilities[:resources] = { subscribe: @resources_subscribe } if filtered_resources.any?
|
134
143
|
|
144
|
+
# Add resumability capability if enabled
|
145
|
+
capabilities[:resumability] = { enabled: @enable_sse_resumability } if @enable_sse_resumability
|
146
|
+
|
135
147
|
capabilities
|
136
148
|
end
|
137
149
|
|
@@ -193,7 +205,7 @@ module ActionMCP
|
|
193
205
|
end
|
194
206
|
|
195
207
|
class << self
|
196
|
-
attr_accessor :
|
208
|
+
attr_accessor :logger
|
197
209
|
|
198
210
|
# Thread-local storage for active profiles
|
199
211
|
def thread_profiles
|
data/lib/action_mcp/engine.rb
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require "rails"
|
4
4
|
require "active_model/railtie"
|
5
|
+
require "jsonrpc-rails"
|
5
6
|
|
6
7
|
module ActionMCP
|
7
8
|
# Engine for integrating ActionMCP with Rails applications.
|
@@ -20,6 +21,8 @@ module ActionMCP
|
|
20
21
|
ActionMCP::ResourceTemplate.registered_templates.clear
|
21
22
|
end
|
22
23
|
|
24
|
+
config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.mcp_endpoint_path ]
|
25
|
+
|
23
26
|
# Load MCP profiles during initialization
|
24
27
|
initializer "action_mcp.load_profiles" do
|
25
28
|
ActionMCP.configuration.load_profiles
|
@@ -13,6 +13,7 @@ module ActionMCP
|
|
13
13
|
|
14
14
|
# Track all registered templates
|
15
15
|
@registered_templates = []
|
16
|
+
attr_reader :execution_context
|
16
17
|
|
17
18
|
class << self
|
18
19
|
attr_reader :registered_templates, :description, :uri_template,
|
@@ -201,6 +202,7 @@ module ActionMCP
|
|
201
202
|
# Initialize with attribute values
|
202
203
|
def initialize(attributes = {})
|
203
204
|
super(attributes)
|
205
|
+
@execution_context = {}
|
204
206
|
validate!
|
205
207
|
end
|
206
208
|
|
@@ -209,6 +211,15 @@ module ActionMCP
|
|
209
211
|
valid?
|
210
212
|
end
|
211
213
|
|
214
|
+
def with_context(context)
|
215
|
+
@execution_context = context
|
216
|
+
self
|
217
|
+
end
|
218
|
+
|
219
|
+
def session
|
220
|
+
execution_context[:session]
|
221
|
+
end
|
222
|
+
|
212
223
|
# Add custom validation for required parameters
|
213
224
|
validate do |_template|
|
214
225
|
self.class.required_parameters.each do |param|
|