actionmcp 0.52.1 → 0.52.2

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.
@@ -39,724 +39,5 @@ module ActionMCP
39
39
  raise NotImplementedError, "#{self.class} must implement #cleanup_expired_sessions"
40
40
  end
41
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
42
  end
762
43
  end