actionmcp 0.1.2 → 0.2.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: 40bfac6cf4d8eff1e92f16eb58c315e3da4578ad69d10bdc83293dcfad100d5b
4
- data.tar.gz: 564672c4c804b061fd53f3f905c6f092ae06b0cb596d3e4bea50adfc0fce8449
3
+ metadata.gz: 3e01fe9b15e57ab4450f9712dd2a38dbf355cbdc50d196a8026cbb205d3d8a07
4
+ data.tar.gz: 8ce8ffd799f5b9487c0bb3b78c7d7242cff31742e8cc1a79a76116e50f6cb896
5
5
  SHA512:
6
- metadata.gz: ed00e79436b6a3afbbeb5cc611d4ddc0f761b71d76ec90e368aa1463c42d98aaa13fbadc73d0871d6715244b4bf3ad5f8ad3b13dc5d0aafc33399c2973885372
7
- data.tar.gz: f40213363967fea34a30ec731b3048b0c54b2bb112a4f3e3f793605946a1c8f2753d9a124f15efe87f79cf53d412700393ec65d56d606b05f5601b7ad07699c9
6
+ metadata.gz: b044250b38fc6680fc5afa90f33dfc1082cb4ca7ec5916339e8e2a1f0b5ff64a32229968c24eeec19ec706a4341c413cc0d3546f7edee8dd9dd4ffd807427056
7
+ data.tar.gz: 8b98f315be3dd31d80f77d85eeddd66ab434af8077d04358928c7be3793e7ad0565d37c9042666a93810694688a45e7147b9a540ae0505b061b34b77a58cf991
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ ## TODO: Adding this so i don't forget
4
+ module ActionMCP
5
+ class Client
6
+ end
7
+ end
@@ -3,13 +3,18 @@
3
3
  module ActionMCP
4
4
  # Configuration class to hold settings for the ActionMCP server.
5
5
  class Configuration
6
- attr_accessor :name, :version, :logging_enabled
6
+ attr_accessor :name, :version, :logging_enabled,
7
+ # Right now, if enabled, the server will send a listChanged notification for tools, prompts, and resources.
8
+ # We can make it more granular in the future, but for now, it's a simple boolean.
9
+ :list_changed,
10
+ :resources_subscribe
7
11
 
8
12
  def initialize
9
13
  # Use Rails.application values if available, or fallback to defaults.
10
14
  @name = defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:name) ? Rails.application.name : "ActionMCP"
11
15
  @version = defined?(Rails) && Rails.respond_to?(:application) && Rails.application.respond_to?(:version) ? Rails.application.version.to_s.presence : "0.0.1"
12
16
  @logging_enabled = true
17
+ @list_changed = false
13
18
  end
14
19
  end
15
20
  end
@@ -8,7 +8,7 @@ module ActionMCP
8
8
 
9
9
  def initialize(text)
10
10
  super("text")
11
- @text = text
11
+ @text = text.to_s
12
12
  end
13
13
 
14
14
  def to_h
@@ -15,9 +15,14 @@ module ActionMCP
15
15
  { type: @type }
16
16
  end
17
17
 
18
- def to_json(*args)
19
- to_h.to_json(*args)
18
+ def to_json(*)
19
+ MultiJson.dump(to_h, *)
20
20
  end
21
21
  end
22
+
23
+ autoload :Image
24
+ autoload :Text
25
+ autoload :Audio
26
+ autoload :Resource
22
27
  end
23
28
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ # This temporary naming extracted from MCPangea
5
+ # If there is a better name, please suggest it or part of ActiveModel, open a PR
6
+ class IntegerArray < ActiveModel::Type::Value
7
+ def cast(value)
8
+ Array(value).map(&:to_i) # Ensure all elements are integers
9
+ end
10
+ end
11
+ end
@@ -63,7 +63,7 @@ module ActionMCP
63
63
 
64
64
  # Converts the error hash to a JSON string.
65
65
  def to_json(*_args)
66
- as_json.to_json
66
+ MultiJson.dump(as_json, *args)
67
67
  end
68
68
  end
69
69
  end
@@ -4,16 +4,15 @@ module ActionMCP
4
4
  module JsonRpc
5
5
  Notification = Data.define(:method, :params) do
6
6
  def initialize(method:, params: nil)
7
- super(method: method, params: params)
7
+ super
8
8
  end
9
9
 
10
10
  def to_h
11
- hash = {
11
+ {
12
12
  jsonrpc: "2.0",
13
- method: method
14
- }
15
- hash[:params] = params if params
16
- hash
13
+ method: method,
14
+ params: params
15
+ }.compact
17
16
  end
18
17
  end
19
18
  end
@@ -2,10 +2,10 @@
2
2
 
3
3
  module ActionMCP
4
4
  module JsonRpc
5
- Request = Data.define(:jsonrpc, :id, :method, :params) do
5
+ Request = Data.define(:id, :method, :params) do
6
6
  def initialize(id:, method:, params: nil)
7
7
  validate_id(id)
8
- super(id: id, method: method, params: params)
8
+ super
9
9
  end
10
10
 
11
11
  def to_h
@@ -17,6 +17,16 @@ module ActionMCP
17
17
  hash[:params] = params if params
18
18
  hash
19
19
  end
