actionmcp 0.55.2 → 0.60.1

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +50 -7
  3. data/app/controllers/action_mcp/application_controller.rb +123 -34
  4. data/app/models/action_mcp/session.rb +2 -2
  5. data/db/migrate/20250608112101_add_oauth_to_sessions.rb +8 -8
  6. data/lib/action_mcp/client/base.rb +2 -1
  7. data/lib/action_mcp/client/elicitation.rb +34 -0
  8. data/lib/action_mcp/client/json_rpc_handler.rb +14 -2
  9. data/lib/action_mcp/configuration.rb +10 -1
  10. data/lib/action_mcp/content/resource_link.rb +42 -0
  11. data/lib/action_mcp/json_rpc_handler_base.rb +3 -0
  12. data/lib/action_mcp/prompt.rb +17 -1
  13. data/lib/action_mcp/renderable.rb +18 -0
  14. data/lib/action_mcp/resource_template.rb +18 -2
  15. data/lib/action_mcp/server/active_record_session_store.rb +28 -0
  16. data/lib/action_mcp/server/capabilities.rb +4 -3
  17. data/lib/action_mcp/server/elicitation.rb +64 -0
  18. data/lib/action_mcp/server/json_rpc_handler.rb +14 -2
  19. data/lib/action_mcp/server/memory_session.rb +16 -3
  20. data/lib/action_mcp/server/messaging.rb +10 -6
  21. data/lib/action_mcp/server/solid_mcp_adapter.rb +171 -0
  22. data/lib/action_mcp/server/test_session_store.rb +28 -0
  23. data/lib/action_mcp/server/tools.rb +1 -0
  24. data/lib/action_mcp/server/transport_handler.rb +1 -0
  25. data/lib/action_mcp/server/volatile_session_store.rb +24 -0
  26. data/lib/action_mcp/server.rb +4 -4
  27. data/lib/action_mcp/tagged_stream_logging.rb +26 -5
  28. data/lib/action_mcp/tool.rb +101 -7
  29. data/lib/action_mcp/tool_response.rb +16 -5
  30. data/lib/action_mcp/types/float_array_type.rb +58 -0
  31. data/lib/action_mcp/version.rb +1 -1
  32. data/lib/action_mcp.rb +9 -3
  33. data/lib/generators/action_mcp/install/install_generator.rb +1 -1
  34. data/lib/generators/action_mcp/install/templates/mcp.yml +3 -2
  35. metadata +22 -4
  36. data/lib/action_mcp/server/solid_cable_adapter.rb +0 -221
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "action_mcp/types/float_array_type"
4
+
3
5
  module ActionMCP
4
6
  # Base class for defining tools.
5
7
  #
@@ -8,6 +10,7 @@ module ActionMCP
8
10
  class Tool < Capability
9
11
  include ActionMCP::Callbacks
10
12
  include ActionMCP::CurrentHelpers
13
+
11
14
  # --------------------------------------------------------------------------
12
15
  # Class Attributes for Tool Metadata and Schema
13
16
  # --------------------------------------------------------------------------
@@ -18,6 +21,8 @@ module ActionMCP
18
21
  class_attribute :_schema_properties, instance_accessor: false, default: {}
19
22
  class_attribute :_required_properties, instance_accessor: false, default: []
20
23
  class_attribute :_annotations, instance_accessor: false, default: {}
24
+ class_attribute :_output_schema, instance_accessor: false, default: nil
25
+ class_attribute :_meta, instance_accessor: false, default: {}
21
26
 
22
27
  # --------------------------------------------------------------------------
23
28
  # Tool Name and Description DSL
@@ -82,6 +87,47 @@ module ActionMCP
82
87
  # Always include annotations now that we only support 2025+
83
88
  _annotations
84
89
  end
90
+
91
+ # Class method to call the tool with arguments
92
+ def call(arguments = {})
93
+ new(arguments).call
94
+ end
95
+
96
+ # Helper methods for checking annotations
97
+ def read_only?
98
+ _annotations["readOnlyHint"] == true
99
+ end
100
+
101
+ def idempotent?
102
+ _annotations["idempotentHint"] == true
103
+ end
104
+
105
+ def destructive?
106
+ _annotations["destructiveHint"] == true
107
+ end
108
+
109
+ def open_world?
110
+ _annotations["openWorldHint"] == true
111
+ end
112
+
113
+ # Sets the output schema for structured content
114
+ def output_schema(schema = nil)
115
+ if schema
116
+ raise NotImplementedError, "Output schema DSL not yet implemented. Coming soon with structured content DSL!"
117
+ else
118
+ _output_schema
119
+ end
120
+ end
121
+
122
+ # Sets or retrieves the _meta field
123
+ def meta(data = nil)
124
+ if data
125
+ raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)
126
+ self._meta = _meta.merge(data)
127
+ else
128
+ _meta
129
+ end
130
+ end
85
131
  end
