actionmcp 0.1.2 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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