actionmcp 0.1.2 → 0.2.3

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +133 -30
  3. data/Rakefile +0 -2
  4. data/exe/actionmcp_cli +221 -0
  5. data/lib/action_mcp/capability.rb +52 -0
  6. data/lib/action_mcp/client.rb +249 -0
  7. data/lib/action_mcp/configuration.rb +55 -1
  8. data/lib/action_mcp/content/audio.rb +9 -0
  9. data/lib/action_mcp/content/image.rb +9 -0
  10. data/lib/action_mcp/content/resource.rb +13 -0
  11. data/lib/action_mcp/content/text.rb +8 -1
  12. data/lib/action_mcp/content.rb +13 -3
  13. data/lib/action_mcp/engine.rb +34 -0
  14. data/lib/action_mcp/gem_version.rb +2 -2
  15. data/lib/action_mcp/integer_array.rb +17 -0
  16. data/lib/action_mcp/json_rpc/json_rpc_error.rb +22 -1
  17. data/lib/action_mcp/json_rpc/notification.rb +13 -6
  18. data/lib/action_mcp/json_rpc/request.rb +26 -2
  19. data/lib/action_mcp/json_rpc/response.rb +42 -31
  20. data/lib/action_mcp/json_rpc.rb +1 -7
  21. data/lib/action_mcp/json_rpc_handler.rb +106 -0
  22. data/lib/action_mcp/logging.rb +19 -0
  23. data/lib/action_mcp/prompt.rb +33 -45
  24. data/lib/action_mcp/prompts_registry.rb +32 -1
  25. data/lib/action_mcp/registry_base.rb +72 -40
  26. data/lib/action_mcp/renderable.rb +54 -0
  27. data/lib/action_mcp/resource.rb +5 -3
  28. data/lib/action_mcp/server.rb +10 -0
  29. data/lib/action_mcp/string_array.rb +14 -0
  30. data/lib/action_mcp/tool.rb +112 -102
  31. data/lib/action_mcp/tools_registry.rb +28 -3
  32. data/lib/action_mcp/transport/capabilities.rb +21 -0
  33. data/lib/action_mcp/transport/messaging.rb +20 -0
  34. data/lib/action_mcp/transport/prompts.rb +19 -0
  35. data/lib/action_mcp/transport/sse_client.rb +309 -0
  36. data/lib/action_mcp/transport/stdio_client.rb +117 -0
  37. data/lib/action_mcp/transport/tools.rb +20 -0
  38. data/lib/action_mcp/transport/transport_base.rb +125 -0
  39. data/lib/action_mcp/transport.rb +1 -238
  40. data/lib/action_mcp/transport_handler.rb +54 -0
  41. data/lib/action_mcp/version.rb +4 -5
  42. data/lib/action_mcp.rb +40 -27
  43. data/lib/generators/action_mcp/install/install_generator.rb +2 -0
  44. data/lib/generators/action_mcp/prompt/templates/prompt.rb.erb +3 -1
  45. data/lib/generators/action_mcp/tool/templates/tool.rb.erb +5 -1
  46. data/lib/tasks/action_mcp_tasks.rake +28 -5
  47. metadata +68 -10
  48. data/exe/action_mcp_stdio +0 -0
  49. data/lib/action_mcp/json_rpc/base.rb +0 -12
  50. data/lib/action_mcp/railtie.rb +0 -27
  51. data/lib/action_mcp/resources_bank.rb +0 -96
@@ -2,51 +2,62 @@
2
2
 
3
3
  module ActionMCP
4
4
  module JsonRpc
5
+ # Represents a JSON-RPC response.
5
6
  Response = Data.define(:id, :result, :error) do
7
+ # Initializes a new Response.
8
+ #
9
+ # @param id [String, Numeric] The request identifier.
10
+ # @param result [Object, nil] The result data (optional).
11
+ # @param error [Object, nil] The error data (optional).
12
+ # @raise [ArgumentError] if neither result nor error is provided, or if both are provided.
6
13
  def initialize(id:, result: nil, error: nil)
7
- processed_error = process_error(error)
8
- processed_result = error ? nil : result
9
- validate_result_error!(processed_result, processed_error)
10
- super(id: id, result: processed_result, error: processed_error)
14
+ validate_presence_of_result_or_error!(result, error)
15
+ validate_absence_of_both_result_and_error!(result, error)
16
+ result, error = transform_value_to_hash!(result, error)
17
+
18
+ super(id: id, result: result, error: error)
11
19
  end