86
132
 
87
133
  # --------------------------------------------------------------------------
@@ -133,16 +179,32 @@ module ActionMCP
133
179
  def self.collection(prop_name, type:, description: nil, required: false, default: [])
134
180
  raise ArgumentError, "Type is required for a collection" if type.nil?
135
181
 
136
- collection_definition = { type: "array", description: description, items: { type: type } }
182
+ collection_definition = { type: "array", items: { type: type } }
183
+ collection_definition[:description] = description if description && !description.empty?
137
184
 
138
185
  self._schema_properties = _schema_properties.merge(prop_name.to_s => collection_definition)
139
186
  self._required_properties = _required_properties.dup.tap do |req|
140
187
  req << prop_name.to_s if required
141
188
  end
142
189
 
143
- type = map_json_type_to_active_model_type("array_#{type}")
144
- attribute prop_name, type, default: default
145
- validates prop_name, presence: true, if: -> { required }
190
+ # Map the type - for number arrays, use our custom type instance
191
+ mapped_type = if type == "number"
192
+ Types::FloatArrayType.new
193
+ else
194
+ map_json_type_to_active_model_type("array_#{type}")
195
+ end
196
+
197
+ attribute prop_name, mapped_type, default: default
198
+
199
+ # For arrays, we need to check if the attribute is nil, not if it's empty
200
+ if required
201
+ validates prop_name, presence: true, unless: -> { self.send(prop_name).is_a?(Array) }
202
+ validate do
203
+ if self.send(prop_name).nil?
204
+ errors.add(prop_name, "can't be blank")
205
+ end
206
+ end
207
+ end
146
208
  end
147
209
 
148
210
  # --------------------------------------------------------------------------
@@ -152,7 +214,10 @@ module ActionMCP
152
214
  #
153
215
  # @return [Hash] The tool definition.
154
216
  def self.to_h(protocol_version: nil)
155
- schema = { type: "object", properties: _schema_properties }
217
+ schema = {
218
+ type: "object",
219
+ properties: _schema_properties
220
+ }
156
221
  schema[:required] = _required_properties if _required_properties.any?
157
222
 
158
223
  result = {
@@ -161,10 +226,16 @@ module ActionMCP
161
226
  inputSchema: schema
162
227
  }.compact
163
228
 
229
+ # Add output schema if defined
230
+ result[:outputSchema] = _output_schema if _output_schema.present?
231
+
164
232
  # Add annotations if protocol supports them
165
233
  annotations = annotations_for_protocol(protocol_version)
166
234
  result[:annotations] = annotations if annotations.any?
167
235
 
236
+ # Add _meta if present
237
+ result[:_meta] = _meta if _meta.any?
238
+
168
239
  result
169
240
  end
170
241
 
@@ -223,6 +294,13 @@ module ActionMCP
223
294
  content # Return the content for potential use in perform
224
295
  end
225
296
 
297
+ # Override render_resource_link to collect ResourceLink objects
298
+ def render_resource_link(**args)
299
+ content = super(**args) # Call Renderable's render_resource_link method
300
+ @response.add(content) # Add to the response
301
+ content # Return the content for potential use in perform
302
+ end
303
+
226
304
  protected
227
305
 
228
306
  # Abstract method for subclasses to implement their logic
@@ -239,6 +317,22 @@ module ActionMCP
239
317
  render text: message
240
318
  end
241
319
 
320
+ # Helper method to set structured content
321
+ def set_structured_content(content)
322
+ return unless @response
323
+
324
+ # Validate against output schema if defined
325
+ if self.class._output_schema
326
+ # TODO: Add JSON Schema validation here
327
+ # For now, just ensure it's a hash/object
328
+ unless content.is_a?(Hash)
329
+ raise ArgumentError, "Structured content must be a hash/object when output_schema is defined"
330
+ end
331
+ end
332
+
333
+ @response.set_structured_content(content)
334
+ end
335
+
242
336
  # Maps a JSON Schema type to an ActiveModel attribute type.
