fast_mcp_pubsub 1.1.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db696202b8d3b7dae7c2231fcb132d6dba086b20e578eac993aac24bb92d820b
4
- data.tar.gz: 67f0d7bf55c87fcc35a1f9a9cc2686fef93329ff958c4377e823027670724aea
3
+ metadata.gz: 9a955c6fc8f0b93d011d31fd196d7605b4925eb114d1355f99232ee2f8a15efb
4
+ data.tar.gz: 5b5ceaa86be4ecdb6cf74d2d913b0efb12b1f7a0668e0fb171a92d859cff1993
5
5
  SHA512:
6
- metadata.gz: 50b8171fab26238c6177e7d450eef9218adc02b76bb1e6434e65a80ef4c0ff998e43f2c3e538fccadde7097ac8928ff0b20c129aba73d36ec8d2a128a213ab2d
7
- data.tar.gz: d386411918a12d71f91cb2b9a40a2b3aaa6bf11d527bb637c231a3bc5c9dc76e53814d91a222d418c8dea02ced23ad41aff72bb5db94d48f1982083e65000fca
6
+ metadata.gz: 67bc13d26facefac99906e013bf444805412422690e444037a3d11779703c879f225038e32813614d6f84f58b50f13d77ac88266d67e4729902e69880ccdbb0d
7
+ data.tar.gz: eaa38f519aad1c30c7aaac06c35dff2e120b27623f7e1097728a70f442de167b0f9fe0b91f1b4f36f93af51f70a8301d99c4ae96a7c2f27dc8e15ae50b7b42ae
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.0] - 2026-08-20
9
+
10
+ ### Fixed
11
+ - **A JSON-RPC response no longer reaches every connected SSE client.** Every
12
+ response was published as a bare NOTIFY payload and then written into every
13
+ entry of every worker's `@sse_clients`, so one client's answer landed in every
14
+ other client's stream — across projects and across users. The only thing
15
+ keeping clients apart was JSON-RPC id matching on the client side, which
16
+ collides whenever two clients open fresh sessions at the same moment and both
17
+ begin counting ids from 1. Observed in production as a runner installed for
18
+ one project picking up a task belonging to another.
19
+ - `stop_listener` now clears `@listener_thread` when the thread has already died
20
+ instead of leaving a dead reference behind, which callers read as "a listener
21
+ is running".
22
+
23
+ ### Added
24
+ - `FastMcpPubsub::CurrentClient` — thread-local record of which SSE client the
25
+ request being served belongs to.
26
+ - `FastMcpPubsub::AddressingPatch` — the three pieces that carry FastMcp's
27
+ `client_id` end to end: it is appended to the endpoint URL handed to the client
28
+ on connect, captured from the client's POSTs, and used to deliver a response to
29
+ that one client via the new `send_local_message_to`.
30
+ - `_pubsub_target` on the NOTIFY envelope, so every worker can tell whether a
31
+ message is addressed to a client it holds.
32
+
33
+ ### Compatibility
34
+ - A message with no target still fans out, so genuine notifications and clients
35
+ that supply no id behave exactly as before.
36
+ - Bare, un-enveloped payloads from a worker still running an older gem are
37
+ recognised and delivered, so a rolling restart does not drop messages.
38
+
8
39
  ## [1.0.0] - 2025-08-19
9
40
 
10
41
  ### Added
data/README.md CHANGED
@@ -104,6 +104,28 @@ FastMcpPubsub::Service.listener_thread&.alive?
104
104
  3. **Listener threads**: Each worker has a dedicated listener thread
105
105
  4. **Local delivery**: Messages are delivered to local SSE clients in each worker
106
106
 
107
+ ### Addressing
108
+
109
+ A JSON-RPC **response** is delivered only to the client that asked for it; a
110
+ **notification** still reaches everyone. FastMcp answers over SSE and writes each
111
+ message to every open stream, so without addressing one client's response lands
112
+ in every other client's session — and the only thing separating them is
113
+ JSON-RPC id matching, which collides as soon as two clients open fresh sessions
114
+ at the same moment and both start counting ids from 1.
115
+
116
+ The gem closes that by carrying FastMcp's own `client_id` end to end:
117
+
118
+ - the endpoint URL handed to a client on connect carries its `client_id`
119
+ - the client's POSTs come back with it, and it is held in `CurrentClient` for the
120
+ length of the request
121
+ - `send_message` stamps it onto the NOTIFY envelope as `_pubsub_target`
122
+ - each worker delivers a targeted message only to that client, and ignores one
123
+ addressed to a client another worker holds
124
+
125
+ A message with no target — a genuine notification, or a request from a client
126
+ that supplied no id — falls back to the previous fan-out, so older clients keep
127
+ working.
128
+
107
129
  ## Configuration Options
108
130
 
