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
-
.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.
-
.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).
-
.text_response(text, suffix: nil) ⇒ Object
Helper: wrap text in an MCP::Tool::Response with safety-net truncation.
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 |
.cache_key ⇒ Object
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_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.
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 |
.config ⇒ Object
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.}" 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_app ⇒ Object
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_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.
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_queries ⇒ Object
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 |