Class: TalkToYourApp::Tool

Inherits:
Object
  • Object
show all
Defined in:
lib/talk_to_your_app/tool.rb

Overview

Base class for MCP tools. Authors subclass it, declare arguments with the class-level DSL, and implement #call(args, ctx).

class DbQueryTool < TalkToYourApp::Tool
name        "db.query"
description "Run a SQL query."
argument    :sql,    :string, required: true
argument    :format, :string, enum: %w[json text html], default: "json"

def call(args, ctx)
  # No name: resolves the connection the operator wired into this plugin.
  ctx.connection { |conn| ... }
end
end

A tool compiles down to an MCP::Tool subclass via .to_mcp_tool; the argument DSL produces the JSON Schema the SDK validates against.

Defined Under Namespace

Classes: Context

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.collecting_custom_toolsObject

While true, every newly defined Tool subclass adds itself to custom_registry. The :custom_tools plugin flips this on only while it loads the host app's app/talk_to_your_app/custom_tools/ directory, so the bundled tools (defined when the gem loads) are never collected.



153
154
155
# File 'lib/talk_to_your_app/tool.rb', line 153

def collecting_custom_tools
  @collecting_custom_tools
end

Class Method Details

.argument(arg_name, type, required: false, enum: nil, default: nil, description: nil, redact: false, minimum: nil, maximum: nil) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/talk_to_your_app/tool.rb', line 130

def argument(arg_name, type, required: false, enum: nil, default: nil, description: nil, redact: false, minimum: nil, maximum: nil)
  arguments[arg_name.to_sym] = {
    type: type.to_s,
    required: required,
    enum: enum,
    default: default,
    description: description,
    redact: redact,
    minimum: minimum,
    maximum: maximum,
  }.compact
end

.argumentsObject



143
144
145
# File 'lib/talk_to_your_app/tool.rb', line 143

def arguments
  @arguments ||= {}
end

.clear_custom_registry!Object

Test seam: forget all collected custom tools.



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

def clear_custom_registry!
  custom_registry.clear
end

.connection(value = NOT_SET) ⇒ Object



126
127
128
# File 'lib/talk_to_your_app/tool.rb', line 126

def connection(value = NOT_SET)
  value == NOT_SET ? @connection : (@connection = value)
end

.custom_registryObject

Tools collected while loading the custom_tools directory, in definition order. Held on the base class; subclasses share the one list.



157
158
159
160
# File 'lib/talk_to_your_app/tool.rb', line 157

def custom_registry
  TalkToYourApp::Tool.instance_variable_get(:@custom_registry) ||
    TalkToYourApp::Tool.instance_variable_set(:@custom_registry, [])
end

.default_argumentsObject

Static metadata, computed once per tool class.



248
249
250
251
252
# File 'lib/talk_to_your_app/tool.rb', line 248

def default_arguments
  @default_arguments ||= arguments.each_with_object({}) do |(arg_name, opts), acc|
    acc[arg_name] = opts[:default] unless opts[:default].nil?
  end
end

.description(value = NOT_SET) ⇒ Object



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

def description(value = NOT_SET)
  value == NOT_SET ? @description : (@description = value)
end

.dispatch(args, plugin_name: nil, log_level: nil) ⇒ Object

Invocation entry point, wrapped by the audit logger: one log line per call. Applies argument defaults, runs the tool, and normalizes the return into an MCP::Tool::Response.



228
229
230
231
232
233
234
235
236
237
238
239
# File 'lib/talk_to_your_app/tool.rb', line 228

def dispatch(args, plugin_name: nil, log_level: nil)
  AuditLogger.around(tool_class: self, plugin_name: plugin_name, log_level: log_level, params: args) do
    if TalkToYourApp.configuration.authorized?(TalkToYourApp::Current.principal, tool_name)
      invoke(args, plugin_name: plugin_name)
    else
      MCP::Tool::Response.new(
        [{ type: "text", text: "Not authorized: principal may not call #{tool_name}." }],
        error: true,
      )
    end
  end
end

.inherited(subclass) ⇒ Object

Records app-defined tools while collection is active (see above). Fires at any subclass depth. Also copies the class-level DSL state (arguments, description, connection) down to the subclass so a subclass can reuse a base tool's shape and only override what differs (the Jobs plugins build per-adapter tools this way). name/tool_name is deliberately NOT copied — every concrete tool must declare its own MCP name.



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

