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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +328 -0
- data/ext/solid_mcp_native/extconf.rb +3 -0
- data/lib/generators/solid_mcp/install/install_generator.rb +29 -0
- data/lib/generators/solid_mcp/install/templates/create_solid_mcp_messages.rb.erb +26 -0
- data/lib/generators/solid_mcp/install/templates/solid_mcp.rb +20 -0
- data/lib/solid_mcp/cleanup_job.rb +12 -0
- data/lib/solid_mcp/configuration.rb +40 -0
- data/lib/solid_mcp/engine.rb +35 -0
- data/lib/solid_mcp/logger.rb +35 -0
- data/lib/solid_mcp/message_writer.rb +191 -0
- data/lib/solid_mcp/native_speedup.rb +140 -0
- data/lib/solid_mcp/pub_sub.rb +60 -0
- data/lib/solid_mcp/subscriber.rb +106 -0
- data/lib/solid_mcp/test_pub_sub.rb +41 -0
- data/lib/solid_mcp/version.rb +5 -0
- data/lib/solid_mcp.rb +42 -0
- data/lib/solid_mcp_native/solid_mcp_native.so +0 -0
- data/sig/solid_mcp.rbs +4 -0
- metadata +187 -0
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,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
|
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"
|
|
Binary file
|
data/sig/solid_mcp.rbs
ADDED
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: []
|