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.
- checksums.yaml +4 -4
- data/README.md +50 -7
- data/app/controllers/action_mcp/application_controller.rb +123 -34
- data/app/models/action_mcp/session.rb +2 -2
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +8 -8
- data/lib/action_mcp/client/base.rb +2 -1
- data/lib/action_mcp/client/elicitation.rb +34 -0
- data/lib/action_mcp/client/json_rpc_handler.rb +14 -2
- data/lib/action_mcp/configuration.rb +10 -1
- data/lib/action_mcp/content/resource_link.rb +42 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +3 -0
- data/lib/action_mcp/prompt.rb +17 -1
- data/lib/action_mcp/renderable.rb +18 -0
- data/lib/action_mcp/resource_template.rb +18 -2
- data/lib/action_mcp/server/active_record_session_store.rb +28 -0
- data/lib/action_mcp/server/capabilities.rb +4 -3
- data/lib/action_mcp/server/elicitation.rb +64 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +14 -2
- data/lib/action_mcp/server/memory_session.rb +16 -3
- data/lib/action_mcp/server/messaging.rb +10 -6
- data/lib/action_mcp/server/solid_mcp_adapter.rb +171 -0
- data/lib/action_mcp/server/test_session_store.rb +28 -0
- data/lib/action_mcp/server/tools.rb +1 -0
- data/lib/action_mcp/server/transport_handler.rb +1 -0
- data/lib/action_mcp/server/volatile_session_store.rb +24 -0
- data/lib/action_mcp/server.rb +4 -4
- data/lib/action_mcp/tagged_stream_logging.rb +26 -5
- data/lib/action_mcp/tool.rb +101 -7
- data/lib/action_mcp/tool_response.rb +16 -5
- data/lib/action_mcp/types/float_array_type.rb +58 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +9 -3
- data/lib/generators/action_mcp/install/install_generator.rb +1 -1
- data/lib/generators/action_mcp/install/templates/mcp.yml +3 -2
- metadata +22 -4
- data/lib/action_mcp/server/solid_cable_adapter.rb +0 -221
data/lib/action_mcp/tool.rb
CHANGED
@@ -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",
|
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
|
144
|
-
|
145
|
-
|
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 = {
|
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 :
|
250
|
-
when "array_integer" then :
|
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
|
-
"
|
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
|
data/lib/action_mcp/version.rb
CHANGED
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
|
-
|
41
|
-
|
42
|
-
SUPPORTED_VERSIONS =
|
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 " -
|
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:
|
110
|
+
adapter: solid_mcp
|
111
111
|
polling_interval: 0.5.seconds
|
112
|
-
|
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.
|
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.
|
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.
|
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/
|
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
|