12
20
 
21
+ # Returns a hash representation of the response.
22
+ #
23
+ # @return [Hash] The hash representation.
13
24
  def to_h
14
- hash = {
25
+ {
15
26
  jsonrpc: "2.0",
16
- id: id
17
- }
18
- if error
19
- hash[:error] = {
20
- code: error[:code],
21
- message: error[:message]
22
- }
23
- hash[:error][:data] = error[:data] if error[:data]
24
- else
25
- hash[:result] = result
26
- end
27
- hash
27
+ id: id,
28
+ result: result,
29
+ error: error
30
+ }.compact
31
+ end
32
+
33
+ def is_error?
34
+ error.present?
28
35
  end
29
36
 
30
37
  private
31
38
 
32
- def process_error(error)
33
- case error
34
- when Symbol
35
- ErrorCodes[error]
36
- when Hash
37
- validate_error!(error)
38
- error
39
- end
39
+ # Validates that either result or error is present.
40
+ #
41
+ # @param result [Object, nil] The result data.
42
+ # @param error [Object, nil] The error data.
43
+ # @raise [ArgumentError] if neither result nor error is provided.
44
+ def validate_presence_of_result_or_error!(result, error)
45
+ raise ArgumentError, "Either result or error must be provided." if result.nil? && error.nil?
40
46
  end
41
47
 
42
- def validate_error!(error)
43
- raise Error, "Error code must be an integer" unless error[:code].is_a?(Integer)
44
- raise Error, "Error message is required" unless error[:message].is_a?(String)
48
+ # Validates that both result and error are not present simultaneously.
49
+ #
50
+ # @param result [Object, nil] The result data.
51
+ # @param error [Object, nil] The error data.
52
+ # @raise [ArgumentError] if both result and error are provided.
53
+ def validate_absence_of_both_result_and_error!(result, error)
54
+ raise ArgumentError, "Both result and error cannot be provided simultaneously." if result && error
45
55
  end
46
56
 
47
- def validate_result_error!(result, error)
48
- raise Error, "Either result or error must be set" unless result || error
49
- raise Error, "Cannot set both result and error" if result && error
57
+ def transform_value_to_hash!(result, error)
58
+ result = result.is_a?(String) ? (MultiJson.load(result) rescue result) : result
59
+ error = error.is_a?(String) ? (MultiJson.load(error) rescue error) : error
60
+ [ result, error ]
50
61
  end
51
62
  end
52
63
  end
@@ -1,13 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
+ # Module for handling JSON-RPC communication.
4
5
  module JsonRpc
5
- extend ActiveSupport::Autoload
6
-
7
- autoload :Base
8
- autoload :JsonRpcError
9
- autoload :Notification
10
- autoload :Request
11
- autoload :Response
12
6
  end