20
+
21
+ private
22
+
23
+ def validate_id(id)
24
+ unless id.is_a?(String) || id.is_a?(Numeric)
25
+ raise JsonRpcError.new(:invalid_params,
26
+ message: "ID must be a string or number")
27
+ end
28
+ raise JsonRpcError.new(:invalid_params, message: "ID must not be null") if id.nil?
29
+ end
20
30
  end
21
31
  end
22
32
  end
@@ -4,49 +4,29 @@ module ActionMCP
4
4
  module JsonRpc
5
5
  Response = Data.define(:id, :result, :error) do
6
6
  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)
7
+ validate_presence_of_result_or_error!(result, error)
8
+ validate_absence_of_both_result_and_error!(result, error)
9
+
10
+ super
11
11
  end
12
12
 
13
13
  def to_h
14
- hash = {
14
+ {
15
15
  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
16
+ id: id,
17
+ result: result,
18
+ error: error
19
+ }.compact
28
20
  end
29
21
 
30
22
  private
31
23
 
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
40
- end
41
-
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)
24
+ def validate_presence_of_result_or_error!(result, error)
25
+ raise ArgumentError, "Either result or error must be provided." if result.nil? && error.nil?
45
26
  end
46
27
 
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
28
+ def validate_absence_of_both_result_and_error!(result, error)
29
+ raise ArgumentError, "Both result and error cannot be provided simultaneously." if result && error
50
30
  end
51
31
  end
52
32
  end
@@ -4,7 +4,6 @@ module ActionMCP
4
4
  module JsonRpc
5
5
  extend ActiveSupport::Autoload
6
6
 
7
- autoload :Base
8
7
  autoload :JsonRpcError
9
8
  autoload :Notification
10
9
  autoload :Request
@@ -6,6 +6,7 @@ module ActionMCP
6
6
  class Prompt
7
7
  include ActiveModel::Model
8
8
  include ActiveModel::Attributes
9
+ include Renderable
9
10
 
10
11
  class_attribute :_prompt_name, instance_accessor: false
11
12
  class_attribute :_description, instance_accessor: false, default: ""
@@ -72,6 +73,9 @@ module ActionMCP
72
73
 
73
74
  # Register the attribute so it's recognized by ActiveModel
74
75
  attribute arg_name, :string, default: default
76
+ return unless required
77
+
78
+ validates arg_name, presence: true
75
79
  end
76
80
 
77
81
  def self.arguments
@@ -5,6 +5,25 @@ module ActionMCP
5
5
  class << self
6
6
  alias prompts items
7
7
  alias available_prompts enabled
8
+
9
+ def prompt_call(prompt_name, arguments)
10
+ prompt = find(prompt_name)
11
+ prompt = prompt.new(arguments)
12
+ prompt.valid?
13
+ if prompt.valid?
14
+ {
15
+ messages: [ {
16
+ role: "user",
17
+ content: prompt.call
18
+ } ]
19
+ }
20
+ else
21
+ {
22
+ content: prompt.errors.full_messages.map { |msg| Content::Text.new(msg) },
23
+ isError: true
24
+ }
25
+ end
26
+ end
8
27
  end
9
28
  end
10
29
  end
@@ -2,26 +2,30 @@
2
2
 
3
3
  module ActionMCP
4
4
  class RegistryBase
5
+ class NotFound < StandardError; end
6
+
5
7
  class << self
6
8
  def items
7
9
  @items ||= {}
8
10
  end
9
11
 
10
- # Register an item by unique name
11
- def register(name, item_class)
12
+ # Register an item by unique name.
13
+ def register(name, klass)
12
14
  raise ArgumentError, "Name can't be blank" if name.blank?
13
15
  raise ArgumentError, "Name '#{name}' is already registered." if items.key?(name)
14
16
 
15
- items[name] = { class: item_class, enabled: true }
17
+ items[name] = { klass: klass, enabled: true }
16
18
  end
17
19
 
18
- # Fetch an item’s metadata
19
- # Returns { class: <Class>, enabled: <Boolean> } or nil
20
- def fetch(name)
21
- items[name]
20
+ # Retrieve an item’s metadata by name.
21
+ def find(name)
22
+ item = items[name]
23
+ raise NotFound, "Item '#{name}' not found." if item.nil?
24
+
25
+ item[:klass]
22
26
  end
23
27
 
24
- # Number of registered items, ignoring abstract ones.
28
+ # Return the number of registered items, ignoring abstract ones.
25
29
  def size
26
30
  items.values.reject { |item| abstract_item?(item) }.size
27
31
  end
@@ -34,37 +38,46 @@ module ActionMCP
34
38
  items.clear
35
39
  end
36
40
 
37
- # List of currently available items, excluding abstract ones.
41
+ # Chainable scope: returns only enabled, non-abstract items.
38
42
  def enabled
39
- items
40
- .reject { |_name, item| item[:class].abstract? }
41
- .select { |_name, item| item[:enabled] }
43
+ RegistryScope.new(items)
42
44
  end
43
45
 
44
- def fetch_available_tool(name)
45
- enabled[name]&.fetch(:class)
46
+ private
47
+
48
+ # Helper to determine if an item is abstract.
49
+ def abstract_item?(item)
50
+ klass = item[:klass]
51
+ klass.respond_to?(:abstract?) && klass.abstract?
46
52
  end
53
+ end
47
54
 
