actionmcp 0.51.0 → 0.52.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 +192 -0
- data/app/controllers/action_mcp/application_controller.rb +12 -6
- data/lib/action_mcp/client/active_record_session_store.rb +57 -0
- data/lib/action_mcp/client/session_store.rb +2 -103
- data/lib/action_mcp/client/session_store_factory.rb +36 -0
- data/lib/action_mcp/client/test_session_store.rb +84 -0
- data/lib/action_mcp/client/volatile_session_store.rb +38 -0
- data/lib/action_mcp/configuration.rb +16 -1
- data/lib/action_mcp/current.rb +19 -0
- data/lib/action_mcp/current_helpers.rb +19 -0
- data/lib/action_mcp/gateway.rb +85 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
- data/lib/action_mcp/jwt_decoder.rb +26 -0
- data/lib/action_mcp/prompt.rb +1 -0
- data/lib/action_mcp/resource_template.rb +1 -0
- data/lib/action_mcp/server/base_messaging.rb +14 -0
- data/lib/action_mcp/server/error_aware.rb +8 -1
- data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
- data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
- data/lib/action_mcp/server/messaging.rb +12 -1
- data/lib/action_mcp/server/registry_management.rb +0 -1
- data/lib/action_mcp/server/response_collector.rb +40 -0
- data/lib/action_mcp/server/session_store.rb +762 -0
- data/lib/action_mcp/server/tools.rb +14 -3
- data/lib/action_mcp/server/transport_handler.rb +9 -5
- data/lib/action_mcp/server.rb +7 -0
- data/lib/action_mcp/tagged_stream_logging.rb +0 -4
- data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
- data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
- data/lib/action_mcp/test_helper.rb +4 -0
- data/lib/action_mcp/tool.rb +1 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +0 -1
- data/lib/generators/action_mcp/install/install_generator.rb +4 -0
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
- metadata +29 -1
@@ -0,0 +1,762 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Server
|
5
|
+
# Abstract interface for server session storage
|
6
|
+
module SessionStore
|
7
|
+
# Create a new session
|
8
|
+
def create_session(session_id = nil, attributes = {})
|
9
|
+
raise NotImplementedError, "#{self.class} must implement #create_session"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Load session by ID
|
13
|
+
def load_session(session_id)
|
14
|
+
raise NotImplementedError, "#{self.class} must implement #load_session"
|
15
|
+
end
|
16
|
+
|
17
|
+
# Save/update session
|
18
|
+
def save_session(session)
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #save_session"
|
20
|
+
end
|
21
|
+
|
22
|
+
# Delete session
|
23
|
+
def delete_session(session_id)
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #delete_session"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Check if session exists
|
28
|
+
def session_exists?(session_id)
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #session_exists?"
|
30
|
+
end
|
31
|
+
|
32
|
+
# Find sessions by criteria
|
33
|
+
def find_sessions(criteria = {})
|
34
|
+
raise NotImplementedError, "#{self.class} must implement #find_sessions"
|
35
|
+
end
|
36
|
+
|
37
|
+
# Cleanup expired sessions
|
38
|
+
def cleanup_expired_sessions(older_than: 24.hours.ago)
|
39
|
+
raise NotImplementedError, "#{self.class} must implement #cleanup_expired_sessions"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Volatile session store for development (data lost on restart)
|
44
|
+
class VolatileSessionStore
|
45
|
+
include SessionStore
|
46
|
+
|
47
|
+
def initialize
|
48
|
+
@sessions = Concurrent::Hash.new
|
49
|
+
end
|
50
|
+
|
51
|
+
def create_session(session_id = nil, attributes = {})
|
52
|
+
session_id ||= SecureRandom.hex(6)
|
53
|
+
|
54
|
+
session_data = {
|
55
|
+
id: session_id,
|
56
|
+
status: "pre_initialize",
|
57
|
+
initialized: false,
|
58
|
+
role: "server",
|
59
|
+
messages_count: 0,
|
60
|
+
sse_event_counter: 0,
|
61
|
+
created_at: Time.current,
|
62
|
+
updated_at: Time.current
|
63
|
+
}.merge(attributes)
|
64
|
+
|
65
|
+
session = MemorySession.new(session_data, self)
|
66
|
+
|
67
|
+
# Initialize server info and capabilities if server role
|
68
|
+
if session.role == "server"
|
69
|
+
session.server_info = {
|
70
|
+
name: ActionMCP.configuration.name,
|
71
|
+
version: ActionMCP.configuration.version
|
72
|
+
}
|
73
|
+
session.server_capabilities = ActionMCP.configuration.capabilities
|
74
|
+
|
75
|
+
# Initialize registries
|
76
|
+
session.tool_registry = ActionMCP.configuration.filtered_tools.map(&:name)
|
77
|
+
session.prompt_registry = ActionMCP.configuration.filtered_prompts.map(&:name)
|
78
|
+
session.resource_registry = ActionMCP.configuration.filtered_resources.map(&:name)
|
79
|
+
end
|
80
|
+
|
81
|
+
@sessions[session_id] = session
|
82
|
+
session
|
83
|
+
end
|
84
|
+
|
85
|
+
def load_session(session_id)
|
86
|
+
session = @sessions[session_id]
|
87
|
+
if session
|
88
|
+
session.instance_variable_set(:@new_record, false)
|
89
|
+
end
|
90
|
+
session
|
91
|
+
end
|
92
|
+
|
93
|
+
def save_session(session)
|
94
|
+
@sessions[session.id] = session
|
95
|
+
end
|
96
|
+
|
97
|
+
def delete_session(session_id)
|
98
|
+
@sessions.delete(session_id)
|
99
|
+
end
|
100
|
+
|
101
|
+
def session_exists?(session_id)
|
102
|
+
@sessions.key?(session_id)
|
103
|
+
end
|
104
|
+
|
105
|
+
def find_sessions(criteria = {})
|
106
|
+
sessions = @sessions.values
|
107
|
+
|
108
|
+
# Filter by status
|
109
|
+
if criteria[:status]
|
110
|
+
sessions = sessions.select { |s| s.status == criteria[:status] }
|
111
|
+
end
|
112
|
+
|
113
|
+
# Filter by role
|
114
|
+
if criteria[:role]
|
115
|
+
sessions = sessions.select { |s| s.role == criteria[:role] }
|
116
|
+
end
|
117
|
+
|
118
|
+
sessions
|
119
|
+
end
|
120
|
+
|
121
|
+
def cleanup_expired_sessions(older_than: 24.hours.ago)
|
122
|
+
expired_ids = @sessions.select do |_id, session|
|
123
|
+
session.updated_at < older_than
|
124
|
+
end.keys
|
125
|
+
|
126
|
+
expired_ids.each { |id| @sessions.delete(id) }
|
127
|
+
expired_ids.count
|
128
|
+
end
|
129
|
+
|
130
|
+
def clear_all
|
131
|
+
@sessions.clear
|
132
|
+
end
|
133
|
+
|
134
|
+
def session_count
|
135
|
+
@sessions.size
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Memory-based session object that mimics ActiveRecord Session
|
140
|
+
class MemorySession
|
141
|
+
attr_accessor :id, :status, :initialized, :role, :messages_count,
|
142
|
+
:sse_event_counter, :protocol_version, :client_info,
|
143
|
+
:client_capabilities, :server_info, :server_capabilities,
|
144
|
+
:tool_registry, :prompt_registry, :resource_registry,
|
145
|
+
:created_at, :updated_at, :ended_at, :last_event_id,
|
146
|
+
:session_data
|
147
|
+
|
148
|
+
def initialize(attributes = {}, store = nil)
|
149
|
+
@store = store
|
150
|
+
@messages = Concurrent::Array.new
|
151
|
+
@subscriptions = Concurrent::Array.new
|
152
|
+
@resources = Concurrent::Array.new
|
153
|
+
@sse_events = Concurrent::Array.new
|
154
|
+
@sse_counter = Concurrent::AtomicFixnum.new(0)
|
155
|
+
@message_counter = Concurrent::AtomicFixnum.new(0)
|
156
|
+
@new_record = true
|
157
|
+
|
158
|
+
attributes.each do |key, value|
|
159
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Mimic ActiveRecord interface
|
164
|
+
def new_record?
|
165
|
+
@new_record
|
166
|
+
end
|
167
|
+
|
168
|
+
def persisted?
|
169
|
+
!@new_record
|
170
|
+
end
|
171
|
+
|
172
|
+
def save
|
173
|
+
self.updated_at = Time.current
|
174
|
+
@store.save_session(self) if @store
|
175
|
+
@new_record = false
|
176
|
+
true
|
177
|
+
end
|
178
|
+
|
179
|
+
def save!
|
180
|
+
save
|
181
|
+
end
|
182
|
+
|
183
|
+
def update(attributes)
|
184
|
+
attributes.each do |key, value|
|
185
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
186
|
+
end
|
187
|
+
save
|
188
|
+
end
|
189
|
+
|
190
|
+
def update!(attributes)
|
191
|
+
update(attributes)
|
192
|
+
end
|
193
|
+
|
194
|
+
def destroy
|
195
|
+
@store.delete_session(id) if @store
|
196
|
+
end
|
197
|
+
|
198
|
+
def reload
|
199
|
+
self
|
200
|
+
end
|
201
|
+
|
202
|
+
def initialized?
|
203
|
+
initialized
|
204
|
+
end
|
205
|
+
|
206
|
+
def initialize!
|
207
|
+
return false if initialized?
|
208
|
+
|
209
|
+
self.initialized = true
|
210
|
+
self.status = "initialized"
|
211
|
+
save
|
212
|
+
end
|
213
|
+
|
214
|
+
def close!
|
215
|
+
self.status = "closed"
|
216
|
+
self.ended_at = Time.current
|
217
|
+
save
|
218
|
+
end
|
219
|
+
|
220
|
+
# Message management
|
221
|
+
def write(data)
|
222
|
+
@messages << {
|
223
|
+
data: data,
|
224
|
+
direction: role == "server" ? "client" : "server",
|
225
|
+
created_at: Time.current
|
226
|
+
}
|
227
|
+
@message_counter.increment
|
228
|
+
self.messages_count = @message_counter.value
|
229
|
+
end
|
230
|
+
|
231
|
+
def read(data)
|
232
|
+
@messages << {
|
233
|
+
data: data,
|
234
|
+
direction: role,
|
235
|
+
created_at: Time.current
|
236
|
+
}
|
237
|
+
@message_counter.increment
|
238
|
+
self.messages_count = @message_counter.value
|
239
|
+
end
|
240
|
+
|
241
|
+
def messages
|
242
|
+
MessageCollection.new(@messages)
|
243
|
+
end
|
244
|
+
|
245
|
+
def subscriptions
|
246
|
+
SubscriptionCollection.new(@subscriptions)
|
247
|
+
end
|
248
|
+
|
249
|
+
def resources
|
250
|
+
ResourceCollection.new(@resources)
|
251
|
+
end
|
252
|
+
|
253
|
+
def sse_events
|
254
|
+
SSEEventCollection.new(@sse_events)
|
255
|
+
end
|
256
|
+
|
257
|
+
# SSE event management
|
258
|
+
def increment_sse_counter!
|
259
|
+
new_value = @sse_counter.increment
|
260
|
+
self.sse_event_counter = new_value
|
261
|
+
save
|
262
|
+
new_value
|
263
|
+
end
|
264
|
+
|
265
|
+
def store_sse_event(event_id, data, max_events = 100)
|
266
|
+
event = { event_id: event_id, data: data, created_at: Time.current }
|
267
|
+
@sse_events << event
|
268
|
+
|
269
|
+
# Maintain cache limit
|
270
|
+
while @sse_events.size > max_events
|
271
|
+
@sse_events.shift
|
272
|
+
end
|
273
|
+
|
274
|
+
event
|
275
|
+
end
|
276
|
+
|
277
|
+
def get_sse_events_after(last_event_id, limit = 50)
|
278
|
+
@sse_events.select { |e| e[:event_id] > last_event_id }
|
279
|
+
.first(limit)
|
280
|
+
end
|
281
|
+
|
282
|
+
def cleanup_old_sse_events(max_age = 15.minutes)
|
283
|
+
cutoff_time = Time.current - max_age
|
284
|
+
@sse_events.delete_if { |e| e[:created_at] < cutoff_time }
|
285
|
+
end
|
286
|
+
|
287
|
+
# Adapter methods
|
288
|
+
def adapter
|
289
|
+
ActionMCP::Server.server.pubsub
|
290
|
+
end
|
291
|
+
|
292
|
+
def session_key
|
293
|
+
"action_mcp:session:#{id}"
|
294
|
+
end
|
295
|
+
|
296
|
+
# Capability methods
|
297
|
+
def server_capabilities_payload
|
298
|
+
{
|
299
|
+
protocolVersion: ActionMCP::PROTOCOL_VERSION,
|
300
|
+
serverInfo: server_info,
|
301
|
+
capabilities: server_capabilities
|
302
|
+
}
|
303
|
+
end
|
304
|
+
|
305
|
+
def set_protocol_version(version)
|
306
|
+
version = ActionMCP::PROTOCOL_VERSION if ActionMCP.configuration.vibed_ignore_version
|
307
|
+
self.protocol_version = version
|
308
|
+
save
|
309
|
+
end
|
310
|
+
|
311
|
+
def store_client_info(info)
|
312
|
+
self.client_info = info
|
313
|
+
end
|
314
|
+
|
315
|
+
def store_client_capabilities(capabilities)
|
316
|
+
self.client_capabilities = capabilities
|
317
|
+
end
|
318
|
+
|
319
|
+
# Subscription management
|
320
|
+
def resource_subscribe(uri)
|
321
|
+
unless @subscriptions.any? { |s| s[:uri] == uri }
|
322
|
+
@subscriptions << { uri: uri, created_at: Time.current }
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
def resource_unsubscribe(uri)
|
327
|
+
@subscriptions.delete_if { |s| s[:uri] == uri }
|
328
|
+
end
|
329
|
+
|
330
|
+
# Progress notification
|
331
|
+
def send_progress_notification(progressToken:, progress:, total: nil, message: nil)
|
332
|
+
handler = ActionMCP::Server::TransportHandler.new(self)
|
333
|
+
handler.send_progress_notification(
|
334
|
+
progressToken: progressToken,
|
335
|
+
progress: progress,
|
336
|
+
total: total,
|
337
|
+
message: message
|
338
|
+
)
|
339
|
+
end
|
340
|
+
|
341
|
+
# Registry management methods
|
342
|
+
def register_tool(tool_class_or_name)
|
343
|
+
tool_name = normalize_name(tool_class_or_name, :tool)
|
344
|
+
return false unless tool_exists?(tool_name)
|
345
|
+
|
346
|
+
self.tool_registry ||= []
|
347
|
+
unless self.tool_registry.include?(tool_name)
|
348
|
+
self.tool_registry << tool_name
|
349
|
+
save!
|
350
|
+
send_tools_list_changed_notification
|
351
|
+
end
|
352
|
+
true
|
353
|
+
end
|
354
|
+
|
355
|
+
def unregister_tool(tool_class_or_name)
|
356
|
+
tool_name = normalize_name(tool_class_or_name, :tool)
|
357
|
+
self.tool_registry ||= []
|
358
|
+
|
359
|
+
return unless self.tool_registry.delete(tool_name)
|
360
|
+
|
361
|
+
save!
|
362
|
+
send_tools_list_changed_notification
|
363
|
+
end
|
364
|
+
|
365
|
+
def register_prompt(prompt_class_or_name)
|
366
|
+
prompt_name = normalize_name(prompt_class_or_name, :prompt)
|
367
|
+
return false unless prompt_exists?(prompt_name)
|
368
|
+
|
369
|
+
self.prompt_registry ||= []
|
370
|
+
unless self.prompt_registry.include?(prompt_name)
|
371
|
+
self.prompt_registry << prompt_name
|
372
|
+
save!
|
373
|
+
send_prompts_list_changed_notification
|
374
|
+
end
|
375
|
+
true
|
376
|
+
end
|
377
|
+
|
378
|
+
def unregister_prompt(prompt_class_or_name)
|
379
|
+
prompt_name = normalize_name(prompt_class_or_name, :prompt)
|
380
|
+
self.prompt_registry ||= []
|
381
|
+
|
382
|
+
return unless self.prompt_registry.delete(prompt_name)
|
383
|
+
|
384
|
+
save!
|
385
|
+
send_prompts_list_changed_notification
|
386
|
+
end
|
387
|
+
|
388
|
+
def register_resource_template(template_class_or_name)
|
389
|
+
template_name = normalize_name(template_class_or_name, :resource_template)
|
390
|
+
return false unless resource_template_exists?(template_name)
|
391
|
+
|
392
|
+
self.resource_registry ||= []
|
393
|
+
unless self.resource_registry.include?(template_name)
|
394
|
+
self.resource_registry << template_name
|
395
|
+
save!
|
396
|
+
send_resources_list_changed_notification
|
397
|
+
end
|
398
|
+
true
|
399
|
+
end
|
400
|
+
|
401
|
+
def unregister_resource_template(template_class_or_name)
|
402
|
+
template_name = normalize_name(template_class_or_name, :resource_template)
|
403
|
+
self.resource_registry ||= []
|
404
|
+
|
405
|
+
return unless self.resource_registry.delete(template_name)
|
406
|
+
|
407
|
+
save!
|
408
|
+
send_resources_list_changed_notification
|
409
|
+
end
|
410
|
+
|
411
|
+
def registered_tools
|
412
|
+
(self.tool_registry || []).filter_map do |tool_name|
|
413
|
+
ActionMCP::ToolsRegistry.find(tool_name)
|
414
|
+
rescue StandardError
|
415
|
+
nil
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
def registered_prompts
|
420
|
+
(self.prompt_registry || []).filter_map do |prompt_name|
|
421
|
+
ActionMCP::PromptsRegistry.find(prompt_name)
|
422
|
+
rescue StandardError
|
423
|
+
nil
|
424
|
+
end
|
425
|
+
end
|
426
|
+
|
427
|
+
def registered_resource_templates
|
428
|
+
(self.resource_registry || []).filter_map do |template_name|
|
429
|
+
ActionMCP::ResourceTemplatesRegistry.find(template_name)
|
430
|
+
rescue StandardError
|
431
|
+
nil
|
432
|
+
end
|
433
|
+
end
|
434
|
+
|
435
|
+
private
|
436
|
+
|
437
|
+
def normalize_name(class_or_name, type)
|
438
|
+
case class_or_name
|
439
|
+
when String
|
440
|
+
class_or_name
|
441
|
+
when Class
|
442
|
+
case type
|
443
|
+
when :tool
|
444
|
+
class_or_name.tool_name
|
445
|
+
when :prompt
|
446
|
+
class_or_name.prompt_name
|
447
|
+
when :resource_template
|
448
|
+
class_or_name.capability_name
|
449
|
+
end
|
450
|
+
else
|
451
|
+
raise ArgumentError, "Expected String or Class, got #{class_or_name.class}"
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
def tool_exists?(tool_name)
|
456
|
+
ActionMCP::ToolsRegistry.find(tool_name)
|
457
|
+
true
|
458
|
+
rescue ActionMCP::RegistryBase::NotFound
|
459
|
+
false
|
460
|
+
end
|
461
|
+
|
462
|
+
def prompt_exists?(prompt_name)
|
463
|
+
ActionMCP::PromptsRegistry.find(prompt_name)
|
464
|
+
true
|
465
|
+
rescue ActionMCP::RegistryBase::NotFound
|
466
|
+
false
|
467
|
+
end
|
468
|
+
|
469
|
+
def resource_template_exists?(template_name)
|
470
|
+
ActionMCP::ResourceTemplatesRegistry.find(template_name)
|
471
|
+
true
|
472
|
+
rescue ActionMCP::RegistryBase::NotFound
|
473
|
+
false
|
474
|
+
end
|
475
|
+
|
476
|
+
def send_tools_list_changed_notification
|
477
|
+
# Only send if server capabilities allow it
|
478
|
+
return unless server_capabilities.dig("tools", "listChanged")
|
479
|
+
|
480
|
+
write(JSON_RPC::Notification.new(method: "notifications/tools/list_changed"))
|
481
|
+
end
|
482
|
+
|
483
|
+
def send_prompts_list_changed_notification
|
484
|
+
return unless server_capabilities.dig("prompts", "listChanged")
|
485
|
+
|
486
|
+
write(JSON_RPC::Notification.new(method: "notifications/prompts/list_changed"))
|
487
|
+
end
|
488
|
+
|
489
|
+
def send_resources_list_changed_notification
|
490
|
+
return unless server_capabilities.dig("resources", "listChanged")
|
491
|
+
|
492
|
+
write(JSON_RPC::Notification.new(method: "notifications/resources/list_changed"))
|
493
|
+
end
|
494
|
+
|
495
|
+
public
|
496
|
+
|
497
|
+
# Simple collection classes to mimic ActiveRecord associations
|
498
|
+
class MessageCollection < Array
|
499
|
+
def create!(attributes)
|
500
|
+
self << attributes
|
501
|
+
attributes
|
502
|
+
end
|
503
|
+
|
504
|
+
def order(field)
|
505
|
+
# Simple ordering implementation
|
506
|
+
sort_by { |msg| msg[field] || msg[field.to_s] }
|
507
|
+
end
|
508
|
+
end
|
509
|
+
|
510
|
+
class SubscriptionCollection < Array
|
511
|
+
def find_or_create_by(attributes)
|
512
|
+
existing = find { |s| s[:uri] == attributes[:uri] }
|
513
|
+
return existing if existing
|
514
|
+
|
515
|
+
subscription = attributes.merge(created_at: Time.current)
|
516
|
+
self << subscription
|
517
|
+
subscription
|
518
|
+
end
|
519
|
+
|
520
|
+
def find_by(attributes)
|
521
|
+
find { |s| s[:uri] == attributes[:uri] }
|
522
|
+
end
|
523
|
+
end
|
524
|
+
|
525
|
+
class ResourceCollection < Array
|
526
|
+
end
|
527
|
+
|
528
|
+
class SSEEventCollection < Array
|
529
|
+
def create!(attributes)
|
530
|
+
self << attributes
|
531
|
+
attributes
|
532
|
+
end
|
533
|
+
|
534
|
+
def count
|
535
|
+
size
|
536
|
+
end
|
537
|
+
|
538
|
+
def where(condition, value)
|
539
|
+
# Simple implementation for "event_id > ?" condition
|
540
|
+
select { |e| e[:event_id] > value }
|
541
|
+
end
|
542
|
+
|
543
|
+
def order(field)
|
544
|
+
sort_by { |e| e[field.is_a?(Hash) ? field.keys.first : field] }
|
545
|
+
end
|
546
|
+
|
547
|
+
def limit(n)
|
548
|
+
first(n)
|
549
|
+
end
|
550
|
+
|
551
|
+
def delete_all
|
552
|
+
clear
|
553
|
+
end
|
554
|
+
end
|
555
|
+
end
|
556
|
+
|
557
|
+
# ActiveRecord-backed session store (default for production)
|
558
|
+
class ActiveRecordSessionStore
|
559
|
+
include SessionStore
|
560
|
+
|
561
|
+
def create_session(session_id = nil, attributes = {})
|
562
|
+
session = ActionMCP::Session.new(attributes)
|
563
|
+
session.id = session_id if session_id
|
564
|
+
session.save!
|
565
|
+
session
|
566
|
+
end
|
567
|
+
|
568
|
+
def load_session(session_id)
|
569
|
+
ActionMCP::Session.find_by(id: session_id)
|
570
|
+
end
|
571
|
+
|
572
|
+
def save_session(session)
|
573
|
+
session.save! if session.is_a?(ActionMCP::Session)
|
574
|
+
end
|
575
|
+
|
576
|
+
def delete_session(session_id)
|
577
|
+
ActionMCP::Session.find_by(id: session_id)&.destroy
|
578
|
+
end
|
579
|
+
|
580
|
+
def session_exists?(session_id)
|
581
|
+
ActionMCP::Session.exists?(id: session_id)
|
582
|
+
end
|
583
|
+
|
584
|
+
def find_sessions(criteria = {})
|
585
|
+
scope = ActionMCP::Session.all
|
586
|
+
|
587
|
+
scope = scope.where(status: criteria[:status]) if criteria[:status]
|
588
|
+
scope = scope.where(role: criteria[:role]) if criteria[:role]
|
589
|
+
|
590
|
+
scope
|
591
|
+
end
|
592
|
+
|
593
|
+
def cleanup_expired_sessions(older_than: 24.hours.ago)
|
594
|
+
ActionMCP::Session.where("updated_at < ?", older_than).destroy_all
|
595
|
+
end
|
596
|
+
end
|
597
|
+
|
598
|
+
# Test session store that tracks all operations for assertions
|
599
|
+
class TestSessionStore < VolatileSessionStore
|
600
|
+
attr_reader :operations, :created_sessions, :loaded_sessions,
|
601
|
+
:saved_sessions, :deleted_sessions, :notifications_sent
|
602
|
+
|
603
|
+
def initialize
|
604
|
+
super
|
605
|
+
@operations = Concurrent::Array.new
|
606
|
+
@created_sessions = Concurrent::Array.new
|
607
|
+
@loaded_sessions = Concurrent::Array.new
|
608
|
+
@saved_sessions = Concurrent::Array.new
|
609
|
+
@deleted_sessions = Concurrent::Array.new
|
610
|
+
@notifications_sent = Concurrent::Array.new
|
611
|
+
@notification_callbacks = Concurrent::Array.new
|
612
|
+
end
|
613
|
+
|
614
|
+
def create_session(session_id = nil, attributes = {})
|
615
|
+
session = super
|
616
|
+
@operations << { type: :create, session_id: session.id, attributes: attributes }
|
617
|
+
@created_sessions << session.id
|
618
|
+
|
619
|
+
# Hook into the session's write method to capture notifications
|
620
|
+
intercept_session_write(session)
|
621
|
+
|
622
|
+
session
|
623
|
+
end
|
624
|
+
|
625
|
+
def load_session(session_id)
|
626
|
+
session = super
|
627
|
+
@operations << { type: :load, session_id: session_id, found: !session.nil? }
|
628
|
+
@loaded_sessions << session_id if session
|
629
|
+
|
630
|
+
# Hook into the session's write method to capture notifications
|
631
|
+
intercept_session_write(session) if session
|
632
|
+
|
633
|
+
session
|
634
|
+
end
|
635
|
+
|
636
|
+
def save_session(session)
|
637
|
+
super
|
638
|
+
@operations << { type: :save, session_id: session.id }
|
639
|
+
@saved_sessions << session.id
|
640
|
+
end
|
641
|
+
|
642
|
+
def delete_session(session_id)
|
643
|
+
result = super
|
644
|
+
@operations << { type: :delete, session_id: session_id }
|
645
|
+
@deleted_sessions << session_id
|
646
|
+
result
|
647
|
+
end
|
648
|
+
|
649
|
+
def cleanup_expired_sessions(older_than: 24.hours.ago)
|
650
|
+
count = super
|
651
|
+
@operations << { type: :cleanup, older_than: older_than, count: count }
|
652
|
+
count
|
653
|
+
end
|
654
|
+
|
655
|
+
# Test helper methods
|
656
|
+
def session_created?(session_id)
|
657
|
+
@created_sessions.include?(session_id)
|
658
|
+
end
|
659
|
+
|
660
|
+
def session_loaded?(session_id)
|
661
|
+
@loaded_sessions.include?(session_id)
|
662
|
+
end
|
663
|
+
|
664
|
+
def session_saved?(session_id)
|
665
|
+
@saved_sessions.include?(session_id)
|
666
|
+
end
|
667
|
+
|
668
|
+
def session_deleted?(session_id)
|
669
|
+
@deleted_sessions.include?(session_id)
|
670
|
+
end
|
671
|
+
|
672
|
+
def operation_count(type = nil)
|
673
|
+
if type
|
674
|
+
@operations.count { |op| op[:type] == type }
|
675
|
+
else
|
676
|
+
@operations.size
|
677
|
+
end
|
678
|
+
end
|
679
|
+
|
680
|
+
# Notification tracking methods
|
681
|
+
def track_notification(notification)
|
682
|
+
@notifications_sent << notification
|
683
|
+
@notification_callbacks.each { |cb| cb.call(notification) }
|
684
|
+
end
|
685
|
+
|
686
|
+
def on_notification(&block)
|
687
|
+
@notification_callbacks << block
|
688
|
+
end
|
689
|
+
|
690
|
+
def notifications_for_token(token)
|
691
|
+
@notifications_sent.select do |n|
|
692
|
+
n.params[:progressToken] == token
|
693
|
+
end
|
694
|
+
end
|
695
|
+
|
696
|
+
def clear_notifications
|
697
|
+
@notifications_sent.clear
|
698
|
+
end
|
699
|
+
|
700
|
+
def reset_tracking!
|
701
|
+
@operations.clear
|
702
|
+
@created_sessions.clear
|
703
|
+
@loaded_sessions.clear
|
704
|
+
@saved_sessions.clear
|
705
|
+
@deleted_sessions.clear
|
706
|
+
@notifications_sent.clear
|
707
|
+
@notification_callbacks.clear
|
708
|
+
end
|
709
|
+
|
710
|
+
private
|
711
|
+
|
712
|
+
def intercept_session_write(session)
|
713
|
+
return unless session
|
714
|
+
|
715
|
+
# Skip if already intercepted
|
716
|
+
return if session.singleton_methods.include?(:write)
|
717
|
+
|
718
|
+
test_store = self
|
719
|
+
|
720
|
+
# Intercept write method to capture all notifications
|
721
|
+
original_write = session.method(:write)
|
722
|
+
|
723
|
+
session.define_singleton_method(:write) do |data|
|
724
|
+
# Track progress notifications before calling original write
|
725
|
+
if data.is_a?(JSON_RPC::Notification) && data.method == "notifications/progress"
|
726
|
+
test_store.track_notification(data)
|
727
|
+
end
|
728
|
+
|
729
|
+
original_write.call(data)
|
730
|
+
end
|
731
|
+
end
|
732
|
+
end
|
733
|
+
|
734
|
+
# Factory for creating session stores
|
735
|
+
class SessionStoreFactory
|
736
|
+
def self.create(type = nil, **options)
|
737
|
+
type ||= default_type
|
738
|
+
|
739
|
+
case type.to_sym
|
740
|
+
when :volatile, :memory
|
741
|
+
VolatileSessionStore.new
|
742
|
+
when :active_record, :persistent
|
743
|
+
ActiveRecordSessionStore.new
|
744
|
+
when :test
|
745
|
+
TestSessionStore.new
|
746
|
+
else
|
747
|
+
raise ArgumentError, "Unknown session store type: #{type}"
|
748
|
+
end
|
749
|
+
end
|
750
|
+
|
751
|
+
def self.default_type
|
752
|
+
if Rails.env.test?
|
753
|
+
:volatile # Use volatile for tests unless explicitly using :test
|
754
|
+
elsif Rails.env.production?
|
755
|
+
:active_record
|
756
|
+
else
|
757
|
+
:volatile
|
758
|
+
end
|
759
|
+
end
|
760
|
+
end
|
761
|
+
end
|
762
|
+
end
|