Class: RailsAiContext::Tools::BaseTool

Inherits:
MCP::Tool
  • Object
show all
Defined in:
lib/rails_ai_context/tools/base_tool.rb

Overview

Base class for all MCP tools exposed by rails-ai-context. Inherits from the official MCP::Tool to get schema validation, annotations, and protocol compliance for free.

Constant Summary collapse

SHARED_CACHE =

Shared cache across all tool subclasses, protected by a Mutex for thread safety in multi-threaded servers (e.g., Puma).

{ mutex: Mutex.new }
SESSION_CONTEXT =

Session-level context tracking. Lets AI avoid redundant queries by recording what tools have been called with what params. In-memory only — resets on server restart (matches conversation lifecycle).

{ mutex: Mutex.new, queries: {} }

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.descendantsObject (readonly)

Returns the value of attribute descendants.



28
29
30
# File 'lib/rails_ai_context/tools/base_tool.rb', line 28

def descendants
  @descendants
end

.registry_mutexObject (readonly)

Returns the value of attribute registry_mutex.



28
29
30
# File 'lib/rails_ai_context/tools/base_tool.rb', line 28

def registry_mutex
  @registry_mutex
end

Class Method Details

.abstract!Object

Mark a tool class as abstract (excluded from registration).



31
32
33
34
# File 'lib/rails_ai_context/tools/base_tool.rb', line 31

def abstract!
  @abstract = true
  registry_mutex.synchronize { descendants.delete(self) }
end

.abstract?Boolean

Returns:

  • (Boolean)


36
37
38
# File 'lib/rails_ai_context/tools/base_tool.rb', line 36

def abstract?
  @abstract == true
end

.cache_keyObject

Cache key for paginated responses — lets agents detect stale data between pages



226
227
228
# File 'lib/rails_ai_context/tools/base_tool.rb', line 226

def cache_key
  SHARED_CACHE[:fingerprint] || "none"
end

.cached_contextObject

Cache introspection results with TTL + fingerprint invalidation. Uses SHARED_CACHE so all tool subclasses share one introspection result instead of each caching independently.



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/rails_ai_context/tools/base_tool.rb', line 106

def cached_context
  SHARED_CACHE[:mutex].synchronize do
    now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    ttl = RailsAiContext.configuration.cache_ttl

    if SHARED_CACHE[:context] && (now - SHARED_CACHE[:timestamp]) < ttl && !Fingerprinter.changed?(rails_app, SHARED_CACHE[:fingerprint])
      return SHARED_CACHE[:context].deep_dup
    end

    SHARED_CACHE[:context] = RailsAiContext.introspect
    SHARED_CACHE[:timestamp] = now
    SHARED_CACHE[:fingerprint] = Fingerprinter.compute(rails_app)
    SHARED_CACHE[:context].deep_dup
  end
end

.configObject



99
100
101
# File 'lib/rails_ai_context/tools/base_tool.rb', line 99

def config
  RailsAiContext.configuration
end

.extract_method_source_from_file(path, method_name) ⇒ Object

Extract method source from a file path. Reads file safely. Returns hash or nil.



277
278
279
280
281
282
# File 'lib/rails_ai_context/tools/base_tool.rb', line 277

def extract_method_source_from_file(path, method_name)
  return nil unless File.exist?(path)
  return nil if File.size(path) > RailsAiContext.configuration.max_file_size
  source = RailsAiContext::SafeFile.read(path) || ""
  extract_method_source_from_string(source, method_name)
end

.extract_method_source_from_string(source, method_name) ⇒ Object

Extract method source from a source string via indentation-based matching. Returns { code:, start_line:, end_line: } or nil. Shared by get_callbacks, get_concern.



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/rails_ai_context/tools/base_tool.rb', line 248

