solid_mcp 0.5.0-x86_64-linux-musl

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cf672c564312b8d0c7c66441e9658f760789084b3aa691b780750ee397f54b30
4
+ data.tar.gz: 367a24bf22aba2d57979f45c1fc2dd781972d2dc935f5b3580d36d19e6626e32
5
+ SHA512:
6
+ metadata.gz: 35b523facc31c635babc42176eed6eee47db6d1d0bd58612c2f29e14bf1ca8de62af33560c2af50a7bf68e0a693704f071bd4bb65069cadace6f56a5f5ce2018
7
+ data.tar.gz: aa9db45969ac26e74d7e43afa356e72e62d79093b032e2f017f47bb4d034350d8564056be8530c3209c148dc166bcbba0d38273b0df25465d199994dd33eb6fc
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Abdelkader Boudih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,328 @@
1
+ # SolidMCP
2
+
3
+ SolidMCP is a high-performance, database-backed pub/sub engine specifically designed for ActionMCP (Model Context Protocol for Rails). It provides reliable message delivery for MCP's Server-Sent Events (SSE) with support for SQLite, PostgreSQL, and MySQL.
4
+
5
+ ## Features
6
+
7
+ - **Database-agnostic**: Works with SQLite, PostgreSQL, and MySQL
8
+ - **Session-based routing**: Optimized for MCP's point-to-point messaging pattern
9
+ - **Batched writes**: Handles SQLite's single-writer limitation efficiently
10
+ - **Automatic cleanup**: Configurable retention periods for delivered/undelivered messages
11
+ - **Thread-safe**: Dedicated writer thread with in-memory queuing
12
+ - **SSE resumability**: Supports reconnection with last-event-id
13
+ - **Rails Engine**: Seamless integration with Rails applications
14
+ - **Multiple backends**: Database backend by default, Redis backend coming soon
15
+
16
+ ## Requirements
17
+
18
+ - Ruby 3.0+
19
+ - Rails 8.0+
20
+ - ActiveRecord 8.0+
21
+ - SQLite, PostgreSQL, or MySQL database
22
+
23
+ ## Installation
24
+
25
+ Add this line to your application's Gemfile:
26
+
27
+ ```ruby
28
+ gem 'solid_mcp'
29
+ ```
30
+
31
+ And then execute:
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ Run the installation generator:
38
+
39
+ ```bash
40
+ bin/rails generate solid_mcp:install
41
+ bin/rails db:migrate
42
+ ```
43
+
44
+ This will:
45
+ - Create a migration for the `solid_mcp_messages` table
46
+ - Create an initializer with default configuration
47
+
48
+ ## Configuration
49
+
50
+ Configure SolidMCP in your Rails application:
51
+
52
+ ```ruby
53
+ # config/initializers/solid_mcp.rb
54
+ SolidMcp.configure do |config|
55
+ # Number of messages to write in a single batch
56
+ config.batch_size = 200
57
+
58
+ # Seconds between batch flushes
59
+ config.flush_interval = 0.05
60
+
61
+ # Polling interval for checking new messages
62
+ config.polling_interval = 0.1
63
+
64
+ # Maximum time to wait for messages before timeout
65
+ config.max_wait_time = 30
66
+
67
+ # How long to keep delivered messages
68
+ config.delivered_retention = 1.hour
69
+
70
+ # How long to keep undelivered messages
71
+ config.undelivered_retention = 24.hours
72
+ end
73
+ ```
74
+
75
+ ## Usage with ActionMCP
76
+
77
+ In your `config/mcp.yml`:
78
+
79
+ ```yaml
80
+ production:
81
+ adapter: solid_mcp
82
+ polling_interval: 0.5.seconds
83
+ batch_size: 200
84
+ flush_interval: 0.05
85
+ ```
86
+
87
+ ## Architecture
88
+
89
+ SolidMCP is implemented as a Rails Engine with the following components:
90
+
91
+ ### Core Components
92
+
93
+ 1. **SolidMCP::MessageWriter**: Singleton that handles batched writes to the database
94
+ - Non-blocking enqueue operation
95
+ - Dedicated writer thread per Rails process
96
+ - Automatic batching and flushing
97
+ - Graceful shutdown with pending message delivery
98
+
99
+ 2. **SolidMCP::PubSub**: Main interface for publishing and subscribing to messages
100
+ - Session-based subscriptions (not channel-based)
101
+ - Automatic listener management per session
102
+ - Thread-safe operations
103
+
104
+ 3. **SolidMCP::Subscriber**: Handles polling for new messages
105
+ - Efficient database queries using indexes
106
+ - Automatic message delivery tracking
107
+ - Configurable polling intervals
108
+
109
+ 4. **SolidMCP::Message**: ActiveRecord model for message storage
110
+ - Optimized indexes for polling and cleanup
111
+ - Scopes for message filtering
112
+ - Built-in cleanup methods
113
+
114
+ ### Message Flow
115
+
116
+ 1. Publisher calls `broadcast(session_id, event_type, data)`
117
+ 2. MessageWriter queues the message in memory
118
+ 3. Writer thread batches messages and writes to database
119
+ 4. Subscriber polls for new messages for its session
120
+ 5. Messages are marked as delivered after successful processing
121
+
122
+ ## Database Schema
123
+
124
+ The gem creates a `solid_mcp_messages` table:
125
+
126
+ ```ruby
127
+ create_table :solid_mcp_messages do |t|
128
+ t.string :session_id, null: false, limit: 36 # MCP session identifier
129
+ t.string :event_type, null: false, limit: 50 # SSE event type
130
+ t.text :data # Message payload (usually JSON)
131
+ t.datetime :created_at, null: false # Message creation time
132
+ t.datetime :delivered_at # Delivery timestamp
133
+
134
+ t.index [:session_id, :id], name: 'idx_solid_mcp_messages_on_session_and_id'
135
+ t.index [:delivered_at, :created_at], name: 'idx_solid_mcp_messages_on_delivered_and_created'
136
+ end
137
+ ```
138
+
139
+ ## Performance Considerations
140
+
141
+ ### SQLite
142
+ - Single writer thread prevents "database is locked" errors
143
+ - Batching reduces write frequency
144
+ - Consider WAL mode for better concurrency
145
+
146
+ ### PostgreSQL/MySQL
147
+ - Benefits from batching to reduce transaction overhead
148
+ - Can handle multiple writers but single writer is maintained for consistency
149
+ - Consider partitioning for high-volume applications
150
+
151
+ ## Maintenance
152
+
153
+ ### Automatic Cleanup
154
+
155
+ Old messages are automatically cleaned up based on retention settings:
156
+
157
+ ```ruby
158
+ # Run periodically (e.g., with whenever gem or solid_queue)
159
+ SolidMCP::CleanupJob.perform_later
160
+
161
+ # Or directly:
162
+ SolidMCP::Message.cleanup
163
+ ```
164
+
165
+ ### Manual Cleanup
166
+
167
+ ```ruby
168
+ # Clean up delivered messages older than 1 hour
169
+ SolidMCP::Message.old_delivered(1.hour).delete_all
170
+
171
+ # Clean up undelivered messages older than 24 hours
172
+ SolidMCP::Message.old_undelivered(24.hours).delete_all
173
+ ```
174
+
175
+ ### Monitoring
176
+
177
+ ```ruby
178
+ # Check message queue size
179
+ SolidMCP::Message.undelivered.count
180
+
181
+ # Check messages for a specific session
182
+ SolidMCP::Message.for_session(session_id).count
183
+
184
+ # Find stuck messages
185
+ SolidMCP::Message.undelivered.where('created_at < ?', 1.hour.ago)
186
+ ```
187
+
188
+ ## Testing
189
+
190
+ The gem includes a test implementation for use in test environments:
191
+
192
+ ```ruby
193
+ # In test environment, SolidMCP::PubSub automatically uses TestPubSub
194
+ # which provides immediate delivery without database persistence
195
+ ```
196
+
197
+ Run the test suite:
198
+
199
+ ```bash
200
+ bundle exec rake test
201
+ ```
202
+
203
+ ### Testing in Your Application
204
+
205
+ ```ruby
206
+ # test/test_helper.rb
207
+ class ActiveSupport::TestCase
208
+ setup do
209
+ SolidMCP::Message.delete_all
210
+ end
211
+ end
212
+
213
+ # In your tests
214
+ test "broadcasts message to session" do
215
+ pubsub = SolidMCP::PubSub.new
216
+ messages = []
217
+
218
+ pubsub.subscribe("test-session") do |msg|
219
+ messages << msg
220
+ end
221
+
222
+ pubsub.broadcast("test-session", "test_event", { data: "test" })
223
+
224
+ assert_equal 1, messages.size
225
+ assert_equal "test_event", messages.first[:event_type]
226
+ end
227
+ ```
228
+
229
+ ## SSE Integration
230
+
231
+ SolidMCP is designed to work seamlessly with Server-Sent Events:
232
+
233
+ ```ruby
234
+ # In your SSE controller
235
+ def sse_endpoint
236
+ response.headers['Content-Type'] = 'text/event-stream'
237
+
238
+ pubsub = SolidMCP::PubSub.new
239
+ last_event_id = request.headers['Last-Event-ID']
240
+
241
+ # Resume from last event if reconnecting
242
+ if last_event_id
243
+ missed_messages = SolidMCP::Message
244
+ .for_session(session_id)
245
+ .after_id(last_event_id)
246
+ .undelivered
247
+
248
+ missed_messages.each do |msg|
249
+ response.stream.write "id: #{msg.id}\n"
250
+ response.stream.write "event: #{msg.event_type}\n"
251
+ response.stream.write "data: #{msg.data}\n\n"
252
+ end
253
+ end
254
+
255
+ # Subscribe to new messages
256
+ pubsub.subscribe(session_id) do |message|
257
+ response.stream.write "id: #{message[:id]}\n"
258
+ response.stream.write "event: #{message[:event_type]}\n"
259
+ response.stream.write "data: #{message[:data]}\n\n"
260
+ end
261
+ ensure
262
+ pubsub&.unsubscribe(session_id)
263
+ response.stream.close
264
+ end
265
+ ```
266
+
267
+ ## Development
268
+
269
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
270
+
271
+ ### Running Tests
272
+
273
+ ```bash
274
+ # Run all tests
275
+ bundle exec rake test
276
+
277
+ # Run specific test file
278
+ bundle exec ruby test/solid_mcp/message_test.rb
279
+ ```
280
+
281
+ ## Roadmap
282
+
283
+ ### Redis Backend (Coming Soon)
284
+
285
+ Future versions will support Redis as an alternative backend:
286
+
287
+ ```ruby
288
+ # config/initializers/solid_mcp.rb
289
+ SolidMCP.configure do |config|
290
+ config.backend = :redis
291
+ config.redis_url = ENV['REDIS_URL']
292
+ end
293
+ ```
294
+
295
+ This will provide:
296
+ - Lower latency for high-traffic applications
297
+ - Pub/Sub without polling
298
+ - Automatic expiration of old messages
299
+ - Better horizontal scaling
300
+
301
+ ## Comparison with Other Solutions
302
+
303
+ | Feature | SolidMCP | ActionCable + Redis | Custom Polling |
304
+ |---------|----------|-------------------|----------------|
305
+ | No Redis Required | ✅ | ❌ | ✅ |
306
+ | SSE Resumability | ✅ | ❌ | Manual |
307
+ | Horizontal Scaling | ✅ (with DB) | ✅ | ❌ |
308
+ | Message Persistence | ✅ | ❌ | Manual |
309
+ | Batch Writing | ✅ | N/A | ❌ |
310
+ | SQLite Support | ✅ | ❌ | ✅ |
311
+
312
+ ## Contributing
313
+
314
+ Bug reports and pull requests are welcome on GitHub at https://github.com/seuros/solid_mcp.
315
+
316
+ ### Development Setup
317
+
318
+ 1. Fork the repository
319
+ 2. Clone your fork
320
+ 3. Install dependencies: `bundle install`
321
+ 4. Create a feature branch: `git checkout -b my-feature`
322
+ 5. Make your changes and add tests
323
+ 6. Run tests: `bundle exec rake test`
324
+ 7. Push to your fork and submit a pull request
325
+
326
+ ## License
327
+
328
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ffi/extconf'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module SolidMCP
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ namespace "solid_mcp:install"
10
+ include ActiveRecord::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ def create_migration_file
15
+ migration_template "create_solid_mcp_messages.rb.erb", "db/migrate/create_solid_mcp_messages.rb"
16
+ end
17
+
18
+ def add_initializer
19
+ template "solid_mcp.rb", "config/initializers/solid_mcp.rb"
20
+ end
21
+
22
+ private
23
+
24
+ def migration_version
25
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,26 @@
1
+ class CreateSolidMCPMessages < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :solid_mcp_messages do |t|
4
+ # Session this message belongs to
5
+ t.string :session_id, null: false, limit: 36
6
+
7
+ # Type of event (e.g., 'message', 'ping', 'connection_closed')
8
+ t.string :event_type, null: false, limit: 50
9
+
10
+ # The actual data payload
11
+ t.text :data
12
+
13
+ # Timestamp when message was created
14
+ t.datetime :created_at, null: false
15
+
16
+ # Timestamp when message was delivered
17
+ t.datetime :delivered_at
18
+
19
+ # Composite index for efficient polling
20
+ t.index [:session_id, :id], name: 'idx_solid_mcp_messages_on_session_and_id'
21
+
22
+ # Index for cleanup
23
+ t.index [:delivered_at, :created_at], name: 'idx_solid_mcp_messages_on_delivered_and_created'
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Initialize SolidMCP message writer
4
+ Rails.application.config.to_prepare do
5
+ # Ensure the writer thread is started
6
+ SolidMCP::MessageWriter.instance
7
+ end
8
+
9
+ # Gracefully shutdown on exit
10
+ at_exit do
11
+ SolidMCP::MessageWriter.instance.shutdown if defined?(SolidMCP::MessageWriter)
12
+ end
13
+
14
+ # Configure SolidMCP
15
+ SolidMCP.configure do |config|
16
+ config.batch_size = 200
17
+ config.flush_interval = 0.05
18
+ config.delivered_retention = 1.hour
19
+ config.undelivered_retention = 24.hours
20
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidMCP
4
+ class CleanupJob < ActiveJob::Base
5
+ def perform
6
+ SolidMCP::Message.cleanup(
7
+ delivered_retention: SolidMCP.configuration.delivered_retention_seconds,
8
+ undelivered_retention: SolidMCP.configuration.undelivered_retention_seconds
9
+ )
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidMCP
4
+ class Configuration
5
+ attr_accessor :batch_size, :flush_interval, :delivered_retention,
6
+ :undelivered_retention, :polling_interval, :max_wait_time, :logger,
7
+ :max_queue_size, :shutdown_timeout
8
+
9
+ def initialize
10
+ @batch_size = 200
11
+ @flush_interval = 0.05 # 50ms
12
+ @polling_interval = 0.1 # 100ms
13
+ @max_wait_time = 30 # 30 seconds
14
+ @delivered_retention = 3600 # 1 hour in seconds
15
+ @undelivered_retention = 86400 # 24 hours in seconds
16
+ @max_queue_size = 10_000 # Maximum messages in memory queue
17
+ @shutdown_timeout = 30 # Maximum seconds to wait for graceful shutdown
18
+ @logger = default_logger
19
+ end
20
+
21
+ def delivered_retention_seconds
22
+ @delivered_retention.seconds
23
+ end
24
+
25
+ def undelivered_retention_seconds
26
+ @undelivered_retention.seconds
27
+ end
28
+
29
+ private
30
+
31
+ def default_logger
32
+ if defined?(Rails) && Rails.respond_to?(:logger)
33
+ Rails.logger
34
+ else
35
+ require 'active_support/tagged_logging'
36
+ ActiveSupport::TaggedLogging.new(::Logger.new($stdout))
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidMCP
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace SolidMCP
6
+
7
+ config.generators do |g|
8
+ g.test_framework :minitest
9
+ end
10
+
11
+ # Ensure app/models is in the autoload paths
12
+ config.autoload_paths << root.join("app/models")
13
+
14
+ # Don't automatically add migrations - use the generator instead
15
+ # initializer "solid_mcp.migrations" do
16
+ # config.paths["db/migrate"].expanded.each do |expanded_path|
17
+ # Rails.application.config.paths["db/migrate"] << expanded_path
18
+ # end
19
+ # end
20
+
21
+ initializer "solid_mcp.configuration" do
22
+ # Set default configuration if not already configured
23
+ SolidMCP.configuration ||= Configuration.new
24
+ end
25
+
26
+ initializer "solid_mcp.start_message_writer" do
27
+ # Start the message writer in non-test environments
28
+ unless Rails.env.test?
29
+ Rails.application.config.to_prepare do
30
+ SolidMCP::MessageWriter.instance
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidMCP
4
+ module Logger
5
+ class << self
6
+ def logger
7
+ SolidMCP.configuration.logger
8
+ end
9
+
10
+ def tagged(*tags, &block)
11
+ logger.tagged(*tags, &block)
12
+ end
13
+
14
+ def debug(message = nil, &block)
15
+ logger.debug(message, &block)
16
+ end
17
+
18
+ def info(message = nil, &block)
19
+ logger.info(message, &block)
20
+ end
21
+
22
+ def warn(message = nil, &block)
23
+ logger.warn(message, &block)
24
+ end
25
+
26
+ def error(message = nil, &block)
27
+ logger.error(message, &block)
28
+ end
29
+
30
+ def fatal(message = nil, &block)
31
+ logger.fatal(message, &block)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ require "concurrent"
5
+
6
+ module SolidMCP
7
+ class MessageWriter
8
+ include Singleton
9
+
10
+ # Reset the singleton (for testing only)
11
+ def self.reset!
12
+ @singleton__instance__ = nil
13
+ end
14
+
15
+ def initialize
16
+ @queue = SizedQueue.new(SolidMCP.configuration.max_queue_size)
17
+ @shutdown = Concurrent::AtomicBoolean.new(false)
18
+ @dropped_count = Concurrent::AtomicFixnum.new(0)
19
+ @worker_ready = Concurrent::CountDownLatch.new(1)
20
+ @executor = Concurrent::ThreadPoolExecutor.new(
21
+ min_threads: 1,
22
+ max_threads: 1, # Single thread for ordered writes
23
+ max_queue: 0, # Unbounded queue
24
+ fallback_policy: :caller_runs
25
+ )
26
+ start_worker
27
+ # Wait for worker thread to be ready (with short timeout)
28
+ # Using 0.1s is enough for worker to start, avoids 1s delay per test
29
+ @worker_ready.wait(0.1)
30
+ end
31
+
32
+ # Called by publish API - non-blocking with backpressure
33
+ def enqueue(session_id, event_type, data)
34
+ message = {
35
+ session_id: session_id,
36
+ event_type: event_type,
37
+ data: data.is_a?(String) ? data : data.to_json,
38
+ created_at: Time.now.utc
39
+ }
40
+
41
+ # Try non-blocking push with backpressure
42
+ begin
43
+ @queue.push(message, true) # non-blocking
44
+ true
45
+ rescue ThreadError
46
+ # Queue full - drop message and log
47
+ @dropped_count.increment
48
+ SolidMCP::Logger.warn "SolidMCP queue full (#{SolidMCP.configuration.max_queue_size}), dropped message for session #{session_id}"
49
+ false
50
+ end
51
+ end
52
+
53
+ # Get count of dropped messages
54
+ def dropped_count
55
+ @dropped_count.value
56
+ end
57
+
58
+ # Blocks until executor has flushed everything
59
+ def shutdown
60
+ SolidMCP::Logger.info "SolidMCP::MessageWriter shutting down, #{@queue.size} messages pending"
61
+
62
+ # Mark as shutting down (worker will exit after draining queue)
63
+ @shutdown.make_true
64
+
65
+ # Wait for executor to finish processing
66
+ @executor.shutdown
67
+ @executor.wait_for_termination(SolidMCP.configuration.shutdown_timeout)
68
+
69
+ if @queue.size > 0
70
+ SolidMCP::Logger.warn "SolidMCP::MessageWriter shutdown timeout, #{@queue.size} messages not written"
71
+ end
72
+ end
73
+
74
+ # Force flush any pending messages (useful for tests)
75
+ def flush
76
+ return unless @executor.running?
77
+
78
+ # Add a marker and wait for it to be processed
79
+ processed = Concurrent::CountDownLatch.new(1)
80
+
81
+ # Use blocking push for flush marker (not subject to queue limits)
82
+ begin
83
+ @queue.push({ flush_marker: processed }, false) # blocking
84
+ rescue ThreadError
85
+ # Queue is shutting down
86
+ return
87
+ end
88
+
89
+ # Wait up to 1 second for flush to complete
90
+ processed.wait(1)
91
+ end
92
+
93
+ private
94
+
95
+ def start_worker
96
+ @executor.post do
97
+ begin
98
+ SolidMCP::Logger.debug "MessageWriter worker thread started" if ENV["DEBUG_SOLID_MCP"]
99
+ run_loop
100
+ rescue => e
101
+ SolidMCP::Logger.error "MessageWriter worker thread crashed: #{e.message}"
102
+ SolidMCP::Logger.error e.backtrace.join("\n")
103
+ end
104
+ end
105
+ end
106
+
107
+ def run_loop
108
+ # Signal that worker is ready
109
+ @worker_ready.count_down
110
+
111
+ loop do
112
+ break if @shutdown.true? && @queue.empty?
113
+
114
+ batch = drain_batch
115
+ if batch.any?
116
+ SolidMCP::Logger.debug "MessageWriter processing batch of #{batch.size} messages" if ENV["DEBUG_SOLID_MCP"]
117
+ write_batch(batch)
118
+ end
119
+ end
120
+ rescue => e
121
+ SolidMCP::Logger.error "SolidMCP::MessageWriter error: #{e.message}"
122
+ retry unless @shutdown.true?
123
+ end
124
+
125
+ def drain_batch
126
+ batch = []
127
+ batch_size = SolidMCP.configuration.batch_size
128
+ flush_markers = []
129
+
130
+ # Get first item - use blocking pop with timeout to avoid busy spin
131
+ item = nil
132
+ until @shutdown.true? && @queue.empty?
133
+ begin
134
+ # Blocking pop with timeout allows clean shutdown checking
135
+ item = @queue.pop(timeout: 0.1)
136
+ break if item
137
+ rescue ThreadError
138
+ # Queue closed, exit
139
+ break
140
+ end
141
+ end
142
+
143
+ return batch unless item
144
+
145
+ # Handle flush markers
146
+ if item.is_a?(Hash) && item[:flush_marker]
147
+ flush_markers << item[:flush_marker]
148
+ else
149
+ batch << item
150
+ end
151
+
152
+ # Get remaining items up to batch size (non-blocking)
153
+ while batch.size < batch_size
154
+ begin
155
+ item = @queue.pop(true) # non-blocking
156
+ # Handle flush markers
157
+ if item.is_a?(Hash) && item[:flush_marker]
158
+ flush_markers << item[:flush_marker]
159
+ else
160
+ batch << item
161
+ end
162
+ rescue ThreadError
163
+ break
164
+ end
165
+ end
166
+
167
+ # Signal any flush markers we've collected
168
+ flush_markers.each(&:count_down)
169
+ batch
170
+ end
171
+
172
+ def write_batch(batch)
173
+ return if batch.empty?
174
+
175
+ # Use ActiveRecord insert_all for safety and database portability
176
+ records = batch.map do |msg|
177
+ {
178
+ session_id: msg[:session_id],
179
+ event_type: msg[:event_type],
180
+ data: msg[:data],
181
+ created_at: msg[:created_at]
182
+ }
183
+ end
184
+
185
+ SolidMCP::Message.insert_all(records)
186
+ rescue => e
187
+ SolidMCP::Logger.error "SolidMCP::MessageWriter batch write error: #{e.message}"
188
+ # Could implement retry logic or dead letter queue here
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Native Rust acceleration for SolidMCP (optional)
4
+ #
5
+ # This module provides a Rust-powered pub/sub engine using Tokio for async I/O.
6
+ # Falls back gracefully to pure Ruby if the native extension is unavailable.
7
+ #
8
+ # Features:
9
+ # - 50-100x faster message throughput
10
+ # - PostgreSQL LISTEN/NOTIFY support (no polling)
11
+ # - SQLite WAL mode with efficient async polling
12
+ # - Compile-time thread safety guarantees
13
+
14
+ module SolidMCP
15
+ module NativeSpeedup
16
+ class << self
17
+ def available?
18
+ @available ||= load_native_extension
19
+ end
20
+
21
+ def version
22
+ return nil unless available?
23
+ SolidMCPNative.version
24
+ end
25
+
26
+ private
27
+
28
+ def load_native_extension
29
+ return false if ENV["DISABLE_SOLID_MCP_NATIVE"]
30
+
31
+ begin
32
+ require "solid_mcp_native/solid_mcp_native"
33
+ log_info "SolidMCP native extension loaded (v#{SolidMCPNative.version})"
34
+ true
35
+ rescue LoadError => e
36
+ log_debug "SolidMCP native extension not available: #{e.message}"
37
+ false
38
+ end
39
+ end
40
+
41
+ def log_info(msg)
42
+ SolidMCP::Logger.info(msg)
43
+ rescue StandardError
44
+ # Logger not ready, silently ignore
45
+ end
46
+
47
+ def log_debug(msg)
48
+ SolidMCP::Logger.debug(msg)
49
+ rescue StandardError
50
+ # Logger not ready, silently ignore
51
+ end
52
+ end
53
+
54
+ # Override MessageWriter with native implementation
55
+ module MessageWriterOverride
56
+ def self.prepended(base)
57
+ # Only prepend if native extension is available
58
+ return unless SolidMCP::NativeSpeedup.available?
59
+
60
+ SolidMCP::Logger.debug "Enabling native MessageWriter"
61
+ end
62
+
63
+ def initialize
64
+ if SolidMCP::NativeSpeedup.available? && !@native_initialized
65
+ # Initialize native engine with SQLite/PostgreSQL URL
66
+ db_config = SolidMCP.configuration.database_config
67
+ database_url = build_database_url(db_config)
68
+
69
+ SolidMCPNative.init_with_config(
70
+ database_url,
71
+ SolidMCP.configuration.batch_size,
72
+ (SolidMCP.configuration.polling_interval * 1000).to_i, # Convert to ms
73
+ SolidMCP.configuration.max_queue_size
74
+ )
75
+ @native_initialized = true
76
+ else
77
+ super
78
+ end
79
+ end
80
+
81
+ def enqueue(session_id, event_type, data)
82
+ if SolidMCP::NativeSpeedup.available? && @native_initialized
83
+ json_data = data.is_a?(String) ? data : data.to_json
84
+ SolidMCPNative.broadcast(session_id.to_s, event_type.to_s, json_data)
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def flush
91
+ if SolidMCP::NativeSpeedup.available? && @native_initialized
92
+ SolidMCPNative.flush
93
+ else
94
+ super
95
+ end
96
+ end
97
+
98
+ def shutdown
99
+ if SolidMCP::NativeSpeedup.available? && @native_initialized
100
+ SolidMCPNative.shutdown
101
+ @native_initialized = false
102
+ else
103
+ super
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def build_database_url(config)
110
+ adapter = config[:adapter] || "sqlite3"
111
+
112
+ case adapter
113
+ when "sqlite3"
114
+ # SQLite URL format
115
+ database = config[:database] || ":memory:"
116
+ "sqlite://#{database}"
117
+ when "postgresql", "postgres"
118
+ # PostgreSQL URL format
119
+ host = config[:host] || "localhost"
120
+ port = config[:port] || 5432
121
+ database = config[:database] || "solid_mcp"
122
+ username = config[:username]
123
+ password = config[:password]
124
+
125
+ auth = username ? "#{username}:#{password}@" : ""
126
+ "postgres://#{auth}#{host}:#{port}/#{database}"
127
+ else
128
+ raise SolidMCP::Error, "Unsupported database adapter: #{adapter}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # Auto-load native extension on require (if available)
136
+ if SolidMCP::NativeSpeedup.available?
137
+ # MessageWriter.prepend(SolidMCP::NativeSpeedup::MessageWriterOverride)
138
+ # Note: Uncommenting this line enables automatic native acceleration
139
+ # For now, keep it opt-in until the Rust code is battle-tested
140
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+ require "concurrent/array"
5
+
6
+ module SolidMCP
7
+ class PubSub
8
+ def initialize(options = {})
9
+ @options = options
10
+ @subscriptions = Concurrent::Map.new
11
+ @listeners = Concurrent::Map.new
12
+ end
13
+
14
+ # Subscribe to messages for a specific session
15
+ def subscribe(session_id, &block)
16
+ # Atomically get or create callbacks array
17
+ callbacks = @subscriptions.compute_if_absent(session_id) { Concurrent::Array.new }
18
+ callbacks << block
19
+
20
+ # Start a listener for this session if not already running
21
+ ensure_listener_for(session_id)
22
+ end
23
+
24
+ # Unsubscribe from a session
25
+ def unsubscribe(session_id)
26
+ @subscriptions.delete(session_id)
27
+ stop_listener_for(session_id)
28
+ end
29
+
30
+ # Broadcast a message to a session (uses MessageWriter for batching)
31
+ def broadcast(session_id, event_type, data)
32
+ MessageWriter.instance.enqueue(session_id, event_type, data)
33
+ end
34
+
35
+ # Shutdown all listeners
36
+ def shutdown
37
+ @listeners.each do |_, listener|
38
+ listener.stop
39
+ end
40
+ @listeners.clear
41
+ MessageWriter.instance.shutdown
42
+ end
43
+
44
+ private
45
+
46
+ def ensure_listener_for(session_id)
47
+ # Atomically create and start listener only once
48
+ @listeners.compute_if_absent(session_id) do
49
+ listener = Subscriber.new(session_id, @subscriptions[session_id])
50
+ listener.start
51
+ listener
52
+ end
53
+ end
54
+
55
+ def stop_listener_for(session_id)
56
+ listener = @listeners.delete(session_id)
57
+ listener&.stop
58
+ end
59
+ end # class PubSub
60
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_boolean"
4
+ require "concurrent/atomic/atomic_reference"
5
+ require "concurrent/timer_task"
6
+
7
+ module SolidMCP
8
+ class Subscriber
9
+ def initialize(session_id, callbacks)
10
+ @session_id = session_id
11
+ @callbacks = callbacks
12
+ @running = Concurrent::AtomicBoolean.new(false)
13
+ @last_message_id = Concurrent::AtomicReference.new(0)
14
+ @timer_task = nil
15
+ @max_retries = ENV["RAILS_ENV"] == "test" ? 3 : Float::INFINITY
16
+ @retry_count = 0
17
+ end
18
+
19
+ def start
20
+ return if @running.true?
21
+
22
+ @running.make_true
23
+ @retry_count = 0
24
+
25
+ @timer_task = Concurrent::TimerTask.new(
26
+ execution_interval: SolidMCP.configuration.polling_interval,
27
+ run_now: true
28
+ ) do
29
+ poll_once
30
+ end
31
+
32
+ @timer_task.execute
33
+ end
34
+
35
+ def stop
36
+ @running.make_false
37
+ @timer_task&.shutdown
38
+ @timer_task&.wait_for_termination(5)
39
+ end
40
+
41
+ private
42
+
43
+ def poll_once
44
+ return unless @running.true?
45
+
46
+ # Ensure connection in thread
47
+ SolidMCP::Message.connection_pool.with_connection do
48
+ messages = fetch_new_messages
49
+ if messages.any?
50
+ process_messages(messages)
51
+ mark_delivered(messages)
52
+ @retry_count = 0
53
+ end
54
+ end
55
+ rescue => e
56
+ @retry_count += 1
57
+ SolidMCP::Logger.error "SolidMCP::Subscriber error for session #{@session_id}: #{e.message} (retry #{@retry_count}/#{@max_retries})"
58
+
59
+ if @retry_count >= @max_retries && @max_retries != Float::INFINITY
60
+ SolidMCP::Logger.error "SolidMCP::Subscriber max retries reached for session #{@session_id}, stopping"
61
+ stop
62
+ end
63
+ end
64
+
65
+ def fetch_new_messages
66
+ SolidMCP::Message
67
+ .for_session(@session_id)
68
+ .undelivered
69
+ .after_id(@last_message_id.get)
70
+ .order(:id)
71
+ .limit(100)
72
+ .to_a
73
+ end
74
+
75
+ def process_messages(messages)
76
+ messages.each do |message|
77
+ # Process all callbacks first
78
+ all_successful = true
79
+ @callbacks.each do |callback|
80
+ begin
81
+ callback.call({
82
+ event_type: message.event_type,
83
+ data: message.data, # data is already a JSON string from the database
84
+ id: message.id
85
+ })
86
+ rescue => e
87
+ all_successful = false
88
+ SolidMCP::Logger.error "SolidMCP callback error: #{e.message}"
89
+ end
90
+ end
91
+
92
+ # Only update last_message_id if all callbacks succeeded
93
+ if all_successful
94
+ @last_message_id.set(message.id)
95
+ else
96
+ # Stop processing remaining messages on first callback failure
97
+ break
98
+ end
99
+ end
100
+ end
101
+
102
+ def mark_delivered(messages)
103
+ SolidMCP::Message.mark_delivered(messages.map(&:id))
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+ require "concurrent/array"
5
+
6
+ module SolidMCP
7
+ # Test implementation of PubSub for use in tests
8
+ class TestPubSub
9
+ attr_reader :subscriptions, :messages
10
+
11
+ def initialize(options = {})
12
+ @options = options
13
+ @subscriptions = Concurrent::Map.new
14
+ @messages = Concurrent::Array.new
15
+ end
16
+
17
+ def subscribe(session_id, &block)
18
+ @subscriptions[session_id] ||= Concurrent::Array.new
19
+ @subscriptions[session_id] << block
20
+ end
21
+
22
+ def unsubscribe(session_id)
23
+ @subscriptions.delete(session_id)
24
+ end
25
+
26
+ def broadcast(session_id, event_type, data)
27
+ message = { session_id: session_id, event_type: event_type, data: data }
28
+ @messages << message
29
+
30
+ callbacks = @subscriptions[session_id] || []
31
+ callbacks.each do |callback|
32
+ callback.call({ event_type: event_type, data: data })
33
+ end
34
+ end
35
+
36
+ def shutdown
37
+ @subscriptions.clear
38
+ @messages.clear
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidMCP
4
+ VERSION = "0.5.0"
5
+ end
data/lib/solid_mcp.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "solid_mcp/version"
4
+ require_relative "solid_mcp/configuration"
5
+ require_relative "solid_mcp/logger"
6
+ require_relative "solid_mcp/engine" if defined?(Rails)
7
+
8
+ # Always load core components
9
+ require_relative "solid_mcp/message_writer"
10
+ require_relative "solid_mcp/subscriber"
11
+ require_relative "solid_mcp/cleanup_job"
12
+
13
+ # Load test components in test environment
14
+ if ENV["RAILS_ENV"] == "test"
15
+ require_relative "solid_mcp/test_pub_sub"
16
+ end
17
+
18
+ # Always require pub_sub after environment-specific components
19
+ require_relative "solid_mcp/pub_sub"
20
+
21
+ module SolidMCP
22
+ class Error < StandardError; end
23
+
24
+ class << self
25
+ attr_accessor :configuration
26
+
27
+ def configure
28
+ self.configuration ||= Configuration.new
29
+ yield(configuration) if block_given?
30
+ end
31
+
32
+ def configured?
33
+ configuration.present?
34
+ end
35
+ end
36
+
37
+ # Initialize with default configuration
38
+ self.configuration = Configuration.new
39
+ end
40
+
41
+ # Load native speedup AFTER module is defined (optional - gracefully falls back to pure Ruby)
42
+ require_relative "solid_mcp/native_speedup"
data/sig/solid_mcp.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module SolidMCP
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,187 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solid_mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.5.0
5
+ platform: x86_64-linux-musl
6
+ authors:
7
+ - Abdelkader Boudih
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-12-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '8.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '8.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '8.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '8.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activejob
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '8.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '8.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: concurrent-ruby
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '2.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '2.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '13.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '13.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rake-compiler
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '1.2'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '1.2'
125
+ description: |
126
+ SolidMCP implements a high-performance, bidirectional Pub/Sub transport for ActionMCP.
127
+ Features optional Rust native extension with Tokio for async I/O, PostgreSQL LISTEN/NOTIFY
128
+ support, and automatic fallback to pure Ruby when native extension is unavailable.
129
+ email:
130
+ - terminale@gmail.com
131
+ executables: []
132
+ extensions: []
133
+ extra_rdoc_files: []
134
+ files:
135
+ - LICENSE.txt
136
+ - README.md
137
+ - ext/solid_mcp_native/extconf.rb
138
+ - lib/generators/solid_mcp/install/install_generator.rb
139
+ - lib/generators/solid_mcp/install/templates/create_solid_mcp_messages.rb.erb
140
+ - lib/generators/solid_mcp/install/templates/solid_mcp.rb
141
+ - lib/solid_mcp.rb
142
+ - lib/solid_mcp/cleanup_job.rb
143
+ - lib/solid_mcp/configuration.rb
144
+ - lib/solid_mcp/engine.rb
145
+ - lib/solid_mcp/logger.rb
146
+ - lib/solid_mcp/message_writer.rb
147
+ - lib/solid_mcp/native_speedup.rb
148
+ - lib/solid_mcp/pub_sub.rb
149
+ - lib/solid_mcp/subscriber.rb
150
+ - lib/solid_mcp/test_pub_sub.rb
151
+ - lib/solid_mcp/version.rb
152
+ - lib/solid_mcp_native/solid_mcp_native.so
153
+ - sig/solid_mcp.rbs
154
+ homepage: https://github.com/seuros/solid_mcp
155
+ licenses:
156
+ - MIT
157
+ metadata:
158
+ homepage_uri: https://github.com/seuros/solid_mcp
159
+ source_code_uri: https://github.com/seuros/solid_mcp
160
+ changelog_uri: https://github.com/seuros/solid_mcp/blob/main/CHANGELOG.md
161
+ bug_tracker_uri: https://github.com/seuros/solid_mcp/issues
162
+ rubygems_mfa_required: 'true'
163
+ cargo_crate_name: solid_mcp_native
164
+ cargo_manifest_path: ext/solid_mcp_native/ffi/Cargo.toml
165
+ post_install_message:
166
+ rdoc_options: []
167
+ require_paths:
168
+ - lib
169
+ required_ruby_version: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '3.4'
174
+ - - "<"
175
+ - !ruby/object:Gem::Version
176
+ version: 3.5.dev
177
+ required_rubygems_version: !ruby/object:Gem::Requirement
178
+ requirements:
179
+ - - ">="
180
+ - !ruby/object:Gem::Version
181
+ version: 3.3.22
182
+ requirements: []
183
+ rubygems_version: 3.5.23
184
+ signing_key:
185
+ specification_version: 4
186
+ summary: Streaming Pub/Sub transport for ActionMCP.
187
+ test_files: []