48
- # Enable an item by name
49
- def enable(name)
50
- raise ArgumentError, "Name '#{name}' not found." unless items.key?(name)
55
+ # Query object for chainable registry scopes.
56
+ class RegistryScope
57
+ include Enumerable
51
58
 
52
- items[name][:enabled] = true
53
- end
59
+ # Using a Data type for items.
60
+ Item = Data.define(:name, :klass)
54
61
 
55
- # Disable an item by name
56
- def disable(name)
57
- raise ArgumentError, "Name '#{name}' not found." unless items.key?(name)
62
+ def initialize(items)
63
+ @items = items.reject do |_name, item|
64
+ RegistryBase.send(:abstract_item?, item) || !item[:enabled]
65
+ end.map { |name, item| Item.new(name, item[:klass]) }
66
+ end
58
67
 
59
- items[name][:enabled] = false
68
+ def each(&)
69
+ @items.each(&)
60
70
  end
61
71
 
62
- private
72
+ # Returns the names (keys) of all enabled items.
73
+ def keys
74
+ @items.map(&:name)
75
+ end
63
76
 
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?
77
+ # Chainable finder for available tools by name.
78
+ def find_available_tool(name)
79
+ item = @items.find { |i| i.name == name }
80
+ item&.klass
68
81
  end
69
82
  end
70
83
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Renderable
5
+ def render_text(text)
6
+ Content::Text.new(text)
7
+ end
8
+
9
+ def render_audio(data, mime_type)
10
+ Content::Audio.new(data, mime_type)
11
+ end
12
+
13
+ def render_image(data, mime_type)
14
+ Content::Image.new(data, mime_type)
15
+ end
16
+
17
+ def render_resource(uri, mime_type, text: nil, blob: nil)
18
+ Content::Resource.new(uri, mime_type, text: text, blob: blob)
19
+ end
20
+
21
+ def render_error(errors)
22
+ {
23
+ isError: true,
24
+ content: errors.map { |error| render_text(error) }
25
+ }
26
+ end
27
+ end
28
+ end
@@ -13,8 +13,8 @@ module ActionMCP
13
13
  end
14
14
 
15
15
  # Convert the resource to a JSON string.
16
- def to_json(*args)
17
- to_h.to_json(*args)
16
+ def to_json(*)
17
+ MultiJson.dump(to_h, *)
18
18
  end
19
19
  end
20
20
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # frozen_string_literal: true
4
-
5
3
  module ActionMCP
6
4
  module ResourcesBank
7
5
  @resources = {} # { uri => content_object }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO: move all server related code here before version 1.0.0
4
+ module ActionMCP
5
+ module Server
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ class StringArray < ActiveModel::Type::Value
5
+ def cast(value)
6
+ Array(value).map(&:to_s) # Ensure all elements are strings
7
+ end
8
+ end
9
+ end
@@ -1,18 +1,30 @@
1
- # lib/action_mcp/tool.rb
2
1
  # frozen_string_literal: true
3
2
 
4
3
  module ActionMCP
4
+ # Base class for defining tools.
5
+ #
6
+ # Provides a DSL for specifying metadata, properties, and nested collection schemas.
7
+ # Tools are registered automatically in the ToolsRegistry unless marked as abstract.
5
8
  class Tool
6
9
  include ActiveModel::Model
7
10
  include ActiveModel::Attributes
11
+ include Renderable
8
12
 
13
+ # --------------------------------------------------------------------------
14
+ # Class Attributes for Tool Metadata and Schema
15
+ # --------------------------------------------------------------------------
9
16
  class_attribute :_tool_name, instance_accessor: false
10
17
  class_attribute :_description, instance_accessor: false, default: ""
11
18
  class_attribute :_schema_properties, instance_accessor: false, default: {}
12
19
  class_attribute :_required_properties, instance_accessor: false, default: []
13
20
  class_attribute :abstract_tool, instance_accessor: false, default: false
14
21
 
15
- # Register each non-abstract subclass in ToolsRegistry
22
+ # --------------------------------------------------------------------------
23
+ # Subclass Registration
24
+ # --------------------------------------------------------------------------
25
+ # Automatically registers non-abstract subclasses in the ToolsRegistry.
26
+ #
27
+ # @param subclass [Class] the subclass inheriting from Tool.
16
28
  def self.inherited(subclass)
17
29
  super
18
30
  return if subclass == Tool
@@ -23,19 +35,27 @@ module ActionMCP
23
35
  ToolsRegistry.register(subclass.tool_name, subclass)
24
36
  end
25
37
 
26
- # Mark this tool as abstract so it won’t be available for use.
38
+ # Marks this tool as abstract so that it won’t be available for use.
39
+ # If the tool is registered in ToolsRegistry, it is unregistered.
27
40
  def self.abstract!
28
41
  self.abstract_tool = true
29
42
  ToolsRegistry.unregister(tool_name) if ToolsRegistry.items.key?(tool_name)
30
43
  end
31
44
 
45
+ # Returns whether this tool is abstract.
46
+ #
47
+ # @return [Boolean] true if abstract, false otherwise.
32
48
  def self.abstract?
33
49
  abstract_tool
34
50
  end
35
51
 
