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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -2
- data/README.md +275 -59
- data/exe/rails-agent-kit +94 -4
- data/lib/rails_agent_kit/client_config.rb +147 -0
- data/lib/rails_agent_kit/routes_inspector.rb +22 -4
- data/lib/rails_agent_kit/ruby_env.rb +174 -0
- data/lib/rails_agent_kit/schema_inspector.rb +125 -26
- data/lib/rails_agent_kit/server.rb +24 -8
- data/lib/rails_agent_kit/skill_installer.rb +105 -19
- data/lib/rails_agent_kit/version.rb +1 -1
- data/lib/rails_agent_kit.rb +2 -0
- data/skills/rails-8-conventions/SKILL.md +3 -2
- metadata +13 -9
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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]}
|
|
17
|
-
"
|
|
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.
|
|
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
|
|
46
|
-
"
|
|
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: {
|
|
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
|