109
131
  | Option | Default | Description |
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module FastMcpPubsub
6
+ # The half of the RackTransport patch that makes a response reachable by the one
7
+ # client that asked for it.
8
+ #
9
+ # FastMcp answers a JSON-RPC request over SSE and writes every message to every
10
+ # connected stream, so before this the only thing keeping two clients apart was
11
+ # JSON-RPC id matching — which fails the moment two of them open fresh sessions
12
+ # in the same minute and both start counting ids from 1.
13
+ #
14
+ # Correlating a response with its client takes three pieces, and none of them
15
+ # works alone: the client id has to reach the client (the endpoint URL), come
16
+ # back on its POSTs (the capture), and have somewhere to be delivered to (the
17
+ # targeted send).
18
+ module AddressingPatch
19
+ def self.apply!
20
+ add_targeted_send
21
+ add_sse_writer
22
+ add_client_id_capture
23
+ add_endpoint_client_id
24
+ end
25
+
26
+ # Delivery to one named SSE client, the counterpart of FastMcp's own
27
+ # send_message. Returns false when this worker does not hold the client,
28
+ # which is the normal answer on every worker but one.
29
+ def self.add_targeted_send
30
+ FastMcp::Transports::RackTransport.class_eval do
31
+ return if method_defined?(:send_local_message_to)
32
+
33
+ define_method(:send_local_message_to) do |client_id, message|
34
+ client = @sse_clients[client_id]
35
+ return false unless client
36
+
37
+ write_sse_payload(client, message.is_a?(String) ? message : JSON.generate(message))
38
+ rescue StandardError => e
39
+ FastMcpPubsub.logger.info "RackTransport: Client #{client_id} unreachable (#{e.message}), unregistering"
40
+ unregister_sse_client(client_id)
41
+ false
42
+ end
43
+ end
44
+ end
45
+
46
+ def self.add_sse_writer
47
+ FastMcp::Transports::RackTransport.class_eval do
48
+ return if method_defined?(:write_sse_payload)
49
+
50
+ define_method(:write_sse_payload) do |client, json_message|
51
+ stream = client[:stream]
52
+ mutex = client[:mutex]
53
+ return false if stream.nil? || mutex.nil? || (stream.respond_to?(:closed?) && stream.closed?)
54
+
55
+ mutex.synchronize do
56
+ stream.write("data: #{json_message}\n\n")
57
+ stream.flush if stream.respond_to?(:flush)
58
+ end
59
+ true
60
+ end
61
+ private :write_sse_payload
62
+ end
63
+ end
64
+
65
+ # Records which client the POST belongs to, so send_message can address the
66
+ # response FastMcp is about to write.
67
+ #
68
+ # request.GET rather than request.params on purpose: params merges the POST
69
+ # body, and reading the body here would leave nothing for the JSON parse that
70
+ # follows.
71
+ def self.add_client_id_capture
72
+ FastMcp::Transports::RackTransport.class_eval do
73
+ return unless private_method_defined?(:handle_message_request_with_server)
74
+ return if private_method_defined?(:handle_message_request_without_pubsub)
75
+
76
+ alias_method :handle_message_request_without_pubsub, :handle_message_request_with_server
77
+
78
+ define_method(:handle_message_request_with_server) do |request, server|
79
+ FastMcpPubsub::CurrentClient.with(request.GET["client_id"]) do
80
+ handle_message_request_without_pubsub(request, server)
81
+ end
82
+ end
83
+ private :handle_message_request_with_server
84
+ end
85
+ end
86
+
87
+ # Puts the client id into the endpoint URL FastMcp hands the client on
88
+ # connect, so the client's POSTs come back carrying it.
89
+ #
90
+ # FastMcp echoes the SSE request's own query string into that endpoint and
91
+ # never adds the id it just generated, so without this the capture above has
92
+ # nothing to read and every response falls back to the fan-out.
93
+ def self.add_endpoint_client_id
94
+ FastMcp::Transports::RackTransport.class_eval do
95
+ return unless private_method_defined?(:setup_sse_connection)
96
+ return if private_method_defined?(:setup_sse_connection_without_pubsub)
97
+
98
+ alias_method :setup_sse_connection_without_pubsub, :setup_sse_connection
99
+
100
+ define_method(:setup_sse_connection) do |client_id, io, env|
101
+ query = FastMcpPubsub::AddressingPatch.query_with_client_id(env["QUERY_STRING"], client_id)
102
+ setup_sse_connection_without_pubsub(client_id, io, env.merge("QUERY_STRING" => query))
103
+ end
104
+ private :setup_sse_connection
105
+ end
106
+ end
107
+
108
+ # Appends client_id to an SSE query string, leaving a client that named its
109
+ # own id alone — FastMcp honours that one, so overriding it would hand back an
110
+ # endpoint pointing at a different session than the stream it arrived on.
111
+ def self.query_with_client_id(query_string, client_id)
112
+ return query_string if query_string.to_s.match?(/(\A|&)client_id=/)
113
+
114
+ [query_string, "client_id=#{CGI.escape(client_id.to_s)}"].reject { |part| part.to_s.empty? }.join("&")
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastMcpPubsub
4
+ # Thread-local record of which SSE client the request being served belongs to.
5
+ #
6
+ # FastMcp answers a JSON-RPC request over the client's SSE stream rather than in
7
+ # the POST body, and MCP::Server#send_response has no idea which stream that is.
8
+ # The transport does know: the client id arrives as a query parameter on the
9
+ # POST. Stashing it here for the length of the request is what lets the response
10
+ # be addressed instead of broadcast.
11
+ #
12
+ # A thread local is the right scope because FastMcp handles the POST
13
+ # synchronously — handle_request calls send_response on the very thread that
14
+ # entered handle_message_request_with_server, so the value set on the way in is
15
+ # still there on the way out.
16
+ module CurrentClient
17
+ KEY = :fast_mcp_pubsub_client_id
18
+
19
+ class << self
20
+ # The client id of the request being served, or nil outside one — which is
21
+ # what a genuine notification looks like, and those really are for everyone.
22
+ def id
23
+ Thread.current[KEY]
24
+ end
25
+
26
+ # Runs the block with client_id in scope, restoring whatever was there
27
+ # before. Restoring rather than clearing keeps nesting honest; Puma reuses
28
+ # its threads, and a stale id left behind would address the next request's
29
+ # response to the previous request's client.
30
+ def with(client_id)
31
+ previous = Thread.current[KEY]
32
+ Thread.current[KEY] = client_id
33
+ yield
34
+ ensure
35
+ Thread.current[KEY] = previous
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastMcpPubsub
4
+ # What a worker does with a message that arrives on the channel.
5
+ #
6
+ # Every worker receives every NOTIFY, so this is where a message is matched to
7
+ # the clients it is actually for: an addressed one reaches the single client
8
+ # that asked, and only on the worker holding it, while an unaddressed one — a
9
+ # genuine notification — still fans out. See AddressingPatch for how the target
10
+ # gets onto the envelope in the first place.
11
+ module Delivery
12
+ private
13
+
14
+ def handle_notification(pid, payload)
15
+ FastMcpPubsub.logger.debug "FastMcpPubsub: Received notification from PID #{pid}: #{payload}"
16
+
17
+ begin
18
+ envelope = JSON.parse(payload)
19
+ message = unwrap(envelope)
20
+ return unless message # Reference already expired
21
+
22
+ deliver_to_transports(message, target_of(envelope))
23
+ rescue JSON::ParserError => e
24
+ FastMcpPubsub.logger.error "FastMcpPubsub: Invalid JSON payload: #{e.message}"
25
+ rescue StandardError => e
26
+ FastMcpPubsub.logger.error "FastMcpPubsub: Error handling notification: #{e.message}"
27
+ end
28
+ end
29
+
30
+ # The client this message is addressed to, or nil for a fan-out.
31
+ def target_of(envelope)
32
+ envelope["_pubsub_target"] if envelope.is_a?(Hash)
33
+ end
34
+
35
+ # Unwraps the three shapes that arrive on the channel: an inline message, a
36
+ # database reference for one too large to fit in a NOTIFY, and a bare
37
+ # JSON-RPC message with no envelope at all — which is what a worker still
38
+ # running an older gem publishes during a rolling restart.
39
+ def unwrap(envelope)
40
+ return envelope unless envelope.is_a?(Hash)
41
+ return envelope["_pubsub_message"] if envelope.key?("_pubsub_message")
42
+ return envelope unless envelope.key?("_pubsub_ref")
43
+
44
+ stored_payload = MessageStore.fetch(envelope["_pubsub_ref"])
45
+ stored_payload && JSON.parse(stored_payload)
46
+ end
47
+
48
+ def deliver_to_transports(message, client_id = nil)
49
+ # Find active RackTransport instances and send to local clients
50
+ return unless defined?(FastMcp::Transports::RackTransport)
51
+
52
+ transports = transport_instances
53
+ FastMcpPubsub.logger.debug "FastMcpPubsub: Found #{transports.size} transport instances"
54
+
55
+ transports.each do |transport|
56
+ FastMcpPubsub.logger.debug "FastMcpPubsub: Sending message to transport #{transport.object_id}"
57
+ next transport.send_local_message_to(client_id, message) if client_id
58
+
59
+ transport.send_local_message(message)
60
+ end
61
+ end
62
+
63
+ def transport_instances
64
+ # Find all RackTransport instances - don't filter by running? since it's not reliably implemented
65
+ ObjectSpace.each_object(FastMcp::Transports::RackTransport).to_a
66
+ rescue StandardError
67
+ []
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastMcpPubsub
4
+ class MessageStore
5
+ TABLE_NAME = "fast_mcp_pubsub_messages"
6
+
7
+ class << self
8
+ def store(payload)
9
+ ensure_table_exists
10
+ id = SecureRandom.uuid
11
+ ActiveRecord::Base.connection.execute(
12
+ "INSERT INTO #{TABLE_NAME} (id, payload, created_at) VALUES (#{quote(id)}, #{quote(payload)}, NOW())"
13
+ )
14
+ id
15
+ end
16
+
17
+ def fetch(id)
18
+ ensure_table_exists
19
+ ActiveRecord::Base.connection.select_value(
20
+ "SELECT payload FROM #{TABLE_NAME} WHERE id = #{quote(id)}"
21
+ )
22
+ end
23
+
24
+ def cleanup(older_than: Time.now - 300)
25
+ return unless @table_exists
26
+
27
+ ActiveRecord::Base.connection.execute(
28
+ "DELETE FROM #{TABLE_NAME} WHERE created_at < #{quote(older_than.iso8601)}"
29
+ )
30
+ end
31
+
32
+ def ensure_table_exists
33
+ return if @table_exists
34
+
35
+ unless ActiveRecord::Base.connection.table_exists?(TABLE_NAME)
36
+ ActiveRecord::Base.connection.execute(<<~SQL)
37
+ CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
38
+ id UUID PRIMARY KEY,
39
+ payload TEXT NOT NULL,
40
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
41
+ )
42
+ SQL
43
+ end
44
+ @table_exists = true
45
+ end
46
+
47
+ private
48
+
49
+ def quote(value)
50
+ ActiveRecord::Base.connection.quote(value)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -30,6 +30,7 @@ module FastMcpPubsub
30
30
  add_basic_methods
