Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions lib/action_mcp/tool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ def self.property(prop_name, type: "string", description: nil, required: false,
# Map the JSON Schema type to an ActiveModel attribute type.
attribute prop_name, map_json_type_to_active_model_type(type), default: default
validates prop_name, presence: true, if: -> { required }
validates prop_name, inclusion: { in: opts[:enum] }, allow_nil: !required if opts[:enum]

return unless %w[number integer].include?(type)

Expand All @@ -337,10 +338,12 @@ def self.property(prop_name, type: "string", description: nil, required: false,
# @param required [Boolean] Whether the collection is required (default: false).
# @param default [Array, nil] The default value for the collection.
# @return [void]
def self.collection(prop_name, type:, description: nil, required: false, default: [])
def self.collection(prop_name, type:, description: nil, required: false, default: [], **opts)
raise ArgumentError, "Type is required for a collection" if type.nil?

collection_definition = { type: "array", items: { type: type } }
items_definition = { type: type }
items_definition[:enum] = opts[:enum] if opts[:enum]
collection_definition = { type: "array", items: items_definition }
collection_definition[:description] = description if description && !description.empty?

self._schema_properties = _schema_properties.merge(prop_name.to_s => collection_definition)
Expand All @@ -356,11 +359,13 @@ def self.collection(prop_name, type:, description: nil, required: false, default
end

attribute prop_name, mapped_type, default: default
validates_with ArrayEnumValidator, prop_name:, enum: opts[:enum] if opts[:enum]

# For arrays, we need to check if the attribute is nil, not if it's empty
return unless required

validates prop_name, presence: true, unless: -> { send(prop_name).is_a?(Array) }

validate do
errors.add(prop_name, "can't be blank") if send(prop_name).nil?
end
Expand Down
13 changes: 13 additions & 0 deletions lib/action_mcp/tool/array_enum_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class ActionMCP::Tool::ArrayEnumValidator < ActiveModel::Validator
def validate(record)
values = record.send(options[:prop_name])
return if values.nil? || !values.is_a?(Array)

invalid_values = values - options[:enum]
return if invalid_values.empty?

record.errors.add(options[:prop_name], "contains invalid value(s) #{invalid_values.inspect}, allowed values are: #{options[:enum].inspect}")
end
end
13 changes: 13 additions & 0 deletions test/dummy/app/mcp/tools/enum_array_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class EnumArrayTool < ApplicationMCPTool
title "Enum Array Tool"
description "accepts array_string attribute"
read_only
idempotent
collection :fruits, type: "string", required: true, enum: [ "apple", "banana", "cherry" ], description: "An array of fruits"

def perform
render text: fruits.join(", ")
end
end
13 changes: 13 additions & 0 deletions test/dummy/app/mcp/tools/enum_tool.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

class EnumTool < ApplicationMCPTool
title "Enum Tool"
description "accepts enum attribute"
read_only
idempotent
property :fruit, type: "string", required: true, enum: [ "apple", "banana", "cherry" ], description: "A fruit"

def perform
render text: fruit
end
end
140 changes: 140 additions & 0 deletions test/tools/enum_array_tool_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# frozen_string_literal: true

require "test_helper"

class EnumArrayToolTest < ActiveSupport::TestCase
include ActionMCP::TestHelper

setup do
@tool = EnumArrayTool.new
end

test "tool is registered and available" do
assert ActionMCP.tools.key?("enum_array")
assert_equal EnumArrayTool, ActionMCP.tools["enum_array"]
end

test "tool metadata is correct" do
assert_equal "Enum Array Tool", EnumArrayTool.title
assert_equal "accepts array_string attribute", EnumArrayTool.description
assert EnumArrayTool.read_only?
assert EnumArrayTool.idempotent?
end

test "schema generation for enum array" do
schema = EnumArrayTool.to_h[:inputSchema]

assert_not_nil schema
assert_equal "object", schema[:type]

# Check fruits property
fruits_prop = schema[:properties]["fruits"] || schema[:properties][:fruits]
assert_not_nil fruits_prop
assert_equal "array", fruits_prop["type"] || fruits_prop[:type]

# Check items schema
items = fruits_prop["items"] || fruits_prop[:items]
assert_not_nil items
assert_equal "string", items["type"] || items[:type]

# Check required
required = schema[:required] || schema["required"]
assert required

# Check enum values are on items, not on the array itself
enum_values = items[:enum] || items["enum"]
assert_equal [ "apple", "banana", "cherry" ], enum_values
end

test "accepts array of fruits" do
@tool.fruits = [ "apple", "banana", "cherry" ]
assert @tool.valid?
assert_equal [ "apple", "banana", "cherry" ], @tool.fruits
end

test "rejects invalid fruit values" do
@tool.fruits = [ "apple", "orange", "banana" ]
assert_not @tool.valid?
assert_includes @tool.errors[:fruits], 'contains invalid value(s) ["orange"], allowed values are: ["apple", "banana", "cherry"]'
end

test "handles nil input" do
@tool.fruits = nil
# Since we cast nil to [], this should be valid
assert @tool.valid?
assert_equal [], @tool.fruits
end

test "handles empty array" do
@tool.fruits = []
# Empty array should be valid - presence validation allows empty arrays
assert @tool.valid?
assert_equal [], @tool.fruits
end

test "requires fruits array" do
# Don't set fruits at all - it should use the default empty array
# The tool was initialized in setup, so fruits should be []
assert @tool.valid?
assert_equal [], @tool.fruits
end

test "perform concatenation correctly" do
@tool.fruits = [ "apple", "banana", "cherry" ]
result = @tool.call
assert_not result.is_error
assert_equal "apple, banana, cherry", result.contents.first.text
end

test "call method works correctly" do
result = EnumArrayTool.call(fruits: [ "apple", "banana", "cherry" ])
assert_not result.is_error
assert_equal "apple, banana, cherry", result.contents.first.text
end

test "call with empty arguments uses default" do
result = EnumArrayTool.call({})
# Should use default empty array and return empty string
assert_not result.is_error
assert_equal "", result.contents.first.text
end

test "integration with JSON parsing" do
# Simulate JSON input
json_input = { "fruits" => [ "apple", "banana", "cherry" ] }
@tool.assign_attributes(json_input)

assert @tool.valid?
assert_equal [ "apple", "banana", "cherry" ], @tool.fruits

result = @tool.call
assert_equal "apple, banana, cherry", result.contents.first.text
end

test "works with MCP execution" do
# Use the test helper to execute the tool
result = execute_mcp_tool("enum_array", fruits: [ "apple", "banana", "cherry" ])
assert_not result.is_error
assert_equal "apple, banana, cherry", result.contents.first.text
end

test "non-required collection still validates enum" do
tool_class = Class.new(ApplicationMCPTool) do
tool_name "optional_enum_array"
description "optional enum array"
collection :tags, type: "string", enum: [ "red", "green", "blue" ]

def perform
render text: tags.join(", ")
end
end

tool = tool_class.new
tool.tags = [ "red", "yellow" ]
assert_not tool.valid?
assert tool.errors[:tags].any? { |e| e.include?("yellow") }

tool.tags = [ "red", "green" ]
assert tool.valid?
end
end
77 changes: 77 additions & 0 deletions test/tools/enum_tool_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# frozen_string_literal: true

require "test_helper"

class EnumToolTest < ActiveSupport::TestCase
include ActionMCP::TestHelper

setup do
@tool = EnumTool.new
end

test "tool is registered and available" do
assert ActionMCP.tools.key?("enum")
assert_equal EnumTool, ActionMCP.tools["enum"]
end

test "tool metadata is correct" do
assert_equal "Enum Tool", EnumTool.title
assert_equal "accepts enum attribute", EnumTool.description
assert EnumTool.read_only?
assert EnumTool.idempotent?
end

test "schema generation for enum array" do
schema = EnumTool.to_h[:inputSchema]

assert_not_nil schema
assert_equal "object", schema[:type]

# Check fruit property
fruit_prop = schema[:properties]["fruit"] || schema[:properties][:fruit]
assert_not_nil fruit_prop
assert_equal "string", fruit_prop["type"] || fruit_prop[:type]
assert_equal [ "apple", "banana", "cherry" ], fruit_prop["enum"] || fruit_prop[:enum]

# Check required
required = schema[:required] || schema["required"]
assert_includes required, "fruit"
end

test "rejects invalid fruit values" do
@tool.fruit = "orange"
assert_not @tool.valid?
assert_includes @tool.errors[:fruit], "is not included in the list"
end

test "handles nil input" do
@tool.fruit = nil
assert_not @tool.valid?
assert_nil @tool.fruit
end

test "call method works correctly" do
result = EnumTool.call(fruit: "apple")
assert_not result.is_error
assert_equal "apple", result.contents.first.text
end

test "integration with JSON parsing" do
# Simulate JSON input
json_input = { "fruit" => "apple" }
@tool.assign_attributes(json_input)

assert @tool.valid?
assert_equal "apple", @tool.fruit

result = @tool.call
assert_equal "apple", result.contents.first.text
end

test "works with MCP execution" do
# Use the test helper to execute the tool
result = execute_mcp_tool("enum", fruit: "apple")
assert_not result.is_error
assert_equal "apple", result.contents.first.text
end
end