solid_mcp 0.2.3 → 0.5.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/ext/solid_mcp_native/Cargo.toml +12 -0
  3. data/ext/solid_mcp_native/core/Cargo.toml +32 -0
  4. data/ext/solid_mcp_native/core/src/config.rs +133 -0
  5. data/ext/solid_mcp_native/core/src/db/mod.rs +154 -0
  6. data/ext/solid_mcp_native/core/src/db/postgres.rs +242 -0
  7. data/ext/solid_mcp_native/core/src/db/sqlite.rs +276 -0
  8. data/ext/solid_mcp_native/core/src/error.rs +38 -0
  9. data/ext/solid_mcp_native/core/src/lib.rs +25 -0
  10. data/ext/solid_mcp_native/core/src/message.rs +191 -0
  11. data/ext/solid_mcp_native/core/src/pubsub.rs +309 -0
  12. data/ext/solid_mcp_native/core/src/subscriber.rs +298 -0
  13. data/ext/solid_mcp_native/core/src/writer.rs +252 -0
  14. data/ext/solid_mcp_native/extconf.rb +3 -0
  15. data/ext/solid_mcp_native/ffi/Cargo.toml +20 -0
  16. data/ext/solid_mcp_native/ffi/extconf.rb +67 -0
  17. data/ext/solid_mcp_native/ffi/src/lib.rs +224 -0
  18. data/lib/solid_mcp/configuration.rb +5 -2
  19. data/lib/solid_mcp/message_writer.rb +80 -45
  20. data/lib/solid_mcp/native_speedup.rb +140 -0
  21. data/lib/solid_mcp/pub_sub.rb +10 -8
  22. data/lib/solid_mcp/subscriber.rb +18 -7
  23. data/lib/solid_mcp/version.rb +1 -1
  24. data/lib/solid_mcp.rb +3 -0
  25. metadata +57 -19
  26. data/.release-please-manifest.json +0 -1
  27. data/CHANGELOG.md +0 -34
  28. data/Gemfile +0 -11
  29. data/Gemfile.lock +0 -140
  30. data/Rakefile +0 -8
  31. data/app/models/solid_mcp/message.rb +0 -25
  32. data/app/models/solid_mcp/record.rb +0 -10
  33. data/bin/console +0 -11
  34. data/bin/rails +0 -15
  35. data/bin/setup +0 -8
  36. data/bin/test +0 -8
  37. data/db/migrate/20250624000001_create_solid_mcp_messages.rb +0 -28
  38. data/release-please-config.json +0 -8
  39. data/solid_mcp.gemspec +0 -39