def extract_method_source_from_string(source, method_name)
  source_lines = source.lines
  escaped = Regexp.escape(method_name.to_s)
  # ? and ! ARE word boundaries, so skip \b after them
  pattern = if method_name.to_s.end_with?("?", "!")
    /\A\s*def\s+#{escaped}/
  else
    /\A\s*def\s+#{escaped}\b/
  end
  start_idx = source_lines.index { |l| l.match?(pattern) }
  return nil unless start_idx

  def_indent = source_lines[start_idx][/\A\s*/].length
  result = []
  end_idx = start_idx

  source_lines[start_idx..].each_with_index do |line, i|
    result << line.rstrip
    end_idx = start_idx + i
    break if i > 0 && line.match?(/\A\s{#{def_indent}}end\b/)
  end

  { code: result.join("\n"), start_line: start_idx + 1, end_line: end_idx + 1 }
rescue => e
  $stderr.puts "[rails-ai-context] extract_method_source_from_string failed: #{e.message}" if ENV["DEBUG"]
  nil
end

.find_closest_match(input, available) ⇒ Object

Fuzzy match: find the closest available name by exact, underscore, substring, or prefix



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/rails_ai_context/tools/base_tool.rb', line 204

def find_closest_match(input, available)
  return nil if available.empty?
  downcased = input.downcase
  underscored = input.underscore.downcase

  # Exact case-insensitive match (including underscore/classify variants)
  exact = available.find do |a|
    a_down = a.downcase
    a_under = a.underscore.downcase
    a_down == downcased || a_under == underscored || a_down == underscored || a_under == downcased
  end
  return exact if exact

  # Substring match — prefer shortest (most specific) to avoid cook → cook_comments
  substring_matches = available.select { |a| a.downcase.include?(downcased) || downcased.include?(a.downcase) }
  return substring_matches.min_by(&:length) if substring_matches.any?

  # Prefix match
  available.find { |a| a.downcase.start_with?(downcased[0..2]) }
end

.fuzzy_find_key(keys, query) ⇒ Object

Case-insensitive fuzzy key lookup for hashes keyed by class/table names. Tries exact, underscore, singularize, and classify variants. Returns matching key or nil. Shared by get_model_details, get_callbacks, get_context, generate_test, dependency_graph.



233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/rails_ai_context/tools/base_tool.rb', line 233

def fuzzy_find_key(keys, query)
  return nil if query.nil? || keys.nil? || keys.empty?
  q = query.to_s.strip
  return nil if q.empty?
  q_down = q.downcase
  q_under = q.underscore.downcase

  keys.find { |k| k.to_s.downcase == q_down } ||
    keys.find { |k| k.to_s.underscore.downcase == q_under } ||
    keys.find { |k| k.to_s.downcase == q.singularize.downcase } ||
    keys.find { |k| k.to_s.downcase == q.classify.downcase }
end

.inherited(subclass) ⇒ Object



19
20
21
22
23
24
25
# File 'lib/rails_ai_context/tools/base_tool.rb', line 19

def self.inherited(subclass)
  super
  subclass.instance_variable_set(:@abstract, false)
  # Thread-safe append. Mutex is NOT held during eager_load!'s const_get
  # (which triggers inherited), so no recursive locking risk here.
  BaseTool.registry_mutex.synchronize { BaseTool.descendants << subclass }
end

.not_found_response(type, name, available, recovery_tool: nil) ⇒ Object

Structured not-found error with fuzzy suggestion and recovery hint. Helps AI agents self-correct without retrying blind.



192
193
194
195
196
197
198
199
200
201
# File 'lib/rails_ai_context/tools/base_tool.rb', line 192

def not_found_response(type, name, available, recovery_tool: nil)
  suggestion = find_closest_match(name, available)
  # Don't suggest the exact same string the user typed — that's useless
  suggestion = nil if suggestion == name
  lines = [ "#{type} '#{name}' not found." ]
  lines << "Did you mean '#{suggestion}'?" if suggestion
  lines << "Available: #{available.first(20).join(', ')}#{"..." if available.size > 20}"
  lines << "_Recovery: #{recovery_tool}_" if recovery_tool
  text_response(lines.join("\n"))
end

.paginate(items, offset:, limit:, default_limit: 50) ⇒ Object

Standardized pagination: slice items with offset/limit and produce a consistent hint. Returns { items:, hint:, total:, offset:, limit: }



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/rails_ai_context/tools/base_tool.rb', line 173

def paginate(items, offset:, limit:, default_limit: 50)
  offset = [ offset.to_i, 0 ].max
  limit  = limit.nil? ? default_limit : [ limit.to_i, 1 ].max
  total  = items.size
  sliced = items.drop(offset).first(limit)

  hint = if sliced.empty? && total > 0
    "_No items at offset #{offset}. Total: #{total}._"
  elsif offset + limit < total
    "_Showing #{offset + 1}-#{offset + sliced.size} of #{total}. Use offset:#{offset + limit} for next page._"
  else
    ""
  end

  { items: sliced, hint: hint, total: total, offset: offset, limit: limit }
end

.rails_appObject

Convenience: access the Rails app and cached introspection



95
96
97
# File 'lib/rails_ai_context/tools/base_tool.rb', line 95

def rails_app
  Rails.application
end

.registered_toolsObject

All non-abstract tool classes. Triggers eager loading first.



41
42
43
44
# File 'lib/rails_ai_context/tools/base_tool.rb', line 41

def registered_tools
  eager_load!
  registry_mutex.synchronize { descendants.reject(&:abstract?) }
end

.reset_all_caches!Object

Reset the shared cache. Used by LiveReload to invalidate on file change.



131
132
133
134
135
# File 'lib/rails_ai_context/tools/base_tool.rb', line 131

def reset_all_caches!
  reset_cache!
  session_reset!
  AstCache.clear
end

.reset_cache!Object



122
123
124
125
126
127
128
# File 'lib/rails_ai_context/tools/base_tool.rb', line 122

def reset_cache!
  SHARED_CACHE[:mutex].synchronize do
    SHARED_CACHE.delete(:context)
    SHARED_CACHE.delete(:timestamp)
    SHARED_CACHE.delete(:fingerprint)
  end
end

.session_queriesObject



159
160
161
162
163
# File 'lib/rails_ai_context/tools/base_tool.rb', line 159

def session_queries
  SESSION_CONTEXT[:mutex].synchronize do
    SESSION_CONTEXT[:queries].values.dup
  end
end

.session_record(tool_name, params, summary = nil) ⇒ Object

── Session context helpers ──────────────────────────────────────



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/rails_ai_context/tools/base_tool.rb', line 139

def session_record(tool_name, params, summary = nil)
  SESSION_CONTEXT[:mutex].synchronize do
    key = session_key(tool_name, params)
    existing = SESSION_CONTEXT[:queries][key]
    if existing
      existing[:call_count] = (existing[:call_count] || 1) + 1
      existing[:last_timestamp] = Time.now.iso8601
      existing[:summary] = summary if summary
    else
      SESSION_CONTEXT[:queries][key] = {
        tool: tool_name.to_s,
        params: params,
        call_count: 1,
        timestamp: Time.now.iso8601,
        summary: summary
      }
    end
  end
end

.session_reset!Object



165
166
167
168
169
# File 'lib/rails_ai_context/tools/base_tool.rb', line 165

def session_reset!
  SESSION_CONTEXT[:mutex].synchronize do
    SESSION_CONTEXT[:queries].clear
  end
end

.set_call_params(**params) ⇒ Object

Store call params for the current tool invocation (thread-safe)



285
286
287
# File 'lib/rails_ai_context/tools/base_tool.rb', line 285

def set_call_params(**params)
  Thread.current[:rails_ai_context_call_params] = params.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
end

.text_response(text) ⇒ Object

Helper: wrap text in an MCP::Tool::Response with safety-net truncation. Auto-records the call in session context so session_context(action:“status”) works.



291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/rails_ai_context/tools/base_tool.rb', line 291

def text_response(text)
  # Auto-track: record this tool call in session context (skip SessionContext itself to avoid recursion)
  if respond_to?(:tool_name) && tool_name != "rails_session_context"
    summary = text.lines.first&.strip&.truncate(80)
    params = Thread.current[:rails_ai_context_call_params] || {}
    session_record(tool_name, params, summary)
    Thread.current[:rails_ai_context_call_params] = nil
  end

  max = RailsAiContext.configuration.max_tool_response_chars
  if max && text.length > max
    truncated = text[0...max]
    truncated += "\n\n---\n_Response truncated (#{text.length} chars). Use `detail:\"summary\"` for an overview, or filter by a specific item (e.g. `table:\"users\"`)._"
    MCP::Tool::Response.new([ { type: "text", text: truncated } ])
  else
    MCP::Tool::Response.new([ { type: "text", text: text } ])
  end
end