mcp_on_ruby 0.1.0 → 0.3.0
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/CHANGELOG.md +28 -0
- data/README.md +157 -58
- data/lib/ruby_mcp/client.rb +43 -0
- data/lib/ruby_mcp/configuration.rb +57 -6
- data/lib/ruby_mcp/models/context.rb +1 -0
- data/lib/ruby_mcp/server/contexts_controller.rb +8 -4
- data/lib/ruby_mcp/storage/active_record.rb +414 -0
- data/lib/ruby_mcp/storage/error.rb +8 -0
- data/lib/ruby_mcp/storage/redis.rb +197 -0
- data/lib/ruby_mcp/storage_factory.rb +43 -0
- data/lib/ruby_mcp/version.rb +1 -1
- data/lib/ruby_mcp.rb +19 -0
- metadata +76 -29
@@ -0,0 +1,414 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'active_record'
|
5
|
+
rescue LoadError
|
6
|
+
# ActiveRecord is an optional dependency
|
7
|
+
# This error will be handled by the storage factory
|
8
|
+
end
|
9
|
+
|
10
|
+
require 'securerandom'
|
11
|
+
require 'json'
|
12
|
+
require_relative 'base'
|
13
|
+
|
14
|
+
module RubyMCP
|
15
|
+
module Storage
|
16
|
+
# ActiveRecord-based storage implementation for RubyMCP
|
17
|
+
class ActiveRecord < Base
|
18
|
+
# Initialize ActiveRecord storage with options
|
19
|
+
# @param options [Hash] Options for ActiveRecord storage
|
20
|
+
# @option options [Hash] :connection ActiveRecord connection configuration
|
21
|
+
# @option options [String] :table_prefix Prefix for table names (default: 'mcp_')
|
22
|
+
def initialize(options = {})
|
23
|
+
super
|
24
|
+
@table_prefix = options[:table_prefix] || 'mcp_'
|
25
|
+
@logger = options[:logger] || RubyMCP.logger
|
26
|
+
|
27
|
+
# Set up ActiveRecord connection if provided
|
28
|
+
::ActiveRecord::Base.establish_connection(options[:connection]) if options[:connection].is_a?(Hash)
|
29
|
+
|
30
|
+
setup_models
|
31
|
+
ensure_tables_exist
|
32
|
+
end
|
33
|
+
|
34
|
+
# Create a new context
|
35
|
+
# @param context [RubyMCP::Models::Context] Context to create
|
36
|
+
# @return [RubyMCP::Models::Context] Created context
|
37
|
+
def create_context(context)
|
38
|
+
# Check if context already exists
|
39
|
+
if @context_model.exists?(external_id: context.id)
|
40
|
+
raise RubyMCP::Errors::ContextError, "Context already exists: #{context.id}"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Create the context record
|
44
|
+
ar_context = @context_model.create!(
|
45
|
+
external_id: context.id,
|
46
|
+
metadata: JSON.generate(context.metadata || {}),
|
47
|
+
created_at: context.created_at,
|
48
|
+
updated_at: context.updated_at
|
49
|
+
)
|
50
|
+
|
51
|
+
# Create message records if any
|
52
|
+
context.messages.each do |message|
|
53
|
+
create_message_record(ar_context.id, message)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Create content records if any
|
57
|
+
context.content_map.each do |content_id, content_data|
|
58
|
+
create_content_record(ar_context.id, content_id, content_data)
|
59
|
+
end
|
60
|
+
|
61
|
+
context
|
62
|
+
end
|
63
|
+
|
64
|
+
# Get a context by ID
|
65
|
+
# @param context_id [String] ID of the context to get
|
66
|
+
# @return [RubyMCP::Models::Context] Found context
|
67
|
+
# @raise [RubyMCP::Errors::ContextError] If context not found
|
68
|
+
def get_context(context_id)
|
69
|
+
ar_context = @context_model.find_by(external_id: context_id)
|
70
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless ar_context
|
71
|
+
|
72
|
+
# Parse metadata
|
73
|
+
metadata = begin
|
74
|
+
json = JSON.parse(ar_context.metadata)
|
75
|
+
symbolize_keys(json)
|
76
|
+
rescue JSON::ParserError => e
|
77
|
+
@logger.warn("Error parsing context metadata: #{e.message}")
|
78
|
+
{}
|
79
|
+
end
|
80
|
+
|
81
|
+
# Create a new context object
|
82
|
+
context = RubyMCP::Models::Context.new(
|
83
|
+
id: ar_context.external_id,
|
84
|
+
metadata: metadata
|
85
|
+
)
|
86
|
+
|
87
|
+
# Set timestamps
|
88
|
+
context.instance_variable_set(:@created_at, ar_context.created_at)
|
89
|
+
context.instance_variable_set(:@updated_at, ar_context.updated_at)
|
90
|
+
|
91
|
+
# Load messages
|
92
|
+
ar_messages = @message_model.where(context_id: ar_context.id).order(:created_at)
|
93
|
+
ar_messages.each do |ar_message|
|
94
|
+
# Parse message metadata
|
95
|
+
msg_metadata = begin
|
96
|
+
json = JSON.parse(ar_message.metadata)
|
97
|
+
symbolize_keys(json)
|
98
|
+
rescue JSON::ParserError => e
|
99
|
+
@logger.warn("Error parsing message metadata: #{e.message}")
|
100
|
+
{}
|
101
|
+
end
|
102
|
+
|
103
|
+
message = RubyMCP::Models::Message.new(
|
104
|
+
id: ar_message.external_id,
|
105
|
+
role: ar_message.role,
|
106
|
+
content: ar_message.content,
|
107
|
+
metadata: msg_metadata
|
108
|
+
)
|
109
|
+
message.instance_variable_set(:@created_at, ar_message.created_at)
|
110
|
+
context.instance_variable_get(:@messages) << message
|
111
|
+
end
|
112
|
+
|
113
|
+
# Load content
|
114
|
+
ar_contents = @content_model.where(context_id: ar_context.id)
|
115
|
+
ar_contents.each do |ar_content|
|
116
|
+
content_data = if ar_content.content_type == 'json'
|
117
|
+
begin
|
118
|
+
json = JSON.parse(ar_content.data_json)
|
119
|
+
symbolize_keys(json)
|
120
|
+
rescue JSON::ParserError => e
|
121
|
+
@logger.warn("Error parsing content JSON: #{e.message}")
|
122
|
+
{}
|
123
|
+
end
|
124
|
+
else
|
125
|
+
ar_content.data_binary
|
126
|
+
end
|
127
|
+
context.instance_variable_get(:@content_map)[ar_content.external_id] = content_data
|
128
|
+
end
|
129
|
+
|
130
|
+
context
|
131
|
+
end
|
132
|
+
|
133
|
+
# Update a context
|
134
|
+
# @param context [RubyMCP::Models::Context] Context to update
|
135
|
+
# @return [RubyMCP::Models::Context] Updated context
|
136
|
+
# @raise [RubyMCP::Errors::ContextError] If context not found
|
137
|
+
def update_context(context)
|
138
|
+
ar_context = @context_model.find_by(external_id: context.id)
|
139
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context.id}" unless ar_context
|
140
|
+
|
141
|
+
# Update the context record
|
142
|
+
ar_context.update!(
|
143
|
+
metadata: JSON.generate(context.metadata || {}),
|
144
|
+
updated_at: context.updated_at
|
145
|
+
)
|
146
|
+
|
147
|
+
# We don't update messages or content here as they are added separately
|
148
|
+
context
|
149
|
+
end
|
150
|
+
|
151
|
+
# Delete a context
|
152
|
+
# @param context_id [String] ID of the context to delete
|
153
|
+
# @return [Boolean] True if deleted
|
154
|
+
# @raise [RubyMCP::Errors::ContextError] If context not found
|
155
|
+
def delete_context(context_id)
|
156
|
+
ar_context = @context_model.find_by(external_id: context_id)
|
157
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless ar_context
|
158
|
+
|
159
|
+
# Delete all related records manually since we can't rely on cascading in tests
|
160
|
+
@message_model.where(context_id: ar_context.id).delete_all
|
161
|
+
@content_model.where(context_id: ar_context.id).delete_all
|
162
|
+
ar_context.destroy
|
163
|
+
|
164
|
+
true
|
165
|
+
end
|
166
|
+
|
167
|
+
# List contexts with pagination
|
168
|
+
# @param limit [Integer] Maximum number of contexts to return
|
169
|
+
# @param offset [Integer] Number of contexts to skip
|
170
|
+
# @return [Array<RubyMCP::Models::Context>] List of contexts
|
171
|
+
def list_contexts(limit: 100, offset: 0)
|
172
|
+
ar_contexts = @context_model.order(updated_at: :desc).limit(limit).offset(offset)
|
173
|
+
|
174
|
+
ar_contexts.map do |ar_context|
|
175
|
+
get_context(ar_context.external_id)
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
# List all content for a context
|
180
|
+
# @param context_id [String] ID of the context
|
181
|
+
# @return [Hash] Map of content_id to content data
|
182
|
+
# @raise [RubyMCP::Errors::ContextError] If context not found
|
183
|
+
def list_content(context_id)
|
184
|
+
ar_context = @context_model.find_by(external_id: context_id)
|
185
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless ar_context
|
186
|
+
|
187
|
+
content_map = {}
|
188
|
+
ar_contents = @content_model.where(context_id: ar_context.id)
|
189
|
+
|
190
|
+
ar_contents.each do |ar_content|
|
191
|
+
content_data = if ar_content.content_type == 'json'
|
192
|
+
begin
|
193
|
+
json = JSON.parse(ar_content.data_json)
|
194
|
+
symbolize_keys(json)
|
195
|
+
rescue JSON::ParserError => e
|
196
|
+
raise RubyMCP::Errors::ContentError,
|
197
|
+
"Invalid JSON in content #{ar_content.external_id}: #{e.message}"
|
198
|
+
end
|
199
|
+
else
|
200
|
+
ar_content.data_binary
|
201
|
+
end
|
202
|
+
content_map[ar_content.external_id] = content_data
|
203
|
+
end
|
204
|
+
|
205
|
+
content_map
|
206
|
+
end
|
207
|
+
|
208
|
+
# Add a message to a context
|
209
|
+
# @param context_id [String] ID of the context
|
210
|
+
# @param message [RubyMCP::Models::Message] Message to add
|
211
|
+
# @return [RubyMCP::Models::Message] Added message
|
212
|
+
# @raise [RubyMCP::Errors::ContextError] If context not found
|
213
|
+
def add_message(context_id, message)
|
214
|
+
ar_context = @context_model.find_by(external_id: context_id)
|
215
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless ar_context
|
216
|
+
|
217
|
+
# Create the message record
|
218
|
+
create_message_record(ar_context.id, message)
|
219
|
+
|
220
|
+
# Update the context's updated_at timestamp
|
221
|
+
ar_context.touch
|
222
|
+
|
223
|
+
message
|
224
|
+
end
|
225
|
+
|
226
|
+
# Add content to a context
|
227
|
+
# @param context_id [String] ID of the context
|
228
|
+
# @param content_id [String] ID of the content
|
229
|
+
# @param content_data [Object] Content data
|
230
|
+
# @return [String] Content ID
|
231
|
+
# @raise [RubyMCP::Errors::ContextError] If context not found
|
232
|
+
def add_content(context_id, content_id, content_data)
|
233
|
+
ar_context = @context_model.find_by(external_id: context_id)
|
234
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless ar_context
|
235
|
+
|
236
|
+
# Create the content record
|
237
|
+
create_content_record(ar_context.id, content_id, content_data)
|
238
|
+
|
239
|
+
# Update the context's updated_at timestamp
|
240
|
+
ar_context.touch
|
241
|
+
|
242
|
+
content_id
|
243
|
+
end
|
244
|
+
|
245
|
+
# Get content from a context
|
246
|
+
# @param context_id [String] ID of the context
|
247
|
+
# @param content_id [String] ID of the content
|
248
|
+
# @return [Object] Content data
|
249
|
+
# @raise [RubyMCP::Errors::ContextError] If context not found
|
250
|
+
# @raise [RubyMCP::Errors::ContentError] If content not found
|
251
|
+
def get_content(context_id, content_id)
|
252
|
+
ar_context = @context_model.find_by(external_id: context_id)
|
253
|
+
raise RubyMCP::Errors::ContextError, "Context not found: #{context_id}" unless ar_context
|
254
|
+
|
255
|
+
ar_content = @content_model.find_by(context_id: ar_context.id, external_id: content_id)
|
256
|
+
raise RubyMCP::Errors::ContentError, "Content not found: #{content_id}" unless ar_content
|
257
|
+
|
258
|
+
if ar_content.content_type == 'json'
|
259
|
+
begin
|
260
|
+
json = JSON.parse(ar_content.data_json)
|
261
|
+
symbolize_keys(json)
|
262
|
+
rescue JSON::ParserError => e
|
263
|
+
raise RubyMCP::Errors::ContentError, "Invalid JSON in content #{content_id}: #{e.message}"
|
264
|
+
end
|
265
|
+
else
|
266
|
+
ar_content.data_binary
|
267
|
+
end
|
268
|
+
end
|
269
|
+
|
270
|
+
private
|
271
|
+
|
272
|
+
# Setup model classes
|
273
|
+
def setup_models
|
274
|
+
prefix = @table_prefix
|
275
|
+
|
276
|
+
# Context model
|
277
|
+
@context_model = Class.new(::ActiveRecord::Base) do
|
278
|
+
self.table_name = "#{prefix}contexts"
|
279
|
+
end
|
280
|
+
Object.const_set("MCPContext#{SecureRandom.hex(4)}", @context_model)
|
281
|
+
|
282
|
+
# Message model
|
283
|
+
@message_model = Class.new(::ActiveRecord::Base) do
|
284
|
+
self.table_name = "#{prefix}messages"
|
285
|
+
end
|
286
|
+
Object.const_set("MCPMessage#{SecureRandom.hex(4)}", @message_model)
|
287
|
+
|
288
|
+
# Content model
|
289
|
+
@content_model = Class.new(::ActiveRecord::Base) do
|
290
|
+
self.table_name = "#{prefix}contents"
|
291
|
+
end
|
292
|
+
Object.const_set("MCPContent#{SecureRandom.hex(4)}", @content_model)
|
293
|
+
end
|
294
|
+
|
295
|
+
# Ensure necessary tables exist
|
296
|
+
def ensure_tables_exist
|
297
|
+
connection = ::ActiveRecord::Base.connection
|
298
|
+
|
299
|
+
# Drop tables if they exist (for clean setup)
|
300
|
+
connection.drop_table("#{@table_prefix}contents") if connection.table_exists?("#{@table_prefix}contents")
|
301
|
+
|
302
|
+
connection.drop_table("#{@table_prefix}messages") if connection.table_exists?("#{@table_prefix}messages")
|
303
|
+
|
304
|
+
connection.drop_table("#{@table_prefix}contexts") if connection.table_exists?("#{@table_prefix}contexts")
|
305
|
+
|
306
|
+
# Create tables in proper order
|
307
|
+
create_contexts_table
|
308
|
+
create_messages_table
|
309
|
+
create_contents_table
|
310
|
+
end
|
311
|
+
|
312
|
+
# Check if a table exists
|
313
|
+
def table_exists?(table_name)
|
314
|
+
::ActiveRecord::Base.connection.table_exists?(table_name)
|
315
|
+
end
|
316
|
+
|
317
|
+
# Create the contexts table
|
318
|
+
def create_contexts_table
|
319
|
+
connection = ::ActiveRecord::Base.connection
|
320
|
+
connection.create_table("#{@table_prefix}contexts") do |t|
|
321
|
+
t.string :external_id, null: false
|
322
|
+
t.text :metadata, default: '{}'
|
323
|
+
t.timestamps
|
324
|
+
end
|
325
|
+
connection.add_index("#{@table_prefix}contexts", :external_id, unique: true)
|
326
|
+
connection.add_index("#{@table_prefix}contexts", :updated_at)
|
327
|
+
end
|
328
|
+
|
329
|
+
# Create the messages table
|
330
|
+
def create_messages_table
|
331
|
+
connection = ::ActiveRecord::Base.connection
|
332
|
+
connection.create_table("#{@table_prefix}messages") do |t|
|
333
|
+
t.bigint :context_id, null: false
|
334
|
+
t.string :external_id, null: false
|
335
|
+
t.string :role, null: false
|
336
|
+
t.text :content, null: false
|
337
|
+
t.text :metadata, default: '{}'
|
338
|
+
t.timestamps
|
339
|
+
end
|
340
|
+
connection.add_index("#{@table_prefix}messages", %i[context_id external_id], unique: true)
|
341
|
+
connection.add_index("#{@table_prefix}messages", :context_id)
|
342
|
+
end
|
343
|
+
|
344
|
+
# Create the contents table
|
345
|
+
def create_contents_table
|
346
|
+
connection = ::ActiveRecord::Base.connection
|
347
|
+
connection.create_table("#{@table_prefix}contents") do |t|
|
348
|
+
t.bigint :context_id, null: false
|
349
|
+
t.string :external_id, null: false
|
350
|
+
t.binary :data_binary
|
351
|
+
t.text :data_json
|
352
|
+
t.string :content_type
|
353
|
+
t.timestamps
|
354
|
+
end
|
355
|
+
connection.add_index("#{@table_prefix}contents", %i[context_id external_id], unique: true)
|
356
|
+
connection.add_index("#{@table_prefix}contents", :context_id)
|
357
|
+
end
|
358
|
+
|
359
|
+
# Create a message record
|
360
|
+
def create_message_record(context_id, message)
|
361
|
+
@message_model.create!(
|
362
|
+
context_id: context_id,
|
363
|
+
external_id: message.id,
|
364
|
+
role: message.role,
|
365
|
+
content: message.content.to_s,
|
366
|
+
metadata: JSON.generate(message.metadata || {}),
|
367
|
+
created_at: message.created_at
|
368
|
+
)
|
369
|
+
end
|
370
|
+
|
371
|
+
# Create a content record
|
372
|
+
def create_content_record(context_id, content_id, content_data)
|
373
|
+
# Validate data type first
|
374
|
+
unless content_data.is_a?(String) || content_data.is_a?(Hash) ||
|
375
|
+
content_data.is_a?(Array) || content_data.is_a?(Numeric) ||
|
376
|
+
content_data.is_a?(TrueClass) || content_data.is_a?(FalseClass) ||
|
377
|
+
content_data.is_a?(NilClass)
|
378
|
+
raise RubyMCP::Errors::ContentError,
|
379
|
+
"Invalid data type: #{content_data.class.name}. Must be String, Hash, Array, Numeric, Boolean, or nil."
|
380
|
+
end
|
381
|
+
|
382
|
+
if content_data.is_a?(Hash) || content_data.is_a?(Array)
|
383
|
+
@content_model.create!(
|
384
|
+
context_id: context_id,
|
385
|
+
external_id: content_id,
|
386
|
+
data_json: JSON.generate(content_data),
|
387
|
+
content_type: 'json'
|
388
|
+
)
|
389
|
+
else
|
390
|
+
@content_model.create!(
|
391
|
+
context_id: context_id,
|
392
|
+
external_id: content_id,
|
393
|
+
data_binary: content_data.to_s,
|
394
|
+
content_type: 'binary'
|
395
|
+
)
|
396
|
+
end
|
397
|
+
end
|
398
|
+
|
399
|
+
# Helper method to symbolize keys in hashes (including nested ones)
|
400
|
+
def symbolize_keys(obj)
|
401
|
+
case obj
|
402
|
+
when Hash
|
403
|
+
obj.each_with_object({}) do |(key, value), result|
|
404
|
+
result[key.to_sym] = symbolize_keys(value)
|
405
|
+
end
|
406
|
+
when Array
|
407
|
+
obj.map { |item| symbolize_keys(item) }
|
408
|
+
else
|
409
|
+
obj
|
410
|
+
end
|
411
|
+
end
|
412
|
+
end
|
413
|
+
end
|
414
|
+
end
|
@@ -0,0 +1,197 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'redis'
|
4
|
+
require 'json'
|
5
|
+
require_relative 'error'
|
6
|
+
|
7
|
+
module RubyMCP
|
8
|
+
module Storage
|
9
|
+
# Redis-based storage implementation for RubyMCP
|
10
|
+
class Redis < Base
|
11
|
+
def initialize(options = {})
|
12
|
+
super
|
13
|
+
@redis = if options[:connection].is_a?(::Redis)
|
14
|
+
options[:connection]
|
15
|
+
elsif options[:connection].is_a?(Hash)
|
16
|
+
::Redis.new(options[:connection])
|
17
|
+
else
|
18
|
+
::Redis.new
|
19
|
+
end
|
20
|
+
@namespace = options[:namespace] || 'ruby_mcp'
|
21
|
+
@ttl = options[:ttl] || 86_400 # Default 1 day TTL in seconds
|
22
|
+
end
|
23
|
+
|
24
|
+
# Context management
|
25
|
+
def create_context(context)
|
26
|
+
# Ensure context has an ID
|
27
|
+
context_id = context['id']
|
28
|
+
raise Error, 'Context must have an ID' unless context_id
|
29
|
+
|
30
|
+
# Check if context already exists
|
31
|
+
raise Error, "Context with ID '#{context_id}' already exists" if get_context(context_id)
|
32
|
+
|
33
|
+
# Store the context
|
34
|
+
store_context(context)
|
35
|
+
|
36
|
+
# Add to index with timestamp for ordering
|
37
|
+
@redis.zadd(
|
38
|
+
contexts_index_key,
|
39
|
+
Time.now.to_f,
|
40
|
+
context_id
|
41
|
+
)
|
42
|
+
|
43
|
+
# Return the created context
|
44
|
+
context
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_context(context_id)
|
48
|
+
# Get the context data
|
49
|
+
context_data = @redis.get(context_key(context_id))
|
50
|
+
return nil unless context_data
|
51
|
+
|
52
|
+
# Parse the context
|
53
|
+
context = JSON.parse(context_data)
|
54
|
+
|
55
|
+
# Get messages if any
|
56
|
+
messages = get_messages(context_id)
|
57
|
+
context['messages'] = messages if messages.any?
|
58
|
+
|
59
|
+
context
|
60
|
+
end
|
61
|
+
|
62
|
+
def update_context(context)
|
63
|
+
context_id = context['id']
|
64
|
+
|
65
|
+
# Check if context exists
|
66
|
+
raise Error, "Context with ID '#{context_id}' does not exist" unless get_context(context_id)
|
67
|
+
|
68
|
+
# Store the updated context
|
69
|
+
store_context(context)
|
70
|
+
|
71
|
+
context
|
72
|
+
end
|
73
|
+
|
74
|
+
def delete_context(context_id)
|
75
|
+
# Remove from index
|
76
|
+
@redis.zrem(contexts_index_key, context_id)
|
77
|
+
|
78
|
+
# Delete context data
|
79
|
+
@redis.del(context_key(context_id))
|
80
|
+
|
81
|
+
# Delete messages
|
82
|
+
@redis.del(messages_key(context_id))
|
83
|
+
|
84
|
+
# Delete all content (using pattern matching)
|
85
|
+
content_pattern = key(['context', context_id, 'content', '*'])
|
86
|
+
content_keys = @redis.keys(content_pattern)
|
87
|
+
@redis.del(*content_keys) if content_keys.any?
|
88
|
+
|
89
|
+
true
|
90
|
+
end
|
91
|
+
|
92
|
+
def list_contexts(limit: 100, offset: 0)
|
93
|
+
# Get context IDs from the index, sorted by score (timestamp) descending
|
94
|
+
context_ids = @redis.zrevrange(contexts_index_key, offset, offset + limit - 1)
|
95
|
+
|
96
|
+
# Return early if no contexts
|
97
|
+
return [] if context_ids.empty?
|
98
|
+
|
99
|
+
# Get each context
|
100
|
+
context_ids.map { |id| get_context(id) }.compact
|
101
|
+
end
|
102
|
+
|
103
|
+
# Message handling
|
104
|
+
def add_message(context_id, message)
|
105
|
+
# Ensure context exists
|
106
|
+
raise Error, "Context with ID '#{context_id}' does not exist" unless get_context(context_id)
|
107
|
+
|
108
|
+
# Add message to the messages list
|
109
|
+
message_json = JSON.generate(message)
|
110
|
+
@redis.rpush(messages_key(context_id), message_json)
|
111
|
+
|
112
|
+
# Set TTL on messages key
|
113
|
+
@redis.expire(messages_key(context_id), @ttl)
|
114
|
+
|
115
|
+
message
|
116
|
+
end
|
117
|
+
|
118
|
+
# Content handling
|
119
|
+
def add_content(context_id, content_id, content_data)
|
120
|
+
# Ensure context exists
|
121
|
+
raise Error, "Context with ID '#{context_id}' does not exist" unless get_context(context_id)
|
122
|
+
|
123
|
+
# Store content
|
124
|
+
key = content_key(context_id, content_id)
|
125
|
+
|
126
|
+
# If content is binary or complex, use Base64 encoding
|
127
|
+
if content_data.is_a?(String) &&
|
128
|
+
(content_data.encoding == Encoding::BINARY || content_data.include?("\0"))
|
129
|
+
@redis.set(key, [content_data].pack('m0'))
|
130
|
+
@redis.set("#{key}:encoding", 'base64')
|
131
|
+
else
|
132
|
+
@redis.set(key, content_data)
|
133
|
+
end
|
134
|
+
|
135
|
+
# Set TTL
|
136
|
+
@redis.expire(key, @ttl)
|
137
|
+
@redis.expire("#{key}:encoding", @ttl) if @redis.exists?("#{key}:encoding")
|
138
|
+
|
139
|
+
content_data
|
140
|
+
end
|
141
|
+
|
142
|
+
def get_content(context_id, content_id)
|
143
|
+
key = content_key(context_id, content_id)
|
144
|
+
content = @redis.get(key)
|
145
|
+
return nil unless content
|
146
|
+
|
147
|
+
# Check if we need to decode from Base64
|
148
|
+
encoding = @redis.get("#{key}:encoding")
|
149
|
+
content = content.unpack1('m0') if encoding == 'base64'
|
150
|
+
|
151
|
+
content
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
def store_context(context)
|
157
|
+
context_id = context['id']
|
158
|
+
|
159
|
+
# Store the context
|
160
|
+
context_json = JSON.generate(context)
|
161
|
+
@redis.set(context_key(context_id), context_json)
|
162
|
+
|
163
|
+
# Set TTL
|
164
|
+
@redis.expire(context_key(context_id), @ttl)
|
165
|
+
end
|
166
|
+
|
167
|
+
def get_messages(context_id)
|
168
|
+
# Get all messages from the list
|
169
|
+
message_jsons = @redis.lrange(messages_key(context_id), 0, -1)
|
170
|
+
|
171
|
+
# Parse each message
|
172
|
+
message_jsons.map { |json| JSON.parse(json) }
|
173
|
+
end
|
174
|
+
|
175
|
+
# Helper methods for key generation
|
176
|
+
def key(parts)
|
177
|
+
[@namespace, *parts].join(':')
|
178
|
+
end
|
179
|
+
|
180
|
+
def context_key(context_id)
|
181
|
+
key(['context', context_id])
|
182
|
+
end
|
183
|
+
|
184
|
+
def messages_key(context_id)
|
185
|
+
key(['context', context_id, 'messages'])
|
186
|
+
end
|
187
|
+
|
188
|
+
def content_key(context_id, content_id)
|
189
|
+
key(['context', context_id, 'content', content_id])
|
190
|
+
end
|
191
|
+
|
192
|
+
def contexts_index_key
|
193
|
+
key(%w[contexts index])
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RubyMCP
|
4
|
+
class StorageFactory
|
5
|
+
def self.create(config)
|
6
|
+
# Support both old and new configuration interfaces
|
7
|
+
storage_config = if config.respond_to?(:storage_config)
|
8
|
+
config.storage_config
|
9
|
+
else
|
10
|
+
config.storage
|
11
|
+
end
|
12
|
+
|
13
|
+
case storage_config[:type]
|
14
|
+
when :memory, nil
|
15
|
+
Storage::Memory.new(storage_config)
|
16
|
+
when :redis
|
17
|
+
# Load Redis dependencies
|
18
|
+
begin
|
19
|
+
require 'redis'
|
20
|
+
require_relative 'storage/redis'
|
21
|
+
rescue LoadError
|
22
|
+
raise LoadError, "Redis storage requires the redis gem. Add it to your Gemfile with: gem 'redis', '~> 5.0'"
|
23
|
+
end
|
24
|
+
|
25
|
+
Storage::Redis.new(storage_config)
|
26
|
+
when :active_record
|
27
|
+
# Load ActiveRecord dependencies
|
28
|
+
begin
|
29
|
+
require 'active_record'
|
30
|
+
require_relative 'storage/active_record'
|
31
|
+
rescue LoadError
|
32
|
+
raise LoadError,
|
33
|
+
"ActiveRecord storage requires the activerecord gem. Add it to your Gemfile with:
|
34
|
+
gem 'activerecord', '~> 6.0'"
|
35
|
+
end
|
36
|
+
|
37
|
+
Storage::ActiveRecord.new(storage_config)
|
38
|
+
else
|
39
|
+
raise ArgumentError, "Unknown storage type: #{storage_config[:type]}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
data/lib/ruby_mcp/version.rb
CHANGED