13
7
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class JsonRpcHandler
5
+ attr_reader :transport
6
+
7
+ def initialize(transport)
8
+ @transport = transport
9
+ end
10
+
11
+ # Process a single line of input.
12
+ def call(line)
13
+ request = if line.is_a?(String)
14
+ line.strip!
15
+ return if line.empty?
16
+ begin
17
+ MultiJson.load(line)
18
+ rescue MultiJson::ParseError => e
19
+ Rails.logger.error("Failed to parse JSON: #{e.message}")
20
+ return
21
+ end
22
+
23
+ else
24
+ line
25
+ end
26
+ process_request(request)
27
+ end
28
+
29
+ private
30
+
31
+ def process_request(request)
32
+ unless request["jsonrpc"] == "2.0"
33
+ puts "Invalid request: #{request}"
34
+ return
35
+ end
36
+ method = request["method"]
37
+ id = request["id"]
38
+ params = request["params"]
39
+
40
+ case method
41
+ when "initialize"
42
+ puts "\e[31mSending capabilities\e[0m"
43
+ transport.send_capabilities(id, params)
44
+ when "ping"
45
+ transport.send_pong(id)
46
+ when /^notifications\//
47
+ puts "\e[31mProcessing notifications\e[0m"
48
+ process_notifications(method, id, params)
49
+ when /^prompts\//
50
+ process_prompts(method, id, params)
51
+ when /^resources\//
52
+ process_resources(method, id, params)
53
+ when /^tools\//
54
+ process_tools(method, id, params)
55
+ else
56
+ puts "\e[31mUnknown method: #{method}\e[0m"
57
+ Rails.logger.warn("Unknown method: #{method}")
58
+ end
59
+ end
60
+
61
+ def process_notifications(method, _id, _params)
62
+ case method
63
+ when "notifications/initialized"
64
+ puts "\e[31mInitialized\e[0m"
65
+ transport.initialized!
66
+ else
67
+ Rails.logger.warn("Unknown notifications method: #{method}")
68
+ end
69
+ end
70
+
71
+ def process_prompts(method, id, params)
72
+ case method
73
+ when "prompts/get"
74
+ transport.send_prompts_get(id, params&.dig("name"), params&.dig("arguments"))
75
+ when "prompts/list"
76
+ transport.send_prompts_list(id)
77
+ else
78
+ Rails.logger.warn("Unknown prompts method: #{method}")
79
+ end
80
+ end
81
+
82
+ def process_resources(method, id, params)
83
+ case method
84
+ when "resources/list"
85
+ transport.send_resources_list(id)
86
+ when "resources/templates/list"
87
+ transport.send_resource_templates_list(id)
88
+ when "resources/read"
89
+ transport.send_resource_read(id, params)
90
+ else
91
+ Rails.logger.warn("Unknown resources method: #{method}")
92
+ end
93
+ end
94
+
95
+ def process_tools(method, id, params)
96
+ case method
97
+ when "tools/list"
98
+ transport.send_tools_list(id)
99
+ when "tools/call"
100
+ transport.send_tools_call(id, params&.dig("name"), params&.dig("arguments"))
101
+ else
102
+ Rails.logger.warn("Unknown tools method: #{method}")
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/tagged_logging"
4
+ require "active_support/logger"
5
+ require "logger"
6
+
7
+ module ActionMCP
8
+ # Module for providing logging functionality to ActionMCP transport.
9
+ module Logging
10
+ extend ActiveSupport::Concern
11
+
12
+ # Included hook to configure the logger.
13
+ included do
14
+ logger_instance = ActiveSupport::Logger.new(STDOUT)
15
+ logger_instance.level = Logger.const_get(ActionMCP.configuration.logging_level.to_s.upcase)
16
+ cattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(logger_instance)
17
+ end
18
+ end
19
+ end
@@ -2,65 +2,44 @@
2
2
 
3
3
  module ActionMCP
4
4
  # Abstract base class for Prompts
5
- # Defines: name, description, arguments, plus auto-registration.
6
- class Prompt
7
- include ActiveModel::Model
8
- include ActiveModel::Attributes
9
-
10
- class_attribute :_prompt_name, instance_accessor: false
11
- class_attribute :_description, instance_accessor: false, default: ""
5
+ class Prompt < Capability
12
6
  class_attribute :_argument_definitions, instance_accessor: false, default: []
13
- class_attribute :abstract_prompt, instance_accessor: false, default: false
14
-
15
- def self.inherited(subclass)
16
- super
17
- return if subclass == Prompt
18
- return if subclass.name == "ApplicationPrompt"
19
-
20
- subclass.abstract_prompt = false
21
-
22
- # Automatically register the subclass with the PromptsRegistry
23
- PromptsRegistry.register(subclass.prompt_name, subclass)
24
- end
25
-
26
- def self.abstract!
27
- self.abstract_prompt = true
28
- # If already registered, you might want to unregister it here.
29
- end
30
-
31
- def self.abstract?
32
- abstract_prompt
33
- end
34
7
 
35
8
  # ---------------------------------------------------
36
9
  # Prompt Name
37
10
  # ---------------------------------------------------
11
+ # Gets or sets the prompt name.
12
+ #
13
+ # @param name [String, nil] The prompt name to set.
14
+ # @return [String] The prompt name.
38
15
  def self.prompt_name(name = nil)
39
16
  if name
40
- self._prompt_name = name
17
+ self._capability_name = name
41
18
  else
42
- _prompt_name || default_prompt_name
19
+ _capability_name || default_prompt_name
43
20
  end
44
21
  end
45
22
 
