actionmcp 0.52.0 → 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.
- checksums.yaml +4 -4
- data/README.md +109 -0
- data/lib/action_mcp/client/active_record_session_store.rb +57 -0
- data/lib/action_mcp/client/session_store.rb +0 -192
- 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/server/active_record_session_store.rb +46 -0
- data/lib/action_mcp/server/memory_session.rb +423 -0
- data/lib/action_mcp/server/session_store.rb +0 -719
- data/lib/action_mcp/server/session_store_factory.rb +32 -0
- data/lib/action_mcp/server/test_session_store.rb +141 -0
- data/lib/action_mcp/server/volatile_session_store.rb +101 -0
- data/lib/action_mcp/version.rb +1 -1
- metadata +10 -1
@@ -0,0 +1,423 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Server
|
5
|
+
# Memory-based session object that mimics ActiveRecord Session
|
6
|
+
class MemorySession
|
7
|
+
attr_accessor :id, :status, :initialized, :role, :messages_count,
|
8
|
+
:sse_event_counter, :protocol_version, :client_info,
|
9
|
+
:client_capabilities, :server_info, :server_capabilities,
|
10
|
+
:tool_registry, :prompt_registry, :resource_registry,
|
11
|
+
:created_at, :updated_at, :ended_at, :last_event_id,
|
12
|
+
:session_data
|
13
|
+
|
14
|
+
def initialize(attributes = {}, store = nil)
|
15
|
+
@store = store
|
16
|
+
@messages = Concurrent::Array.new
|
17
|
+
@subscriptions = Concurrent::Array.new
|
18
|
+
@resources = Concurrent::Array.new
|
19
|
+
@sse_events = Concurrent::Array.new
|
20
|
+
@sse_counter = Concurrent::AtomicFixnum.new(0)
|
21
|
+
@message_counter = Concurrent::AtomicFixnum.new(0)
|
22
|
+
@new_record = true
|
23
|
+
|
24
|
+
attributes.each do |key, value|
|
25
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Mimic ActiveRecord interface
|
30
|
+
def new_record?
|
31
|
+
@new_record
|
32
|
+
end
|
33
|
+
|
34
|
+
def persisted?
|
35
|
+
!@new_record
|
36
|
+
end
|
37
|
+
|
38
|
+
def save
|
39
|
+
self.updated_at = Time.current
|
40
|
+
@store.save_session(self) if @store
|
41
|
+
@new_record = false
|
42
|
+
true
|
43
|
+
end
|
44
|
+
|
45
|
+
def save!
|
46
|
+
save
|
47
|
+
end
|
48
|
+
|
49
|
+
def update(attributes)
|
50
|
+
attributes.each do |key, value|
|
51
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
52
|
+
end
|
53
|
+
save
|
54
|
+
end
|
55
|
+
|
56
|
+
def update!(attributes)
|
57
|
+
update(attributes)
|
58
|
+
end
|
59
|
+
|
60
|
+
def destroy
|
61
|
+
@store.delete_session(id) if @store
|
62
|
+
end
|
63
|
+
|
64
|
+
def reload
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
def initialized?
|
69
|
+
initialized
|
70
|
+
end
|
71
|
+
|
72
|
+
def initialize!
|
73
|
+
return false if initialized?
|
74
|
+
|
75
|
+
self.initialized = true
|
76
|
+
self.status = "initialized"
|
77
|
+
save
|
78
|
+
end
|
79
|
+
|
80
|
+
def close!
|
81
|
+
self.status = "closed"
|
82
|
+
self.ended_at = Time.current
|
83
|
+
save
|
84
|
+
end
|
85
|
+
|
86
|
+
# Message management
|
87
|
+
def write(data)
|
88
|
+
@messages << {
|
89
|
+
data: data,
|
90
|
+
direction: role == "server" ? "client" : "server",
|
91
|
+
created_at: Time.current
|
92
|
+
}
|
93
|
+
@message_counter.increment
|
94
|
+
self.messages_count = @message_counter.value
|
95
|
+
end
|
96
|
+
|
97
|
+
def read(data)
|
98
|
+
@messages << {
|
99
|
+
data: data,
|
100
|
+
direction: role,
|
101
|
+
created_at: Time.current
|
102
|
+
}
|
103
|
+
@message_counter.increment
|
104
|
+
self.messages_count = @message_counter.value
|
105
|
+
end
|
106
|
+
|
107
|
+
def messages
|
108
|
+
MessageCollection.new(@messages)
|
109
|
+
end
|
110
|
+
|
111
|
+
def subscriptions
|
112
|
+
SubscriptionCollection.new(@subscriptions)
|
113
|
+
end
|
114
|
+
|
115
|
+
def resources
|
116
|
+
ResourceCollection.new(@resources)
|
117
|
+
end
|
118
|
+
|
119
|
+
def sse_events
|
120
|
+
SSEEventCollection.new(@sse_events)
|
121
|
+
end
|
122
|
+
|
123
|
+
# SSE event management
|
124
|
+
def increment_sse_counter!
|
125
|
+
new_value = @sse_counter.increment
|
126
|
+
self.sse_event_counter = new_value
|
127
|
+
save
|
128
|
+
new_value
|
129
|
+
end
|
130
|
+
|
131
|
+
def store_sse_event(event_id, data, max_events = 100)
|
132
|
+
event = { event_id: event_id, data: data, created_at: Time.current }
|
133
|
+
@sse_events << event
|
134
|
+
|
135
|
+
# Maintain cache limit
|
136
|
+
while @sse_events.size > max_events
|
137
|
+
@sse_events.shift
|
138
|
+
end
|
139
|
+
|
140
|
+
event
|
141
|
+
end
|
142
|
+
|
143
|
+
def get_sse_events_after(last_event_id, limit = 50)
|
144
|
+
@sse_events.select { |e| e[:event_id] > last_event_id }
|
145
|
+
.first(limit)
|
146
|
+
end
|
147
|
+
|
148
|
+
def cleanup_old_sse_events(max_age = 15.minutes)
|
149
|
+
cutoff_time = Time.current - max_age
|
150
|
+
@sse_events.delete_if { |e| e[:created_at] < cutoff_time }
|
151
|
+
end
|
152
|
+
|
153
|
+
# Adapter methods
|
154
|
+
def adapter
|
155
|
+
ActionMCP::Server.server.pubsub
|
156
|
+
end
|
157
|
+
|
158
|
+
def session_key
|
159
|
+
"action_mcp:session:#{id}"
|
160
|
+
end
|
161
|
+
|
162
|
+
# Capability methods
|
163
|
+
def server_capabilities_payload
|
164
|
+
{
|
165
|
+
protocolVersion: ActionMCP::PROTOCOL_VERSION,
|
166
|
+
serverInfo: server_info,
|
167
|
+
capabilities: server_capabilities
|
168
|
+
}
|
169
|
+
end
|
170
|
+
|
171
|
+
def set_protocol_version(version)
|
172
|
+
version = ActionMCP::PROTOCOL_VERSION if ActionMCP.configuration.vibed_ignore_version
|
173
|
+
self.protocol_version = version
|
174
|
+
save
|
175
|
+
end
|
176
|
+
|
177
|
+
def store_client_info(info)
|
178
|
+
self.client_info = info
|
179
|
+
end
|
180
|
+
|
181
|
+
def store_client_capabilities(capabilities)
|
182
|
+
self.client_capabilities = capabilities
|
183
|
+
end
|
184
|
+
|
185
|
+
# Subscription management
|
186
|
+
def resource_subscribe(uri)
|
187
|
+
unless @subscriptions.any? { |s| s[:uri] == uri }
|
188
|
+
@subscriptions << { uri: uri, created_at: Time.current }
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def resource_unsubscribe(uri)
|
193
|
+
@subscriptions.delete_if { |s| s[:uri] == uri }
|
194
|
+
end
|
195
|
+
|
196
|
+
# Progress notification
|
197
|
+
def send_progress_notification(progressToken:, progress:, total: nil, message: nil)
|
198
|
+
handler = ActionMCP::Server::TransportHandler.new(self)
|
199
|
+
handler.send_progress_notification(
|
200
|
+
progressToken: progressToken,
|
201
|
+
progress: progress,
|
202
|
+
total: total,
|
203
|
+
message: message
|
204
|
+
)
|
205
|
+
end
|
206
|
+
|
207
|
+
# Registry management methods
|
208
|
+
def register_tool(tool_class_or_name)
|
209
|
+
tool_name = normalize_name(tool_class_or_name, :tool)
|
210
|
+
return false unless tool_exists?(tool_name)
|
211
|
+
|
212
|
+
self.tool_registry ||= []
|
213
|
+
unless self.tool_registry.include?(tool_name)
|
214
|
+
self.tool_registry << tool_name
|
215
|
+
save!
|
216
|
+
send_tools_list_changed_notification
|
217
|
+
end
|
218
|
+
true
|
219
|
+
end
|
220
|
+
|
221
|
+
def unregister_tool(tool_class_or_name)
|
222
|
+
tool_name = normalize_name(tool_class_or_name, :tool)
|
223
|
+
self.tool_registry ||= []
|
224
|
+
|
225
|
+
return unless self.tool_registry.delete(tool_name)
|
226
|
+
|
227
|
+
save!
|
228
|
+
send_tools_list_changed_notification
|
229
|
+
end
|
230
|
+
|
231
|
+
def register_prompt(prompt_class_or_name)
|
232
|
+
prompt_name = normalize_name(prompt_class_or_name, :prompt)
|
233
|
+
return false unless prompt_exists?(prompt_name)
|
234
|
+
|
235
|
+
self.prompt_registry ||= []
|
236
|
+
unless self.prompt_registry.include?(prompt_name)
|
237
|
+
self.prompt_registry << prompt_name
|
238
|
+
save!
|
239
|
+
send_prompts_list_changed_notification
|
240
|
+
end
|
241
|
+
true
|
242
|
+
end
|
243
|
+
|
244
|
+
def unregister_prompt(prompt_class_or_name)
|
245
|
+
prompt_name = normalize_name(prompt_class_or_name, :prompt)
|
246
|
+
self.prompt_registry ||= []
|
247
|
+
|
248
|
+
return unless self.prompt_registry.delete(prompt_name)
|
249
|
+
|
250
|
+
save!
|
251
|
+
send_prompts_list_changed_notification
|
252
|
+
end
|
253
|
+
|
254
|
+
def register_resource_template(template_class_or_name)
|
255
|
+
template_name = normalize_name(template_class_or_name, :resource_template)
|
256
|
+
return false unless resource_template_exists?(template_name)
|
257
|
+
|
258
|
+
self.resource_registry ||= []
|
259
|
+
unless self.resource_registry.include?(template_name)
|
260
|
+
self.resource_registry << template_name
|
261
|
+
save!
|
262
|
+
send_resources_list_changed_notification
|
263
|
+
end
|
264
|
+
true
|
265
|
+
end
|
266
|
+
|
267
|
+
def unregister_resource_template(template_class_or_name)
|
268
|
+
template_name = normalize_name(template_class_or_name, :resource_template)
|
269
|
+
self.resource_registry ||= []
|
270
|
+
|
271
|
+
return unless self.resource_registry.delete(template_name)
|
272
|
+
|
273
|
+
save!
|
274
|
+
send_resources_list_changed_notification
|
275
|
+
end
|
276
|
+
|
277
|
+
def registered_tools
|
278
|
+
(self.tool_registry || []).filter_map do |tool_name|
|
279
|
+
ActionMCP::ToolsRegistry.find(tool_name)
|
280
|
+
rescue StandardError
|
281
|
+
nil
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def registered_prompts
|
286
|
+
(self.prompt_registry || []).filter_map do |prompt_name|
|
287
|
+
ActionMCP::PromptsRegistry.find(prompt_name)
|
288
|
+
rescue StandardError
|
289
|
+
nil
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
def registered_resource_templates
|
294
|
+
(self.resource_registry || []).filter_map do |template_name|
|
295
|
+
ActionMCP::ResourceTemplatesRegistry.find(template_name)
|
296
|
+
rescue StandardError
|
297
|
+
nil
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
private
|
302
|
+
|
303
|
+
def normalize_name(class_or_name, type)
|
304
|
+
case class_or_name
|
305
|
+
when String
|
306
|
+
class_or_name
|
307
|
+
when Class
|
308
|
+
case type
|
309
|
+
when :tool
|
310
|
+
class_or_name.tool_name
|
311
|
+
when :prompt
|
312
|
+
class_or_name.prompt_name
|
313
|
+
when :resource_template
|
314
|
+
class_or_name.capability_name
|
315
|
+
end
|
316
|
+
else
|
317
|
+
raise ArgumentError, "Expected String or Class, got #{class_or_name.class}"
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
def tool_exists?(tool_name)
|
322
|
+
ActionMCP::ToolsRegistry.find(tool_name)
|
323
|
+
true
|
324
|
+
rescue ActionMCP::RegistryBase::NotFound
|
325
|
+
false
|
326
|
+
end
|
327
|
+
|
328
|
+
def prompt_exists?(prompt_name)
|
329
|
+
ActionMCP::PromptsRegistry.find(prompt_name)
|
330
|
+
true
|
331
|
+
rescue ActionMCP::RegistryBase::NotFound
|
332
|
+
false
|
333
|
+
end
|
334
|
+
|
335
|
+
def resource_template_exists?(template_name)
|
336
|
+
ActionMCP::ResourceTemplatesRegistry.find(template_name)
|
337
|
+
true
|
338
|
+
rescue ActionMCP::RegistryBase::NotFound
|
339
|
+
false
|
340
|
+
end
|
341
|
+
|
342
|
+
def send_tools_list_changed_notification
|
343
|
+
# Only send if server capabilities allow it
|
344
|
+
return unless server_capabilities.dig("tools", "listChanged")
|
345
|
+
|
346
|
+
write(JSON_RPC::Notification.new(method: "notifications/tools/list_changed"))
|
347
|
+
end
|
348
|
+
|
349
|
+
def send_prompts_list_changed_notification
|
350
|
+
return unless server_capabilities.dig("prompts", "listChanged")
|
351
|
+
|
352
|
+
write(JSON_RPC::Notification.new(method: "notifications/prompts/list_changed"))
|
353
|
+
end
|
354
|
+
|
355
|
+
def send_resources_list_changed_notification
|
356
|
+
return unless server_capabilities.dig("resources", "listChanged")
|
357
|
+
|
358
|
+
write(JSON_RPC::Notification.new(method: "notifications/resources/list_changed"))
|
359
|
+
end
|
360
|
+
|
361
|
+
public
|
362
|
+
|
363
|
+
# Simple collection classes to mimic ActiveRecord associations
|
364
|
+
class MessageCollection < Array
|
365
|
+
def create!(attributes)
|
366
|
+
self << attributes
|
367
|
+
attributes
|
368
|
+
end
|
369
|
+
|
370
|
+
def order(field)
|
371
|
+
# Simple ordering implementation
|
372
|
+
sort_by { |msg| msg[field] || msg[field.to_s] }
|
373
|
+
end
|
374
|
+
end
|
375
|
+
|
376
|
+
class SubscriptionCollection < Array
|
377
|
+
def find_or_create_by(attributes)
|
378
|
+
existing = find { |s| s[:uri] == attributes[:uri] }
|
379
|
+
return existing if existing
|
380
|
+
|
381
|
+
subscription = attributes.merge(created_at: Time.current)
|
382
|
+
self << subscription
|
383
|
+
subscription
|
384
|
+
end
|
385
|
+
|
386
|
+
def find_by(attributes)
|
387
|
+
find { |s| s[:uri] == attributes[:uri] }
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
class ResourceCollection < Array
|
392
|
+
end
|
393
|
+
|
394
|
+
class SSEEventCollection < Array
|
395
|
+
def create!(attributes)
|
396
|
+
self << attributes
|
397
|
+
attributes
|
398
|
+
end
|
399
|
+
|
400
|
+
def count
|
401
|
+
size
|
402
|
+
end
|
403
|
+
|
404
|
+
def where(condition, value)
|
405
|
+
# Simple implementation for "event_id > ?" condition
|
406
|
+
select { |e| e[:event_id] > value }
|
407
|
+
end
|
408
|
+
|
409
|
+
def order(field)
|
410
|
+
sort_by { |e| e[field.is_a?(Hash) ? field.keys.first : field] }
|
411
|
+
end
|
412
|
+
|
413
|
+
def limit(n)
|
414
|
+
first(n)
|
415
|
+
end
|
416
|
+
|
417
|
+
def delete_all
|
418
|
+
clear
|
419
|
+
end
|
420
|
+
end
|
421
|
+
end
|
422
|
+
end
|
423
|
+
end
|