Class: ActionMCP::Tool

Inherits:
Capability show all
Extended by:
SchemaHelpers
Includes:
Callbacks, CurrentHelpers
Defined in:
lib/action_mcp/tool.rb

Overview

Base class for defining tools.

Provides a DSL for specifying metadata, properties, and nested collection schemas. Tools are registered automatically in the ToolsRegistry unless marked as abstract.

Direct Known Subclasses

ApplicationMCPTool

Instance Attribute Summary collapse

Attributes inherited from Capability

#execution_context

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Capability

abstract!, abstract?, capability_name, #client_supports_ui?, description, #session, #with_context

Constructor Details

#initialize(attributes = {}) ⇒ Tool

Override initialize to validate parameters before ActiveModel conversion



488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'lib/action_mcp/tool.rb', line 488

def initialize(attributes = {})
  validate_property_alias_conflicts(attributes)

  # Separate additional properties from defined attributes if enabled
  if self.class.accepts_additional_properties?
    defined_keys = self.class.schema_property_keys
    # Use partition for single-pass separation - more efficient than except/slice
    defined_attrs, additional_attrs = attributes.partition { |k, _|
      defined_keys.include?(k.to_s)
    }.map(&:to_h)
    @_additional_params = additional_attrs
    attributes = defined_attrs
  else
    @_additional_params = {}
  end

  # Validate parameters before ActiveModel processes them
  validate_parameter_types(attributes)
  super
end

Instance Attribute Details

#_required_propertiesArray<String>

Returns The required properties of the tool.

Returns:

  • (Array<String>)

    The required properties of the tool.



24
# File 'lib/action_mcp/tool.rb', line 24

class_attribute :_schema_properties, instance_accessor: false, default: {}

#_schema_propertiesHash

Returns The schema properties of the tool.

Returns:

  • (Hash)

    The schema properties of the tool.



24
# File 'lib/action_mcp/tool.rb', line 24

class_attribute :_schema_properties, instance_accessor: false, default: {}

Class Method Details

.accepts_additional_properties?Boolean

Returns whether this tool accepts additional properties

Returns:

  • (Boolean)


292
293
294
# File 'lib/action_mcp/tool.rb', line 292

def accepts_additional_properties?
  !_additional_properties.nil? && _additional_properties != false
end

.additional_properties(enabled = nil) ⇒ Object

Sets or retrieves the additionalProperties setting for the input schema

Parameters:

  • enabled (Boolean, Hash) (defaults to: nil)

    true to allow any additional properties, false to disallow them, or a Hash for typed additional properties



283
284
285
286
287
288
289
# File 'lib/action_mcp/tool.rb', line 283

def additional_properties(enabled = nil)
  if enabled.nil?
    _additional_properties
  else
    self._additional_properties = enabled
  end
end

.alias_property(alias_name, property_name) ⇒ Object

Creates an alternate input name for an existing property.