36
- # ---------------------------------------------------
37
- # Tool Name & Description
38
- # ---------------------------------------------------
52
+ # --------------------------------------------------------------------------
53
+ # Tool Name and Description DSL
54
+ # --------------------------------------------------------------------------
55
+ # Sets or retrieves the tool's name.
56
+ #
57
+ # @param name [String, nil] Optional. The name to set for the tool.
58
+ # @return [String] The current tool name.
39
59
  def self.tool_name(name = nil)
40
60
  if name
41
61
  self._tool_name = name
@@ -44,10 +64,17 @@ module ActionMCP
44
64
  end
45
65
  end
46
66
 
67
+ # Returns a default tool name based on the class name.
68
+ #
69
+ # @return [String] The default tool name.
47
70
  def self.default_tool_name
48
71
  name.demodulize.underscore.dasherize.sub(/-tool$/, "")
49
72
  end
50
73
 
74
+ # Sets or retrieves the tool's description.
75
+ #
76
+ # @param text [String, nil] Optional. The description text to set.
77
+ # @return [String] The current description.
51
78
  def self.description(text = nil)
52
79
  if text
53
80
  self._description = text
@@ -56,86 +83,71 @@ module ActionMCP
56
83
  end
57
84
  end
58
85
 
59
- # ---------------------------------------------------
86
+ # --------------------------------------------------------------------------
60
87
  # Property DSL (Direct Declaration)
61
- # ---------------------------------------------------
88
+ # --------------------------------------------------------------------------
89
+ # Defines a property for the tool.
90
+ #
91
+ # This method builds a JSON Schema definition for the property, registers it
92
+ # in the tool's schema, and creates an ActiveModel attribute for it.
93
+ #
94
+ # @param prop_name [Symbol, String] The property name.
95
+ # @param type [String] The JSON Schema type (default: "string").
96
+ # @param description [String, nil] Optional description for the property.
97
+ # @param required [Boolean] Whether the property is required (default: false).
98
+ # @param default [Object, nil] The default value for the property.
99
+ # @param opts [Hash] Additional options for the JSON Schema.
62
100
  def self.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
63
- # Build JSON Schema definition for the property.
101
+ # Build the JSON Schema definition.
64
102
  prop_definition = { type: type }
65
103
  prop_definition[:description] = description if description && !description.empty?
66
104
  prop_definition.merge!(opts) if opts.any?
67
105
 
68
106
  self._schema_properties = _schema_properties.merge(prop_name.to_s => prop_definition)
69
- self._required_properties = _required_properties.dup
70
- _required_properties << prop_name.to_s if required
71
-
72
- # Map our DSL type to an ActiveModel attribute type.
73
- am_type = case type.to_s
74
- when "number" then :float
75
- when "integer" then :integer
76
- when "array" then :string
77
- else
78
- :string
107
+ self._required_properties = _required_properties.dup.tap do |req|
108
+ req << prop_name.to_s if required
79
109
  end
80
- attribute prop_name, am_type, default: default
110
+
111
+ # Map the JSON Schema type to an ActiveModel attribute type.
112
+ attribute prop_name, map_json_type_to_active_model_type(type), default: default
113
+ validates prop_name, presence: true, if: -> { required }
114
+
115
+ return unless %w[number integer].include?(type)
116
+
117
+ validates prop_name, numericality: true
81
118
  end
82
119
 
83
- # ---------------------------------------------------
120
+ # --------------------------------------------------------------------------
84
121
  # Collection DSL
85
- # ---------------------------------------------------
86
- # Supports two forms:
87
- #
88
- # 1. Without a block:
89
- # collection :args, type: "string", description: "Command arguments"
122
+ # --------------------------------------------------------------------------
123
+ # Defines a collection property for the tool.
90
124
  #
91
- # 2. With a block (defining a nested object):
92
- # collection :files, description: "List of Files" do
93
- # property :file, required: true, description: 'file uri'
94
- # property :checksum, required: true, description: 'checksum to verify'
95
- # end
96
- def self.collection(prop_name, type: nil, description: nil, required: false, default: nil, **_opts, &block)
97
- if block_given?
98
- # Build nested schema for an object.
99
- nested_schema = { type: "object", properties: {}, required: [] }
100
- dsl = CollectionDSL.new(nested_schema)
101
- dsl.instance_eval(&block)
102
- collection_definition = { type: "array", description: description, items: nested_schema }
103
- else
104
- raise ArgumentError, "Type is required for a collection without a block" if type.nil?
125
+ # @param prop_name [Symbol, String] The collection property name.
126
+ # @param type [String] The type for collection items.
127
+ # @param description [String, nil] Optional description for the collection.
128
+ # @param required [Boolean] Whether the collection is required (default: false).
129
+ # @param default [Array, nil] The default value for the collection.
130
+ def self.collection(prop_name, type:, description: nil, required: false, default: [])
131
+ raise ArgumentError, "Type is required for a collection" if type.nil?
105
132
 
106
- collection_definition = { type: "array", description: description, items: { type: type } }
107
- end
133
+ collection_definition = { type: "array", description: description, items: { type: type } }
108
134
 
109
135
  self._schema_properties = _schema_properties.merge(prop_name.to_s => collection_definition)
110
- self._required_properties = _required_properties.dup
111
- _required_properties << prop_name.to_s if required
112
-
113
- # Register the property as an attribute.
114
- # (Mapping for a collection can be customized; here we use :string to mimic previous behavior.)
115
- attribute prop_name, :string, default: default
116
- end
117
-
118
- # DSL for building a nested schema within a collection block.
119
- class CollectionDSL
120
- attr_reader :schema
121
-
122
- def initialize(schema)
123
- @schema = schema
136
+ self._required_properties = _required_properties.dup.tap do |req|
137
+ req << prop_name.to_s if required
124
138
  end