243
337
  #
244
338
  # @param type [String] The JSON Schema type.
@@ -246,8 +340,8 @@ module ActionMCP
246
340
  def self.map_json_type_to_active_model_type(type)
247
341
  case type.to_s
248
342
  when "number" then :float # JSON Schema "number" is a float in Ruby, the spec doesn't have an integer type yet.
249
- when "array_number" then :integer_array
250
- when "array_integer" then :string_array
343
+ when "array_number" then :float_array
344
+ when "array_integer" then :integer_array
251
345
  when "array_string" then :string_array
252
346
  else :string
253
347
  end
@@ -3,13 +3,14 @@
3
3
  module ActionMCP
4
4
  # Manages the collection of content objects for tool results
5
5
  class ToolResponse < BaseResponse
6
- attr_reader :contents
6
+ attr_reader :contents, :structured_content
7
7
 
8
8
  delegate :empty?, :size, :each, :find, :map, to: :contents
9
9
 
10
10
  def initialize
11
11
  super
12
12
  @contents = []
13
+ @structured_content = nil
13
14
  end
14
15
 
15
16
  # Add content to the response
@@ -18,26 +19,36 @@ module ActionMCP
18
19
  content # Return the content for chaining
19
20
  end
20
21
 
22
+ # Set structured content for the response
23
+ def set_structured_content(content)
24
+ @structured_content = content
25
+ end
26
+
21
27
  # Implementation of build_success_hash for ToolResponse
22
28
  def build_success_hash