@@ -0,0 +1,276 @@
1
+ //! SQLite database backend for solid-mcp-core
2
+
3
+ use crate::{Message, Result};
4
+ use async_trait::async_trait;
5
+ use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
6
+ use sqlx::{Pool, Sqlite};
7
+ use std::str::FromStr;
8
+ use std::time::Duration;
9
+
10
+ /// SQLite connection pool
11
+ #[derive(Clone)]
12
+ pub struct SqlitePool {
13
+ pool: Pool<Sqlite>,
14
+ }
15
+
16
+ impl SqlitePool {
17
+ /// Create a new SQLite pool from a database URL
18
+ ///
19
+ /// The database and tables must already exist (created by Ruby migrations).
20
+ pub async fn new(database_url: &str) -> Result<Self> {
21
+ // Parse the URL and configure for WAL mode (better concurrency)
22
+ let options = SqliteConnectOptions::from_str(database_url)?
23
+ .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
24
+ .synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
25
+ .busy_timeout(Duration::from_secs(30));
26
+
27
+ let pool = SqlitePoolOptions::new()
28
+ .max_connections(1) // SQLite works best with single writer
29
+ .connect_with(options)
30
+ .await?;
31
+
32
+ Ok(Self { pool })
33
+ }
34
+
35
+ /// Create tables for testing purposes only
36
+ #[cfg(test)]
37
+ pub(crate) async fn setup_test_schema(&self) -> Result<()> {
38
+ sqlx::query(
39
+ r#"
40
+ CREATE TABLE IF NOT EXISTS solid_mcp_messages (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ session_id TEXT NOT NULL,
43
+ event_type TEXT NOT NULL,
44
+ data TEXT NOT NULL,
45
+ created_at TEXT NOT NULL,
46
+ delivered_at TEXT
47
+ )
48
+ "#,
49
+ )
50
+ .execute(&self.pool)
51
+ .await?;
52
+
53
+ sqlx::query(
54
+ r#"
55
+ CREATE INDEX IF NOT EXISTS idx_solid_mcp_messages_session_id
56
+ ON solid_mcp_messages(session_id, id)
57
+ "#,
58
+ )
59
+ .execute(&self.pool)
60
+ .await?;
61
+
62
+ sqlx::query(
63
+ r#"
64
+ CREATE INDEX IF NOT EXISTS idx_solid_mcp_messages_delivered
65
+ ON solid_mcp_messages(delivered_at, created_at)
66
+ "#,
67
+ )
68
+ .execute(&self.pool)
69
+ .await?;
70
+
71
+ Ok(())
72
+ }
73
+ }
74
+
75
+ #[async_trait]
76
+ impl super::Database for SqlitePool {
77
+ async fn insert_batch(&self, messages: &[Message]) -> Result<()> {
78
+ if messages.is_empty() {
79
+ return Ok(());
80
+ }
81
+
82
+ // Build batch insert query
83
+ let mut query = String::from(
84
+ "INSERT INTO solid_mcp_messages (session_id, event_type, data, created_at) VALUES ",
85
+ );
86
+
87
+ let mut params: Vec<String> = Vec::with_capacity(messages.len() * 4);
88
+
89
+ for (i, msg) in messages.iter().enumerate() {
90
+ if i > 0 {
91
+ query.push_str(", ");
92
+ }
93
+ let base = i * 4 + 1;
94
+ query.push_str(&format!(
95
+ "(${}, ${}, ${}, ${})",
96
+ base,
97
+ base + 1,
98
+ base + 2,
99
+ base + 3
100
+ ));
101
+ params.push(msg.session_id.clone());
102
+ params.push(msg.event_type.clone());
103
+ params.push(msg.data.clone());
104
+ params.push(msg.created_at.to_rfc3339());
105
+ }
106
+
107
+ // Execute with parameters
108
+ let mut q = sqlx::query(&query);
109
+ for param in &params {
110
+ q = q.bind(param);
111
+ }
112
+ q.execute(&self.pool).await?;
113
+
114
+ Ok(())
115
+ }
116
+
117
+ async fn fetch_after(
118
+ &self,
119
+ session_id: &str,
120
+ after_id: i64,
121
+ limit: i64,
122
+ ) -> Result<Vec<Message>> {
123
+ let rows = sqlx::query_as::<_, (i64, String, String, String, String, Option<String>)>(
124
+ r#"
125
+ SELECT id, session_id, event_type, data, created_at, delivered_at
126
+ FROM solid_mcp_messages
127
+ WHERE session_id = $1 AND delivered_at IS NULL AND id > $2
128
+ ORDER BY id
129
+ LIMIT $3
130
+ "#,
131
+ )
132
+ .bind(session_id)
133
+ .bind(after_id)
134
+ .bind(limit)
135
+ .fetch_all(&self.pool)
136
+ .await?;
137
+
138
+ let messages = rows
139
+ .into_iter()
140
+ .map(
141
+ |(id, session_id, event_type, data, created_at, delivered_at)| Message {
142
+ id,
143
+ session_id,
144
+ event_type,
145
+ data,
146
+ created_at: chrono::DateTime::parse_from_rfc3339(&created_at)
147
+ .unwrap_or_default()
148
+ .with_timezone(&chrono::Utc),
149
+ delivered_at: delivered_at.and_then(|d| {
150
+ chrono::DateTime::parse_from_rfc3339(&d)
151
+ .ok()
152
+ .map(|dt| dt.with_timezone(&chrono::Utc))
153
+ }),
154
+ },
155
+ )
156
+ .collect();
157
+
158
+ Ok(messages)
159
+ }
160
+
161
+ async fn mark_delivered(&self, ids: &[i64]) -> Result<()> {
162
+ if ids.is_empty() {
163
+ return Ok(());
164
+ }
165
+
166
+ // $1 is for the timestamp, ids start from $2
167
+ let placeholders: Vec<String> = (2..=ids.len() + 1).map(|i| format!("${}", i)).collect();
168
+ let query = format!(
169
+ "UPDATE solid_mcp_messages SET delivered_at = $1 WHERE id IN ({})",
170
+ placeholders.join(", ")
171
+ );
172
+
173
+ let now = chrono::Utc::now().to_rfc3339();
174
+ let mut q = sqlx::query(&query).bind(&now);
175
+ for id in ids {
176
+ q = q.bind(id);
177
+ }
178
+ q.execute(&self.pool).await?;
179
+
180
+ Ok(())
181
+ }
182
+
183
+ async fn cleanup_delivered(&self, older_than: Duration) -> Result<u64> {
184
+ let cutoff =
185
+ (chrono::Utc::now() - chrono::Duration::from_std(older_than).unwrap()).to_rfc3339();
186
+
187
+ let result = sqlx::query(
188
+ r#"
189
+ DELETE FROM solid_mcp_messages
190
+ WHERE delivered_at IS NOT NULL AND delivered_at < $1
191
+ "#,
192
+ )
193
+ .bind(&cutoff)
194
+ .execute(&self.pool)
195
+ .await?;
196
+
197
+ Ok(result.rows_affected())
198
+ }
199
+
200
+ async fn cleanup_undelivered(&self, older_than: Duration) -> Result<u64> {
201
+ let cutoff =
202
+ (chrono::Utc::now() - chrono::Duration::from_std(older_than).unwrap()).to_rfc3339();
203
+
204
+ let result = sqlx::query(
205
+ r#"
206
+ DELETE FROM solid_mcp_messages
207
+ WHERE delivered_at IS NULL AND created_at < $1
208
+ "#,
209
+ )
210
+ .bind(&cutoff)
211
+ .execute(&self.pool)
212
+ .await?;
213
+
214
+ Ok(result.rows_affected())
215
+ }
216
+
217
+ async fn max_id(&self) -> Result<i64> {
218
+ let row: (Option<i64>,) = sqlx::query_as("SELECT MAX(id) FROM solid_mcp_messages")
219
+ .fetch_one(&self.pool)
220
+ .await?;
221
+
222
+ Ok(row.0.unwrap_or(0))
223
+ }
224
+ }
225
+
226
+ #[cfg(test)]
227
+ mod tests {
228
+ use super::*;
229
+ use crate::db::Database;
230
+
231
+ async fn create_test_pool() -> SqlitePool {
232
+ let pool = SqlitePool::new("sqlite::memory:").await.unwrap();
233
+ pool.setup_test_schema().await.unwrap();
234
+ pool
235
+ }
236
+
237
+ #[tokio::test]
238
+ async fn test_sqlite_pool_creation() {
239
+ let pool = create_test_pool().await;
240
+ assert_eq!(pool.max_id().await.unwrap(), 0);
241
+ }
242
+
243
+ #[tokio::test]
244
+ async fn test_insert_and_fetch() {
245
+ let pool = create_test_pool().await;
246
+
247
+ let messages = vec![
248
+ Message::new("session-1", "message", r#"{"test":1}"#),
249
+ Message::new("session-1", "message", r#"{"test":2}"#),
250
+ ];
251
+
252
+ pool.insert_batch(&messages).await.unwrap();
253
+
254
+ let fetched = pool.fetch_after("session-1", 0, 100).await.unwrap();
255
+ assert_eq!(fetched.len(), 2);
256
+ assert_eq!(fetched[0].data, r#"{"test":1}"#);
257
+ assert_eq!(fetched[1].data, r#"{"test":2}"#);
258
+ }
259
+
260
+ #[tokio::test]
261
+ async fn test_mark_delivered() {
262
+ let pool = create_test_pool().await;
263
+
264
+ let messages = vec![Message::new("session-1", "message", r#"{}"#)];
265
+ pool.insert_batch(&messages).await.unwrap();
266
+
267
+ let fetched = pool.fetch_after("session-1", 0, 100).await.unwrap();
268
+ assert_eq!(fetched.len(), 1);
269
+
270
+ pool.mark_delivered(&[fetched[0].id]).await.unwrap();
271
+
272
+ // Should not fetch delivered messages
273
+ let fetched = pool.fetch_after("session-1", 0, 100).await.unwrap();
274
+ assert_eq!(fetched.len(), 0);
275
+ }
276
+ }
@@ -0,0 +1,38 @@
1
+ //! Error types for solid-mcp-core
2
+
3
+ use thiserror::Error;
4
+
5
+ /// Result type alias using our Error type
6
+ pub type Result<T> = std::result::Result<T, Error>;
7
+
8
+ /// Errors that can occur in solid-mcp-core
9
+ #[derive(Error, Debug)]
10
+ pub enum Error {
11
+ /// Database operation failed
12
+ #[error("database error: {0}")]
13
+ Database(#[from] sqlx::Error),
14
+
15
+ /// JSON serialization/deserialization failed
16
+ #[error("json error: {0}")]
17
+ Json(#[from] serde_json::Error),
18
+
19
+ /// Channel send failed (queue full or shutdown)
20
+ #[error("channel send error: queue full or shutdown")]
21
+ ChannelSend,
22
+
23
+ /// Channel receive failed (shutdown)
24
+ #[error("channel receive error: shutdown")]
25
+ ChannelRecv,
26
+
27
+ /// Configuration error
28
+ #[error("configuration error: {0}")]
29
+ Config(String),
30
+
31
+ /// Shutdown requested
32
+ #[error("shutdown requested")]
33
+ Shutdown,
34
+
35
+ /// Session not found
36
+ #[error("session not found: {0}")]
37
+ SessionNotFound(String),
38
+ }
@@ -0,0 +1,25 @@
1
+ //! # solid-mcp-core
2
+ //!
3
+ //! High-performance async pub/sub engine for MCP (Model Context Protocol).
4
+ //!
5
+ //! This crate provides the core functionality for solid_mcp:
6
+ //! - Async message writing with batching
7
+ //! - Session-based subscriptions with PostgreSQL LISTEN/NOTIFY or SQLite polling
8
+ //! - Database-backed message persistence
9
+ //!
10
+ //! ## Features
11
+ //! - `sqlite` - Enable SQLite backend (default)
12
+ //! - `postgres` - Enable PostgreSQL backend with LISTEN/NOTIFY (default)
13
+
14
+ pub mod config;
15
+ pub mod db;
16
+ pub mod error;
17
+ pub mod message;
18
+ pub mod pubsub;
19
+ pub mod subscriber;
20
+ pub mod writer;
21
+
22
+ pub use config::Config;
23
+ pub use error::{Error, Result};
24
+ pub use message::Message;
25
+ pub use pubsub::PubSub;
@@ -0,0 +1,191 @@
1
+ //! Message type for solid-mcp-core
2
+
3
+ use chrono::{DateTime, Utc};
4
+ use serde::{Deserialize, Serialize};
5
+
6
+ /// A message in the pub/sub system
7
+ #[derive(Debug, Clone, Serialize, Deserialize)]
8
+ pub struct Message {
9
+ /// Unique message ID (database primary key)
10
+ #[serde(default)]
11
+ pub id: i64,
12
+
13
+ /// Session ID this message belongs to (UUID format, 36 chars)
14
+ pub session_id: String,
15
+
16
+ /// Event type (e.g., "message", "ping", "notification")
17
+ pub event_type: String,
18
+
19
+ /// JSON payload
20
+ pub data: String,
21
+
22
+ /// When the message was created
23
+ pub created_at: DateTime<Utc>,
24
+
25
+ /// When the message was delivered (None = undelivered)
26
+ #[serde(default)]
27
+ pub delivered_at: Option<DateTime<Utc>>,
28
+ }
29
+
30
+ impl Message {
31
+ /// Create a new message (id will be set by database)
32
+ pub fn new(
33
+ session_id: impl Into<String>,
34
+ event_type: impl Into<String>,
35
+ data: impl Into<String>,
36
+ ) -> Self {
37
+ Self {
38
+ id: 0,
39
+ session_id: session_id.into(),
40
+ event_type: event_type.into(),
41
+ data: data.into(),
42
+ created_at: Utc::now(),
43
+ delivered_at: None,
44
+ }
45
+ }
46
+
47
+ /// Create a message with JSON data
48
+ pub fn with_json<T: Serialize>(
49
+ session_id: impl Into<String>,
50
+ event_type: impl Into<String>,
51
+ data: &T,
52
+ ) -> Result<Self, serde_json::Error> {
53
+ let json = serde_json::to_string(data)?;
54
+ Ok(Self::new(session_id, event_type, json))
55
+ }
56
+
57
+ /// Check if this message has been delivered
58
+ pub fn is_delivered(&self) -> bool {
59
+ self.delivered_at.is_some()
60
+ }
61
+
62
+ /// Mark this message as delivered now
63
+ pub fn mark_delivered(&mut self) {
64
+ self.delivered_at = Some(Utc::now());
65
+ }
66
+ }
67
+
68
+ /// Batch of messages for efficient database operations
69
+ #[derive(Debug, Default)]
70
+ pub struct MessageBatch {
71
+ messages: Vec<Message>,
72
+ }
73
+
74
+ impl MessageBatch {
75
+ /// Create a new empty batch
76
+ pub fn new() -> Self {
77
+ Self::default()
78
+ }
79
+
80
+ /// Create a batch with pre-allocated capacity
81
+ pub fn with_capacity(capacity: usize) -> Self {
82
+ Self {
83
+ messages: Vec::with_capacity(capacity),
84
+ }
85
+ }
86
+
87
+ /// Add a message to the batch
88
+ pub fn push(&mut self, message: Message) {
89
+ self.messages.push(message);
90
+ }
91
+
92
+ /// Get the number of messages in the batch
93
+ pub fn len(&self) -> usize {
94
+ self.messages.len()
95
+ }
96
+
97
+ /// Check if the batch is empty
98
+ pub fn is_empty(&self) -> bool {
99
+ self.messages.is_empty()
100
+ }
101
+
102
+ /// Clear the batch
103
+ pub fn clear(&mut self) {
104
+ self.messages.clear();
105
+ }
106
+
107
+ /// Get the messages as a slice
108
+ pub fn as_slice(&self) -> &[Message] {
109
+ &self.messages
110
+ }
111
+
112
+ /// Take ownership of the messages
113
+ pub fn into_vec(self) -> Vec<Message> {
114
+ self.messages
115
+ }
116
+
117
+ /// Iterate over messages
118
+ pub fn iter(&self) -> impl Iterator<Item = &Message> {
119
+ self.messages.iter()
120
+ }
121
+ }
122
+
123
+ impl IntoIterator for MessageBatch {
124
+ type Item = Message;
125
+ type IntoIter = std::vec::IntoIter<Message>;
126
+
127
+ fn into_iter(self) -> Self::IntoIter {
128
+ self.messages.into_iter()
129
+ }
130
+ }
131
+
132
+ impl FromIterator<Message> for MessageBatch {
133
+ fn from_iter<T: IntoIterator<Item = Message>>(iter: T) -> Self {
134
+ Self {
135
+ messages: iter.into_iter().collect(),
136
+ }
137
+ }
138
+ }
139
+
140
+ #[cfg(test)]
141
+ mod tests {
142
+ use super::*;
143
+
144
+ #[test]
145
+ fn test_message_new() {
146
+ let msg = Message::new("session-123", "message", r#"{"hello":"world"}"#);
147
+ assert_eq!(msg.session_id, "session-123");
148
+ assert_eq!(msg.event_type, "message");
149
+ assert_eq!(msg.data, r#"{"hello":"world"}"#);
150
+ assert!(!msg.is_delivered());
151
+ }
152
+
153
+ #[test]
154
+ fn test_message_with_json() {
155
+ #[derive(Serialize)]
156
+ struct Payload {
157
+ hello: String,
158
+ }
159
+
160
+ let payload = Payload {
161
+ hello: "world".to_string(),
162
+ };
163
+ let msg = Message::with_json("session-123", "message", &payload).unwrap();
164
+ assert_eq!(msg.data, r#"{"hello":"world"}"#);
165
+ }
166
+
167
+ #[test]
168
+ fn test_mark_delivered() {
169
+ let mut msg = Message::new("session-123", "message", "{}");
170
+ assert!(!msg.is_delivered());
171
+
172
+ msg.mark_delivered();
173
+ assert!(msg.is_delivered());
174
+ assert!(msg.delivered_at.is_some());
175
+ }
176
+
177
+ #[test]
178
+ fn test_message_batch() {
179
+ let mut batch = MessageBatch::with_capacity(10);
180
+ assert!(batch.is_empty());
181
+
182
+ batch.push(Message::new("s1", "msg", "{}"));
183
+ batch.push(Message::new("s2", "msg", "{}"));
184
+
185
+ assert_eq!(batch.len(), 2);
186
+ assert!(!batch.is_empty());
187
+
188
+ let messages: Vec<_> = batch.into_iter().collect();
189
+ assert_eq!(messages.len(), 2);
190
+ }
191
+ }