125
139
 
126
- def property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
127
- prop_definition = { type: type }
128
- prop_definition[:description] = description if description && !description.empty?
129
- prop_definition.merge!(opts) if opts.any?
130
-
131
- @schema[:properties][prop_name.to_s] = prop_definition
132
- @schema[:required] << prop_name.to_s if required
133
- end
140
+ type = map_json_type_to_active_model_type("array_#{type}")
141
+ attribute prop_name, type, default: default
142
+ validates prop_name, presence: true, if: -> { required }
134
143
  end
135
144
 
136
- # ---------------------------------------------------
137
- # Convert Tool Definition to Hash
138
- # ---------------------------------------------------
145
+ # --------------------------------------------------------------------------
146
+ # Tool Definition Serialization
147
+ # --------------------------------------------------------------------------
148
+ # Returns a hash representation of the tool definition including its JSON Schema.
149
+ #
150
+ # @return [Hash] The tool definition.
139
151
  def self.to_h
140
152
  schema = { type: "object", properties: _schema_properties }
141
153
  schema[:required] = _required_properties if _required_properties.any?
@@ -145,5 +157,40 @@ module ActionMCP
145
157
  inputSchema: schema
146
158
  }.compact
147
159
  end
160
+
161
+ # --------------------------------------------------------------------------
162
+ # Instance Methods
163
+ # --------------------------------------------------------------------------
164
+ # Abstract method to perform the tool's action.
165
+ #
166
+ # Subclasses must implement this method.
167
+ #
168
+ # @raise [NotImplementedError] Always raised if not implemented in a subclass.
169
+ def call
170
+ raise NotImplementedError, "Subclasses must implement the call method"
171
+ # Default implementation (no-op)
172
+ # In a real subclass, you might do:
173
+ # def call
174
+ # # Perform logic, e.g. analyze code, etc.
175
+ # # Array of Content objects is expected as return value
176
+ # end
177
+ end
178
+
179
+ private
180
+
181
+ # Maps a JSON Schema type to an ActiveModel attribute type.
182
+ #
183
+ # @param type [String] The JSON Schema type.
184
+ # @return [Symbol] The corresponding ActiveModel attribute type.
185
+ def self.map_json_type_to_active_model_type(type)
186
+ case type.to_s
187
+ when "number" then :float # JSON Schema "number" is a float in Ruby, the spec doesn't have an integer type yet.
188
+ when "array_number" then :integer_array
189
+ when "array_integer" then :string_array
190
+ when "array_string" then :string_array
191
+ else :string
192
+ end
193
+ end
194
+ private_class_method :map_json_type_to_active_model_type
148
195
  end
149
196
  end
@@ -1,12 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # frozen_string_literal: true
4
-
5
3
  module ActionMCP
6
4
  class ToolsRegistry < RegistryBase
7
5
  class << self
8
6
  alias tools items
9
7
  alias available_tools enabled
8
+
9
+ def tool_call(tool_name, arguments, _metadata = {})
10
+ tool = find(tool_name)
11
+ tool = tool.new(arguments)
12
+ tool.validate
13
+ if tool.valid?
14
+ { content: [ tool.call ] }
15
+ else
16
+ {
17
+ content: tool.errors.full_messages.map { |msg| Content::Text.new(msg) },
18
+ isError: true
19
+ }
20
+ end
21
+ end
10
22
  end
11
23
  end
12
24
  end
@@ -3,27 +3,38 @@
3
3
  module ActionMCP
4
4
  class Transport
5
5
  HEARTBEAT_INTERVAL = 15 # seconds
6
+ attr_reader :initialized
6
7
 
8
+ # Initializes a new Transport.
9
+ #
10
+ # @param output_io [IO] An IO-like object where events will be written.
7
11
  def initialize(output_io)
8
12
  # output_io can be any IO-like object where we write events.
9
13
  @output = output_io
10
14
  @output.sync = true
15
+ @initialized = false
16
+ @client_capabilities = {}
17
+ @client_info = {}
18
+ @protocol_version = ""
11
19
  end
12
20
 
13
21
  # Sends the capabilities JSON-RPC notification.
14
22
  #
15
23
  # @param request_id [String, Integer] The request identifier.
16
- def send_capabilities(request_id)
24
+ def send_capabilities(request_id, params = {})
25
+ @protocol_version = params["protocolVersion"]
26
+ @client_info = params["clientInfo"]
27
+ @client_capabilities = params["capabilities"]
28
+ Rails.logger.info("Client capabilities stored: #{@client_capabilities}")
17
29
  capabilities = {}
18
30
 
19
31
  # Only include each capability if the corresponding registry is non-empty.
