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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +75 -7
- data/lib/ruby_mcp/client.rb +30 -2
- data/lib/ruby_mcp/configuration.rb +29 -8
- 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_factory.rb +14 -3
- data/lib/ruby_mcp/version.rb +1 -1
- data/lib/ruby_mcp.rb +5 -2
- metadata +58 -29
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 04166e9711a0a9c233c43a2f1bba53b27d0a716a7cc996c52f597433e559909d
|
4
|
+
data.tar.gz: 588bbbd27e9bc620e4cf4ea14b97b4e8c5cd869b672f8956a2a17b16b179afdb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
[](https://github.com/nagstler/ruby_mcp/actions/workflows/test.yml)
|
9
9
|
[](https://codecov.io/github/nagstler/ruby_mcp)
|
10
10
|
|
11
|
-
<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
|

|
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
|
-
- [πΎ
|
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
|
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
|
-
|
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
|
-
- [
|
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
|
data/lib/ruby_mcp/client.rb
CHANGED
@@ -9,7 +9,35 @@ module RubyMCP
|
|
9
9
|
@storage = storage
|
10
10
|
end
|
11
11
|
|
12
|
-
|
13
|
-
|
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,
|
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
|
-
|
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
|
-
|
40
|
-
|
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
|
-
|
43
|
-
|
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
|
|
@@ -53,10 +53,14 @@ module RubyMCP
|
|
53
53
|
end
|
54
54
|
|
55
55
|
def destroy
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
23
|
-
raise LoadError, "Redis storage requires the redis gem. Add it to your Gemfile:
|
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
|
data/lib/ruby_mcp/version.rb
CHANGED
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.
|
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-
|
11
|
+
date: 2025-04-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
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
|
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
|
26
|
+
version: '1.2'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
|
-
name:
|
28
|
+
name: dry-schema
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: '
|
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: '
|
40
|
+
version: '1.13'
|
41
41
|
- !ruby/object:Gem::Dependency
|
42
|
-
name:
|
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:
|
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: '
|
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: '
|
68
|
+
version: '3.0'
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
|
-
name:
|
70
|
+
name: jwt
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
72
72
|
requirements:
|
73
73
|
- - "~>"
|
74
74
|
- !ruby/object:Gem::Version
|
75
|
-
version: '
|
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: '
|
82
|
+
version: '2.7'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
|
-
name:
|
84
|
+
name: rack
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
86
86
|
requirements:
|
87
87
|
- - "~>"
|
88
88
|
- !ruby/object:Gem::Version
|
89
|
-
version: '
|
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: '
|
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: '
|
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: '
|
110
|
+
version: '1.1'
|
111
111
|
- !ruby/object:Gem::Dependency
|
112
|
-
name:
|
112
|
+
name: webrick
|
113
113
|
requirement: !ruby/object:Gem::Requirement
|
114
114
|
requirements:
|
115
115
|
- - "~>"
|
116
116
|
- !ruby/object:Gem::Version
|
117
|
-
version: '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.
|
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: :
|
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:
|
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:
|
154
|
+
name: sqlite3
|
155
155
|
requirement: !ruby/object:Gem::Requirement
|
156
156
|
requirements:
|
157
157
|
- - "~>"
|
158
158
|
- !ruby/object:Gem::Version
|
159
|
-
version: '
|
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: '
|
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
|