31
31
  add_send_message_override
32
32
  add_fallback_method
33
+ AddressingPatch.apply!
33
34
  end
34
35
 
35
36
  def self.add_basic_methods
@@ -46,7 +47,9 @@ module FastMcpPubsub
46
47
  alias_method :send_message_original, :send_message if method_defined?(:send_message)
47
48
 
48
49
  define_method(:send_message) do |message|
49
- FastMcpPubsub.config.enabled ? broadcast_with_fallback(message) : send_local_message(message)
50
+ return send_local_message(message) unless FastMcpPubsub.config.enabled
51
+
52
+ broadcast_with_fallback(message, FastMcpPubsub::CurrentClient.id)
50
53
  end
51
54
 
52
55
  alias_method :send_message_with_pubsub, :send_message
@@ -57,12 +60,15 @@ module FastMcpPubsub
57
60
  FastMcp::Transports::RackTransport.class_eval do
58
61
  return if method_defined?(:broadcast_with_fallback)
59
62
 
60
- define_method(:broadcast_with_fallback) do |message|
63
+ define_method(:broadcast_with_fallback) do |message, client_id = nil|
61
64
  FastMcpPubsub.logger.debug "RackTransport: Broadcasting message via PostgreSQL PubSub"
62
- FastMcpPubsub::Service.broadcast(message)
65
+ FastMcpPubsub::Service.broadcast(message, client_id)
63
66
  rescue StandardError => e
