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.
@@ -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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyMCP
4
+ module Storage
5
+ # Error class for storage-related errors
6
+ class Error < StandardError; end
7
+ end
8
+ 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
@@ -2,5 +2,5 @@
2
2
 
3
3
  # lib/ruby_mcp/version.rb
4
4
  module RubyMCP
5
- VERSION = '0.1.0'
5
+ VERSION = '0.3.0'
6
6
  end