Both ‘root_id` and `thread_id` are accepted when initializing the tool, and both readers resolve to the same ActiveModel attribute.

Examples:

property :thread_id, type: "string", required: true
alias_property :root_id, :thread_id


312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/action_mcp/tool.rb', line 312

def alias_property(alias_name, property_name)
  alias_key = alias_name.to_s
  property_key = canonical_property_name(property_name)

  unless _schema_properties.key?(property_key)
    raise ArgumentError, "Cannot alias unknown property '#{property_name}'"
  end

  if alias_key == property_key
    raise ArgumentError, "Cannot alias property '#{property_key}' to itself"
  end

  if _schema_properties.key?(alias_key)
    raise ArgumentError, "Cannot alias '#{alias_key}' because it is already defined as a property"
  end

  existing_alias = _property_aliases[alias_key]
  if existing_alias && existing_alias != property_key
    raise ArgumentError, "Alias '#{alias_key}' already points to property '#{existing_alias}'"
  end

  self._property_aliases = _property_aliases.merge(alias_key => property_key)
  alias_attribute alias_key, property_key
  invalidate_schema_cache
end

.annotate(key, value) ⇒ Object



97
98
99
# File 'lib/action_mcp/tool.rb', line 97

def annotate(key, value)
  self._annotations = _annotations.merge(key.to_s => value)
end

.annotations_for_protocol(_protocol_version = nil) ⇒ Object

Return annotations for the tool



127
128
129
130
# File 'lib/action_mcp/tool.rb', line 127

def annotations_for_protocol(_protocol_version = nil)
  # Always include annotations now that we only support 2025+
  _annotations
end

.call(arguments = {}) ⇒ Object

Class method to call the tool with arguments



133
134
135
# File 'lib/action_mcp/tool.rb', line 133

def call(arguments = {})
  new(arguments).call
end

.canonical_property_name(name) ⇒ Object



338
339
340
# File 'lib/action_mcp/tool.rb', line 338

def canonical_property_name(name)
  _property_aliases[name.to_s] || name.to_s
end

.collection(prop_name, type:, description: nil, required: false, default: []) ⇒ void

This method returns an undefined value.


Collection DSL


Defines a collection property for the tool.

Parameters:

  • prop_name (Symbol, String)

    The collection property name.

  • type (String)

    The type for collection items.

  • description (String, nil) (defaults to: nil)

    Optional description for the collection.

  • required (Boolean) (defaults to: false)

    Whether the collection is required (default: false).

  • default (Array, nil) (defaults to: [])

    The default value for the collection.

Raises:

  • (ArgumentError)


402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/action_mcp/tool.rb', line 402

def self.collection(prop_name, type:, description: nil, required: false, default: [])
  raise ArgumentError, "Type is required for a collection" if type.nil?

  if _property_aliases.key?(prop_name.to_s)
    raise ArgumentError, "Cannot define collection '#{prop_name}' because it is already defined as an alias"
  end

  invalidate_schema_cache

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

  self._schema_properties = _schema_properties.merge(prop_name.to_s => collection_definition)
  new_required = _required_properties.dup
  new_required << prop_name.to_s if required
  self._required_properties = new_required

  # Map the type - for number arrays, use our custom type instance
  mapped_type = if type == "number"
                  Types::FloatArrayType.new
  else
                  map_json_type_to_active_model_type("array_#{type}")
  end

  attribute prop_name, mapped_type, default: default

  # 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
end

.default_tool_nameString Also known as: default_capability_name

Returns a default tool name based on the class name.

Returns:

  • (String)

    The default tool name.



71
72
73
74
75
# File 'lib/action_mcp/tool.rb', line 71

def self.default_tool_name
  return "" if name.nil?

  name.underscore.gsub("/", "__").sub(/_tool$/, "")
end

.destructive(enabled = true) ⇒ Object



110
111
112
# File 'lib/action_mcp/tool.rb', line 110

def destructive(enabled = true)
  annotate(:destructiveHint, enabled)
end

.destructive?Boolean

Returns:

  • (Boolean)


146
147
148
# File 'lib/action_mcp/tool.rb', line 146

def destructive?
  _annotations["destructiveHint"] == true
end

.execution_metadataObject

Returns the execution metadata including task support



258
259
260
261
262
# File 'lib/action_mcp/tool.rb', line 258

def 
  {
    taskSupport: _task_support.to_s
  }
end

.idempotent(enabled = true) ⇒ Object



118
119
120
# File 'lib/action_mcp/tool.rb', line 118

def idempotent(enabled = true)
  annotate(:idempotentHint, enabled)
end

.idempotent?Boolean

Returns:

  • (Boolean)


142
143
144
# File 'lib/action_mcp/tool.rb', line 142

def idempotent?
  _annotations["idempotentHint"] == true
end

.inherited(subclass) ⇒ Object

Hook called when a class inherits from Tool



89
90
91
92
93
94
95
# File 'lib/action_mcp/tool.rb', line 89

def inherited(subclass)
  super
  # Run the ActiveSupport load hook when a tool is defined
  subclass.class_eval do
    ActiveSupport.run_load_hooks(:action_mcp_tool, subclass)
  end
end

.meta(data = nil) ⇒ Object

Sets or retrieves the _meta field



181
182
183
184
185
186
187
188
189
# File 'lib/action_mcp/tool.rb', line 181

def meta(data = nil)
  if data
    raise ArgumentError, "_meta must be a hash" unless data.is_a?(Hash)

    self._meta = _meta.merge(data)
  else
    _meta
  end
end

.open_world(enabled = true) ⇒ Object



122
123
124
# File 'lib/action_mcp/tool.rb', line 122

def open_world(enabled = true)
  annotate(:openWorldHint, enabled)
end

.open_world?Boolean

Returns:

  • (Boolean)


150
151
152
# File 'lib/action_mcp/tool.rb', line 150

def open_world?
  _annotations["openWorldHint"] == true
end

.output_schema(&block) ⇒ Hash

Schema DSL for output structure

Parameters:

  • block (Proc)

    Block containing output schema definition

Returns:

  • (Hash)

    The generated JSON Schema



158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/action_mcp/tool.rb', line 158

def output_schema(&block)
  return _output_schema unless block_given?

  builder = OutputSchemaBuilder.new
  builder.instance_eval(&block)

  # Store both the builder and the generated schema
  self._output_schema_builder = builder
  self._output_schema = builder.to_json_schema

  _output_schema
end

.output_schema_legacy(schema = nil) ⇒ Object

Legacy output_schema method for backward compatibility



172
173
174
175
176
177
178
# File 'lib/action_mcp/tool.rb', line 172

def output_schema_legacy(schema = nil)
  if schema
    raise NotImplementedError, "Legacy output schema not yet implemented. Use output_schema DSL instead!"
  end

  _output_schema
end

.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts) ⇒ void

This method returns an undefined value.


Property DSL (Direct Declaration)


Defines a property for the tool.

This method builds a JSON Schema definition for the property, registers it in the tool’s schema, and creates an ActiveModel attribute for it.

Parameters:

  • prop_name (Symbol, String)

    The property name.

  • type (String) (defaults to: "string")

    The JSON Schema type (default: “string”).

  • description (String, nil) (defaults to: nil)

    Optional description for the property.

  • required (Boolean) (defaults to: false)

    Whether the property is required (default: false).

  • default (Object, nil) (defaults to: nil)

    The default value for the property.

  • opts (Hash)

    Additional options for the JSON Schema.



365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
# File 'lib/action_mcp/tool.rb', line 365

def self.property(prop_name, type: "string", description: nil, required: false, default: nil, **opts)
  if _property_aliases.key?(prop_name.to_s)
    raise ArgumentError, "Cannot define property '#{prop_name}' because it is already defined as an alias"
  end

  invalidate_schema_cache

  # Build the JSON Schema definition.
  prop_definition = { type: type }
  prop_definition[:description] = description if description && !description.empty?
  prop_definition.merge!(opts) if opts.any?

  self._schema_properties = _schema_properties.merge(prop_name.to_s => prop_definition)
  new_required = _required_properties.dup
  new_required << prop_name.to_s if required
  self._required_properties = new_required

  # 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 }

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

  validates prop_name, numericality: true, allow_nil: !required
end

.read_only(enabled = true) ⇒ Object



114
115
116
# File 'lib/action_mcp/tool.rb', line 114

def read_only(enabled = true)
  annotate(:readOnlyHint, enabled)
end

.read_only?Boolean

Helper methods for checking annotations

Returns:

  • (Boolean)


138
139
140
# File 'lib/action_mcp/tool.rb', line 138

def read_only?
  _annotations["readOnlyHint"] == true
end

.renders_ui(resource_uri, visibility: nil) ⇒ Object

Declares the UI resource this tool renders. Merges a ‘ui:` entry into `_meta` so the tool listing advertises the dashboard.

