Class: RailsAiContext::Tools::BaseTool
- Inherits:
-
MCP::Tool
- Object
- MCP::Tool
- RailsAiContext::Tools::BaseTool
- 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.
Direct Known Subclasses
AnalyzeFeature, DependencyGraph, Diagnose, GenerateTest, GetApi, GetCallbacks, GetComponentCatalog, GetConcern, GetConfig, GetContext, GetControllers, GetConventions, GetEditContext, GetEnv, GetFrontendStack, GetGems, GetHelperMethods, GetJobPattern, GetModelDetails, GetPartialInterface, GetRoutes, GetSchema, GetServicePattern, GetStimulus, GetTestInfo, GetTurboMap, GetView, MigrationAdvisor, Onboard, PerformanceCheck, Query, ReadLogs, ReviewChanges, RuntimeInfo, SearchCode, SearchDocs, SecurityScan, SessionContext, Validate
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
-
.descendants ⇒ Object
readonly
Returns the value of attribute descendants.
-
.registry_mutex ⇒ Object
readonly
Returns the value of attribute registry_mutex.
Class Method Summary collapse
-
.abstract! ⇒ Object
Mark a tool class as abstract (excluded from registration).
- .abstract? ⇒ Boolean
-
.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.
-
.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.
-
.cache_key ⇒ Object
Cache key for paginated responses - lets agents detect stale data between pages.
-
.cached_context ⇒ Object
Cache introspection results with TTL + fingerprint invalidation.
- .config ⇒ Object
-
.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).
-
.extract_method_source_from_file(path, method_name) ⇒ Object
Extract method source from a file path.
-
.extract_method_source_from_string(source, method_name) ⇒ Object
Extract method source from a source string via indentation-based matching.
-
.find_closest_match(input, available) ⇒ Object
Fuzzy match: find the closest available name by exact, underscore, substring, or prefix.
-
.fuzzy_find_key(keys, query) ⇒ Object
Case-insensitive fuzzy key lookup for hashes keyed by class/table names.
- .inherited(subclass) ⇒ Object
-
.introspection_warnings_note(ctx) ⇒ Object
One-line banner listing introspectors that failed during context generation.
-
.not_found_response(type, name, available, recovery_tool: nil) ⇒ Object
Structured not-found error with fuzzy suggestion and recovery hint.
-
.paginate(items, offset:, limit:, default_limit: 50) ⇒ Object
Standardized pagination: slice items with offset/limit and produce a consistent hint.
-
.rails_app ⇒ Object
Convenience: access the Rails app and cached introspection.
-
.rails_env_name ⇒ Object
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.
-
.registered_tools ⇒ Object
All non-abstract tool classes.
-
.reset_all_caches! ⇒ Object
Reset the shared cache.
- .reset_cache! ⇒ Object
- .session_queries ⇒ Object
-
.session_record(tool_name, params, summary = nil) ⇒ Object
── Session context helpers ──────────────────────────────────────.
- .session_reset! ⇒ Object
-
.set_call_params(**params) ⇒ Object
Store call params for the current tool invocation (thread-safe).
-
.static_tier_banner ⇒ Object
One banner per response in static tier: consumers must never mistake static analysis for runtime-confirmed data.
-
.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).
-
.text_response(text, suffix: nil) ⇒ Object
Helper: wrap text in an MCP::Tool::Response with safety-net truncation.
-
.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.
Instance Method Summary collapse
-
#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
resourcesupdate route).
Class Attribute Details
.descendants ⇒ Object (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_mutex ⇒ Object (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
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.
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(section_label) return nil unless api_only_app? "Not applicable: this is an API-only app (config.api_only), so #{section_label} does not exist." end |
.cache_key ⇒ Object
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_context ⇒ Object
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 |
.config ⇒ Object
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 = text += if 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.}" 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_app ⇒ Object
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_name ⇒ Object
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_tools ⇒ Object
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_queries ⇒ Object
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_banner ⇒ Object
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 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, ].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 |