solid_mcp 0.0.1 → 0.2.2

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.
@@ -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,37 @@
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
+
8
+ def initialize
9
+ @batch_size = 200
10
+ @flush_interval = 0.05 # 50ms
11
+ @polling_interval = 0.1 # 100ms
12
+ @max_wait_time = 30 # 30 seconds
13
+ @delivered_retention = 3600 # 1 hour in seconds
14
+ @undelivered_retention = 86400 # 24 hours in seconds
15
+ @logger = default_logger
16
+ end
17
+
18
+ def delivered_retention_seconds
19
+ @delivered_retention.seconds
20
+ end
21
+
22
+ def undelivered_retention_seconds
23
+ @undelivered_retention.seconds
24
+ end
25
+
26
+ private
27
+
28
+ def default_logger
29
+ if defined?(Rails) && Rails.respond_to?(:logger)
30
+ Rails.logger
31
+ else
32
+ require 'active_support/tagged_logging'
33
+ ActiveSupport::TaggedLogging.new(::Logger.new($stdout))
34
+ end
35
+ end
36
+ end
37
+ 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,156 @@
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 = Queue.new
17
+ @shutdown = Concurrent::AtomicBoolean.new(false)
18
+ @executor = Concurrent::ThreadPoolExecutor.new(
19
+ min_threads: 1,
20
+ max_threads: 1, # Single thread for ordered writes
21
+ max_queue: 0, # Unbounded queue
22
+ fallback_policy: :caller_runs
23
+ )
24
+ start_worker
25
+ end
26
+
27
+ # Called by publish API - non-blocking
28
+ def enqueue(session_id, event_type, data)
29
+ @queue << {
30
+ session_id: session_id,
31
+ event_type: event_type,
32
+ data: data.is_a?(String) ? data : data.to_json,
33
+ created_at: Time.current
34
+ }
35
+ end
36
+
37
+ # Blocks until executor has flushed everything
38
+ def shutdown
39
+ # Process any remaining messages in the queue
40
+ flush if @executor.running?
41
+
42
+ @shutdown.make_true
43
+ @executor.shutdown
44
+ @executor.wait_for_termination(10) # Wait up to 10 seconds
45
+ end
46
+
47
+ # Force flush any pending messages (useful for tests)
48
+ def flush
49
+ return unless @executor.running?
50
+
51
+ # Add a marker and wait for it to be processed
52
+ processed = Concurrent::CountDownLatch.new(1)
53
+ @queue << { flush_marker: processed }
54
+
55
+ # Wait up to 1 second for flush to complete
56
+ processed.wait(1)
57
+ end
58
+
59
+ private
60
+
61
+ def start_worker
62
+ @executor.post do
63
+ begin
64
+ SolidMCP::Logger.debug "MessageWriter worker thread started" if ENV["DEBUG_SOLID_MCP"]
65
+ run_loop
66
+ rescue => e
67
+ SolidMCP::Logger.error "MessageWriter worker thread crashed: #{e.message}"
68
+ SolidMCP::Logger.error e.backtrace.join("\n")
69
+ end
70
+ end
71
+ end
72
+
73
+ def run_loop
74
+ loop do
75
+ break if @shutdown.true? && @queue.empty?
76
+
77
+ batch = drain_batch
78
+ if batch.any?
79
+ SolidMCP::Logger.debug "MessageWriter processing batch of #{batch.size} messages" if ENV["DEBUG_SOLID_MCP"]
80
+ write_batch(batch)
81
+ else
82
+ sleep SolidMCP.configuration.flush_interval
83
+ end
84
+ end
85
+ rescue => e
86
+ SolidMCP::Logger.error "SolidMCP::MessageWriter error: #{e.message}"
87
+ retry unless @shutdown.true?
88
+ end
89
+
90
+ def drain_batch
91
+ batch = []
92
+ batch_size = SolidMCP.configuration.batch_size
93
+ flush_markers = []
94
+
95
+ # Try to get first item (non-blocking)
96
+ begin
97
+ item = @queue.pop(true)
98
+ # Handle flush markers
99
+ if item.is_a?(Hash) && item[:flush_marker]
100
+ flush_markers << item[:flush_marker]
101
+ else
102
+ batch << item
103
+ end
104
+ rescue ThreadError
105
+ # Signal any flush markers we've collected
106
+ flush_markers.each(&:count_down)
107
+ return batch
108
+ end
109
+
110
+ # Get remaining items up to batch size
111
+ while batch.size < batch_size
112
+ begin
113
+ item = @queue.pop(true)
114
+ # Handle flush markers
115
+ if item.is_a?(Hash) && item[:flush_marker]
116
+ flush_markers << item[:flush_marker]
117
+ else
118
+ batch << item
119
+ end
120
+ rescue ThreadError
121
+ break
122
+ end
123
+ end
124
+
125
+ # Signal any flush markers we've collected
126
+ flush_markers.each(&:count_down)
127
+ batch
128
+ end
129
+
130
+ def write_batch(batch)
131
+ return if batch.empty?
132
+
133
+ # Use raw SQL for maximum performance
134
+ SolidMCP::Message.connection_pool.with_connection do |conn|
135
+ values = batch.map do |msg|
136
+ [
137
+ conn.quote(msg[:session_id]),
138
+ conn.quote(msg[:event_type]),
139
+ conn.quote(msg[:data]),
140
+ conn.quote(msg[:created_at].utc.to_fs(:db))
141
+ ].join(",")
142
+ end
143
+
144
+ sql = <<-SQL
145
+ INSERT INTO solid_mcp_messages (session_id, event_type, data, created_at)
146
+ VALUES #{values.map { |v| "(#{v})" }.join(",")}
147
+ SQL
148
+
149
+ conn.execute(sql)
150
+ end
151
+ rescue => e
152
+ SolidMCP::Logger.error "SolidMCP::MessageWriter batch write error: #{e.message}"
153
+ # Could implement retry logic or dead letter queue here
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,58 @@
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
+ @subscriptions[session_id] ||= Concurrent::Array.new
17
+ @subscriptions[session_id] << block
18
+
19
+ # Start a listener for this session if not already running
20
+ ensure_listener_for(session_id)
21
+ end
22
+
23
+ # Unsubscribe from a session
24
+ def unsubscribe(session_id)
25
+ @subscriptions.delete(session_id)
26
+ stop_listener_for(session_id)
27
+ end
28
+
29
+ # Broadcast a message to a session (uses MessageWriter for batching)
30
+ def broadcast(session_id, event_type, data)
31
+ MessageWriter.instance.enqueue(session_id, event_type, data)
32
+ end
33
+
34
+ # Shutdown all listeners
35
+ def shutdown
36
+ @listeners.each do |_, listener|
37
+ listener.stop
38
+ end
39
+ @listeners.clear
40
+ MessageWriter.instance.shutdown
41
+ end
42
+
43
+ private
44
+
45
+ def ensure_listener_for(session_id)
46
+ return if @listeners[session_id]
47
+
48
+ listener = Subscriber.new(session_id, @subscriptions[session_id])
49
+ listener.start
50
+ @listeners[session_id] = listener
51
+ end
52
+
53
+ def stop_listener_for(session_id)
54
+ listener = @listeners.delete(session_id)
55
+ listener&.stop
56
+ end
57
+ end # class PubSub
58
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/atomic/atomic_boolean"
4
+ require "concurrent/timer_task"
5
+
6
+ module SolidMCP
7
+ class Subscriber
8
+ def initialize(session_id, callbacks)
9
+ @session_id = session_id
10
+ @callbacks = callbacks
11
+ @running = Concurrent::AtomicBoolean.new(false)
12
+ @last_message_id = 0
13
+ @timer_task = nil
14
+ @max_retries = ENV["RAILS_ENV"] == "test" ? 3 : Float::INFINITY
15
+ @retry_count = 0
16
+ end
17
+
18
+ def start
19
+ return if @running.true?
20
+
21
+ @running.make_true
22
+ @retry_count = 0
23
+
24
+ @timer_task = Concurrent::TimerTask.new(
25
+ execution_interval: SolidMCP.configuration.polling_interval,
26
+ run_now: true
27
+ ) do
28
+ poll_once
29
+ end
30
+
31
+ @timer_task.execute
32
+ end
33
+
34
+ def stop
35
+ @running.make_false
36
+ @timer_task&.shutdown
37
+ @timer_task&.wait_for_termination(5)
38
+ end
39
+
40
+ private
41
+
42
+ def poll_once
43
+ return unless @running.true?
44
+
45
+ # Ensure connection in thread
46
+ SolidMCP::Message.connection_pool.with_connection do
47
+ messages = fetch_new_messages
48
+ if messages.any?
49
+ process_messages(messages)
50
+ mark_delivered(messages)
51
+ @retry_count = 0
52
+ end
53
+ end
54
+ rescue => e
55
+ @retry_count += 1
56
+ SolidMCP::Logger.error "SolidMCP::Subscriber error for session #{@session_id}: #{e.message} (retry #{@retry_count}/#{@max_retries})"
57
+
58
+ if @retry_count >= @max_retries && @max_retries != Float::INFINITY
59
+ SolidMCP::Logger.error "SolidMCP::Subscriber max retries reached for session #{@session_id}, stopping"
60
+ stop
61
+ end
62
+ end
63
+
64
+ def fetch_new_messages
65
+ SolidMCP::Message
66
+ .for_session(@session_id)
67
+ .undelivered
68
+ .after_id(@last_message_id)
69
+ .order(:id)
70
+ .limit(100)
71
+ .to_a
72
+ end
73
+
74
+ def process_messages(messages)
75
+ messages.each do |message|
76
+ @callbacks.each do |callback|
77
+ begin
78
+ callback.call({
79
+ event_type: message.event_type,
80
+ data: message.data, # data is already a JSON string from the database
81
+ id: message.id
82
+ })
83
+ rescue => e
84
+ SolidMCP::Logger.error "SolidMCP callback error: #{e.message}"
85
+ end
86
+ end
87
+ @last_message_id = message.id
88
+ end
89
+ end
90
+
91
+ def mark_delivered(messages)
92
+ SolidMCP::Message.mark_delivered(messages.map(&:id))
93
+ end
94
+ end
95
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SolidMcp
4
- VERSION = "0.0.1"
3
+ module SolidMCP
4
+ VERSION = "0.2.2"
5
5
  end
