mcp_on_ruby 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebcb9d413504e157ae357d8904fd719a2d5c264c2b483042e295848a91413960
4
- data.tar.gz: 79f2057e8da7aefb9a093b079dc593a0d8aa786c53b1c54cec0654b485fef76f
3
+ metadata.gz: 04166e9711a0a9c233c43a2f1bba53b27d0a716a7cc996c52f597433e559909d
4
+ data.tar.gz: 588bbbd27e9bc620e4cf4ea14b97b4e8c5cd869b672f8956a2a17b16b179afdb
5
5
  SHA512:
6
- metadata.gz: 50769ce05c60dfd97cc896f9259ef53f774f3fb4bb91accb05c37b0bba86e4829c208cd82c26367086a0ed4793b4e7dcd03d4a6074946ab5ed846cfef8715dc4
7
- data.tar.gz: b34e216121ba7765d81234afcb373067f55d59dc7fe21141ca74fe3a205b0e6344d753c423f5b4fda128bf6f6d8b7af1dcea5cb0834fbc69d4f96aad669b1389
6
+ metadata.gz: c22a5a6e40162c8754975bb89288ee5147314e41564a97ea25ab7b85fae03eecfcdc7246a0567520d6813ead8bc22e88ffcfa265ccf16653c2da8f06da8adb28
7
+ data.tar.gz: e16b57bd9c57d50f1f9f46328d29b94986e54785b166cbf321db1d06366fcd0fa2d43dd7206468ac3671219be7800c8a1f8ccbaaea2eaa77ca4e95217c32da2f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2023-05-01
4
+
5
+ ### Added
6
+ - ActiveRecord storage backend for database persistence
7
+ - Support for Rails integration with ActiveRecord storage
8
+ - Auto-creation of database tables with configurable prefixes
9
+ - Proper handling of different data types (text, binary, JSON)
10
+ - Symbolization of hash keys for consistent API
11
+ - Comprehensive test suite for ActiveRecord storage
12
+
13
+ ### Changed
14
+ - Enhanced `StorageFactory` to support ActiveRecord backend
15
+ - Updated configuration system with ActiveRecord options
16
+ - Improved documentation with ActiveRecord storage examples
17
+
3
18
  ## [0.2.0] - 2025-04-21
4
19
 
5
20
  ### Added