64
67
  FastMcpPubsub.logger.error "RackTransport: Error broadcasting message: #{e.message}"
65
- send_local_message(message)
68
+ # An addressed message stays addressed even when the cluster hop fails.
69
+ # Falling back to the fan-out would answer every other open session with
70
+ # this client's data, which is the failure the addressing exists to end.
71
+ client_id ? send_local_message_to(client_id, message) : send_local_message(message)
66
72
  end
67
73
  end
68
74
  end
@@ -27,6 +27,27 @@ module FastMcpPubsub
27
27
  FastMcpPubsub::RackTransportPatch.apply_patch!
28
28
  end
29
29
 
30
+ # Handle hot-reload in development mode - stop/restart listener to avoid connection pool issues
31
+ initializer "fast_mcp_pubsub.reloader_hooks" do |app|
32
+ railtie = self
33
+
34
+ # Stop listener before hot-reload to release dedicated connection
35
+ app.reloader.before_class_unload do
36
+ if FastMcpPubsub::Service.listener_thread&.alive?
37
+ Rails.logger.info "FastMcpPubsub: Stopping listener before code reload"
38
+ FastMcpPubsub::Service.stop_listener
39
+ end
40
+ end
41
+
42
+ # Restart listener after hot-reload completes
43
+ app.reloader.to_complete do
44
+ if railtie.send(:should_restart_listener?)
45
+ Rails.logger.info "FastMcpPubsub: Restarting listener after code reload"
46
+ FastMcpPubsub::Service.start_listener
47
+ end
48
+ end
49
+ end
50
+
30
51
  # NOTE: For cluster mode, add FastMcpPubsub::Service.start_listener to your
31
52
  # on_worker_boot hook in config/puma/production.rb
32
53
 
@@ -40,8 +61,25 @@ module FastMcpPubsub
40
61
  !instance_variable_get(:@listener_started)
41
62
  end
42
63
 
64
+ def should_restart_listener?
65
+ # For reloader - don't check @listener_started, just basic conditions
66
+ web_server_environment? &&
67
+ !cluster_mode? &&
68
+ FastMcpPubsub.config.enabled &&
69
+ FastMcpPubsub.config.auto_start &&
70
+ !FastMcpPubsub::Service.listener_thread&.alive?
71
+ end
72
+
43
73
  def web_server_environment?