def inherited(subclass)
  super
  subclass.instance_variable_set(:@arguments, arguments.dup)
  subclass.instance_variable_set(:@description, description) unless description.nil?
  subclass.instance_variable_set(:@connection, connection) unless connection.nil?
  TalkToYourApp::Tool.custom_registry << subclass if TalkToYourApp::Tool.collecting_custom_tools
end

.input_schema_hashObject

JSON Schema (object) for the declared arguments.



184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/talk_to_your_app/tool.rb', line 184

def input_schema_hash
  properties = arguments.each_with_object({}) do |(arg_name, opts), acc|
    prop = { type: opts[:type] }
    prop[:enum] = opts[:enum] if opts[:enum]
    prop[:description] = opts[:description] if opts[:description]
    prop[:minimum] = opts[:minimum] if opts[:minimum]
    prop[:maximum] = opts[:maximum] if opts[:maximum]
    acc[arg_name] = prop
  end
  required = arguments.select { |_, o| o[:required] }.keys.map(&:to_s)
  schema = { properties: properties }
  # Draft-04 (the SDK's metaschema) rejects an empty `required` array.
  schema[:required] = required unless required.empty?
  schema
end

.invoke(args, plugin_name: nil) ⇒ Object



241
242
243
244
245
# File 'lib/talk_to_your_app/tool.rb', line 241

def invoke(args, plugin_name: nil)
  merged = default_arguments.merge(args)
  ctx = Context.new(tool_class: self, plugin_name: plugin_name, logger: TalkToYourApp.configuration.logger)
  normalize_response(new.call(merged, ctx))
end

.name(value = NOT_SET) ⇒ Object

The MCP tool name (e.g. "db.query"). Overrides Class#name as a setter while preserving it as a reader before one is assigned.



110
111
112
113
114
115
116
# File 'lib/talk_to_your_app/tool.rb', line 110

def name(value = NOT_SET)
  if value == NOT_SET
    defined?(@tool_name) && @tool_name ? @tool_name : super()
  else
    @tool_name = value
  end
end

.normalize_response(result) ⇒ Object



254
255
256
257
258
259
260
261
262
263
# File 'lib/talk_to_your_app/tool.rb', line 254

def normalize_response(result)
  case result
  when MCP::Tool::Response
    result
  when String
    MCP::Tool::Response.new([{ type: "text", text: result }])
  else
    MCP::Tool::Response.new([{ type: "text", text: result.to_json }])
  end
end

.to_mcp_definitionObject

The shape used by tests and documentation.



201
202
203
# File 'lib/talk_to_your_app/tool.rb', line 201

def to_mcp_definition
  { name: tool_name, description: description, input_schema: input_schema_hash }
end

.to_mcp_tool(plugin_name: nil, log_level: nil, description: nil) ⇒ Object

Builds the MCP::Tool subclass the server registers. Its class-level call(**args, server_context:) bridges to this tool's #call(args, ctx). plugin_name and log_level are threaded through for the audit logger.



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/talk_to_your_app/tool.rb', line 208

def to_mcp_tool(plugin_name: nil, log_level: nil, description: nil)
  if tool_name.nil? || tool_name.to_s.empty?
    raise TalkToYourApp::ConfigurationError,
      "#{inspect} has no MCP tool name — call `name \"your.tool\"` in the tool class."
  end

  ttya_tool = self
  resolved_description = description || ttya_tool.description
  MCP::Tool.define(
    name: ttya_tool.tool_name,
    description: resolved_description,
    input_schema: ttya_tool.input_schema_hash,
  ) do |server_context: nil, **args|
    ttya_tool.dispatch(args, plugin_name: plugin_name, log_level: log_level)
  end
end

.tool_nameObject



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

def tool_name
  @tool_name
end

Instance Method Details

#call(_args, _ctx) ⇒ Object

Tool authors override this.

Raises:

  • (NotImplementedError)


267
268
269
# File 'lib/talk_to_your_app/tool.rb', line 267

def call(_args, _ctx)
  raise NotImplementedError, "#{self.class}#call must be implemented"
end