Parameters:

  • resource_uri (String)

    a ‘ui://` URI

  • visibility (Array<Symbol, String>, nil) (defaults to: nil)

    subset of ‘[:model, :app]`



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/action_mcp/tool.rb', line 196

def renders_ui(resource_uri, visibility: nil)
  unless resource_uri.is_a?(String) && Apps::URI_SCHEME.match?(resource_uri)
    raise ArgumentError, "renders_ui requires a ui:// URI, got: #{resource_uri.inspect}"
  end

  normalized_visibility =
    if visibility
      normalized = Array(visibility).map(&:to_s)
      invalid = normalized - Apps::VISIBILITY_VALUES
      if invalid.any?
        raise ArgumentError,
              "renders_ui visibility must be #{Apps::VISIBILITY_VALUES.join('/')}, got: #{visibility.inspect}"
      end
      normalized
    end

  ui_meta = { resourceUri: resource_uri, visibility: normalized_visibility }.compact
  self._meta = _meta.deep_merge(ui: ui_meta)
end

.requires_consent!Object

Marks this tool as requiring consent before execution



217
218
219
# File 'lib/action_mcp/tool.rb', line 217

def requires_consent!
  self._requires_consent = true
end

.requires_consent?Boolean

Returns whether this tool requires consent

Returns:

  • (Boolean)


222
223
224
# File 'lib/action_mcp/tool.rb', line 222

def requires_consent?
  _requires_consent
end

.resumable_steps(&block) ⇒ void

This method returns an undefined value.


Resumable Steps DSL (ActiveJob::Continuable support)


Defines resumable execution steps for long-running tools

Parameters:

  • block (Proc)

    Block containing step definitions



270
271
272
# File 'lib/action_mcp/tool.rb', line 270

def resumable_steps(&block)
  self._resumable_steps_block = block
end

.resumable_steps_defined?Boolean

Checks if tool has resumable steps defined

Returns:

  • (Boolean)


276
277
278
# File 'lib/action_mcp/tool.rb', line 276

def resumable_steps_defined?
  _resumable_steps_block.present?
end

.schema_property_keysObject

Returns cached string keys for schema properties to avoid repeated conversions



297
298
299
300
301
302
# File 'lib/action_mcp/tool.rb', line 297

def schema_property_keys
  return _cached_schema_property_keys if _cached_schema_property_keys

  self._cached_schema_property_keys = (_schema_properties.keys + _property_aliases.keys).map(&:to_s)
  _cached_schema_property_keys
end

.task_forbidden!Object



253
254
255
# File 'lib/action_mcp/tool.rb', line 253

def task_forbidden!
  self._task_support = :forbidden
end

.task_optional!Object



249
250
251
# File 'lib/action_mcp/tool.rb', line 249

def task_optional!
  self._task_support = :optional
end

.task_required!Object

Convenience methods for task support



245
246
247
# File 'lib/action_mcp/tool.rb', line 245

def task_required!
  self._task_support = :required
end

.task_support(mode = nil) ⇒ Symbol


Task Support DSL (MCP 2025-11-25)


Sets or retrieves the task support mode for this tool

Parameters:

  • mode (Symbol, nil) (defaults to: nil)

    :required, :optional, or :forbidden (default)

Returns:

  • (Symbol)

    The current task support mode



232
233
234
235
236
237
238
239
240
241
242
# File 'lib/action_mcp/tool.rb', line 232

def task_support(mode = nil)
  if mode
    unless %i[required optional forbidden].include?(mode)
      raise ArgumentError, "task_support must be :required, :optional, or :forbidden"
    end

    self._task_support = mode
  else
    _task_support
  end
end

.title(value = nil) ⇒ Object

Convenience methods for common annotations



102
103
104
105
106
107
108
# File 'lib/action_mcp/tool.rb', line 102

def title(value = nil)
  if value
    annotate(:title, value)
  else
    _annotations["title"]
  end
end

.to_h(protocol_version: nil) ⇒ Hash


Tool Definition Serialization


Returns a hash representation of the tool definition including its JSON Schema.

Returns:

  • (Hash)

    The tool definition.



443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/action_mcp/tool.rb', line 443

def self.to_h(protocol_version: nil)
  schema = {
    type: "object",
    properties: _schema_properties
  }
  schema[:required] = _required_properties if _required_properties.any?

  # Add additionalProperties if configured
  add_additional_properties_to_schema(schema, _additional_properties)

  result = {
    name: tool_name,
    description: description.presence,
    inputSchema: schema
  }.compact

  # Add output schema if defined
  result[:outputSchema] = _output_schema if _output_schema.present?

  # Add annotations if protocol supports them
  annotations = annotations_for_protocol(protocol_version)
  result[:annotations] = annotations if annotations.any?

  # Add execution metadata (MCP 2025-11-25)
  # Only include if not default (forbidden) to minimize payload
  if _task_support && _task_support != :forbidden && (protocol_version)
    result[:execution] = 
  end

  # Add _meta if present
  result[:_meta] = _meta if _meta.any?

  result
end

.tool_name(name = nil) ⇒ String


Tool Name and Description DSL


Sets or retrieves the tool’s name.

Parameters:

  • name (String, nil) (defaults to: nil)

    Optional. The name to set for the tool.

Returns:

  • (String)

    The current tool name.



44
45
46
47
48
49
50
51
52
# File 'lib/action_mcp/tool.rb', line 44

def self.tool_name(name = nil)
  if name
    self._capability_name = name
    re_register_if_needed
    name
  else
    _capability_name || default_tool_name
  end
end

.typeObject



80
81
82
# File 'lib/action_mcp/tool.rb', line 80

def type
  :tool
end

.unregister_from_registryObject



84
85
86
# File 'lib/action_mcp/tool.rb', line 84

def unregister_from_registry
  ActionMCP::ToolsRegistry.unregister(self) if ActionMCP::ToolsRegistry.items.values.include?(self)
end

Instance Method Details

#additional_paramsObject

Returns additional parameters that were passed but not defined in the schema



510
511
512
# File 'lib/action_mcp/tool.rb', line 510

def additional_params
  @_additional_params || {}
end

#callObject

Public entry point for executing the tool Returns an array of Content objects collected from render calls



516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
# File 'lib/action_mcp/tool.rb', line 516

def call
  @response = ToolResponse.new
  performed = false            # ← track execution

  if valid?
    begin
      run_callbacks :perform do
        performed = true       # ← set if we reach the block
        perform
      end
    rescue StandardError => e
      # Show generic error message for HTTP requests, detailed for direct calls
      error_message = if execution_context[:request].present?
                         "An unexpected error occurred."
      else
        e.message
      end
      @response.mark_as_error!(:internal_error, message: error_message)
    end
  else
    @response.mark_as_error!(:invalid_params,
                             message: "Invalid input",
                             data: errors.full_messages)
  end

  # If callbacks halted execution (`performed` still false) and
  # nothing else marked an error, surface it as invalid_params.
  if !performed && !@response.error?
    @response.mark_as_error!(:invalid_params, message: "Tool execution was aborted")
  end

  @response
end

#inspectObject



550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
# File 'lib/action_mcp/tool.rb', line 550

def inspect
  attributes_hash = attributes.transform_values(&:inspect)

  response_info = if defined?(@response) && @response
                    "response: #{@response.contents.size} content(s), isError: #{@response.is_error}"
  else
                    "response: nil"
  end

  errors_info = errors.any? ? ", errors: #{errors.full_messages}" : ""

  "#<#{self.class.name} #{attributes_hash.map do |k, v|
    "#{k}: #{v.inspect}"
  end.join(', ')}, #{response_info}#{errors_info}>"
end

#render(structured: nil, **args) ⇒ Object

Override render to collect Content objects and support structured content



567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/action_mcp/tool.rb', line 567

def render(structured: nil, **args)
  if structured
    # Validate structured content against output_schema if enabled
    validate_structured_content!(structured) if self.class._output_schema

    # Render structured content
    set_structured_content(structured)
    structured
  else
    # Normal content rendering
    content = super(**args) # Call Renderable's render method
    @response.add(content)  # Add to the response
    content # Return the content for potential use in perform
  end
end

Override render_resource_link to collect ResourceLink objects



584
585
586
587
588
# File 'lib/action_mcp/tool.rb', line 584

def render_resource_link(**args)
  content = super(**args) # Call Renderable's render_resource_link method
  @response.add(content)  # Add to the response
  content # Return the content for potential use in perform
end