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

.api_only_app?Boolean

API-only apps legitimately have no views, partials, Stimulus, or Turbo surface; a bare empty listing is indistinguishable from a full-stack app that has none yet, so name the reason.

Returns:

  • (Boolean)


267
268
269
270
271
272
273
274
275
# File 'lib/rails_ai_context/tools/base_tool.rb', line 267

def api_only_app?
  api = cached_context[:api]
  return api[:api_only] == true if api.is_a?(Hash) && api.key?(:api_only)

  app = rails_app
  app.respond_to?(:config) && app.config.respond_to?(:api_only) && app.config.api_only == true
rescue StandardError
  false
end

.api_only_note(section_label) ⇒ Object

Short honest line for a view/frontend section that doesn't apply on an API-only app, or nil when the app has a view layer. Tools check this before rendering "no X found" copy so a legitimately absent surface never reads as "not built yet".



281
282
283
284
285
# File 'lib/rails_ai_context/tools/base_tool.rb', line 281

def api_only_note()
  return nil unless api_only_app?

  "Not applicable: this is an API-only app (config.api_only), so #{} does not exist."
end

.cache_keyObject

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



347
348
349
# File 'lib/rails_ai_context/tools/base_tool.rb', line 347

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.



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/rails_ai_context/tools/base_tool.rb', line 124

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



107
108
109
# File 'lib/rails_ai_context/tools/base_tool.rb', line 107

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.



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

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
  banner = static_tier_banner
  text += banner if banner
  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.



398
399
400
401
402
403
# File 'lib/rails_ai_context/tools/base_tool.rb', line 398

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.



369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'lib/rails_ai_context/tools/base_tool.rb', line 369

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



321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/rails_ai_context/tools/base_tool.rb', line 321

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.



354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/rails_ai_context/tools/base_tool.rb', line 354

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.



243
244
245
246
247
248
249
250
# File 'lib/rails_ai_context/tools/base_tool.rb', line 243

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.



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

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: }



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/rails_ai_context/tools/base_tool.rb', line 210

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. Routes through RailsAiContext.default_app so this resolves to the booted app in runtime tier and to a StaticApp in static tier - tools that call rails_app directly (get_concern, analyze_feature, migration_advisor, ...) work in both tiers without their own checks.



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

def rails_app
  RailsAiContext.default_app
end

.rails_env_nameObject

The current environment name without requiring a booted app: Rails.env when a real Rails is loaded and responds to it, the ambient RAILS_ENV otherwise. Tools that only need the environment name (not the full StringInquirer API) use this instead of a bare Rails.env reference, which would NameError under --no-boot or early boot death.



117
118
119
# File 'lib/rails_ai_context/tools/base_tool.rb', line 117

def rails_env_name
  defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : (ENV["RAILS_ENV"] || "development")
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.



168
169
170
171
172
# File 'lib/rails_ai_context/tools/base_tool.rb', line 168

def reset_all_caches!
  reset_cache!
  session_reset!
  AstCache.clear
end

.reset_cache!Object



155
156
157
158
159
160
161
162
163
164
165
# File 'lib/rails_ai_context/tools/base_tool.rb', line 155

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



196
197
198
199
200
# File 'lib/rails_ai_context/tools/base_tool.rb', line 196

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



176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/rails_ai_context/tools/base_tool.rb', line 176

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



202
203
204
205
206
# File 'lib/rails_ai_context/tools/base_tool.rb', line 202

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)



406
407
408
# File 'lib/rails_ai_context/tools/base_tool.rb', line 406

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

.static_tier_bannerObject

One banner per response in static tier: consumers must never mistake static analysis for runtime-confirmed data. Rides the suffix mechanism so it survives truncation.



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

def static_tier_banner
  return nil unless RailsAiContext.static_tier?

  reason = RailsAiContext.static_reason
  headline = if reason.to_s.include?("--no-boot")
    "Static mode (#{reason})"
  elsif reason
    "App boot failed (#{reason})"
  else
    "Static mode"
  end
  "\n\n---\n_[STATIC] #{headline}. Serving static analysis; runtime-only data is marked " \
    "[UNAVAILABLE]. Run `rails-ai-context doctor` for details._"
end

.static_tier_refusal(capability) ⇒ Object

Tools that only make sense against a booted app must refuse in the static tier instead of half-running against whatever a failed boot happened to load (live DB access from a "static" response contradicts the tier banner in the same reply).



309
310
311
312
313
314
315
316
317
318
# File 'lib/rails_ai_context/tools/base_tool.rb', line 309

def static_tier_refusal(capability)
  return nil unless RailsAiContext.static_tier?

  reason = RailsAiContext.static_reason
  text_response(
    "[UNAVAILABLE: static tier] #{capability} requires a booted Rails app" \
    "#{reason ? " (static tier active: #{reason})" : ""}. " \
    "Fix the boot failure (see `rails-ai-context doctor`) or rerun without `--no-boot`."
  )
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. In static tier, the tier banner rides along on the same mechanism so every response - caller-suffixed or not - ends with it.



417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/rails_ai_context/tools/base_tool.rb', line 417

def text_response(text, suffix: nil)
  suffix = [ suffix, static_tier_banner ].compact.join
  suffix = nil if suffix.empty?

  # 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

.unavailable_note(section_data) ⇒ Object

Short honest line for a context section that could not be produced because the app isn't booted, as opposed to one that ran and found nothing. Tools check this before rendering "not found"/empty copy so a missing runtime capability never reads as a confirmed negative (e.g. "No notable gems found" when gems were never inspected at all).



258
259
260
261
262
# File 'lib/rails_ai_context/tools/base_tool.rb', line 258

def unavailable_note(section_data)
  return nil unless section_data.is_a?(Hash) && section_data[:unavailable]

  "[UNAVAILABLE: #{section_data[:unavailable]}]"
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.



534
535
536
537
538
539
540
541
542
543
544
545
# File 'lib/rails_ai_context/tools/base_tool.rb', line 534

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