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


Instance Methods



526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
# File 'lib/action_mcp/tool.rb', line 526

def initialize(attributes = {})
  @_alias_conflicts = []

  defined_attributes = {}
  additional_attributes = {}

  if attributes.is_a?(Hash)
    sources = {}
    attributes.each do |key, value|
      key_string = key.to_s
      property_name = self.class.canonical_property_name(key_string)

      if self.class._schema_properties.key?(property_name)
        if defined_attributes.key?(property_name) && sources[property_name] != key_string &&
            defined_attributes[property_name] != value
          @_alias_conflicts <<
            "conflicting values for '#{property_name}' via '#{sources[property_name]}' and '#{key_string}'"
          next
        end

        defined_attributes[property_name] = value
        sources[property_name] = key_string
      else
        additional_attributes[key] = value
      end
    end
  end

  @_provided_additional_attributes = additional_attributes
  @_additional_params = self.class.accepts_additional_properties? ? additional_attributes : {}
  super(defined_attributes)
end

Instance Attribute Details

#_required_propertiesArray<String>

Returns The required properties of the tool.

Returns:

  • (Array<String>)

    The required properties of the tool.



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

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.



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

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

Class Method Details

.accepts_additional_properties?Boolean

Returns whether this tool accepts additional properties

Returns:

  • (Boolean)


303
304
305
# File 'lib/action_mcp/tool.rb', line 303

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



289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/action_mcp/tool.rb', line 289

def additional_properties(enabled = nil)
  if enabled.nil?
    _additional_properties
  else
    unless enabled == true || enabled == false || enabled.is_a?(Hash)
      raise ArgumentError, "additional_properties must be true, false, or a JSON Schema hash"
    end

    self._additional_properties = enabled
    invalidate_schema_cache
  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


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

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



102
103
104
# File 'lib/action_mcp/tool.rb', line 102

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



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

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



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

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

.canonical_property_name(name) ⇒ Object



389
390
391
# File 'lib/action_mcp/tool.rb', line 389

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)


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
# File 'lib/action_mcp/tool.rb', line 449

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
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.



76
77
78
79
80
# File 'lib/action_mcp/tool.rb', line 76

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

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

.destructive(enabled = true) ⇒ Object



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

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

.destructive?Boolean

Returns:

  • (Boolean)


159
160
161
# File 'lib/action_mcp/tool.rb', line 159

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

.execution_metadataObject

Returns the execution metadata including task support



264
265
266
267
268
# File 'lib/action_mcp/tool.rb', line 264

def 
  {
    taskSupport: _task_support.to_s
  }
end

.from_wire(arguments) ⇒ Object

Builds a tool from decoded MCP arguments. The untouched input is kept for JSON Schema validation before ActiveModel coercion.



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

def from_wire(arguments)
  new(arguments).tap do |tool|
    tool.instance_variable_set(:@_wire_arguments, arguments)
  end
end

.idempotent(enabled = true) ⇒ Object



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

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

.idempotent?Boolean

Returns:

  • (Boolean)


155
156
157
# File 'lib/action_mcp/tool.rb', line 155

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

.inherited(subclass) ⇒ Object

Hook called when a class inherits from Tool



94
95
96
97
98
99
100
# File 'lib/action_mcp/tool.rb', line 94

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

.input_schemaObject



315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/action_mcp/tool.rb', line 315

def input_schema
  properties = _schema_properties.deep_dup
  _property_aliases.each do |alias_name, property_name|
    properties[alias_name] = _schema_properties.fetch(property_name).deep_dup
  end

  schema = {
    "$schema" => SchemaValidator::DEFAULT_DIALECT,
    type: "object",
    properties: properties
  }

  required = []
  alias_constraints = []
  _required_properties.each do |property_name|
    aliases = _property_aliases.select { |_alias_name, target| target == property_name }.keys
    if aliases.empty?
      required << property_name
    else
      alternatives = [ property_name, *aliases ].map { |name| { required: [ name ] } }
      alias_constraints << { anyOf: alternatives }
    end
  end

  schema[:required] = required if required.any?
  schema[:allOf] = alias_constraints if alias_constraints.any?
  add_additional_properties_to_schema(schema, _additional_properties)
  schema
end

.input_schemerObject



345
346
347
# File 'lib/action_mcp/tool.rb', line 345

def input_schemer
  self._input_schemer ||= SchemaValidator.compile(input_schema, context: "input schema for #{tool_name}")
end

