rails-agent-kit 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module RailsAgentKit
7
+ # Writes the project-level MCP configuration that starts rails-agent-kit for each supported client.
8
+ #
9
+ # cursor -> .cursor/mcp.json ("mcpServers", paths relative to ${workspaceFolder})
10
+ # claude -> .mcp.json ("mcpServers", started from the project root)
11
+ # vscode -> .vscode/mcp.json ("servers", paths relative to ${workspaceFolder})
12
+ # codex -> ~/.codex/config.toml is a global file, so its snippet is returned, never written
13
+ #
14
+ # Existing files keep every other server. An existing rails-agent-kit entry that differs from the generated
15
+ # one is left alone unless force: true.
16
+ class ClientConfig
17
+ SERVER_NAME = "rails-agent-kit"
18
+ AGENTS = %w[cursor claude vscode codex].freeze
19
+ FILES = {
20
+ "cursor" => [".cursor/mcp.json", "mcpServers"],
21
+ "claude" => [".mcp.json", "mcpServers"],
22
+ "vscode" => [".vscode/mcp.json", "servers"]
23
+ }.freeze
24
+
25
+ Result = Struct.new(:agent, :path, :status, :snippet, :message, keyword_init: true)
26
+
27
+ # Parses "cursor,claude" or "all" into a list of agents. Raises Error for unknown names.
28
+ def self.parse_agents(value)
29
+ names = value.to_s.split(",").map { |a| a.strip.downcase }.reject(&:empty?)
30
+ return AGENTS.dup if names.empty? || names.include?("all")
31
+
32
+ unknown = names - AGENTS
33
+ raise Error, "unknown agent #{unknown.first.inspect} (supported: #{AGENTS.join(', ')}, all)" if unknown.any?
34
+
35
+ names.uniq
36
+ end
37
+
38
+ attr_reader :root
39
+
40
+ def initialize(root, force: false, dry_run: false, bundled: nil)
41
+ @root = File.expand_path(root)
42
+ @force = force
43
+ @dry_run = dry_run
44
+ @bundled = bundled.nil? ? self.class.bundled?(@root) : bundled
45
+ end
46
+
47
+ # True when the app's Gemfile (or Gemfile.lock) includes rails-agent-kit, so the server must run through
48
+ # `bundle exec`. Otherwise the globally installed executable is used.
49
+ def self.bundled?(root)
50
+ lock = File.join(root, "Gemfile.lock")
51
+ return File.read(lock).match?(/^ {4}rails-agent-kit \(/) if File.file?(lock)
52
+
53
+ gemfile = File.join(root, "Gemfile")
54
+ File.file?(gemfile) && File.read(gemfile).match?(/^\s*gem\s+["']rails-agent-kit["']/)
55
+ end
56
+
57
+ def bundled?
58
+ @bundled
59
+ end
60
+
61
+ def write(agents)
62
+ agents.map { |agent| agent == "codex" ? codex_result : write_json(agent) }
63
+ end
64
+
65
+ # The server entry for one client, as a Hash ready to be stored under the client's servers key.
66
+ def entry_for(agent)
67
+ case agent
68
+ when "cursor", "vscode"
69
+ folder = "${workspaceFolder}"
70
+ entry = agent == "vscode" ? { "type" => "stdio" } : {}
71
+ entry.merge(command_for(folder, env: { "BUNDLE_GEMFILE" => "#{folder}/Gemfile" }))
72
+ when "claude"
73
+ # Claude Code starts project servers from the project root, so a relative root works for the whole team.
74
+ command_for(".")
75
+ else
76
+ raise Error, "no JSON config for #{agent.inspect}"
77
+ end
78
+ end
79
+
80
+ def codex_snippet
81
+ args = bundled? ? ["exec", SERVER_NAME, "--root", root] : ["--root", root]
82
+ lines = ["[mcp_servers.#{SERVER_NAME}]", "command = #{toml(bundled? ? 'bundle' : SERVER_NAME)}",
83
+ "args = [#{args.map { |a| toml(a) }.join(', ')}]"]
84
+ lines << "env = { BUNDLE_GEMFILE = #{toml(File.join(root, 'Gemfile'))} }" if bundled?
85
+ "#{lines.join("\n")}\n"
86
+ end
87
+
88
+ private
89
+
90
+ def command_for(app_root, env: {})
91
+ return { "command" => SERVER_NAME, "args" => ["--root", app_root] } unless bundled?
92
+
93
+ entry = { "command" => "bundle", "args" => ["exec", SERVER_NAME, "--root", app_root] }
94
+ entry["env"] = env unless env.empty?
95
+ entry
96
+ end
97
+
98
+ def write_json(agent)
99
+ relative, key = FILES.fetch(agent)
100
+ path = File.join(root, relative)
101
+ entry = entry_for(agent)
102
+ config = read_json(path)
103
+ return Result.new(agent: agent, path: path, status: :error, message: config) if config.is_a?(String)
104
+
105
+ servers = config[key] ||= {}
106
+ unless servers.is_a?(Hash)
107
+ return Result.new(agent: agent, path: path, status: :error, message: "#{key.inspect} is not an object")
108
+ end
109
+
110
+ status =
111
+ if !File.exist?(path) then :created
112
+ elsif !servers.key?(SERVER_NAME) then :added
113
+ elsif servers[SERVER_NAME] == entry then :unchanged
114
+ elsif @force then :updated
115
+ else :skipped
116
+ end
117
+
118
+ if %i[created added updated].include?(status) && !@dry_run
119
+ servers[SERVER_NAME] = entry
120
+ FileUtils.mkdir_p(File.dirname(path))
121
+ File.write(path, "#{JSON.pretty_generate(config)}\n")
122
+ end
123
+ Result.new(agent: agent, path: path, status: status)
124
+ end
125
+
126
+ # Returns the parsed object, {} for a missing or empty file, or an error message String.
127
+ def read_json(path)
128
+ return {} unless File.exist?(path)
129
+
130
+ text = File.read(path)
131
+ return {} if text.strip.empty?
132
+
133
+ parsed = JSON.parse(text)
134
+ parsed.is_a?(Hash) ? parsed : "not a JSON object"
135
+ rescue JSON::ParserError => e
136
+ "invalid JSON (#{e.message.lines.first.to_s.strip}); fix or remove the file and run init again"
137
+ end
138
+
139
+ def codex_result
140
+ Result.new(agent: "codex", path: File.join("~", ".codex", "config.toml"), status: :print, snippet: codex_snippet)
141
+ end
142
+
143
+ def toml(value)
144
+ JSON.generate(value.to_s)
145
+ end
146
+ end
147
+ end
@@ -13,14 +13,17 @@ module RailsAgentKit
13
13
  # not possible (no bin/rails, boot error, timeout) it degrades gracefully:
14
14
  # it says why and returns the raw config/routes.rb so the agent can still read it.
15
15
  # A pre-generated routes dump can be supplied with `routes_file:` to skip booting.
16
+ # The command runs with the app's own Ruby (see RubyEnv), which is reported as `ruby` in the result.
16
17
  class RoutesInspector
17
18
  DEFAULT_TIMEOUT = 60
18
19
 
19
- def initialize(root, routes_file: nil, timeout: nil, command: nil)
20
+ def initialize(root, routes_file: nil, timeout: nil, command: nil, ruby_env: nil)
20
21
  @root = File.expand_path(root)
21
22
  @routes_file = routes_file && File.expand_path(routes_file, @root)
22
23
  @timeout = (timeout || ENV.fetch("RAILS_AGENT_KIT_ROUTES_TIMEOUT", DEFAULT_TIMEOUT)).to_f
23
24
  @command = command
25
+ @ruby_env = ruby_env
26
+ @ruby = nil
24
27
  end
25
28
 
26
29
  def call(filter: nil)
@@ -32,7 +35,9 @@ module RailsAgentKit
32
35
  end
33
36
 
34
37
  matched = apply_filter(routes, filter)
35
- { source: source, total: routes.size, count: matched.size, routes: matched }
38
+ result = { source: source, total: routes.size, count: matched.size }
39
+ result[:ruby] = @ruby.to_h if @ruby
40
+ result.merge(routes: matched)
36
41
  rescue RoutesUnavailable => e
37
42
  fallback(e.message)
38
43
  end
@@ -59,12 +64,15 @@ module RailsAgentKit
59
64
  bin_rails = File.join(@root, "bin", "rails")
60
65
  raise RoutesUnavailable, "No bin/rails found in #{@root}; cannot boot the app to list routes." unless File.file?(bin_rails)
61
66
 
62
- [RbConfig.ruby, "bin/rails", "routes", "--expanded"]
67
+ @ruby = @ruby_env || RubyEnv.detect(@root)
68
+ raise RoutesUnavailable, RubyEnv.missing_message(@ruby) unless @ruby.found
69
+
70
+ [*@ruby.command_prefix, "bin/rails", "routes", "--expanded"]
63
71
  end
64
72
 
65
73
  def run(cmd)
66
74
  with_app_env do
67
- Open3.popen3(*cmd, chdir: @root, pgroup: true) do |stdin, stdout, stderr, wait|
75
+ Open3.popen3(ruby_env_vars, *cmd, chdir: @root, pgroup: true) do |stdin, stdout, stderr, wait|
68
76
  stdin.close
69
77
  out_reader = reader(stdout)
70
78
  err_reader = reader(stderr)
@@ -84,6 +92,16 @@ module RailsAgentKit
84
92
  raise RoutesUnavailable, "Could not run `#{cmd.join(' ')}`: #{e.message}"
85
93
  end
86
94
 
95
+ # When the app uses a different Ruby than this server, put its bin first on PATH and drop the gem paths of
96
+ # the server's Ruby so the app's Ruby uses its own gems.
97
+ def ruby_env_vars
98
+ return {} if @ruby.nil? || @ruby.manager.nil?
99
+
100
+ bin = File.dirname(@ruby.ruby)
101
+ { "PATH" => [bin, ENV.fetch("PATH", nil)].compact.join(File::PATH_SEPARATOR),
102
+ "GEM_HOME" => nil, "GEM_PATH" => nil, "RUBYOPT" => nil, "RUBYLIB" => nil }
103
+ end
104
+
87
105
  # Run the app with its own Gemfile, not this gem's bundle.
88
106
  def with_app_env(&block)
89
107
  if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+ require "rubygems"
5
+
6
+ module RailsAgentKit
7
+ # Works out which Ruby a Rails app wants and where to find it, without a shell profile.
8
+ #
9
+ # The required version comes from the first of `.ruby-version`, `.tool-versions`, `mise.toml` (or `.mise.toml`)
10
+ # and the Gemfile `ruby` directive, looked up from the app root upwards like rbenv and mise do. If the Ruby running
11
+ # rails-agent-kit satisfies it, that Ruby is used. Otherwise the installs of mise, rbenv, asdf and chruby are
12
+ # searched directly, so this also works when an editor starts the server with a minimal PATH.
13
+ class RubyEnv
14
+ Result = Struct.new(:required_version, :source_file, :manager, :ruby, :version, :found, keyword_init: true) do
15
+ # The command that runs a Ruby script with the app's Ruby, e.g. ["/home/me/.rbenv/versions/3.3.5/bin/ruby"].
16
+ def command_prefix
17
+ found ? [ruby] : []
18
+ end
19
+
20
+ def to_h
21
+ { version: version, required: required_version, source: source_file, manager: manager, command: ruby }.compact
22
+ end
23
+ end
24
+
25
+ VERSION_FILES = [".ruby-version", ".tool-versions", "mise.toml", ".mise.toml"].freeze
26
+ INSTALL_HINTS = {
27
+ "mise" => "mise install ruby@%s",
28
+ "rbenv" => "rbenv install %s",
29
+ "asdf" => "asdf install ruby %s",
30
+ "chruby" => "ruby-install ruby %s"
31
+ }.freeze
32
+
33
+ def self.detect(root, env: ENV)
34
+ new(root, env: env).detect
35
+ end
36
+
37
+ def initialize(root, env: ENV, current_ruby: RbConfig.ruby, current_version: RUBY_VERSION)
38
+ @root = File.expand_path(root)
39
+ @env = env
40
+ @current_ruby = current_ruby
41
+ @current_version = current_version
42
+ end
43
+
44
+ def detect
45
+ required, source = required_version
46
+ return current(nil, nil) if required.nil?
47
+ return current(required, source) if satisfies?(@current_version, required)
48
+
49
+ manager, version, ruby = find_install(required)
50
+ if ruby
51
+ Result.new(required_version: required, source_file: source, manager: manager, ruby: ruby, version: version,
52
+ found: true)
53
+ else
54
+ Result.new(required_version: required, source_file: source, manager: preferred_manager(source),
55
+ found: false)
56
+ end
57
+ end
58
+
59
+ # A one-line explanation for a Result that wasn't found.
60
+ def self.missing_message(result)
61
+ hint = format(INSTALL_HINTS.fetch(result.manager || "rbenv"), result.required_version.sub(/\A[~><=\s]+/, ""))
62
+ "The app wants Ruby #{result.required_version} (from #{result.source_file}), but it isn't installed. " \
63
+ "Install it with `#{hint}`, or set RAILS_AGENT_KIT_ROUTES_COMMAND."
64
+ end
65
+
66
+ private
67
+
68
+ def current(required, source)
69
+ Result.new(required_version: required, source_file: source, manager: nil, ruby: @current_ruby,
70
+ version: @current_version, found: true)
71
+ end
72
+
73
+ # Returns [requirement, source file relative to the app root] or nil.
74
+ def required_version
75
+ dir = @root
76
+ loop do
77
+ VERSION_FILES.each do |name|
78
+ path = File.join(dir, name)
79
+ next unless File.file?(path)
80
+
81
+ version = parse_version_file(name, File.read(path))
82
+ return [version, relative(path)] if version
83
+ end
84
+ gemfile = File.join(dir, "Gemfile")
85
+ if File.file?(gemfile) && (version = parse_gemfile(File.read(gemfile)))
86
+ return [version, relative(gemfile)]
87
+ end
88
+
89
+ parent = File.dirname(dir)
90
+ return nil if parent == dir
91
+
92
+ dir = parent
93
+ end
94
+ end
95
+
96
+ def parse_version_file(name, text)
97
+ case name
98
+ when ".ruby-version"
99
+ normalize(text.lines.map(&:strip).find { |l| !l.empty? && !l.start_with?("#") })
100
+ when ".tool-versions"
101
+ line = text.lines.map(&:strip).find { |l| l.match?(/\Aruby\s+\S/) }
102
+ line && normalize(line.split[1])
103
+ else
104
+ match = text.match(/^\s*ruby\s*=\s*["']([^"']+)["']/)
105
+ match && normalize(match[1])
106
+ end
107
+ end
108
+
109
+ def parse_gemfile(text)
110
+ match = text.match(/^\s*ruby\s*\(?\s*["']([^"']+)["']/)
111
+ match && match[1].strip
112
+ end
113
+
114
+ # "ruby-3.3.5" -> "3.3.5". Non-MRI engines (jruby-9.4, truffleruby) and "system" are not handled.
115
+ def normalize(version)
116
+ return nil if version.nil?
117
+
118
+ version = version.strip.delete_prefix("ruby-")
119
+ version.match?(/\A\d+(\.\d+)*\z/) ? version : nil
120
+ end
121
+
122
+ def satisfies?(candidate, required)
123
+ if required.match?(/\A\s*[~><=!]/)
124
+ Gem::Requirement.new(*required.split(",").map(&:strip)).satisfied_by?(Gem::Version.new(candidate))
125
+ else
126
+ candidate == required || candidate.start_with?("#{required}.")
127
+ end
128
+ rescue ArgumentError
129
+ false
130
+ end
131
+
132
+ def preferred_manager(source)
133
+ case File.basename(source.to_s)
134
+ when ".tool-versions" then "asdf"
135
+ when "mise.toml", ".mise.toml" then "mise"
136
+ else installed_managers.first
137
+ end
138
+ end
139
+
140
+ def installed_managers
141
+ install_roots.select { |_, dirs| dirs.any? { |d| File.directory?(d) } }.map(&:first)
142
+ end
143
+
144
+ # manager => directories that hold one subdirectory per installed Ruby.
145
+ def install_roots
146
+ home = @env["HOME"] || Dir.home
147
+ {
148
+ "mise" => [File.join(@env["MISE_DATA_DIR"] || File.join(home, ".local/share/mise"), "installs/ruby")],
149
+ "rbenv" => [File.join(@env["RBENV_ROOT"] || File.join(home, ".rbenv"), "versions")],
150
+ "asdf" => [File.join(@env["ASDF_DATA_DIR"] || File.join(home, ".asdf"), "installs/ruby")],
151
+ "chruby" => [File.join(home, ".rubies"), "/opt/rubies"]
152
+ }
153
+ end
154
+
155
+ # Returns [manager, version, ruby path] for the newest install that satisfies the requirement.
156
+ def find_install(required)
157
+ candidates = install_roots.flat_map do |manager, dirs|
158
+ dirs.flat_map do |dir|
159
+ Dir.glob(File.join(dir, "*", "bin", "ruby")).filter_map do |ruby|
160
+ version = File.basename(File.dirname(File.dirname(ruby))).delete_prefix("ruby-")
161
+ next unless version.match?(/\A\d+(\.\d+)*\z/) && File.executable?(ruby) && !File.symlink?(File.dirname(File.dirname(ruby)))
162
+
163
+ [manager, version, ruby] if satisfies?(version, required)
164
+ end
165
+ end
166
+ end
167
+ candidates.max_by { |_, version, _| Gem::Version.new(version) }
168
+ end
169
+
170
+ def relative(path)
171
+ path.start_with?("#{@root}/") ? path.delete_prefix("#{@root}/") : path
172
+ end
173
+ end
174
+ end
@@ -1,51 +1,150 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module RailsAgentKit
4
6
  # Reads the schema of a Rails app at `root` without booting it.
5
7
  # Prefers db/schema.rb, falls back to db/structure.sql.
8
+ #
9
+ # Agents usually call the schema tool with no arguments first, and a real app can have hundreds of tables,
10
+ # so the default answer is a compact overview (table names, column counts, primary keys and foreign key
11
+ # targets). Details come from `table:` (one name, an array, or a comma-separated list), `search:` (a
12
+ # case-insensitive match on table or column names) or `detail: "full"`.
6
13
  class SchemaInspector
7
- def initialize(root)
14
+ DETAILS = %w[overview full].freeze
15
+
16
+ # Responses larger than this (serialized JSON, in bytes) are replaced by the overview plus a note on
17
+ # how to narrow the request, so a single call can't flood the agent's context.
18
+ MAX_BYTES = 60_000
19
+
20
+ OVERVIEW_HINT = "This is an overview. Pass table: \"users\" (or an array of names) for columns, indexes and " \
21
+ "foreign keys, search: \"email\" to find tables and columns by name, or detail: \"full\" " \
22
+ "for the whole schema."
23
+
24
+ def initialize(root, max_bytes: MAX_BYTES)
8
25
  @root = File.expand_path(root)
26
+ @max_bytes = max_bytes
9
27
  end
10
28
 
11
29
  # Returns a Hash with :source, :tables, ... or :error.
12
- def call(table: nil)
13
- schema_rb = File.join(@root, "db", "schema.rb")
14
- structure_sql = File.join(@root, "db", "structure.sql")
30
+ #
31
+ # - table: a table name, an Array of names, or "a, b". Returns those tables in full.
32
+ # - search: case-insensitive substring of a table or column name.
33
+ # - detail: "overview" or "full". Defaults to "full" when `table` or `search` is given, else "overview".
34
+ def call(table: nil, search: nil, detail: nil)
35
+ result = load_schema
36
+ return result if result.key?(:error)
15
37
 
16
- result =
17
- if File.file?(schema_rb)
18
- SchemaParser.parse_file(schema_rb).to_h.merge(source: "db/schema.rb")
19
- elsif File.file?(structure_sql)
20
- StructureSqlParser.parse_file(structure_sql).merge(
21
- source: "db/structure.sql",
22
- note: "Parsed from db/structure.sql on a best-effort basis; column types are raw SQL and " \
23
- "foreign keys/constraints are not extracted. db/schema.rb gives more precise results."
24
- )
25
- else
26
- return { error: "No db/schema.rb or db/structure.sql found under #{@root}. " \
27
- "Run `bin/rails db:schema:dump` in the app, or pass --root pointing at the Rails app." }
28
- end
38
+ names = normalize_tables(table)
39
+ search = search.to_s.strip
40
+ detail = detail.to_s.strip.downcase
41
+ detail = nil if detail.empty?
42
+ if detail && !DETAILS.include?(detail)
43
+ return { error: "Unknown detail #{detail.inspect}. Use \"overview\" or \"full\"." }
44
+ end
29
45
 
30
- filter(result, table)
46
+ if names.any?
47
+ result = select_tables(result, names)
48
+ return result if result.key?(:error)
49
+ end
50
+
51
+ return search_result(result, search) unless search.empty?
52
+
53
+ detail ||= names.any? ? "full" : "overview"
54
+ out = detail == "overview" ? overview(result) : result
55
+ guard_size(out, result)
31
56
  end
32
57
 
33
58
  private
34
59
 
35
- def filter(result, table)
36
- return result if table.nil? || table.to_s.strip.empty?
60
+ def load_schema
61
+ schema_rb = File.join(@root, "db", "schema.rb")
62
+ structure_sql = File.join(@root, "db", "structure.sql")
63
+
64
+ if File.file?(schema_rb)
65
+ SchemaParser.parse_file(schema_rb).to_h.merge(source: "db/schema.rb")
66
+ elsif File.file?(structure_sql)
67
+ StructureSqlParser.parse_file(structure_sql).merge(
68
+ source: "db/structure.sql",
69
+ note: "Parsed from db/structure.sql on a best-effort basis; column types are raw SQL and " \
70
+ "foreign keys/constraints are not extracted. db/schema.rb gives more precise results."
71
+ )
72
+ else
73
+ { error: "No db/schema.rb or db/structure.sql found under #{@root}. " \
74
+ "Run `bin/rails db:schema:dump` in the app, or pass --root pointing at the Rails app." }
75
+ end
76
+ end
77
+
78
+ def normalize_tables(table)
79
+ Array(table).flat_map { |t| t.to_s.split(",") }.map(&:strip).reject(&:empty?).uniq
80
+ end
37
81
 
38
- tables = result[:tables].select { |t| t[:name] == table.to_s }
39
- if tables.empty?
40
- names = result[:tables].map { |t| t[:name] }
41
- return { error: "Table #{table.inspect} not found in #{result[:source]}.", available_tables: names }
82
+ def select_tables(result, names)
83
+ known = result[:tables].map { |t| t[:name] }
84
+ missing = names - known
85
+ if missing.size == names.size
86
+ label = names.size == 1 ? "Table #{names.first.inspect}" : "Tables #{names.map(&:inspect).join(', ')}"
87
+ return { error: "#{label} not found in #{result[:source]}.", available_tables: known }
42
88
  end
43
89
 
44
- out = result.merge(tables: tables)
90
+ out = result.merge(tables: result[:tables].select { |t| names.include?(t[:name]) })
45
91
  if out.key?(:foreign_keys)
46
- out[:foreign_keys] = out[:foreign_keys].select { |fk| fk[:from_table] == table.to_s || fk[:to_table] == table.to_s }
92
+ out[:foreign_keys] = out[:foreign_keys].select do |fk|
93
+ names.include?(fk[:from_table]) || names.include?(fk[:to_table])
94
+ end
47
95
  end
96
+ out[:missing_tables] = missing if missing.any?
48
97
  out
49
98
  end
99
+
100
+ def overview(result)
101
+ fks = Array(result[:foreign_keys]).group_by { |fk| fk[:from_table] }
102
+ tables = result[:tables].map do |t|
103
+ entry = { name: t[:name], columns: t[:columns].size }
104
+ entry[:primary_key] = t[:primary_key] || false if t.key?(:primary_key) && t[:primary_key] != "id"
105
+ refs = Array(fks[t[:name]]).map { |fk| fk[:to_table] }.uniq
106
+ entry[:references] = refs if refs.any?
107
+ entry
108
+ end
109
+ meta(result).merge(detail: "overview", table_count: tables.size, hint: OVERVIEW_HINT, tables: tables)
110
+ end
111
+
112
+ def search_result(result, search)
113
+ needle = search.downcase
114
+ matches = result[:tables].filter_map do |t|
115
+ table_match = t[:name].downcase.include?(needle)
116
+ columns = t[:columns].select { |c| c[:name].downcase.include?(needle) }
117
+ next unless table_match || columns.any?
118
+
119
+ entry = { table: t[:name] }
120
+ entry[:table_name_match] = true if table_match
121
+ entry[:columns] = columns.map { |c| c.slice(:name, :type, :null, :primary_key).compact }
122
+ entry[:column_names] = t[:columns].map { |c| c[:name] } if table_match
123
+ entry
124
+ end
125
+ out = meta(result).merge(search: search, match_count: matches.size, matches: matches)
126
+ if matches.empty?
127
+ out[:hint] = "No table or column name contains #{search.inspect}. Call rails_schema without arguments " \
128
+ "for the list of tables."
129
+ end
130
+ out[:missing_tables] = result[:missing_tables] if result[:missing_tables]
131
+ guard_size(out, result)
132
+ end
133
+
134
+ def meta(result)
135
+ result.slice(:source, :version, :note)
136
+ end
137
+
138
+ def guard_size(out, result)
139
+ size = JSON.generate(out).bytesize
140
+ return out if size <= @max_bytes || out[:detail] == "overview"
141
+
142
+ overview(result).merge(
143
+ truncated: true,
144
+ message: "The full answer is #{size / 1024} KB, over the #{@max_bytes / 1024} KB limit, so this is the " \
145
+ "overview instead. Narrow the request: pass table: with one or a few table names, or search: " \
146
+ "with part of a table or column name."
147
+ )
148
+ end
50
149
  end
51
150
  end
@@ -13,8 +13,11 @@ module RailsAgentKit
13
13
  MCP::Server.new(
14
14
  name: "rails-agent-kit",
15
15
  version: RailsAgentKit::VERSION,
16
- instructions: "Ground truth about the Rails app at #{context[:root]}: use rails_schema for tables/columns/indexes " \
17
- "and rails_routes for HTTP routes before guessing model attributes or URL helpers.",
16
+ instructions: "Ground truth about the Rails app at #{context[:root]}. Check it before guessing model " \
17
+ "attributes or URL helpers. rails_schema with no arguments lists every table (column counts " \
18
+ "and foreign key targets); then pass table: (one name or an array) for columns, indexes " \
19
+ "and foreign keys, or search: to find a column by name. rails_routes lists HTTP routes; " \
20
+ "pass filter: to narrow them.",
18
21
  tools: [Tools::RailsSchema, Tools::RailsRoutes],
19
22
  server_context: context
20
23
  )
@@ -34,7 +37,7 @@ module RailsAgentKit
34
37
  end
35
38
 
36
39
  def json_response(data, error: false)
37
- MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(data) }], error: error)
40
+ MCP::Tool::Response.new([{ type: "text", text: JSON.generate(data) }], error: error)
38
41
  end
39
42
  end
40
43
 
@@ -42,18 +45,31 @@ module RailsAgentKit
42
45
  extend Helpers
43
46
 
44
47
  tool_name "rails_schema"
45
- description "Database schema of the Rails app (tables, columns with types/null/default, indexes, foreign keys), " \
46
- "parsed from db/schema.rb (or db/structure.sql) without booting the app."
48
+ description "Database schema of the Rails app, parsed from db/schema.rb (or db/structure.sql) without booting " \
49
+ "the app. With no arguments it returns a compact overview: every table with its column count, " \
50
+ "non-default primary key and foreign key targets. Pass table for full columns (type, null, " \
51
+ "default, ...), indexes and foreign keys, or search to find tables and columns by name."
47
52
  input_schema(
48
53
  properties: {
49
- table: { type: "string", description: "Only return this table (exact name). Omit for all tables." }
54
+ table: {
55
+ anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }],
56
+ description: "Table name(s) to return in full: one exact name, an array, or a comma-separated list."
57
+ },
58
+ search: {
59
+ type: "string",
60
+ description: "Case-insensitive substring matched against table and column names, e.g. \"email\"."
61
+ },
62
+ detail: {
63
+ type: "string", enum: SchemaInspector::DETAILS,
64
+ description: "\"overview\" (default with no table/search) or \"full\" (default with table)."
65
+ }
50
66
  }
51
67
  )
52
68
  annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
53
69
 
54
- def self.call(server_context:, table: nil)
70
+ def self.call(server_context:, table: nil, search: nil, detail: nil)
55
71
  root = context_value(server_context, :root) || Dir.pwd
56
- result = SchemaInspector.new(root).call(table: table)
72
+ result = SchemaInspector.new(root).call(table: table, search: search, detail: detail)
57
73
  json_response(result, error: result.key?(:error))
58
74
  end
59
75
  end