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



245
246
247
# File 'lib/rails_ai_context/tools/base_tool.rb', line 245

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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# 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

    # 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 31 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



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.



296
297
298
299
300
301
# File 'lib/rails_ai_context/tools/base_tool.rb', line 296

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.



267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/rails_ai_context/tools/base_tool.rb', line 267

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



223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/rails_ai_context/tools/base_tool.rb', line 223

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.



252
253
254
255
256
257
258
259
260
261
262
263
# File 'lib/rails_ai_context/tools/base_tool.rb', line 252

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.



211
212
213
214
215
216
217
218
219
220
# File 'lib/rails_ai_context/tools/base_tool.rb', line 211

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



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/rails_ai_context/tools/base_tool.rb', line 192

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.



150
151
152
153
154
# File 'lib/rails_ai_context/tools/base_tool.rb', line 150

def reset_all_caches!
  reset_cache!
  session_reset!
  AstCache.clear
end

.reset_cache!Object



137
138
139
140
141
142
143
144
145
146
147
# File 'lib/rails_ai_context/tools/base_tool.rb', line 137

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



178
179
180
181
182
# File 'lib/rails_ai_context/tools/base_tool.rb', line 178

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



158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/rails_ai_context/tools/base_tool.rb', line 158

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



184
185
186
187
188
# File 'lib/rails_ai_context/tools/base_tool.rb', line 184

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)



304
305
306
# File 'lib/rails_ai_context/tools/base_tool.rb', line 304

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.



310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
# File 'lib/rails_ai_context/tools/base_tool.rb', line 310

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