data/README.md CHANGED
@@ -8,12 +8,12 @@
8
8
  [![Test](https://github.com/nagstler/ruby_mcp/actions/workflows/test.yml/badge.svg)](https://github.com/nagstler/ruby_mcp/actions/workflows/test.yml)
9
9
  [![codecov](https://codecov.io/github/nagstler/ruby_mcp/graph/badge.svg?token=SG4EJEIHW3)](https://codecov.io/github/nagstler/ruby_mcp)
10
10
 
11
- <strong>The Ruby way to build MCP servers and clients.</strong>
11
+ <strong> **Turn your Rails APIs into an MCP server.**</strong>
12
+
12
13
  </div>
13
14
 
14
15
  ## πŸ” Introduction
15
-
16
- The [Model Context Protocol](https://modelcontextprotocol.io) provides a standardized way for applications to interact with language models. Similar to how REST standardized web APIs, MCP creates a consistent interface for working with providers like OpenAI and Anthropic.
16
+ The [Model Context Protocol](https://modelcontextprotocol.io) standardizes how applications interact with AI models, serving as the "REST for LLMs." **MCP on Ruby** brings this standard to the Ruby ecosystem. Create contexts, manage conversations, connect to multiple providers, and handle streaming responses with clean, Ruby code.
17
17
 
18
18
  ![System Component Flow (Horizontal)](https://github.com/user-attachments/assets/085ad9b8-bee0-4d60-a4b7-ecf02d07f53c)
19
19
 
@@ -36,7 +36,7 @@ The [Model Context Protocol](https://modelcontextprotocol.io) provides a standar
36
36
  - [Uploading Content](#uploading-content)
37
37
  - [Using Tool Calls](#using-tool-calls)
38
38
  - [πŸš„ Rails Integration](#-rails-integration)
39
- - [πŸ’Ύ Custom Storage Backend](#-storage-backends)
39
+ - [πŸ’Ύ Storage Backend](#-storage-backends)
40
40
  - [πŸ”’ Authentication](#-authentication)
41
41
  - [πŸ› οΈ Development](#️-development)
42
42
  - [πŸ—ΊοΈ Roadmap](#️-roadmap)
@@ -110,6 +110,11 @@ ruby server.rb
110
110
  # Terminal 2: Run the client
111
111
  cd examples/simple_server
112
112
  ruby client.rb
113
+
114
+ # ActiveRecord Storage Demo
115
+ # Demonstrates database storage with SQLite
116
+ cd examples/simple_server
117
+ ruby activerecord_demo.rb
113
118
  ```
114
119
 
115
120
  This demo provides a guided tour of the MCP functionality, showing each step of creating contexts, adding messages, and generating responses with detailed explanations.
@@ -131,9 +136,31 @@ RubyMCP.configure do |config|
131
136
  }
132
137
  }
133
138
 
134
- # Storage backend (:memory, :redis, :active_record, or custom)
139
+ # Storage backend
140
+
141
+ # Option 1: Memory storage (default)
135
142
  config.storage = :memory
136
143
 
144
+ # Option 2: Redis storage
145
+ config.storage = :redis
146
+ config.redis = {
147
+ url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'),
148
+ namespace: 'my_app_mcp',
149
+ ttl: 86400 # 1 day in seconds
150
+ }
151
+
152
+ # Option 3: ActiveRecord storage
153
+ config.storage = :active_record
154
+ config.active_record = {
155
+ # Connection settings (not needed in Rails)
156
+ connection: {
157
+ adapter: 'sqlite3',
158
+ database: 'db/mcp.sqlite3'
159
+ },
160
+ # Table prefix to avoid name collisions
161
+ table_prefix: 'mcp_'
162
+ }
163
+
137
164
  # Server settings
138
165
  config.server_port = 3000
139
166
  config.server_host = "0.0.0.0"
@@ -331,7 +358,11 @@ RubyMCP.configure do |config|
331
358
  if Rails.env.development? || Rails.env.test?
332
359
  config.storage = :memory
333
360
  else
334
- config.storage = :memory # Replace with persistent option when implemented
361
+ # Use ActiveRecord for production (uses your Rails database)
362
+ config.storage = :active_record
363
+ config.active_record = {
364
+ table_prefix: "mcp_#{Rails.env}_" # Environment-specific prefix
365
+ }
335
366
  end
336
367
 
337
368
  # Enable authentication in production
@@ -390,6 +421,43 @@ MCP on Ruby supports Redis as a persistent storage backend:
390
421
 
391
422
  For detailed integration examples, see the [[Redis Storage](https://github.com/nagstler/mcp_on_ruby/wiki/Redis-Storage)] wiki page.
392
423
 
424
+ ### ActiveRecord Storage
425
+
426
+ For integration with Rails or any app needing database storage:
427
+
428
+ ```ruby
429
+ # Add to Gemfile
430
+ gem 'activerecord', '~> 6.1'
431
+ gem 'sqlite3', '~> 1.4' # or pg, mysql2, etc.
432
+
433
+ # Configure RubyMCP
434
+ RubyMCP.configure do |config|
435
+ config.storage = :active_record
436
+ config.active_record = {
437
+ # Connection (not needed in Rails)
438
+ connection: {
439
+ adapter: 'sqlite3',
440
+ database: 'db/mcp.sqlite3'
441
+ },
442
+ # Table prefix to avoid name collisions
443
+ table_prefix: 'mcp_'
444
+ }
445
+ end
446
+ ```
447
+
448
+ In Rails applications, it uses your app's database connection automatically:
449
+
450
+ ```ruby
451
+ # config/initializers/ruby_mcp.rb
452
+ RubyMCP.configure do |config|
453
+ config.storage = :active_record
454
+ config.active_record = {
455
+ table_prefix: "mcp_#{Rails.env}_" # Environment-specific prefix
456
+ }
457
+ end
458
+ ```
459
+
460
+ The ActiveRecord adapter automatically creates the necessary tables with appropriate indexes, and handles different types of data (text, binary, JSON) appropriately.
393
461
 
394
462
  ### Custom storage
395
463
  You can implement custom storage backends by extending the base storage class:
@@ -497,7 +565,7 @@ bundle exec ruby examples/simple_server/server.rb
497
565
  While RubyMCP is functional for basic use cases, there are several areas planned for improvement:
498
566
 
499
567
  - [x] Redis persistent storage backend
500
- - [ ] ActiveRecord storage backend
568
+ - [x] ActiveRecord storage backend
501
569
  - [ ] Complete test coverage, including integration tests
502
570
  - [ ] Improved error handling and recovery strategies
503
571
  - [ ] Rate limiting for provider APIs
@@ -9,7 +9,35 @@ module RubyMCP
9
9
  @storage = storage
10
10
  end
11
11
 
12
- # You can add additional convenience methods here
13
- # that delegate to storage or provide higher-level functionality
12
+ def create_context(messages = [], metadata = {})
13
+ context = RubyMCP::Models::Context.new(messages: messages, metadata: metadata)
14
+ storage.create_context(context)
15
+ end
16
+
17
+ def list_contexts(limit: 50, offset: 0)
18
+ storage.list_contexts(limit: limit, offset: offset)
19
+ end
20
+
21
+ def get_context(context_id)
22
+ storage.get_context(context_id)
23
+ end
24
+
25
+ def delete_context(context_id)
26
+ storage.delete_context(context_id)
27
+ end
28
+
29
+ def add_message(context_id, role, content, metadata: {})
30
+ message = RubyMCP::Models::Message.new(role: role, content: content, metadata: metadata)
31
+ storage.add_message(context_id, message)
32
+ end
33
+
34
+ def add_content(context_id, content_data, content_id = nil)
35
+ content_id ||= "cnt_#{SecureRandom.hex(10)}"
36
+ storage.add_content(context_id, content_id, content_data)
37
+ end
38
+
39
+ def get_content(context_id, content_id)
40
+ storage.get_content(context_id, content_id)
41
+ end
14
42
  end
15
43
  end
@@ -3,7 +3,8 @@
3
3
  module RubyMCP
4
4
  class Configuration
5
5
  attr_accessor :providers, :storage, :server_port, :server_host,
6
- :auth_required, :jwt_secret, :token_expiry, :max_contexts, :redis
6
+ :auth_required, :jwt_secret, :token_expiry, :max_contexts,
7
+ :redis, :active_record
7
8
 
8
9
  def initialize
9
10
  @providers = {}
@@ -14,18 +15,25 @@ module RubyMCP
14
15
  @jwt_secret = nil
15
16
  @token_expiry = 3600 # 1 hour
16
17
  @max_contexts = 1000
17
- @storage = :memory # Default to memory storage
18
18
  @redis = {} # Default empty Redis config
19
+ @active_record = {} # Default empty ActiveRecord config
19
20
  end
20
21
 
21
22
  def storage_config
22
- if @storage == :redis
23
+ case @storage
24
+ when :redis
23
25
  {
24
26
  type: :redis,
25
27
  connection: redis_connection_config,
26
28
  namespace: @redis[:namespace] || 'ruby_mcp',
27
29
  ttl: @redis[:ttl] || 86_400
28
30
  }
31
+ when :active_record
32
+ {
33
+ type: :active_record,
34
+ connection: @active_record[:connection],
35
+ table_prefix: @active_record[:table_prefix] || 'mcp_'
36
+ }
29
37
  else
30
38
  { type: @storage }
31
39
  end
@@ -36,18 +44,31 @@ module RubyMCP
36
44
  when :memory
37
45
  RubyMCP::Storage::Memory.new
38
46
  when :redis
39
- # Future implementation
40
- raise RubyMCP::Errors::ConfigurationError, 'Redis storage not yet implemented'
47
+ begin
48
+ require 'redis'
49
+ require_relative 'storage/redis'
50
+ RubyMCP::Storage::Redis.new(storage_config)
51
+ rescue LoadError
52
+ raise RubyMCP::Errors::ConfigurationError,
53
+ "Redis storage requires the redis gem. Add it to your Gemfile with:
54
+ gem 'redis', '~> 5.0'"
55
+ end
41
56
  when :active_record
42
- # Future implementation
43
- raise RubyMCP::Errors::ConfigurationError, 'ActiveRecord storage not yet implemented'
57
+ begin
58
+ require 'active_record'
59
+ require_relative 'storage/active_record'
60
+ RubyMCP::Storage::ActiveRecord.new(storage_config)
61
+ rescue LoadError
62
+ raise RubyMCP::Errors::ConfigurationError,
63
+ "ActiveRecord storage requires the activerecord gem. Add it to your Gemfile with:
64
+ gem 'activerecord', '~> 6.0'"
65
+ end
44
66
  else
45
67
  unless @storage.is_a?(RubyMCP::Storage::Base)
46
68
  raise RubyMCP::Errors::ConfigurationError, "Unknown storage type: #{@storage}"
47
69
  end
48
70
 
49
71
  @storage # Allow custom storage instance
50
-
51
72
  end
52
73
  end
53
74
 
@@ -37,6 +37,7 @@ module RubyMCP
37
37
  {
38
38
  id: @id,
39
39
  messages: @messages.map(&:to_h),
40
+ content_map: @content_map,
40
41
  created_at: @created_at.iso8601,
41
42
  updated_at: @updated_at.iso8601,
42
43
  metadata: @metadata
@@ -53,10 +53,14 @@ module RubyMCP
53
53
  end
54
54
 
55
55
  def destroy
56
- context = storage.delete_context(params[:id])
57
- ok(context.to_h)
58
- rescue RubyMCP::Errors::ContextError => e
59
- not_found(e.message)
56
+ context_id = params[:id]
57
+
58
+ begin
59
+ storage.delete_context(context_id)
60
+ ok({ success: true })
61
+ rescue RubyMCP::Errors::ContextError => e
62
+ not_found("Context not found: #{e.message}")
63
+ end
60
64
  end
61
65
  end
62
66
  end
@@ -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
@@ -1,4 +1,3 @@
1
- # lib/ruby_mcp/storage_factory.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module RubyMCP
@@ -19,11 +18,23 @@ module RubyMCP
19
18
  begin
20
19
  require 'redis'
21
20
  require_relative 'storage/redis'
22
- rescue LoadError => e
23
- raise LoadError, "Redis storage requires the redis gem. Add it to your Gemfile: #{e.message}"
21
+ rescue LoadError
22
+ raise LoadError, "Redis storage requires the redis gem. Add it to your Gemfile with: gem 'redis', '~> 5.0'"
24
23
  end
25
24
 
26
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)
27
38
  else
28
39
  raise ArgumentError, "Unknown storage type: #{storage_config[:type]}"
29
40
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  # lib/ruby_mcp/version.rb
4
4
  module RubyMCP
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.0'
6
6
  end
data/lib/ruby_mcp.rb CHANGED
@@ -30,9 +30,13 @@ require_relative 'ruby_mcp/server/generate_controller'
30
30
  require_relative 'ruby_mcp/providers/openai'
31
31
  require_relative 'ruby_mcp/providers/anthropic'
32
32
 
33
+ # Optional storage backends - don't require them directly
34
+ # require_relative 'ruby_mcp/storage/redis'
35
+ # require_relative 'ruby_mcp/storage/active_record'
36
+
33
37
  require_relative 'ruby_mcp/schemas'
34
38
  require_relative 'ruby_mcp/validator'
35
-
39
+ require_relative 'ruby_mcp/storage_factory'
36
40
  require_relative 'ruby_mcp/client'
37
41
 
38
42
  module RubyMCP
@@ -61,7 +65,6 @@ module RubyMCP
61
65
  private
62
66
 
63
67
  def initialize_components
64
- require_relative 'ruby_mcp/storage_factory'
65
68
  @storage = StorageFactory.create(configuration)
66
69
  end
67
70
  end
metadata CHANGED
@@ -1,45 +1,45 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcp_on_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nagendra Dhanakeerthi
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-04-21 00:00:00.000000000 Z
11
+ date: 2025-04-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: faraday
14
+ name: concurrent-ruby
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '2.7'
19
+ version: '1.2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '2.7'
26
+ version: '1.2'
27
27
  - !ruby/object:Gem::Dependency
28
- name: faraday-net_http
28
+ name: dry-schema
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '3.0'
33
+ version: '1.13'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '3.0'
40
+ version: '1.13'
41
41
  - !ruby/object:Gem::Dependency
42
- name: jwt
42
+ name: faraday
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - "~>"
@@ -53,75 +53,75 @@ dependencies:
53
53
  - !ruby/object:Gem::Version
54
54
  version: '2.7'
55
55
  - !ruby/object:Gem::Dependency
56
- name: concurrent-ruby
56
+ name: faraday-net_http
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '1.2'
61
+ version: '3.0'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: '1.2'
68
+ version: '3.0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: dry-schema
70
+ name: jwt
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '1.13'
75
+ version: '2.7'
76
76
  type: :runtime
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
- version: '1.13'
82
+ version: '2.7'
83
83
  - !ruby/object:Gem::Dependency
84
- name: webrick
84
+ name: rack
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '1.7'
89
+ version: '2.2'
90
90
  type: :runtime
91
91
  prerelease: false
92
92
  version_requirements: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
- version: '1.7'
96
+ version: '2.2'
97
97
  - !ruby/object:Gem::Dependency
98
- name: rack
98
+ name: rack-cors
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: '2.2'
103
+ version: '1.1'
104
104
  type: :runtime
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: '2.2'
110
+ version: '1.1'
111
111
  - !ruby/object:Gem::Dependency
112
- name: rack-cors
112
+ name: webrick
113
113
  requirement: !ruby/object:Gem::Requirement
114
114
  requirements:
115
115
  - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: '1.1'
117
+ version: '1.7'
118
118
  type: :runtime
119
119
  prerelease: false
120
120
  version_requirements: !ruby/object:Gem::Requirement
121
121
  requirements:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: '1.1'
124
+ version: '1.7'
125
125
  - !ruby/object:Gem::Dependency
126
126
  name: redis
127
127
  requirement: !ruby/object:Gem::Requirement
@@ -129,7 +129,7 @@ dependencies:
129
129
  - - "~>"
130
130
  - !ruby/object:Gem::Version
131
131
  version: '5.0'
132
- type: :runtime
132
+ type: :development
133
133
  prerelease: false
134
134
  version_requirements: !ruby/object:Gem::Requirement
135
135
  requirements:
@@ -137,7 +137,7 @@ dependencies:
137
137
  - !ruby/object:Gem::Version
138
138
  version: '5.0'
139
139
  - !ruby/object:Gem::Dependency
140
- name: vcr
140
+ name: activerecord
141
141
  requirement: !ruby/object:Gem::Requirement
142
142
  requirements:
143
143
  - - "~>"
@@ -151,19 +151,19 @@ dependencies:
151
151
  - !ruby/object:Gem::Version
152
152
  version: '6.1'
153
153
  - !ruby/object:Gem::Dependency
154
- name: webmock
154
+ name: sqlite3
155
155
  requirement: !ruby/object:Gem::Requirement
156
156
  requirements:
157
157
  - - "~>"
158
158
  - !ruby/object:Gem::Version
159
- version: '3.18'
159
+ version: '1.4'
160
160
  type: :development
161
161
  prerelease: false
162
162
  version_requirements: !ruby/object:Gem::Requirement
163
163
  requirements:
164
164
  - - "~>"
165
165
  - !ruby/object:Gem::Version
166
- version: '3.18'
166
+ version: '1.4'
167
167
  - !ruby/object:Gem::Dependency
168
168
  name: codecov
169
169
  requirement: !ruby/object:Gem::Requirement
@@ -220,6 +220,34 @@ dependencies:
220
220
  - - "~>"
221
221
  - !ruby/object:Gem::Version
222
222
  version: '2.1'
223
+ - !ruby/object:Gem::Dependency
224
+ name: vcr
225
+ requirement: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - "~>"
228
+ - !ruby/object:Gem::Version
229
+ version: '6.1'
230
+ type: :development
231
+ prerelease: false
232
+ version_requirements: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - "~>"
235
+ - !ruby/object:Gem::Version
236
+ version: '6.1'
237
+ - !ruby/object:Gem::Dependency
238
+ name: webmock
239
+ requirement: !ruby/object:Gem::Requirement
240
+ requirements:
241
+ - - "~>"
242
+ - !ruby/object:Gem::Version
243
+ version: '3.18'
244
+ type: :development
245
+ prerelease: false
246
+ version_requirements: !ruby/object:Gem::Requirement
247
+ requirements:
248
+ - - "~>"
249
+ - !ruby/object:Gem::Version
250
+ version: '3.18'
223
251
  description: |-
224
252
  A comprehensive Ruby gem for implementing Model Context Protocol servers
225
253
  to standardize interactions with AI language models
@@ -254,6 +282,7 @@ files:
254
282
  - lib/ruby_mcp/server/generate_controller.rb
255
283
  - lib/ruby_mcp/server/messages_controller.rb
256
284
  - lib/ruby_mcp/server/router.rb
285
+ - lib/ruby_mcp/storage/active_record.rb
257
286
  - lib/ruby_mcp/storage/base.rb
258
287
  - lib/ruby_mcp/storage/error.rb
259
288
  - lib/ruby_mcp/storage/memory.rb