23
+ # Returns the default prompt name based on the class name.
24
+ #
25
+ # @return [String] The default prompt name.
46
26
  def self.default_prompt_name
47
- name.demodulize.underscore.dasherize.sub(/-prompt$/, "")
27
+ name.demodulize.underscore.sub(/_prompt$/, "")
48
28
  end
49
-
50
- # ---------------------------------------------------
51
- # Description
52
- # ---------------------------------------------------
53
- def self.description(text = nil)
54
- if text
55
- self._description = text
56
- else
57
- _description
58
- end
29
+ class << self
30
+ alias default_capability_name default_prompt_name
59
31
  end
60
32
 
61
33
  # ---------------------------------------------------
62
34
  # Argument DSL
63
35
  # ---------------------------------------------------
36
+ # Defines an argument for the prompt.
37
+ #
38
+ # @param arg_name [Symbol] The name of the argument.
39
+ # @param description [String] The description of the argument.
40
+ # @param required [Boolean] Whether the argument is required.
41
+ # @param default [Object] The default value of the argument.
42
+ # @return [void]
64
43
  def self.argument(arg_name, description: "", required: false, default: nil)
65
44
  arg_def = {
66
45
  name: arg_name.to_s,
@@ -72,8 +51,14 @@ module ActionMCP
72
51
 
73
52
  # Register the attribute so it's recognized by ActiveModel
74
53
  attribute arg_name, :string, default: default
54
+ return unless required
55
+
56
+ validates arg_name, presence: true
75
57
  end
76
58
 
59
+ # Returns the list of argument definitions.
60
+ #
61
+ # @return [Array<Hash>] The list of argument definitions.
77
62
  def self.arguments
78
63
  _argument_definitions
79
64
  end
@@ -81,6 +66,7 @@ module ActionMCP
81
66
  # ---------------------------------------------------
82
67
  # Convert prompt definition to Hash
83
68
  # ---------------------------------------------------
69
+ # @return [Hash] The prompt definition as a Hash.
84
70
  def self.to_h
85
71
  {
86
72
  name: prompt_name,
@@ -102,6 +88,8 @@ module ActionMCP
102
88
  # Raises:
103
89
  # ActionMCP::JsonRpc::JsonRpcError(:invalid_params) if validation fails.
104
90
  #
91
+ # @param params [Hash] The parameters for the prompt.
92
+ # @return [Object] The result of the prompt's call method.
105
93
  def self.call(params)
106
94
  prompt = new(params) # Initialize an instance with provided params
107
95
  unless prompt.valid?
@@ -127,14 +115,14 @@ module ActionMCP
127
115
  #
128
116
  # Usage: Called internally after validation in self.call
129
117
  #
118
+ # @raise [NotImplementedError] Subclasses must implement the call method.
119
+ # @return [Array<Content>] Array of Content objects is expected as return value
130
120
  def call
131
121
  raise NotImplementedError, "Subclasses must implement the call method"
132
122
  # Default implementation (no-op)
133
123
  # In a real subclass, you might do:
134
- # def call
135
- # # Perform logic, e.g. analyze code, etc.
136
- # # Return something meaningful.
137
- # end
124
+ # # Perform logic, e.g. analyze code, etc.
125
+ # # Return something meaningful.
138
126
  end
139
127
  end
140
128
  end
@@ -1,10 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
+ # Registry for managing prompts.
4
5
  class PromptsRegistry < RegistryBase
5
6
  class << self
7
+ # @!method prompts
8
+ # Returns all registered prompts.
9
+ # @return [Hash] A hash of registered prompts.
6
10
  alias prompts items
7
- alias available_prompts enabled
11
+
12
+ # Calls a prompt with the given name and arguments.
13
+ #
14
+ # @param prompt_name [String] The name of the prompt to call.
15
+ # @param arguments [Hash] The arguments to pass to the prompt.
16
+ # @return [Hash] A hash containing the prompt's response.
17
+ def prompt_call(prompt_name, arguments)
18
+ prompt = find(prompt_name)
19
+ prompt = prompt.new(arguments)
20
+ prompt.valid?
21
+ if prompt.valid?
22
+ {
23
+ messages: [ {
24
+ role: "user",
25
+ content: prompt.call
26
+ } ]
27
+ }
28
+ else
29
+ {
30
+ content: prompt.errors.full_messages.map { |msg| Content::Text.new(msg) },
31
+ isError: true
32
+ }
33
+ end
34
+ end
35
+
36
+ def item_klass
37
+ Prompt
38
+ end
8
39
  end
9
40
  end
10
41
  end
@@ -1,70 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
+ # Base class for registries.
4
5
  class RegistryBase
6
+ # Error raised when an item is not found in the registry.
7
+ class NotFound < StandardError; end
8
+
5
9
  class << self
10
+ # Returns all registered items.
11
+ #
12
+ # @return [Hash] A hash of registered items.
6
13
  def items
7
- @items ||= {}
14
+ @items = item_klass.descendants.each_with_object({}) do |klass, hash|
15
+ next if klass.abstract?
16
+ hash[klass.capability_name] = klass
17
+ end
8
18
  end
9
19
 
10
- # Register an item by unique name
11
- def register(name, item_class)
12
- raise ArgumentError, "Name can't be blank" if name.blank?
13
- raise ArgumentError, "Name '#{name}' is already registered." if items.key?(name)
14
-
15
- items[name] = { class: item_class, enabled: true }
16
- end
20
+ # Retrieve an item by name.
21
+ #
22
+ # @param name [String] The name of the item to find.
23
+ # @raise [NotFound] if the item is not found.
24
+ # @return [Class] The class of the item.
25
+ def find(name)
26
+ item = items[name]
27
+ raise NotFound, "Item '#{name}' not found." if item.nil?
17
28
 
18
- # Fetch an item’s metadata
19
- # Returns { class: <Class>, enabled: <Boolean> } or nil
20
- def fetch(name)
21
- items[name]
29
+ item
22
30
  end
23
31
 
24
- # Number of registered items, ignoring abstract ones.
32
+ # Return the number of registered items, ignoring abstract ones.
33
+ #
34
+ # @return [Integer] The number of registered items.
25
35
  def size
26
- items.values.reject { |item| abstract_item?(item) }.size
36
+ items.size
27
37
  end
28
38
 
29
- def unregister(name)
30
- items.delete(name)
39
+ # Chainable scope: returns only non-abstract items.
40
+ #
41
+ # @return [RegistryScope] A RegistryScope instance.
42
+ def non_abstract
43
+ RegistryScope.new(items)
31
44
  end
32
45
 
33
- def clear!
34
- items.clear
35
- end
46
+ private
36
47
 
37
- # List of currently available items, excluding abstract ones.
38
- def enabled
39
- items
40
- .reject { |_name, item| item[:class].abstract? }
41
- .select { |_name, item| item[:enabled] }
48
+ # Helper to determine if an item is abstract.
49
+ #
50
+ # @param klass [Class] The class to check.
51
+ # @return [Boolean] True if the class is abstract, false otherwise.
52
+ def abstract_item?(klass)
53
+ klass.respond_to?(:abstract?) && klass.abstract?
42
54
  end
43
55
 
44
- def fetch_available_tool(name)
45
- enabled[name]&.fetch(:class)
56
+ def item_klass
57
+ raise NotImplementedError, "Implement in subclass"
46
58
  end
59
+ end
47
60
 
48
- # Enable an item by name
49
- def enable(name)
50
- raise ArgumentError, "Name '#{name}' not found." unless items.key?(name)
61
+ # Query object for chainable registry scopes.
62
+ class RegistryScope
63
+ include Enumerable
51
64
 
52
- items[name][:enabled] = true
53
- end
65
+ # Using a Data type for items.
66
+ Item = Data.define(:name, :klass)
54
67
 
55
- # Disable an item by name
56
- def disable(name)
57
- raise ArgumentError, "Name '#{name}' not found." unless items.key?(name)
68
+ # Initializes a new RegistryScope instance.
69
+ #
70
+ # @param items [Hash] The items to scope.
71
+ # @return [void]
72
+ def initialize(items)
73
+ @items = items.reject do |_name, klass|
74
+ RegistryBase.send(:abstract_item?, klass)
75
+ end.map { |name, klass| Item.new(name, klass) }
76
+ end
58
77
 
59
- items[name][:enabled] = false
78
+ # Iterates over the items in the scope.
79
+ #
80
+ # @yield [Item] The item to yield.
81
+ # @return [void]
82
+ def each(&)
83
+ @items.each(&)
60
84
  end
61
85
 
62
- private
86
+ # Returns the names (keys) of all non-abstract items.
87
+ #
88
+ # @return [Array<String>] The names of all non-abstract items.
89
+ def keys
90
+ @items.map(&:name)
91
+ end
63
92
 
64
- # Helper to determine if an item is abstract.
65
- def abstract_item?(item)
66
- klass = item[:class]
67
- klass.respond_to?(:abstract?) && klass.abstract?
93
+ # Chainable finder for available tools by name.
94
+ #
95
+ # @param name [String] The name of the tool to find.
96
+ # @return [Class, nil] The class of the tool, or nil if not found.
97
+ def find_available_tool(name)
98
+ item = @items.find { |i| i.name == name }
99
+ item&.klass
68
100
  end
69
101
  end
70
102
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Module for rendering content.
5
+ module Renderable
6
+ # Renders text content.
7
+ #
8
+ # @param text [String] The text to render.
9
+ # @return [Content::Text] The rendered text content.
10
+ def render_text(text)
11
+ Content::Text.new(text)
12
+ end
13
+
14
+ # Renders audio content.
15
+ #
16
+ # @param data [String] The audio data.
17
+ # @param mime_type [String] The MIME type of the audio data.
18
+ # @return [Content::Audio] The rendered audio content.
19
+ def render_audio(data, mime_type)
20
+ Content::Audio.new(data, mime_type)
21
+ end
22
+
23
+ # Renders image content.
24
+ #
25
+ # @param data [String] The image data.
26
+ # @param mime_type [String] The MIME type of the image data.
27
+ # @return [Content::Image] The rendered image content.
28
+ def render_image(data, mime_type)
29
+ Content::Image.new(data, mime_type)
30
+ end
31
+
32
+ # Renders a resource.
33
+ #
34
+ # @param uri [String] The URI of the resource.
35
+ # @param mime_type [String] The MIME type of the resource.
36
+ # @param text [String, nil] The text associated with the resource.
37
+ # @param blob [String, nil] The blob associated with the resource.
38
+ # @return [Content::Resource] The rendered resource content.
39
+ def render_resource(uri, mime_type, text: nil, blob: nil)
40
+ Content::Resource.new(uri, mime_type, text: text, blob: blob)
41
+ end
42
+
43
+ # Renders an error.
44
+ #
45
+ # @param errors [Array<String>] The errors to render.
46
+ # @return [Hash] A hash containing the error information.
47
+ def render_error(errors)
48
+ {
49
+ isError: true,
50
+ content: errors.map { |error| render_text(error) }
51
+ }
52
+ end
53
+ end
54
+ end
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionMCP
4
+ # Represents a resource with its metadata.
4
5
  Resource = Data.define(:uri, :name, :description, :mime_type, :size) do
5
6
  # Convert the resource to a hash with the keys expected by MCP.
6
7
  # Note: The key for mime_type is converted to 'mimeType' as specified.
8
+ #
9
+ # @return [Hash] A hash representation of the resource.
7
10
  def to_h
8
11
  hash = { uri: uri, name: name }
9
12
  hash[:description] = description if description
@@ -12,9 +15,8 @@ module ActionMCP
12
15
  hash
13
16
  end
14
17
 
15
- # Convert the resource to a JSON string.
16
- def to_json(*args)
17
- to_h.to_json(*args)
18
+ def to_json(*)
19
+ MultiJson.dump(to_h, *)
18
20
  end
19
21
  end
20
22
  end
@@ -0,0 +1,10 @@
1
+
2
+ # TODO: move all server related code here before version 1.0.0
3
+ module ActionMCP
4
+ # Module for server-related functionality.
5
+ module Server
6
+ module_function def server
7
+ @server ||= ActionCable::Server::Base.new
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # Custom type for handling arrays of strings in ActiveModel.
5
+ class StringArray < ActiveModel::Type::Value
6
+ # Casts the given value to an array of strings.
7
+ #
8
+ # @param value [Object] The value to cast.
9
+ # @return [Array<String>] The array of strings.
10
+ def cast(value)
11
+ Array(value).map(&:to_s) # Ensure all elements are strings
12
+ end
13
+ end
14
+ end