44
- defined?(Rails::Server) || defined?(Puma) || ENV["MCP_SERVER_AUTO_START"] == "true"
74
+ defined?(Rails::Server) || puma_cli_running? || ENV["MCP_SERVER_AUTO_START"] == "true"
75
+ end
76
+
77
+ def puma_cli_running?
78
+ # defined?(Puma) is true whenever Puma gem is loaded (even in test runner, rake tasks, etc.)
79
+ # Puma.cli_config is only set when Puma was actually started via CLI (rails server, puma command)
80
+ defined?(Puma) &&
81
+ Puma.respond_to?(:cli_config) &&
82
+ !Puma.cli_config.nil?
45
83
  end
46
84
 
47
85
  def cluster_mode?
@@ -6,12 +6,22 @@ module FastMcpPubsub
6
6
  MAX_PAYLOAD_SIZE = 7800 # PostgreSQL NOTIFY limit is 8000 bytes, leave some margin
7
7
 
8
8
  class << self
9
- attr_reader :listener_thread
10
-
11
- def broadcast(message)
12
- payload = message.to_json
13
-
14
- payload_too_large?(payload) ? send_error_response(message, payload) : send_payload(payload)
9
+ include Delivery
10
+
11
+ attr_reader :listener_thread, :dedicated_connection
12
+ attr_accessor :shutdown_requested
13
+
14
+ # Publishes one MCP message to the cluster.
15
+ #
16
+ # client_id names the SSE client the message answers; nil means it is for
17
+ # everyone, which is what a genuine notification is. A response carries an
18
+ # id because every worker receives every NOTIFY, and without one the worker
19
+ # holding an unrelated session would write this answer into it.
20
+ def broadcast(message, client_id = nil)
21
+ envelope = envelope_for(message)
22
+ envelope[:_pubsub_target] = client_id if client_id
23
+
24
+ send_payload(envelope.to_json)
15
25
  rescue StandardError => e
16
26
  FastMcpPubsub.logger.error "FastMcpPubsub: Error broadcasting message: #{e.message}"
17
27
  raise
@@ -40,16 +50,51 @@ module FastMcpPubsub
40
50
  end
41
51
 
42
52
  def stop_listener
43
- return unless @listener_thread&.alive?
53
+ # A thread that has already died on its own still leaves its reference
54
+ # behind, and every caller reads that reference as "a listener is
55
+ # running". Clearing it is part of stopping, not a separate errand.
56
+ return @listener_thread = nil unless @listener_thread&.alive?
44
57
 
45
58
  FastMcpPubsub.logger.info "FastMcpPubsub: Stopping listener thread for PID #{Process.pid}"
46
- @listener_thread.kill
47
- @listener_thread.join(5) # Wait max 5 seconds
48
- @listener_thread = nil
59
+ @shutdown_requested = true
60
+
61
+ wake_listener
62
+ join_listener
63
+ discard_dedicated_connection
64
+ @shutdown_requested = false
49
65
  end
50
66
 
51
67
  private
52
68
 
69
+ # Cancels the in-flight wait_for_notify so the thread reaches its next
70
+ # shutdown check instead of sitting out the rest of the timeout.
71
+ def wake_listener
72
+ @dedicated_connection&.cancel
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ # Five seconds to notice, then it is taken down: a thread stuck inside
78
+ # PG.connect during a reconnect never notices on its own.
79
+ def join_listener
80
+ @listener_thread.join(5)
81
+
82
+ if @listener_thread&.alive?
83
+ @listener_thread.kill
84
+ @listener_thread.join(1)
85
+ end
86
+
87
+ @listener_thread = nil
88
+ end
89
+
90
+ def discard_dedicated_connection
91
+ @dedicated_connection&.close
92
+ rescue StandardError
93
+ nil
94
+ ensure
95
+ @dedicated_connection = nil
96
+ end
97
+
53
98
  def send_payload(payload)
54
99
  channel = FastMcpPubsub.config.channel_name
55
100
  FastMcpPubsub.logger.debug "FastMcpPubsub: Broadcasting message to #{channel}: #{payload.bytesize} bytes"
@@ -63,83 +108,88 @@ module FastMcpPubsub
63
108
  payload.bytesize > MAX_PAYLOAD_SIZE
64
109
  end
65
110
 
66
- def send_error_response(message, payload)
67
- FastMcpPubsub.logger.error "FastMcpPubsub: Payload too large (#{payload.bytesize} bytes > #{MAX_PAYLOAD_SIZE} bytes)"
68
-
69
- error_message = {
70
- jsonrpc: "2.0",
71
- id: message[:id],
72
- error: {
73
- code: -32_001,
74
- message: "Response too large for PostgreSQL NOTIFY. Try requesting smaller page size."
75
- }
76
- }
111
+ # Wraps the message for the wire: inline when it fits in a NOTIFY, a
112
+ # database reference when it does not. The envelope is also what gives the
113
+ # target somewhere to live.
114
+ def envelope_for(message)
115
+ payload = message.to_json
116
+ return { _pubsub_message: message } unless payload_too_large?(payload)
77
117
 