data/lib/solid_mcp.rb CHANGED
@@ -1,8 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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)
4
7
 
5
- module SolidMcp
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
6
22
  class Error < StandardError; end
7
- # Your code goes here...
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
8
39
  end
@@ -0,0 +1,8 @@
1
+ {
2
+ "packages": {
3
+ ".": {
4
+ "release-type": "ruby",
5
+ "package-name": "solid_mcp"
6
+ }
7
+ }
8
+ }
data/sig/solid_mcp.rbs CHANGED
@@ -1,4 +1,4 @@
1
- module SolidMcp
1
+ module SolidMCP
2
2
  VERSION: String
3
3
  # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
4
  end
data/solid_mcp.gemspec CHANGED
@@ -4,7 +4,7 @@ require_relative "lib/solid_mcp/version"
4
4
 
5
5
  Gem::Specification.new do |spec|
6
6
  spec.name = "solid_mcp"
7
- spec.version = SolidMcp::VERSION
7
+ spec.version = SolidMCP::VERSION
8
8
  spec.authors = ["Abdelkader Boudih"]
9
9
  spec.email = ["terminale@gmail.com"]
10
10
 
@@ -28,7 +28,12 @@ Gem::Specification.new do |spec|
28
28
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
29
  spec.require_paths = ["lib"]
30
30
 
31
- spec.add_dependency "actionmcp"
32
- spec.add_dependency "activerecord", ">= 8"
33
- spec.add_dependency "railties", ">= 8"
31
+ spec.add_dependency "activerecord", ">= 8.0"
32
+ spec.add_dependency "railties", ">= 8.0"
33
+ spec.add_dependency "activejob", ">= 8.0"
34
+ spec.add_dependency "concurrent-ruby", "~> 1.0"
35
+
36
+ spec.add_development_dependency "minitest", "~> 5.0"
37
+ spec.add_development_dependency "sqlite3", "~> 2.0"
38
+ spec.add_development_dependency "rake", "~> 13.0"
34
39
  end