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 read-only SQL query."
  connection  :replica_readonly
  argument    :sql,    :string, required: true
  argument    :format, :string, enum: %w[json text html], default: "json"

  def call(args, ctx)
    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 Method Summary collapse

Instance Method Summary collapse

Class Method Details

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



82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/talk_to_your_app/tool.rb', line 82

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



95
96
97
# File 'lib/talk_to_your_app/tool.rb', line 95

def arguments
  @arguments ||= {}
end

.connection(value = NOT_SET) ⇒ Object



78
79
80
# File 'lib/talk_to_your_app/tool.rb', line 78

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

.default_argumentsObject

Static metadata, computed once per tool class.



165
166
167
168
169
# File 'lib/talk_to_your_app/tool.rb', line 165

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



74
75
76
# File 'lib/talk_to_your_app/tool.rb', line 74

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.



145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/talk_to_your_app/tool.rb', line 145

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)
    else
      MCP::Tool::Response.new(
        [{ type: "text", text: "Not authorized: principal may not call #{tool_name}." }],
        error: true,
      )
    end
  end
end

.input_schema_hashObject

JSON Schema (object) for the declared arguments.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/talk_to_your_app/tool.rb', line 102

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) ⇒ Object



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

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

.name(value = NOT_SET) ⇒ Object Also known as: tool_name

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



65
66
67
68
69
70
71
# File 'lib/talk_to_your_app/tool.rb', line 65

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



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

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.



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

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

.to_mcp_tool(plugin_name: nil, log_level: 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.



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

def to_mcp_tool(plugin_name: nil, log_level: 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
  MCP::Tool.define(
    name: ttya_tool.tool_name,
    description: ttya_tool.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

Instance Method Details

#call(_args, _ctx) ⇒ Object

Tool authors override this.

Raises:

  • (NotImplementedError)


184
185
186
# File 'lib/talk_to_your_app/tool.rb', line 184

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