78
- send_payload(error_message.to_json)
118
+ ref_id = MessageStore.store(payload)
119
+ FastMcpPubsub.logger.debug "FastMcpPubsub: Payload too large (#{payload.bytesize} bytes), stored as #{ref_id}"
120
+ { _pubsub_ref: ref_id }
79
121
  end
80
122
 
123
+ # The listener thread's whole life. `retry` re-enters the begin block
124
+ # without running the ensure, which is what lets a reconnect keep the
125
+ # LISTEN it is about to re-establish — so the split below has to keep the
126
+ # begin/rescue/ensure here rather than push it into a helper.
81
127
  def listen_loop
82
128
  channel = FastMcpPubsub.config.channel_name
83
129
 
84
130
  begin
85
- ActiveRecord::Base.connection_pool.with_connection do |conn|
86
- raw_conn = conn.raw_connection
87
-
88
- FastMcpPubsub.logger.info "FastMcpPubsub: Listening on #{channel} for PID #{Process.pid}"
89
- raw_conn.async_exec("LISTEN #{channel}")
90
-
91
- begin
92
- loop do
93
- raw_conn.wait_for_notify do |channel, pid, payload|
94
- handle_notification(channel, pid, payload)
95
- end
96
- end
97
- ensure
98
- begin
99
- raw_conn.async_exec("UNLISTEN #{channel}")
100
- rescue StandardError => e
101
- FastMcpPubsub.logger.error "FastMcpPubsub: Error during UNLISTEN: #{e.message}"
102
- end
103
- end
104
- end
131
+ open_listener(channel)
132
+ consume_notifications
105
133
  rescue StandardError => e
106
- FastMcpPubsub.logger.error "FastMcpPubsub: Listener error: #{e.message}"
107
- FastMcpPubsub.logger.error e.backtrace.join("\n")
108
-
109
- # Restart after error
110
- sleep 1
111
- retry
134
+ retry if listener_should_recover?(e)
135
+ ensure
136
+ close_listener(channel)
112
137
  end
113
138
  end
114
139
 
115
- def handle_notification(_channel, pid, payload)
116
- FastMcpPubsub.logger.debug "FastMcpPubsub: Received notification from PID #{pid}: #{payload}"
140
+ def open_listener(channel)
141
+ @dedicated_connection = create_dedicated_connection
117
142
 
118
- begin
119
- message = JSON.parse(payload)
143
+ FastMcpPubsub.logger.info "FastMcpPubsub: Listening on #{channel} for PID #{Process.pid}"
144
+ @dedicated_connection.exec("LISTEN #{channel}")
145
+ end
146
+
147
+ def consume_notifications
148
+ loop do
149
+ break if @shutdown_requested
120
150
 
121
- # Find active RackTransport instances and send to local clients
122
- if defined?(FastMcp::Transports::RackTransport)
123
- transports = transport_instances
124
- FastMcpPubsub.logger.debug "FastMcpPubsub: Found #{transports.size} transport instances"
151
+ @cleanup_counter = (@cleanup_counter || 0) + 1
152
+ MessageStore.cleanup if (@cleanup_counter % 60).zero? # Every ~60s (1s per loop)
125
153
 
126
- transports.each do |transport|
127
- FastMcpPubsub.logger.debug "FastMcpPubsub: Sending message to transport #{transport.object_id}"
128
- transport.send_local_message(message)
129
- end
154
+ @dedicated_connection.wait_for_notify(1) do |_channel, pid, payload|
155
+ handle_notification(pid, payload)
130
156
  end
131
- rescue JSON::ParserError => e
132
- FastMcpPubsub.logger.error "FastMcpPubsub: Invalid JSON payload: #{e.message}"
133
- rescue StandardError => e
134
- FastMcpPubsub.logger.error "FastMcpPubsub: Error handling notification: #{e.message}"
135
157
  end
136
158
  end
137
159
 
138
- def transport_instances
139
- # Find all RackTransport instances - don't filter by running? since it's not reliably implemented
140
- ObjectSpace.each_object(FastMcp::Transports::RackTransport).to_a
141
- rescue StandardError
142
- []
160
+ # Reports whether the loop should come back up, having logged and paused if
161
+ # so. A shutdown in progress is not an error to recover from — it is the
162
+ # exit, and the error is the connection being cancelled out from under us.
163
+ def listener_should_recover?(error)
164
+ return false if @shutdown_requested
165
+
166
+ FastMcpPubsub.logger.error "FastMcpPubsub: Listener error: #{error.message}"
167
+ FastMcpPubsub.logger.error error.backtrace.join("\n")
168
+ sleep 1
169
+ true
170
+ end
171
+
172
+ def close_listener(channel)
173
+ @dedicated_connection&.exec("UNLISTEN #{channel}")
174
+ @dedicated_connection&.close
175
+ rescue StandardError => e
176
+ FastMcpPubsub.logger.error "FastMcpPubsub: Error during cleanup: #{e.message}"
177
+ ensure
178
+ @dedicated_connection = nil
179
+ end
180
+
181
+ def create_dedicated_connection
182
+ db_config = ActiveRecord::Base.connection_db_config.configuration_hash
183
+
184
+ conn_params = {
185
+ host: db_config[:host],
186
+ port: db_config[:port],
187
+ dbname: db_config[:database],
188
+ user: db_config[:username],
189
+ password: db_config[:password]
190
+ }.compact
191
+
192
+ PG.connect(conn_params)
143
193
  end
