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

Instance Method Summary collapse

Class Attribute Details

.descendantsObject (readonly)

Returns the value of attribute descendants.



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

def descendants
  @descendants
end

.registry_mutexObject (readonly)

Returns the value of attribute registry_mutex.



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

def registry_mutex
  @registry_mutex
end

Class Method Details

.abstract!Object

Mark a tool class as abstract (excluded from registration). Reaches back to BaseTool explicitly: registry_mutex/descendants are ivars on the BaseTool object, and a subclass calling this method has no ivar storage of its own to read them from.



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

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

.abstract?Boolean

Returns:

  • (Boolean)


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

def abstract?
  @abstract == true
end

.cache_keyObject

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



265
266
267
# File 'lib/rails_ai_context/tools/base_tool.rb', line 265

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.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/rails_ai_context/tools/base_tool.rb', line 110

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

    # Fast path: within TTL window, trust the cache and skip the
    # fingerprint walk entirely. Fingerprinter stats every *.rb file
    # in WATCHED_DIRS (plus, in dev-mode path: installs, every file
    # in the gem's own lib/ tree) - measured at ~12ms per call in
    # dev mode, ~0.5ms in production. Since LiveReload fires
    # reset_all_caches! on actual file-change events, stale-cache
    # risk during a short TTL window is already covered.
    if SHARED_CACHE[:context] && (now - SHARED_CACHE[:timestamp]) < ttl
      return SHARED_CACHE[:context].deep_dup
    end

    # TTL expired: re-validate via fingerprint before re-introspecting.
    # If fingerprint is unchanged, bump the timestamp and reuse the
    # cached context - saves re-running all 39 introspectors.
    if SHARED_CACHE[:context] && !Fingerprinter.changed?(rails_app, SHARED_CACHE[:fingerprint])
      SHARED_CACHE[:timestamp] = now
      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



103
104
105
# File 'lib/rails_ai_context/tools/base_tool.rb', line 103

def config
  RailsAiContext.configuration
end

.error_response(text) ⇒ Object

Helper: wrap text in an MCP::Tool::Response flagged as an error (isError: true) so MCP clients and the CLI treat the call as failed (non-zero exit). Mirrors the SafeCall rescue wrapper. Use for genuine execution failures only - policy blocks and guidance messages stay informational via text_response.



359
360
361
362
363
364
# File 'lib/rails_ai_context/tools/base_tool.rb', line 359

def error_response(text)
  # A failed call must not leak its recorded params into the next
  # call's session entry.
  Thread.current[:rails_ai_context_call_params] = nil
  MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
end

.extract_method_source_from_file(path, method_name) ⇒ Object

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



316
317
318
319
320
321
# File 'lib/rails_ai_context/tools/base_tool.rb', line 316

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.



287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
# File 'lib/rails_ai_context/tools/base_tool.rb', line 287

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



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/rails_ai_context/tools/base_tool.rb', line 239

def find_closest_match(input, available)
  return nil if available.empty?
  # A blank query matches everything via substring ("".include? anything),
  # so it would otherwise surface an arbitrary "Did you mean" suggestion
  # for input that isn't a typo at all - just missing.
  return nil if input.to_s.strip.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 post → post_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.



272
273
274
275
276
277
278
279
280
281
282
283
# File 'lib/rails_ai_context/tools/base_tool.rb', line 272

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
26
# File 'lib/rails_ai_context/tools/base_tool.rb', line 19

def self.inherited(subclass)
  super
  subclass.instance_variable_set(:@abstract, false)
  subclass.singleton_class.prepend(SafeCall)
  # 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

.introspection_warnings_note(ctx) ⇒ Object

One-line banner listing introspectors that failed during context generation. Aggregate tools append this so AI clients know which sections are missing rather than empty.



229
230
231
232
233
234
235
236
# File 'lib/rails_ai_context/tools/base_tool.rb', line 229

def introspection_warnings_note(ctx)
  warnings = ctx.is_a?(Hash) ? ctx[:_warnings] : nil
  return nil unless warnings.is_a?(Array) && warnings.any?

  failed = warnings.map { |w| w[:introspector] }.compact.join(", ")
  "\n\n---\n_Partial context: introspection failed for #{failed}. " \
    "Data from those sections is missing, not empty._"
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.



215
216
217
218
219
220
221
222
223
224
# File 'lib/rails_ai_context/tools/base_tool.rb', line 215

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}" if available.any?
  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: }



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/rails_ai_context/tools/base_tool.rb', line 196

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



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

def rails_app
  Rails.application
end

.registered_toolsObject

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



45
46
47
48
# File 'lib/rails_ai_context/tools/base_tool.rb', line 45

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.



154
155
156
157
158
# File 'lib/rails_ai_context/tools/base_tool.rb', line 154

def reset_all_caches!
  reset_cache!
  session_reset!
  AstCache.clear
end

.reset_cache!Object



141
142
143
144
145
146
147
148
149
150
151
# File 'lib/rails_ai_context/tools/base_tool.rb', line 141

def reset_cache!
  SHARED_CACHE[:mutex].synchronize do
    SHARED_CACHE.delete(:context)
    SHARED_CACHE.delete(:timestamp)
    SHARED_CACHE.delete(:fingerprint)
  end
  # Also invalidate the memoized gem-lib fingerprint so active gem
  # development sees a fresh scan on next call without a process
  # restart. No-op for production installs.
  Fingerprinter.reset_gem_lib_fingerprint!
end

.session_queriesObject



182
183
184
185
186
# File 'lib/rails_ai_context/tools/base_tool.rb', line 182

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



162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/rails_ai_context/tools/base_tool.rb', line 162

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



188
189
190
191
192
# File 'lib/rails_ai_context/tools/base_tool.rb', line 188

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)



324
325
326
# File 'lib/rails_ai_context/tools/base_tool.rb', line 324

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, suffix: nil) ⇒ 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. suffix:, when given, is appended after the truncation footer (or after the text itself when untruncated) so callers can attach a short trailing note that must survive truncation instead of being cut off with the tail.



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
# File 'lib/rails_ai_context/tools/base_tool.rb', line 333

def text_response(text, suffix: nil)
  # 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\"`)._"
    truncated += suffix if suffix
    MCP::Tool::Response.new([ { type: "text", text: truncated } ])
  else
    text += suffix if suffix
    MCP::Tool::Response.new([ { type: "text", text: text } ])
  end
end

Instance Method Details

#dedupe_put_patch_routes(actions) ⇒ Object

Merge duplicate PUT/PATCH entries for the same path+action into a single "PATCH|PUT" entry (Rails generates both for every resources update route). Public: the VFS routes resource uses it too, so route counts stay consistent across every surface that reports them.



445
446
447
448
449
450
451
452
453
454
455
456
# File 'lib/rails_ai_context/tools/base_tool.rb', line 445

public def dedupe_put_patch_routes(actions)
  deduped = []
  actions.each do |r|
    existing = deduped.find { |d| d[:path] == r[:path] && d[:action] == r[:action] }
    if existing && %w[PUT PATCH].include?(r[:verb]) && %w[PUT PATCH].include?(existing[:verb])
      existing[:verb] = "PATCH|PUT"
    else
      deduped << r.dup
    end
  end
  deduped
end