20
- capabilities[:tools] = { listChanged: true } if ActionMCP::ToolsRegistry.available_tools.any?
21
-
22
- capabilities[:prompts] = { listChanged: true } if ActionMCP::PromptsRegistry.available_prompts.any?
23
-
24
- capabilities[:resources] = { listChanged: true } if ActionMCP::ResourcesBank.all_resources.any?
25
-
26
- # Add logging capability only if enabled by configuration.
32
+ capabilities[:tools] = { listChanged: ActionMCP.configuration.list_changed } if ToolsRegistry.available_tools.any?
33
+ if PromptsRegistry.available_prompts.any?
34
+ capabilities[:prompts] =
35
+ { listChanged: ActionMCP.configuration.list_changed }
36
+ end
37
+ capabilities[:resources] = { subscribe: ActionMCP.configuration.list_changed } if ResourcesBank.all_resources.any?
27
38
  capabilities[:logging] = {} if ActionMCP.configuration.logging_enabled
28
39
 
29
40
  payload = {
@@ -37,25 +48,22 @@ module ActionMCP
37
48
  send_jsonrpc_response(request_id, result: payload)
38
49
  end
39
50
 
40
- # Sends the tools list JSON-RPC notification.
41
- #
42
- # @param request_id [String, Integer] The request identifier.
43
- def send_tools_list(request_id)
44
- tools = format_registry_items(ActionMCP::ToolsRegistry.available_tools)
45
- send_jsonrpc_response(request_id, result: { tools: tools })
51
+ def initialized!
52
+ @initialized = true
53
+ Rails.logger.info("Transport initialized.")
46
54
  end
47
55
 
48
56
  # Sends the resources list JSON-RPC response.
49
57
  #
50
58
  # @param request_id [String, Integer] The request identifier.
51
59
  def send_resources_list(request_id)
52
- resources = ActionMCP::ResourcesBank.all_resources # fetch all resources
60
+ resources = ResourcesBank.all_resources # fetch all resources
53
61
  result_data = { "resources" => resources }
54
62
  send_jsonrpc_response(request_id, result: result_data)
55
63
  Rails.logger.info("resources/list: Returned #{resources.size} resources.")
56
64
  rescue StandardError => e
57
65
  Rails.logger.error("resources/list failed: #{e.message}")
58
- error_obj = JsonRpcError.new(
66
+ error_obj = JsonRpc::JsonRpcError.new(
59
67
  :internal_error,
60
68
  message: "Failed to list resources: #{e.message}"
61
69
  ).as_json
@@ -66,13 +74,13 @@ module ActionMCP
66
74
  #
67
75
  # @param request_id [String, Integer] The request identifier.
68
76
  def send_resource_templates_list(request_id)
69
- templates = ActionMCP::ResourcesBank.all_templates # get all resource templates
77
+ templates = ResourcesBank.all_templates # get all resource templates
70
78
  result_data = { "resourceTemplates" => templates }
71
79
  send_jsonrpc_response(request_id, result: result_data)
72
80
  Rails.logger.info("resources/templates/list: Returned #{templates.size} resource templates.")
73
81
  rescue StandardError => e
74
82
  Rails.logger.error("resources/templates/list failed: #{e.message}")
75
- error_obj = JsonRpcError.new(
83
+ error_obj = JsonRpc::JsonRpcError.new(
76
84
  :internal_error,
77
85
  message: "Failed to list resource templates: #{e.message}"
78
86
  ).as_json
@@ -87,7 +95,7 @@ module ActionMCP
87
95
  uri = params&.fetch("uri", nil)
88
96
  if uri.nil? || uri.empty?
89
97
  Rails.logger.error("resources/read: 'uri' parameter is missing")
90
- error_obj = JsonRpcError.new(
98
+ error_obj = JsonRpc::JsonRpcError.new(
91
99
  :invalid_params,
92
100
  message: "Missing 'uri' parameter for resources/read"
93
101
  ).as_json
@@ -95,10 +103,10 @@ module ActionMCP
95
103
  end
96
104
 
97
105
  begin
98
- content = ActionMCP::ResourcesBank.read(uri) # Expecting an instance of an ActionMCP::Content subclass
106
+ content = ResourcesBank.read(uri) # Expecting an instance of an ActionMCP::Content subclass
99
107
  if content.nil?
100
108
  Rails.logger.error("resources/read: Resource not found for URI #{uri}")
101
- error_obj = JsonRpcError.new(
109
+ error_obj = JsonRpc::JsonRpcError.new(
102
110
  :invalid_params,
103
111
  message: "Resource not found: #{uri}"
104
112
  ).as_json
@@ -114,7 +122,7 @@ module ActionMCP
114
122
  Rails.logger.info(log_msg)
115
123
  rescue StandardError => e
116
124
  Rails.logger.error("resources/read: Error reading #{uri} - #{e.message}")
117
- error_obj = JsonRpcError.new(
125
+ error_obj = JsonRpc::JsonRpcError.new(
118
126
  :internal_error,
119
127
  message: "Failed to read resource: #{e.message}"
120
128
  ).as_json
@@ -122,73 +130,47 @@ module ActionMCP
122
130
  end
123
131
  end
124
132
 
125
- # Sends a call to a tool. Currently logs the call details.
133
+ # Sends the tools list JSON-RPC notification.
134
+ #
135
+ # @param request_id [String, Integer] The request identifier.
136
+ def send_tools_list(request_id)
137
+ tools = format_registry_items(ToolsRegistry.available_tools)
138
+ send_jsonrpc_response(request_id, result: { tools: tools })
139
+ end
140
+
141
+ # Sends a call to a tool.
126
142
  #
127
143
  # @param request_id [String, Integer] The request identifier.
128
144
  # @param tool_name [String] The name of the tool.
129
- # @param params [Hash] The parameters for the tool.
130
- def send_tools_call(request_id, tool_name, params)
131
- ActionMCP::ToolsRegistry.fetch_available_tool(tool_name.to_s)
132
- Rails.logger.info("Sending tool call: #{tool_name} with params: #{params}")
133
- # TODO: Implement tool call handling and response if needed.
134
- rescue StandardError => e
135
- Rails.logger.error("tools/call: Failed to call tool #{tool_name} - #{e.message}")
136
- error_obj = JsonRpcError.new(
137
- :internal_error,
138
- message: "Failed to call tool #{tool_name}: #{e.message}"
139
- ).as_json
140
- send_jsonrpc_response(request_id, error: error_obj)
145
+ # @param arguments [Hash] The arguments for the tool.
146
+ # @param _meta [Hash] Additional metadata.
147
+ def send_tools_call(request_id, tool_name, arguments, _meta = {})
148
+ result = ToolsRegistry.tool_call(tool_name, arguments, _meta)
149
+ send_jsonrpc_response(request_id, result:)
150
+ rescue RegistryBase::NotFound
151
+ send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(:method_not_found,
152
+ message: "Tool not found: #{tool_name}").as_json)
141
153
  end
142
154
 
143
155
  # Sends the prompts list JSON-RPC notification.
144
156
  #
145
157
  # @param request_id [String, Integer] The request identifier.
146
158
  def send_prompts_list(request_id)
147
- prompts = format_registry_items(ActionMCP::PromptsRegistry.available_prompts)
159
+ prompts = format_registry_items(PromptsRegistry.available_prompts)
148
160
  send_jsonrpc_response(request_id, result: { prompts: prompts })
149
- rescue StandardError => e
150
- Rails.logger.error("prompts/list failed: #{e.message}")
151
- error_obj = JsonRpcError.new(
152
- :internal_error,
153
- message: "Failed to list prompts: #{e.message}"
154
- ).as_json
155
- send_jsonrpc_response(request_id, error: error_obj)
156
161
  end
157
162
 
158
- def send_prompts_get(request_id, params)
159
- prompt_name = params&.fetch("name", nil)
160
- if prompt_name.nil? || prompt_name.strip.empty?
161
- Rails.logger.error("prompts/get: 'name' parameter is missing")
162
- error_obj = JsonRpcError.new(
163
- :invalid_params,
164
- message: "Missing 'name' parameter for prompts/get"
165
- ).as_json
166
- return send_jsonrpc_response(request_id, error: error_obj)
167
- end
168
-
169
- begin
170
- # Assume a method similar to fetch_available_tool exists for prompts.
171
- prompt = ActionMCP::PromptsRegistry.fetch_available_prompt(prompt_name.to_s)
172
- if prompt.nil?
173
- Rails.logger.error("prompts/get: Prompt not found for name #{prompt_name}")
174
- error_obj = JsonRpcError.new(
175
- :invalid_params,
176
- message: "Prompt not found: #{prompt_name}"
177
- ).as_json
178
- return send_jsonrpc_response(request_id, error: error_obj)
179
- end
163
+ def send_prompts_get(request_id, prompt_name, params)
164
+ send_jsonrpc_response(request_id, result: PromptsRegistry.prompt_call(prompt_name.to_s, params))
165
+ rescue RegistryBase::NotFound
166
+ send_jsonrpc_response(request_id, error: JsonRpc::JsonRpcError.new(:method_not_found,
167
+ message: "Prompt not found: #{prompt_name}").as_json)
168
+ end
180
169
 
181
- result_data = { "prompt" => prompt.to_h }
182
- send_jsonrpc_response(request_id, result: result_data)
183
- Rails.logger.info("prompts/get: Returned prompt #{prompt_name}")
184
- rescue StandardError => e
185
- Rails.logger.error("prompts/get: Error retrieving prompt #{prompt_name} - #{e.message}")
186
- error_obj = JsonRpcError.new(
187
- :internal_error,
188
- message: "Failed to get prompt: #{e.message}"
189
- ).as_json
190
- send_jsonrpc_response(request_id, error: error_obj)
191
- end
170
+ # Sends the roots list JSON-RPC request.
171
+ # TODO: test it
172
+ def send_roots_list
173
+ send_jsonrpc_request("roots/list")
192
174
  end
193
175
 
194
176
  # Sends a JSON-RPC pong response.
@@ -199,6 +181,19 @@ module ActionMCP
199
181
  send_jsonrpc_response(request_id, result: {})
200
182
  end
201
183
 
184
+ def send_ping
185
+ send_jsonrpc_request("ping")
186
+ end
187
+
188
+ # Sends a JSON-RPC request.
189
+ # @param method [String] The JSON-RPC method.
190
+ # @param params [Hash] The parameters for the method.
191
+ # @param id [String] The request identifier.
192
+ def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
193
+ request = JsonRpc::Request.new(id: id, method: method, params: params)
194
+ write_message(request.to_json)
195
+ end
196
+
202
197
  # Sends a JSON-RPC response.
203
198
  #
204
199
  # @param request_id [String, Integer] The request identifier.
@@ -213,7 +208,7 @@ module ActionMCP
213
208
  #
214
209
  # @param method [String] The JSON-RPC method.
215
210
  # @param params [Hash] The parameters for the method.
216
- def send_jsonrpc_notification(method, params = {})
211
+ def send_jsonrpc_notification(method, params = nil)
217
212
  notification = JsonRpc::Notification.new(method: method, params: params)
218
213
  write_message(notification.to_json)
219
214
  end
@@ -225,17 +220,19 @@ module ActionMCP
225
220
  # @param registry [Hash] The registry containing tool or prompt definitions.
226
221
  # @return [Array<Hash>] The formatted registry items.
227
222
  def format_registry_items(registry)
228
- registry.map { |_, item| item[:class].to_h }
223
+ registry.map { |item| item.klass.to_h }
229
224
  end
230
225
 
231
226
  # Writes a message to the output IO.
232
227
  #
233
228
  # @param data [String] The data to write.
234
229
  def write_message(data)
235
- Rails.logger.debug("Response Sent: #{data}")
236
- @output.write("#{data}\n")
237
- rescue IOError => e
238
- Rails.logger.error("Failed to write message: #{e.message}")
230
+ Timeout.timeout(5) do # 5 second timeout
231
+ @output.write("#{data}\n")
232
+ end
233
+ rescue Timeout::Error
234
+ Rails.logger.error("Write operation timed out")
235
+ # Handle timeout appropriately
239
236
  end
240
237
  end
241
238
  end
@@ -3,7 +3,7 @@
3
3
  require_relative "gem_version"
4
4
 
5
5
  module ActionMCP
6
- VERSION = "0.1.2"
6
+ VERSION = "0.2.0"
7
7
  # Returns the currently loaded version of Active MCP as a +Gem::Version+.
8
8
  def self.version
9
9
  gem_version
data/lib/action_mcp.rb CHANGED
@@ -4,7 +4,10 @@ require "rails"
4
4
  require "active_support"
5
5
  require "active_model"
6
6
  require "action_mcp/version"
7
+ require "multi_json"
7
8
  require "action_mcp/railtie" if defined?(Rails)
9
+ require_relative "action_mcp/integer_array"
10
+ require_relative "action_mcp/string_array"
8
11
 
9
12
  ActiveSupport::Inflector.inflections(:en) do |inflect|
10
13
  inflect.acronym "MCP"
@@ -20,6 +23,10 @@ module ActionMCP
20
23
  autoload :Tool
21
24
  autoload :Prompt
22
25
  autoload :JsonRpc
26
+ autoload :Transport
27
+ autoload :Content
28
+ autoload :Renderable
29
+
23
30
  eager_autoload do
24
31
  autoload :Configuration
25
32
  end
@@ -50,4 +57,7 @@ module ActionMCP
50
57
  def available_prompts
51
58
  PromptsRegistry.available_prompts
52
59
  end
60
+
61
+ ActiveModel::Type.register(:string_array, StringArray)
62
+ ActiveModel::Type.register(:integer_array, IntegerArray)
53
63
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActionMCP
2
4
  module Generators
3
5
  class InstallGenerator < Rails::Generators::Base
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-02-14 00:00:00.000000000 Z
10
+ date: 2025-02-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activemodel
@@ -65,6 +65,7 @@ files:
65
65
  - Rakefile
66
66
  - exe/action_mcp_stdio
67
67
  - lib/action_mcp.rb
68
+ - lib/action_mcp/client.rb
68
69
  - lib/action_mcp/configuration.rb
69
70
  - lib/action_mcp/content.rb
70
71
  - lib/action_mcp/content/audio.rb
@@ -72,8 +73,8 @@ files:
72
73
  - lib/action_mcp/content/resource.rb
73
74
  - lib/action_mcp/content/text.rb
74
75
  - lib/action_mcp/gem_version.rb
76
+ - lib/action_mcp/integer_array.rb
75
77
  - lib/action_mcp/json_rpc.rb
76
- - lib/action_mcp/json_rpc/base.rb
77
78
  - lib/action_mcp/json_rpc/json_rpc_error.rb
78
79
  - lib/action_mcp/json_rpc/notification.rb
79
80
  - lib/action_mcp/json_rpc/request.rb
@@ -82,8 +83,11 @@ files:
82
83
  - lib/action_mcp/prompts_registry.rb
83
84
  - lib/action_mcp/railtie.rb
84
85
  - lib/action_mcp/registry_base.rb
86
+ - lib/action_mcp/renderable.rb
85
87
  - lib/action_mcp/resource.rb
86
88
  - lib/action_mcp/resources_bank.rb
89
+ - lib/action_mcp/server.rb
90
+ - lib/action_mcp/string_array.rb
87
91
  - lib/action_mcp/tool.rb
88
92
  - lib/action_mcp/tools_registry.rb
89
93
  - lib/action_mcp/transport.rb
@@ -103,6 +107,7 @@ licenses:
103
107
  metadata:
104
108
  homepage_uri: https://github.com/seuros/action_mcp
105
109
  source_code_uri: https://github.com/seuros/action_mcp
110
+ rubygems_mfa_required: 'true'
106
111
  rdoc_options: []
107
112
  require_paths:
108
113
  - lib
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActionMCP
4
- module JsonRpc
5
- private
6
-
7
- def validate_id(id)
8
- raise Error, "ID must be a string or number" unless id.is_a?(String) || id.is_a?(Numeric)
9
- raise Error, "ID must not be null" if id.nil?
10
- end
11
- end
12
- end