144
194
  end
145
195
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastMcpPubsub
4
- VERSION = "1.1.0"
4
+ VERSION = "1.4.0"
5
5
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require_relative "fast_mcp_pubsub/version"
4
4
  require_relative "fast_mcp_pubsub/configuration"
5
+ require_relative "fast_mcp_pubsub/current_client"
6
+ require_relative "fast_mcp_pubsub/message_store"
7
+ require_relative "fast_mcp_pubsub/delivery"
5
8
  require_relative "fast_mcp_pubsub/service"
6
9
 
7
10
  # PostgreSQL NOTIFY/LISTEN clustering support for FastMcp RackTransport.
@@ -30,5 +33,6 @@ module FastMcpPubsub
30
33
  end
31
34
 
32
35
  # Load patch after module is fully defined
36
+ require_relative "fast_mcp_pubsub/addressing_patch"
33
37
  require_relative "fast_mcp_pubsub/rack_transport_patch"
34
38
  require_relative "fast_mcp_pubsub/railtie" if defined?(Rails)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast_mcp_pubsub
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - josefchmel
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-10-02 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: concurrent-ruby
@@ -115,17 +115,16 @@ executables: []
115
115
  extensions: []
116
116
  extra_rdoc_files: []
117
117
  files:
118
- - ".mcp.json"
119
- - ".rubocop.yml"
120
- - ".ruby-version"
121
118
  - CHANGELOG.md
122
- - CLAUDE.md
123
119
  - LICENSE.txt
124
120
  - README.md
125
121
  - Rakefile
126
- - lib/.DS_Store
127
122
  - lib/fast_mcp_pubsub.rb
123
+ - lib/fast_mcp_pubsub/addressing_patch.rb
128
124
  - lib/fast_mcp_pubsub/configuration.rb
125
+ - lib/fast_mcp_pubsub/current_client.rb
126
+ - lib/fast_mcp_pubsub/delivery.rb
127
+ - lib/fast_mcp_pubsub/message_store.rb
129
128
  - lib/fast_mcp_pubsub/rack_transport_patch.rb
130
129
  - lib/fast_mcp_pubsub/railtie.rb
131
130
  - lib/fast_mcp_pubsub/service.rb
@@ -154,7 +153,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
154
153
  - !ruby/object:Gem::Version
155
154
  version: '0'
156
155
  requirements: []
157
- rubygems_version: 3.6.2
156
+ rubygems_version: 4.0.6
158
157
  specification_version: 4
159
158
  summary: PostgreSQL NOTIFY/LISTEN clustering support for FastMcp RackTransport
160
159
  test_files: []