.meta(data = nil) ⇒ Object

Sets or retrieves the _meta field



187
188
189
190
191
192
193
194
195
# File 'lib/action_mcp/tool.rb', line 187

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



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

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

.open_world?Boolean

Returns:

  • (Boolean)


163
164
165
# File 'lib/action_mcp/tool.rb', line 163

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



171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/action_mcp/tool.rb', line 171

def output_schema(&block)
  return _output_schema unless block_given?

  builder = OutputSchemaBuilder.new
  builder.instance_eval(&block)
  schema = builder.to_json_schema
  schemer = SchemaValidator.compile(schema, context: "output schema for #{tool_name}")

  self._output_schema_builder = builder
  self._output_schema = schema
  self._output_schemer = schemer

  _output_schema
end

.output_schemerObject



349
350
351
352
353
# File 'lib/action_mcp/tool.rb', line 349

def output_schemer
  return unless _output_schema

  self._output_schemer ||= SchemaValidator.compile(_output_schema, context: "output schema for #{tool_name}")
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.



417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/action_mcp/tool.rb', line 417

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
end

.read_only(enabled = true) ⇒ Object



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

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

.read_only?Boolean

Helper methods for checking annotations

Returns:

  • (Boolean)


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

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]



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/action_mcp/tool.rb', line 202

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



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

def requires_consent!
  self._requires_consent = true
end

.requires_consent?Boolean

Returns whether this tool requires consent

Returns:

  • (Boolean)


228
229
230
# File 'lib/action_mcp/tool.rb', line 228

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



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

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

.resumable_steps_defined?Boolean

Checks if tool has resumable steps defined

Returns:

  • (Boolean)


282
283
284
# File 'lib/action_mcp/tool.rb', line 282

def resumable_steps_defined?
  _resumable_steps_block.present?
end

.schema_property_keysObject

Returns cached string keys for schema properties to avoid repeated conversions



308
309
310
311
312
313
# File 'lib/action_mcp/tool.rb', line 308

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



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

def task_forbidden!
  self._task_support = :forbidden
end

.task_optional!Object



255
256
257
# File 'lib/action_mcp/tool.rb', line 255

def task_optional!
  self._task_support = :optional
end

.task_required!Object

Convenience methods for task support



251
252
253
# File 'lib/action_mcp/tool.rb', line 251

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



238
239
240
241
242
243
244
245
246
247
248
# File 'lib/action_mcp/tool.rb', line 238

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



107
108
109
110
111
112
113
# File 'lib/action_mcp/tool.rb', line 107

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.



482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
# File 'lib/action_mcp/tool.rb', line 482

def self.to_h(protocol_version: nil)
  schema = input_schema
  input_schemer

  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
    result[:execution] = 
  end

  # Add _meta if present
  if _meta.any?
    resolved_meta = _meta.deep_dup
    ui_meta = resolved_meta[:ui] || resolved_meta["ui"]
    if ui_meta.is_a?(Hash)
      resource_uri_key = :resourceUri if ui_meta.key?(:resourceUri)
      resource_uri_key ||= "resourceUri" if ui_meta.key?("resourceUri")
      if resource_uri_key
        ui_meta[resource_uri_key] = Apps::ViewManifest.resolve_resource_uri(ui_meta[resource_uri_key])
      end
    end
    result[:_meta] = resolved_meta
  end

  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.



49
50
51
52
53
54
55
56
57
# File 'lib/action_mcp/tool.rb', line 49

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



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

def type
  :tool
end

.unregister_from_registryObject



89
90
91
# File 'lib/action_mcp/tool.rb', line 89

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



560
561
562
# File 'lib/action_mcp/tool.rb', line 560

def additional_params
  @_additional_params || {}
end

#callObject

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



566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
# File 'lib/action_mcp/tool.rb', line 566

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.report_tool_error(error_message)
    end
  else
    @response.report_tool_error("Invalid input: #{errors.full_messages.join(', ')}")
  end

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

  if performed && !@response.error? && self.class._output_schema && @response.structured_content.nil?
    @response.report_tool_error("Tool declared an output schema but returned no structured content")
  end

  @response
end

#inspectObject



602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
# File 'lib/action_mcp/tool.rb', line 602

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



619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'lib/action_mcp/tool.rb', line 619

def render(structured: nil, **args)
  unless structured.nil?
    validate_structured_content!(structured) if self.class._output_schema

    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



634
635
636
637
638
# File 'lib/action_mcp/tool.rb', line 634

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