23
- {
29
+ result = {
24
30
  content: @contents.map(&:to_h)
25
31
  }
32
+ result[:structuredContent] = @structured_content if @structured_content
33
+ result
26
34
  end
27
35
 
28
36
  # Implementation of compare_with_same_class for ToolResponse
29
37
  def compare_with_same_class(other)
30
- contents == other.contents && is_error == other.is_error
38
+ contents == other.contents && is_error == other.is_error && structured_content == other.structured_content
31
39
  end
32
40
 
33
41
  # Implementation of hash_components for ToolResponse
34
42
  def hash_components
35
- [ contents, is_error ]
43
+ [ contents, is_error, structured_content ]
36
44
  end
37
45
 
38
46
  # Pretty print for better debugging
39
47
  def inspect
40
- "#<#{self.class.name} content: #{contents.inspect}, isError: #{is_error}>"
48
+ parts = [ "content: #{contents.inspect}" ]
49
+ parts << "structuredContent: #{structured_content.inspect}" if structured_content
50
+ parts << "isError: #{is_error}"
51
+ "#<#{self.class.name} #{parts.join(', ')}>"
41
52
  end
42
53
  end
43
54
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Types
5
+ # Custom ActiveModel type for handling arrays of floating point numbers
6
+ class FloatArrayType < ActiveModel::Type::Value
7
+ def type
8
+ :float_array
9
+ end
10
+
11
+ def cast(value)
12
+ return [] if value.nil?
13
+ return value if value.is_a?(Array) && value.all? { |v| v.is_a?(Float) }
14
+
15
+ Array(value).map do |v|
16
+ case v
17
+ when Float then v
18
+ when Numeric then v.to_f
19
+ when String
20
+ case v.downcase
21
+ when "infinity", "+infinity"
22
+ Float::INFINITY
23
+ when "-infinity"
24
+ -Float::INFINITY
25
+ when "nan"
26
+ Float::NAN
27
+ else
28
+ Float(v) rescue nil
29
+ end
30
+ else
31
+ nil
32
+ end
33
+ end.compact
34
+ end
35
+
36
+ def serialize(value)
37
+ cast(value)
38
+ end
39
+
40
+ def deserialize(value)
41
+ return value if value.is_a?(Array)
42
+ return [] if value.nil?
43
+
44
+ # Handle JSON deserialization
45
+ if value.is_a?(String)
46
+ begin
47
+ parsed = JSON.parse(value)
48
+ cast(parsed)
49
+ rescue JSON::ParserError
50
+ []
51
+ end
52
+ else
53
+ cast(value)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.55.2"
5
+ VERSION = "0.60.1"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
data/lib/action_mcp.rb CHANGED
@@ -37,9 +37,15 @@ module ActionMCP
37
37
  require_relative "action_mcp/version"
38
38
  require_relative "action_mcp/client"
39
39
  include Logging
40
- PROTOCOL_VERSION = "2025-03-26" # Default version
41
- CURRENT_VERSION = "2025-03-26" # Current version
42
- SUPPORTED_VERSIONS = %w[2025-03-26].freeze
40
+
41
+ # Protocol version constants
42
+ SUPPORTED_VERSIONS = [
43
+ "2025-06-18", # Dr. Identity McBouncer - OAuth 2.1, elicitation, structured output, resource links
44
+ "2025-03-26" # The Persistent Negotiator - StreamableHTTP, resumability, audio support
45
+ ].freeze
46
+
47
+ LATEST_VERSION = SUPPORTED_VERSIONS.first.freeze
48
+ DEFAULT_PROTOCOL_VERSION = "2025-03-26".freeze # Default to initial stable version for backwards compatibility
43
49
  class << self
44
50
  # Returns a Rack-compatible application for serving MCP requests
45
51
  # This makes ActionMCP.server work similar to ActionCable.server
@@ -49,7 +49,7 @@ module ActionMCP
49
49
  say "Available adapters:"
50
50
  say " - simple : In-memory adapter for development"
51
51
  say " - test : Test adapter for testing environments"
52
- say " - solid_cable : Database-backed adapter (requires solid_cable gem)"
52
+ say " - solid_mcp : Database-backed adapter optimized for MCP (requires solid_mcp gem)"
53
53
  say " - redis : Redis-backed adapter (requires redis gem)"
54
54
  say ""
55
55
  say "Next steps:"
@@ -107,9 +107,10 @@ production:
107
107
  # Choose one of the following adapters:
108
108
 
109
109
  # 1. Database-backed adapter (recommended)
110
- adapter: solid_cable
110
+ adapter: solid_mcp
111
111
  polling_interval: 0.5.seconds
112
- # connects_to: cable # Optional: specify a different database connection
112
+ batch_size: 200 # Number of messages to write in a single batch
113
+ flush_interval: 0.05 # Seconds between batch flushes
113
114
 
114
115
  # Thread pool configuration (optional)
115
116
  min_threads: 10 # Minimum number of threads in the pool
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.55.2
4
+ version: 0.60.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 0.5.1
46
+ version: 0.5.3
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 0.5.1
53
+ version: 0.5.3
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: multi_json
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -177,6 +177,20 @@ dependencies:
177
177
  - - "~>"
178
178
  - !ruby/object:Gem::Version
179
179
  version: '1.0'
180
+ - !ruby/object:Gem::Dependency
181
+ name: json_schemer
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '2.0'
187
+ type: :development
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '2.0'
180
194
  description: It offers base classes and helpers for creating MCP applications, making
181
195
  it easier to integrate your Ruby/Rails application with the MCP standard
182
196
  email:
@@ -215,6 +229,7 @@ files:
215
229
  - lib/action_mcp/client/blueprint.rb
216
230
  - lib/action_mcp/client/catalog.rb
217
231
  - lib/action_mcp/client/collection.rb
232
+ - lib/action_mcp/client/elicitation.rb
218
233
  - lib/action_mcp/client/json_rpc_handler.rb
219
234
  - lib/action_mcp/client/logging.rb
220
235
  - lib/action_mcp/client/messaging.rb
@@ -242,6 +257,7 @@ files:
242
257
  - lib/action_mcp/content/base.rb
243
258
  - lib/action_mcp/content/image.rb
244
259
  - lib/action_mcp/content/resource.rb
260
+ - lib/action_mcp/content/resource_link.rb
245
261
  - lib/action_mcp/content/text.rb
246
262
  - lib/action_mcp/current.rb
247
263
  - lib/action_mcp/current_helpers.rb
@@ -275,6 +291,7 @@ files:
275
291
  - lib/action_mcp/server/base_messaging.rb
276
292
  - lib/action_mcp/server/capabilities.rb
277
293
  - lib/action_mcp/server/configuration.rb
294
+ - lib/action_mcp/server/elicitation.rb
278
295
  - lib/action_mcp/server/error_aware.rb
279
296
  - lib/action_mcp/server/error_handling.rb
280
297
  - lib/action_mcp/server/handlers/prompt_handler.rb
@@ -294,7 +311,7 @@ files:
294
311
  - lib/action_mcp/server/session_store.rb
295
312
  - lib/action_mcp/server/session_store_factory.rb
296
313
  - lib/action_mcp/server/simple_pub_sub.rb
297
- - lib/action_mcp/server/solid_cable_adapter.rb
314
+ - lib/action_mcp/server/solid_mcp_adapter.rb
298
315
  - lib/action_mcp/server/test_session_store.rb
299
316
  - lib/action_mcp/server/tools.rb
300
317
  - lib/action_mcp/server/transport_handler.rb
@@ -309,6 +326,7 @@ files:
309
326
  - lib/action_mcp/tool_response.rb
310
327
  - lib/action_mcp/tools_registry.rb
311
328
  - lib/action_mcp/transport.rb
329
+ - lib/action_mcp/types/float_array_type.rb
312
330
  - lib/action_mcp/uri_ambiguity_checker.rb
313
331
  - lib/action_mcp/version.rb
314
332
  - lib/actionmcp.rb
@@ -1,221 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require "concurrent/map"
5
- require "concurrent/array"
6
- require "concurrent/executor/thread_pool_executor"
7
-
8
- module ActionMCP
9
- module Server
10
- # Mock SolidCable::PubSub for testing
11
- class MockSolidCablePubSub
12
- attr_reader :subscriptions, :messages
13
-
14
- def initialize(options = {})
15
- @options = options
16
- @subscriptions = Concurrent::Map.new
17
- @messages = Concurrent::Array.new
18
- end
19
-
20
- def subscribe(channel, &block)
21
- @subscriptions[channel] ||= Concurrent::Array.new
22
- @subscriptions[channel] << block
23
- end
24
-
25
- def unsubscribe(channel)
26
- @subscriptions.delete(channel)
27
- end
28
-
29
- def broadcast(channel, message)
30
- @messages << { channel: channel, message: message }
31
- callbacks = @subscriptions[channel] || []
32
- callbacks.each { |callback| callback.call(message) }
33
- end
34
- end
35
-
36
- # Adapter for SolidCable PubSub
37
- class SolidCableAdapter
38
- # Thread pool configuration
39
- DEFAULT_MIN_THREADS = 5
40
- DEFAULT_MAX_THREADS = 10
41
- DEFAULT_MAX_QUEUE = 100
42
- DEFAULT_THREAD_TIMEOUT = 60 # seconds
43
-
44
- def initialize(options = {})
45
- @options = options
46
- @subscriptions = Concurrent::Map.new
47
- @channels = Concurrent::Map.new
48
- @channel_subscribed = Concurrent::Map.new # Track channel subscription status
49
-
50
- # Initialize thread pool for callbacks
51
- pool_options = {
52
- min_threads: options["min_threads"] || DEFAULT_MIN_THREADS,
53
- max_threads: options["max_threads"] || DEFAULT_MAX_THREADS,
54
- max_queue: options["max_queue"] || DEFAULT_MAX_QUEUE,
55
- fallback_policy: :caller_runs, # Execute in the caller's thread if queue is full
56
- idletime: DEFAULT_THREAD_TIMEOUT
57
- }
58
- @thread_pool = Concurrent::ThreadPoolExecutor.new(pool_options)
59
-
60
- # Configure SolidCable with options from mcp.yml
61
- # The main option we care about is polling_interval
62
- pubsub_options = {}
63
-
64
- if @options["polling_interval"]
65
- # Convert from ActiveSupport::Duration if needed (e.g., "0.1.seconds")
66
- interval = @options["polling_interval"]
67
- interval = interval.to_f if interval.respond_to?(:to_f)
68
- pubsub_options[:polling_interval] = interval
69
- end
70
-
71
- # If there's a connects_to option, pass it along
72
- pubsub_options[:connects_to] = @options["connects_to"] if @options["connects_to"]
73
-
74
- # Use mock version for testing or real version in production
75
- @solid_cable_pubsub = if defined?(SolidCable) && !testing?
76
- SolidCable::PubSub.new(pubsub_options)
77
- else
78
- MockSolidCablePubSub.new(pubsub_options)
79
- end
80
- end
81
-
82
- # Subscribe to a channel
83
- # @param channel [String] The channel name
84
- # @param message_callback [Proc] Callback for received messages
85
- # @param success_callback [Proc] Callback for successful subscription
86
- # @return [String] Subscription ID
87
- def subscribe(channel, message_callback, success_callback = nil)
88
- subscription_id = SecureRandom.uuid
89
-
90
- @subscriptions[subscription_id] = {
91
- channel: channel,
92
- message_callback: message_callback
93
- }
94
-
95
- @channels[channel] ||= Concurrent::Array.new
96
- @channels[channel] << subscription_id
97
-
98
- # Subscribe to SolidCable only if we haven't already subscribed to this channel
99
- unless subscribed_to_solid_cable?(channel)
100
- @solid_cable_pubsub.subscribe(channel) do |message|
101
- dispatch_message(channel, message)
102
- end
103
- @channel_subscribed[channel] = true
104
- end
105
-
106
- log_subscription_event(channel, "Subscribed", subscription_id)
107
- success_callback&.call
108
-
109
- subscription_id
110
- end
111
-
112
- # Unsubscribe from a channel
113
- # @param channel [String] The channel name
114
- # @param callback [Proc] Optional callback for unsubscribe completion
115
- def unsubscribe(channel, callback = nil)
116
- # Remove our subscriptions
117
- subscription_ids = @channels[channel] || []
118
- subscription_ids.each do |subscription_id|
119
- @subscriptions.delete(subscription_id)
120
- end
121
-
122
- @channels.delete(channel)
123
-
124
- # Only unsubscribe from SolidCable if we're actually subscribed
125
- if subscribed_to_solid_cable?(channel)
126
- @solid_cable_pubsub.unsubscribe(channel)
127
- @channel_subscribed.delete(channel)
128
- end
129
-
130
- log_subscription_event(channel, "Unsubscribed")
131
- callback&.call
132
- end
133
-
134
- # Broadcast a message to a channel
135
- # @param channel [String] The channel name
136
- # @param message [String] The message to broadcast
137
- def broadcast(channel, message)
138
- @solid_cable_pubsub.broadcast(channel, message)
139
- log_broadcast_event(channel, message)
140
- end
141
-
142
- # Check if a channel has subscribers
143
- # @param channel [String] The channel name
144
- # @return [Boolean] True if channel has subscribers
145
- def has_subscribers?(channel)
146
- subscribers = @channels[channel]
147
- return false unless subscribers
148
-
149
- !subscribers.empty?
150
- end
151
-
152
- # Check if we're already subscribed to a channel
153
- # @param channel [String] The channel name
154
- # @return [Boolean] True if we're already subscribed
155
- def subscribed_to?(channel)
156
- channel_subs = @channels[channel]
157
- return false if channel_subs.nil?
158
-
159
- !channel_subs.empty?
160
- end
161
-
162
- # Shut down the thread pool gracefully
163
- def shutdown
164
- @thread_pool.shutdown
165
- @thread_pool.wait_for_termination(5) # Wait up to 5 seconds for tasks to complete
166
- end
167
-
168
- private
169
-
170
- # Check if we're in a testing environment
171
- def testing?
172
- defined?(Minitest) || ENV["RAILS_ENV"] == "test"
173
- end
174
-
175
- # Check if we're already subscribed to this channel in SolidCable
176
- def subscribed_to_solid_cable?(channel)
177
- @channel_subscribed[channel] == true
178
- end
179
-
180
- def dispatch_message(channel, message)
181
- subscription_ids = @channels[channel] || []
182
-
183
- subscription_ids.each do |subscription_id|
184
- subscription = @subscriptions[subscription_id]
185
- next unless subscription && subscription[:message_callback]
186
-
187
- @thread_pool.post do
188
- subscription[:message_callback].call(message)
189
- rescue StandardError => e
190
- log_error("Error in message callback: #{e.message}\n#{e.backtrace.join("\n")}")
191
- end
192
- end
193
- end
194
-
195
- def log_subscription_event(channel, action, subscription_id = nil)
196
- return unless defined?(Rails) && Rails.respond_to?(:logger)
197
-
198
- message = "SolidCableAdapter: #{action} channel=#{channel}"
199
- message += " subscription_id=#{subscription_id}" if subscription_id
200
-
201
- Rails.logger.debug(message)
202
- end
203
-
204
- def log_broadcast_event(channel, message)
205
- return unless defined?(Rails) && Rails.respond_to?(:logger)
206
-
207
- # Truncate the message for logging
208
- truncated_message = message.to_s[0..100]
209
- truncated_message += "..." if message.to_s.length > 100
210
-
211
- Rails.logger.debug("SolidCableAdapter: Broadcasting to channel=#{channel} message=#{truncated_message}")
212
- end
213
-
214
- def log_error(message)
215
- return unless defined?(Rails) && Rails.respond_to?(:logger)
216
-
217
- Rails.logger.error("SolidCableAdapter: #{message}")
218
- end
219
- end
220
- end
221
- end