data/.mcp.json DELETED
@@ -1,30 +0,0 @@
1
- {
2
- "mcpServers": {
3
- "workvector-production": {
4
- "type": "sse",
5
- "name": "WorkVector Production",
6
- "url": "https://workvector.com/mcp/sse",
7
- "headers": {
8
- "Authorization": "Bearer ${WORKVECTOR_TOKEN}"
9
- }
10
- },
11
- "filesystem-project": {
12
- "type": "stdio",
13
- "name": "Filesystem",
14
- "command": "npx",
15
- "args": [
16
- "-y",
17
- "@modelcontextprotocol/server-filesystem",
18
- "${PWD}"
19
- ]
20
- },
21
- "llmmn-production": {
22
- "type": "sse",
23
- "name": "LLM Memory Notes Production",
24
- "url": "https://llm-memory.com/mcp/sse",
25
- "headers": {
26
- "Authorization": "Bearer ${LLMMN_TOKEN}"
27
- }
28
- }
29
- }
30
- }
data/.rubocop.yml DELETED
@@ -1,40 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.1
3
- NewCops: enable
4
- SuggestExtensions: false
5
-
6
- plugins:
7
- - rubocop-minitest
8
-
9
- Layout/LineLength:
10
- Max: 200
11
-
12
- Style/StringLiterals:
13
- EnforcedStyle: double_quotes
14
-
15
- Style/StringLiteralsInInterpolation:
16
- EnforcedStyle: double_quotes
17
-
18
- # Relax some metrics for reasonable code
19
- Metrics/ClassLength:
20
- Max: 150
21
-
22
- Metrics/MethodLength:
23
- Max: 30
24
-
25
- Metrics/AbcSize:
26
- Max: 35
27
-
28
- # Allow development dependencies in gemspec for gems
29
- Gemspec/DevelopmentDependencies:
30
- Enabled: false
31
-
32
- Style/Documentation:
33
- Enabled: false
34
-
35
- # Allow longer blocks for configuration and setup
36
- Metrics/BlockLength:
37
- Exclude:
38
- - "test/**/*"
39
- - "*.gemspec"
40
- - "lib/fast_mcp_pubsub/railtie.rb" # Debug logging temporarily increases block length
data/.ruby-version DELETED
@@ -1 +0,0 @@
1
- ruby-3.4.2
data/CLAUDE.md DELETED
@@ -1,108 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code when working with the `fast_mcp_pubsub` gem.
4
-
5
- ## Gem Overview
6
-
7
- FastMcp PubSub provides PostgreSQL NOTIFY/LISTEN clustering support for FastMcp RackTransport, enabling message broadcasting across multiple Puma workers in cluster mode.
8
-
9
- ## Code Conventions
10
-
11
- ### Code Quality
12
- - Max 200 chars/line (soft limit - prefer readability over strict compliance)
13
- - breaking Ruby chain calls destroys the natural sentence flow and readability
14
- - 14 lines/method, 110 lines/class
15
- - Comments and tests in English
16
- - KEEP CODE DRY (Don't Repeat Yourself)
17
-
18
- ### Error Handling
19
- - Use meaningful exception classes (not generic StandardError)
20
- - Log errors with context using the configured logger
21
- - Proper error propagation with fallback mechanisms
22
- - Use `rescue_from` for common exceptions in Rails integration
23
-
24
- ### Performance Considerations
25
- - Use database connection pooling efficiently
26
- - Avoid blocking operations in main threads
27
- - Cache expensive operations
28
- - Monitor thread lifecycle and cleanup
29
-
30
- ### Thread Safety
31
- - All operations must be thread-safe for cluster mode
32
- - Use proper synchronization when accessing shared resources
33
- - Handle thread lifecycle correctly (creation, monitoring, cleanup)
34
- - Use connection checkout/checkin pattern for database operations
35
-
36
- ### Gem Specific Guidelines
37
-
38
- #### Configuration
39
- - Use configuration object pattern for all settings
40
- - Provide sensible defaults that work out of the box
41
- - Make all components configurable but not required
42
- - Support both programmatic and initializer-based configuration
43
-
44
- #### Rails Integration
45
- - Use Railtie for automatic Rails integration
46
- - Hook into appropriate Rails lifecycle events
47
- - Respect Rails conventions for logging and error handling
48
- - Provide manual configuration options for non-Rails usage
49
-
50
- #### Error Recovery
51
- - Implement automatic retry with backoff for transient errors
52
- - Provide fallback mechanisms when PubSub fails
53
- - Log errors appropriately without flooding logs
54
- - Handle connection failures gracefully
55
-
56
- #### Testing
57
- - Test all public interfaces
58
- - Mock external dependencies (PostgreSQL, FastMcp)
59
- - Test error conditions and edge cases
60
- - Provide test helpers for gem users
61
- - Test both Rails and non-Rails usage
62
-
63
- ## Architecture
64
-
65
- ### Components
66
-
67
- 1. **FastMcpPubsub::Service** - Core PostgreSQL NOTIFY/LISTEN service
68
- 2. **FastMcpPubsub::Configuration** - Configuration management
69
- 3. **FastMcpPubsub::RackTransportPatch** - Monkey patch for FastMcp transport
70
- 4. **FastMcpPubsub::Railtie** - Rails integration and lifecycle management
71
-
72
- ### Message Flow
73
-
74
- 1. `RackTransport#send_message` → `FastMcpPubsub::Service.broadcast`
75
- 2. `Service.broadcast` → PostgreSQL NOTIFY
76
- 3. Each worker's listener thread receives NOTIFY
77
- 4. Listener calls `RackTransport#send_local_message` for local clients
78
-
79
- ### Thread Management
80
-
81
- - One listener thread per worker process
82
- - Thread cleanup on process exit
83
- - Automatic restart on listener errors
84
- - Connection pooling for database operations
85
-
86
- ## Dependencies
87
-
88
- - **Rails** (>= 7.0) - Core framework integration
89
- - **PostgreSQL** (via pg gem >= 1.0) - Database NOTIFY/LISTEN
90
- - **ActiveRecord** - Connection pooling and database access
91
- - **FastMcp** - The transport being patched (development/test dependency)
92
-
93
- ## Development
94
-
95
- ### Running Tests
96
- ```bash
97
- bundle exec rake test
98
- ```
99
-
100
- ### Linting
101
- ```bash
102
- bundle exec rubocop
103
- ```
104
-
105
- ### Console
106
- ```bash
107
- bundle exec rake console
108
- ```
data/lib/.DS_Store DELETED
Binary file