rails-agent-kit 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4204d99ae173d2d33485290dabf6cd45f769bf718fee6d9abe5bc6de4783dbc4
4
+ data.tar.gz: 4b1697ce2c95405f50edfb4106ae145b8d6aa9c6957fa9be92c08749d1c591cf
5
+ SHA512:
6
+ metadata.gz: b099152c12dc8939cdb776a0f582c2c6df5c36110a6e8c3af9607d42496505b1d6bba2d6492fcc060801add37c6e3bcbf53ab13d787ef8b8110deafbee3fa481
7
+ data.tar.gz: 7757d654bf17f8824a7d59168acbb9911fdf1086db0ab3ef1a85e3301da3a6727fa20b05345f81e966e57d2aa38754c15d34b7c27f357e52815fff64d64d9483
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - An MCP stdio server (`rails-agent-kit`, built on the official `mcp` Ruby SDK) with two read-only tools:
14
+ - `rails_schema`: tables, columns, indexes and foreign keys. Parsed statically from `db/schema.rb` without
15
+ booting the app, with a best-effort fallback to `db/structure.sql`. Takes an optional `table` filter.
16
+ - `rails_routes`: verb, path, `controller#action` and name for each route, from `bin/rails routes --expanded`.
17
+ Takes an optional `filter`. Use `--routes-file` or `RAILS_AGENT_KIT_ROUTES_COMMAND` when the app can't boot
18
+ locally. If the routes command fails, the tool falls back to the raw `config/routes.rb`.
19
+ - A Rails 8 conventions agent skill (`skills/rails-8-conventions/SKILL.md`) for Cursor and Claude Code.
20
+ - `rails-agent-kit install-skills [--agent cursor|claude] [--force]`, which installs the bundled skills into an app.
21
+ - CI on Ruby 3.2, 3.3 and 3.4.
22
+
23
+ [Unreleased]: https://github.com/afurm/rails-agent-kit/compare/v0.1.0...HEAD
24
+ [0.1.0]: https://github.com/afurm/rails-agent-kit/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrii Furmanets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # rails-agent-kit
2
+
3
+ A tiny [Model Context Protocol](https://modelcontextprotocol.io) server that gives coding agents
4
+ (Cursor, Claude Code, …) **ground truth about your Rails app**: its database schema and its routes.
5
+
6
+ > Status: early scaffold (v0.1.0). Not published to RubyGems yet — run it from source.
7
+
8
+ ## Why (the 30-second version)
9
+
10
+ Agents writing Rails code constantly guess: *is it `user.full_name` or `user.name`? is the column nullable?
11
+ is the helper `post_comments_path` or `comments_path`?* Wrong guesses become broken code and wasted review cycles.
12
+ With rails-agent-kit the agent just asks:
13
+
14
+ | Tool | What it returns | Boots the app? |
15
+ | --- | --- | --- |
16
+ | `rails_schema` | Tables, columns (type, null, default, limit, …), indexes, foreign keys. Optional `table` filter. | **No** — statically parses `db/schema.rb` (falls back to a best-effort `db/structure.sql` parse). |
17
+ | `rails_routes` | Every route: name/prefix, verb, path, `controller#action`, source location. Optional `filter`. | Yes — runs `bin/rails routes --expanded` with the app's own bundle. If that fails or times out, it says why and returns the raw `config/routes.rb` instead. |
18
+
19
+ Both tools are read-only. Output is JSON.
20
+
21
+ ## Install
22
+
23
+ From source (today):
24
+
25
+ ```sh
26
+ git clone https://github.com/afurm/rails-agent-kit
27
+ cd rails-agent-kit
28
+ bundle install
29
+ # try it against an app:
30
+ BUNDLE_GEMFILE=$PWD/Gemfile bundle exec rails-agent-kit --root /path/to/your/rails/app
31
+ ```
32
+
33
+ Once released (not yet): `gem install rails-agent-kit`.
34
+
35
+ Requires Ruby 3.2+. Runtime dependency: the official [`mcp`](https://github.com/modelcontextprotocol/ruby-sdk) Ruby SDK.
36
+
37
+ ## Usage
38
+
39
+ ```
40
+ rails-agent-kit [--root PATH] [--routes-file FILE]
41
+
42
+ --root PATH Rails app root (default: current directory)
43
+ --routes-file FILE Use a saved `bin/rails routes --expanded` dump instead of booting the app
44
+ ```
45
+
46
+ The server speaks MCP over stdio. Environment knobs:
47
+
48
+ - `RAILS_AGENT_KIT_ROUTES_TIMEOUT` — seconds to wait for `bin/rails routes` (default `60`).
49
+ - `RAILS_AGENT_KIT_ROUTES_COMMAND` — override the routes command, e.g. `"bundle exec rails routes --expanded"`
50
+ or `"docker compose exec -T web bin/rails routes --expanded"`. Runs in `--root`.
51
+
52
+ If your app can't boot locally (needs a DB, secrets, Docker…), dump the routes once and point the server at the file:
53
+
54
+ ```sh
55
+ bin/rails routes --expanded > tmp/routes.txt
56
+ rails-agent-kit --root . --routes-file tmp/routes.txt
57
+ ```
58
+
59
+ ## MCP client config
60
+
61
+ Replace the paths with absolute paths on your machine.
62
+
63
+ **Cursor** — `.cursor/mcp.json` in your Rails app (or `~/.cursor/mcp.json`):
64
+
65
+ ```json
66
+ {
67
+ "mcpServers": {
68
+ "rails-agent-kit": {
69
+ "command": "bundle",
70
+ "args": ["exec", "rails-agent-kit", "--root", "/path/to/your/rails/app"],
71
+ "env": { "BUNDLE_GEMFILE": "/path/to/rails-agent-kit/Gemfile" }
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ **Claude Code**:
78
+
79
+ ```sh
80
+ claude mcp add rails-agent-kit \
81
+ -e BUNDLE_GEMFILE=/path/to/rails-agent-kit/Gemfile \
82
+ -- bundle exec rails-agent-kit --root /path/to/your/rails/app
83
+ ```
84
+
85
+ **Claude Desktop** — `claude_desktop_config.json` uses the same `mcpServers` block as Cursor above.
86
+
87
+ `rails_routes` runs the app's `bin/rails` with Bundler's environment cleared, so your app uses its own Gemfile,
88
+ not rails-agent-kit's.
89
+
90
+ ## Agent skill: Rails 8 conventions
91
+
92
+ The gem bundles a [Cursor skill](https://cursor.com/docs/skills),
93
+ [`skills/rails-8-conventions/SKILL.md`](skills/rails-8-conventions/SKILL.md). It tells the agent to check
94
+ `rails_schema`, `rails_routes` and `Gemfile.lock` before it edits code, then follow Rails 8 defaults:
95
+ `params.expect`, the built-in authentication generator, `rate_limit`, Solid Queue/Cache/Cable, Hotwire with
96
+ importmap/Propshaft, reversible migrations, and tests in the framework the app already uses. It also covers a
97
+ paired Next.js frontend. Install it into your app:
98
+
99
+ ```sh
100
+ rails-agent-kit install-skills --root /path/to/your/rails/app # -> .cursor/skills/rails-8-conventions/SKILL.md
101
+ rails-agent-kit install-skills --root /path/to/your/rails/app --agent claude # -> .claude/skills/...
102
+ ```
103
+
104
+ Running it again is safe. It won't overwrite a skill you've edited unless you pass `--force`.
105
+
106
+ ## Development
107
+
108
+ ```sh
109
+ bundle install
110
+ bundle exec rake test
111
+ ```
112
+
113
+ Tests use small fixture apps in `test/fixtures` (a `schema.rb`, a `structure.sql`, captured `rails routes`
114
+ output and a stand-in `bin/rails`), plus an end-to-end test that drives `exe/rails-agent-kit` over stdio JSON-RPC.
115
+
116
+ ## License
117
+
118
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require_relative "../lib/rails_agent_kit"
6
+
7
+ if ARGV.first == "install-skills"
8
+ ARGV.shift
9
+ opts = { root: Dir.pwd, agent: "cursor", force: false }
10
+ skills_parser = OptionParser.new do |o|
11
+ o.banner = "Usage: rails-agent-kit install-skills [--root PATH] [--agent cursor|claude] [--force] [SKILL...]\n\n" \
12
+ "Copies the bundled agent skills into the app (e.g. .cursor/skills/<name>/SKILL.md)."
13
+ o.on("--root PATH", "Rails app root (default: current directory)") { |v| opts[:root] = v }
14
+ o.on("--agent AGENT", "cursor (default) or claude") { |v| opts[:agent] = v }
15
+ o.on("--force", "Overwrite skills you have edited locally") { opts[:force] = true }
16
+ o.on("-h", "--help", "Show this help") do
17
+ puts o
18
+ exit
19
+ end
20
+ end
21
+
22
+ begin
23
+ skills_parser.parse!(ARGV)
24
+ installer = RailsAgentKit::SkillInstaller.new(opts[:root], agent: opts[:agent], force: opts[:force])
25
+ results = ARGV.empty? ? installer.install : installer.install(ARGV)
26
+ rescue OptionParser::ParseError, RailsAgentKit::Error => e
27
+ warn "rails-agent-kit: #{e.message}"
28
+ exit 64
29
+ end
30
+ results.each do |r|
31
+ note = r.status == :skipped ? " (differs from bundled copy; use --force to overwrite)" : ""
32
+ puts "#{r.status.to_s.ljust(9)} #{r.path}#{note}"
33
+ end
34
+ exit 0
35
+ end
36
+
37
+ options = { root: Dir.pwd, routes_file: nil }
38
+ parser = OptionParser.new do |o|
39
+ o.banner = "Usage: rails-agent-kit [--root PATH] [--routes-file FILE]\n" \
40
+ " rails-agent-kit install-skills --help\n\n" \
41
+ "Starts an MCP server on stdio exposing the Rails app's schema and routes."
42
+ o.on("--root PATH", "Rails app root (default: current directory)") { |v| options[:root] = v }
43
+ o.on("--routes-file FILE", "Use a saved `bin/rails routes --expanded` dump instead of booting the app") { |v| options[:routes_file] = v }
44
+ o.on("-v", "--version", "Print version") do
45
+ puts RailsAgentKit::VERSION
46
+ exit
47
+ end
48
+ o.on("-h", "--help", "Show this help") do
49
+ puts o
50
+ exit
51
+ end
52
+ end
53
+
54
+ begin
55
+ parser.parse!(ARGV)
56
+ rescue OptionParser::ParseError => e
57
+ warn e.message
58
+ warn parser
59
+ exit 64
60
+ end
61
+
62
+ root = File.expand_path(options[:root])
63
+ unless File.directory?(root)
64
+ warn "rails-agent-kit: --root #{root} is not a directory"
65
+ exit 66
66
+ end
67
+
68
+ require_relative "../lib/rails_agent_kit/server"
69
+ RailsAgentKit::Server.run_stdio(root: root, routes_file: options[:routes_file])
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+ require "shellwords"
6
+ require "timeout"
7
+
8
+ module RailsAgentKit
9
+ # Lists routes for the Rails app at `root`.
10
+ #
11
+ # Listing routes needs a booted app, so this shells out to
12
+ # `bin/rails routes --expanded` in the app's own bundle. When that is
13
+ # not possible (no bin/rails, boot error, timeout) it degrades gracefully:
14
+ # it says why and returns the raw config/routes.rb so the agent can still read it.
15
+ # A pre-generated routes dump can be supplied with `routes_file:` to skip booting.
16
+ class RoutesInspector
17
+ DEFAULT_TIMEOUT = 60
18
+
19
+ def initialize(root, routes_file: nil, timeout: nil, command: nil)
20
+ @root = File.expand_path(root)
21
+ @routes_file = routes_file && File.expand_path(routes_file, @root)
22
+ @timeout = (timeout || ENV.fetch("RAILS_AGENT_KIT_ROUTES_TIMEOUT", DEFAULT_TIMEOUT)).to_f
23
+ @command = command
24
+ end
25
+
26
+ def call(filter: nil)
27
+ output, source = fetch_output
28
+ routes = RoutesParser.parse(output)
29
+ if routes.empty?
30
+ return fallback("`#{source}` produced no parseable routes " \
31
+ "(expected `bin/rails routes --expanded` output).")
32
+ end
33
+
34
+ matched = apply_filter(routes, filter)
35
+ { source: source, total: routes.size, count: matched.size, routes: matched }
36
+ rescue RoutesUnavailable => e
37
+ fallback(e.message)
38
+ end
39
+
40
+ class RoutesUnavailable < StandardError; end
41
+
42
+ private
43
+
44
+ def fetch_output
45
+ if @routes_file
46
+ raise RoutesUnavailable, "Routes file not found: #{@routes_file}" unless File.file?(@routes_file)
47
+
48
+ return [File.read(@routes_file), File.basename(@routes_file)]
49
+ end
50
+
51
+ cmd = @command || default_command
52
+ [run(cmd), cmd.join(" ")]
53
+ end
54
+
55
+ def default_command
56
+ custom = ENV.fetch("RAILS_AGENT_KIT_ROUTES_COMMAND", "").strip
57
+ return Shellwords.split(custom) unless custom.empty?
58
+
59
+ bin_rails = File.join(@root, "bin", "rails")
60
+ raise RoutesUnavailable, "No bin/rails found in #{@root}; cannot boot the app to list routes." unless File.file?(bin_rails)
61
+
62
+ [RbConfig.ruby, "bin/rails", "routes", "--expanded"]
63
+ end
64
+
65
+ def run(cmd)
66
+ with_app_env do
67
+ Open3.popen3(*cmd, chdir: @root, pgroup: true) do |stdin, stdout, stderr, wait|
68
+ stdin.close
69
+ out_reader = reader(stdout)
70
+ err_reader = reader(stderr)
71
+ unless wait.join(@timeout)
72
+ kill(wait.pid)
73
+ raise RoutesUnavailable, "`#{cmd.join(' ')}` timed out after #{@timeout.to_i}s while booting the app."
74
+ end
75
+ out = out_reader.value
76
+ err = err_reader.value
77
+ unless wait.value.success?
78
+ raise RoutesUnavailable, "`#{cmd.join(' ')}` failed (exit #{wait.value.exitstatus}): #{tail(err.empty? ? out : err)}"
79
+ end
80
+ out
81
+ end
82
+ end
83
+ rescue SystemCallError => e
84
+ raise RoutesUnavailable, "Could not run `#{cmd.join(' ')}`: #{e.message}"
85
+ end
86
+
87
+ # Run the app with its own Gemfile, not this gem's bundle.
88
+ def with_app_env(&block)
89
+ if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
90
+ Bundler.with_unbundled_env(&block)
91
+ else
92
+ yield
93
+ end
94
+ end
95
+
96
+ def reader(io)
97
+ Thread.new do
98
+ Thread.current.report_on_exception = false
99
+ io.read
100
+ rescue IOError
101
+ ""
102
+ end
103
+ end
104
+
105
+ # Kill the whole process group (bin/rails may spawn spring or other children).
106
+ def kill(pid)
107
+ Process.kill("TERM", -pid)
108
+ sleep 0.2
109
+ Process.kill("KILL", -pid)
110
+ rescue SystemCallError
111
+ nil
112
+ end
113
+
114
+ def tail(text, lines: 15)
115
+ text.to_s.lines.last(lines).join.strip
116
+ end
117
+
118
+ def apply_filter(routes, filter)
119
+ return routes if filter.nil? || filter.to_s.strip.empty?
120
+
121
+ needle = filter.to_s.downcase
122
+ routes.select do |r|
123
+ [r[:name], r[:path], r[:controller_action], r[:verb]].compact.any? { |v| v.downcase.include?(needle) }
124
+ end
125
+ end
126
+
127
+ def fallback(reason)
128
+ routes_rb = File.join(@root, "config", "routes.rb")
129
+ result = {
130
+ routes_unavailable: true,
131
+ message: "Could not list routes: #{reason} Falling back to the raw config/routes.rb " \
132
+ "(no route expansion, helpers or engine routes). Tip: run " \
133
+ "`bin/rails routes --expanded > tmp/routes.txt` and start the server with --routes-file tmp/routes.txt."
134
+ }
135
+ if File.file?(routes_rb)
136
+ result[:fallback_source] = "config/routes.rb"
137
+ result[:routes_rb] = File.read(routes_rb)
138
+ else
139
+ result[:message] += " config/routes.rb was not found either."
140
+ end
141
+ result
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentKit
4
+ # Parses the output of `bin/rails routes --expanded` (preferred) or the
5
+ # default table output of `bin/rails routes`.
6
+ class RoutesParser
7
+ HEADER = /\A--\[\s*Route\s+\d+\s*\]-*\s*\z/
8
+ FIELD = /\A([^|]+?)\s*\|\s?(.*)\z/
9
+ KEYS = {
10
+ "Prefix" => :name,
11
+ "Verb" => :verb,
12
+ "URI" => :path,
13
+ "Controller#Action" => :action,
14
+ "Source Location" => :source_location
15
+ }.freeze
16
+
17
+ def self.parse(output)
18
+ new(output).parse
19
+ end
20
+
21
+ def initialize(output)
22
+ @output = output.to_s
23
+ end
24
+
25
+ TABLE_HEADER = /\A\s*Prefix\s+Verb\s+URI Pattern\s+Controller#Action/
26
+
27
+ def parse
28
+ lines = @output.lines.map(&:chomp)
29
+ header = lines.find { |l| TABLE_HEADER.match?(l) }
30
+ return parse_table(lines, header) if header && lines.none? { |l| HEADER.match?(l.strip) }
31
+
32
+ parse_expanded
33
+ end
34
+
35
+ private
36
+
37
+ def parse_table(lines, header)
38
+ verb_at = header.index("Verb")
39
+ uri_at = header.index("URI Pattern")
40
+ action_at = header.index("Controller#Action")
41
+ lines.drop(lines.index(header) + 1).filter_map do |line|
42
+ next if line.strip.empty? || line.length <= uri_at
43
+
44
+ # Long values can push later columns right, so split the tail on whitespace.
45
+ name = line[0...verb_at].strip
46
+ verb = line[verb_at...uri_at].strip
47
+ path, action = line[uri_at..].strip.split(/\s+/, 2)
48
+ next if path.nil? || !path.start_with?("/")
49
+
50
+ normalize({ name: name.empty? ? nil : name, verb: verb, path: path, action: action&.strip })
51
+ end
52
+ end
53
+
54
+ def parse_expanded
55
+ routes = []
56
+ current = nil
57
+ @output.each_line do |raw|
58
+ line = raw.chomp
59
+ if HEADER.match?(line.strip)
60
+ routes << current if current
61
+ current = {}
62
+ elsif current && (m = FIELD.match(line))
63
+ key = KEYS[m[1].strip] || m[1].strip.downcase.gsub(/\W+/, "_").to_sym
64
+ value = m[2].strip
65
+ current[key] = value.empty? ? nil : value
66
+ end
67
+ end
68
+ routes << current if current
69
+ routes.map { |r| normalize(r) }
70
+ end
71
+
72
+ def normalize(route)
73
+ route[:verb] = route[:verb].to_s.empty? ? nil : route[:verb]
74
+ if route[:action]&.include?("#")
75
+ controller, action = route[:action].split("#", 2)
76
+ route[:controller] = controller
77
+ route[:action] = action.split(/\s/).first
78
+ route[:controller_action] = "#{controller}##{route[:action]}"
79
+ end
80
+ route.compact
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentKit
4
+ # Reads the schema of a Rails app at `root` without booting it.
5
+ # Prefers db/schema.rb, falls back to db/structure.sql.
6
+ class SchemaInspector
7
+ def initialize(root)
8
+ @root = File.expand_path(root)
9
+ end
10
+
11
+ # 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")
15
+
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
29
+
30
+ filter(result, table)
31
+ end
32
+
33
+ private
34
+
35
+ def filter(result, table)
36
+ return result if table.nil? || table.to_s.strip.empty?
37
+
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 }
42
+ end
43
+
44
+ out = result.merge(tables: tables)
45
+ 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 }
47
+ end
48
+ out
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentKit
4
+ # Parses db/schema.rb statically (no eval, no app boot) into tables, columns,
5
+ # indexes and foreign keys.
6
+ class SchemaParser
7
+ Result = Struct.new(:version, :extensions, :tables, :foreign_keys, keyword_init: true) do
8
+ def to_h
9
+ { version: version, extensions: extensions, tables: tables, foreign_keys: foreign_keys }
10
+ end
11
+ end
12
+
13
+ CREATE_TABLE = /\A\s*create_table\s+["']([^"']+)["'](?:\s*,\s*(.*?))?\s+do\s*\|(\w+)\|\s*\z/
14
+ COLUMN = /\A\s*(\w+)\.(\w+)\s+["']([^"']+)["']\s*(?:,\s*(.*))?\z/
15
+ T_INDEX = /\A\s*(\w+)\.index\s+(\[[^\]]*\]|["'][^"']*["'])\s*(?:,\s*(.*))?\z/
16
+ ADD_INDEX = /\A\s*add_index\s+["']([^"']+)["']\s*,\s*(\[[^\]]*\]|["'][^"']*["'])\s*(?:,\s*(.*))?\z/
17
+ ADD_FK = /\A\s*add_foreign_key\s+["']([^"']+)["']\s*,\s*["']([^"']+)["']\s*(?:,\s*(.*))?\z/
18
+ EXTENSION = /\A\s*enable_extension\s+["']([^"']+)["']/
19
+ VERSION_RE = /define\(\s*version:\s*([\d_]+)\s*\)/
20
+
21
+ def self.parse_file(path)
22
+ new(File.read(path)).parse
23
+ end
24
+
25
+ def initialize(source)
26
+ @source = source
27
+ end
28
+
29
+ def parse
30
+ tables = []
31
+ foreign_keys = []
32
+ extensions = []
33
+ version = @source[VERSION_RE, 1]&.delete("_")
34
+ current = nil
35
+ block_var = nil
36
+
37
+ @source.each_line do |raw|
38
+ line = strip_comment(raw).rstrip
39
+ next if line.strip.empty?
40
+
41
+ if current
42
+ if line.strip == "end"
43
+ tables << current
44
+ current = nil
45
+ elsif (m = T_INDEX.match(line)) && m[1] == block_var
46
+ current[:indexes] << build_index(current[:name], m[2], m[3])
47
+ elsif (m = COLUMN.match(line)) && m[1] == block_var
48
+ current[:columns] << build_column(m[2], m[3], m[4])
49
+ end
50
+ next
51
+ end
52
+
53
+ if (m = CREATE_TABLE.match(line))
54
+ opts = parse_options(m[2])
55
+ block_var = m[3]
56
+ current = { name: m[1], primary_key: primary_key_for(opts), columns: [], indexes: [] }
57
+ current[:comment] = opts[:comment] if opts[:comment]
58
+ if current[:primary_key]
59
+ current[:columns] << { name: current[:primary_key], type: id_type(opts), null: false, primary_key: true }
60
+ end
61
+ elsif (m = ADD_INDEX.match(line))
62
+ index = build_index(m[1], m[2], m[3])
63
+ table = tables.find { |t| t[:name] == m[1] }
64
+ table ? table[:indexes] << index : nil
65
+ elsif (m = ADD_FK.match(line))
66
+ opts = parse_options(m[3])
67
+ fk = { from_table: m[1], to_table: m[2] }
68
+ fk[:column] = opts[:column] || "#{singularize(m[2])}_id"
69
+ fk[:primary_key] = opts[:primary_key] if opts[:primary_key]
70
+ fk[:on_delete] = opts[:on_delete] if opts[:on_delete]
71
+ fk[:on_update] = opts[:on_update] if opts[:on_update]
72
+ fk[:name] = opts[:name] if opts[:name]
73
+ foreign_keys << fk
74
+ elsif (m = EXTENSION.match(line))
75
+ extensions << m[1]
76
+ end
77
+ end
78
+
79
+ Result.new(version: version, extensions: extensions, tables: tables, foreign_keys: foreign_keys)
80
+ end
81
+
82
+ private
83
+
84
+ def build_column(type, name, opts_src)
85
+ opts = parse_options(opts_src)
86
+ col = { name: name, type: type, null: opts.key?(:null) ? opts[:null] : true }
87
+ %i[default limit precision scale array comment].each { |k| col[k] = opts[k] if opts.key?(k) }
88
+ col
89
+ end
90
+
91
+ def build_index(table, cols_src, opts_src)
92
+ opts = parse_options(opts_src)
93
+ columns = parse_value(cols_src)
94
+ index = { table: table, name: opts[:name], columns: Array(columns), unique: opts[:unique] == true }
95
+ %i[where using order].each { |k| index[k] = opts[k] if opts.key?(k) }
96
+ index
97
+ end
98
+
99
+ def primary_key_for(opts)
100
+ return nil if opts[:id] == false
101
+ return nil if opts[:primary_key].is_a?(Array)
102
+
103
+ opts[:primary_key] || "id"
104
+ end
105
+
106
+ def id_type(opts)
107
+ case opts[:id]
108
+ when nil, true then "bigint"
109
+ else opts[:id].to_s
110
+ end
111
+ end
112
+
113
+ def singularize(word)
114
+ if word.end_with?("ies") then word[0..-4] + "y"
115
+ elsif word.end_with?("ses") then word[0..-3]
116
+ elsif word.end_with?("s") then word[0..-2]
117
+ else word
118
+ end
119
+ end
120
+
121
+ # Removes a trailing `# comment` that is not inside a string literal.
122
+ def strip_comment(line)
123
+ in_str = nil
124
+ line.each_char.with_index do |ch, i|
125
+ if in_str
126
+ in_str = nil if ch == in_str && line[i - 1] != "\\"
127
+ elsif ch == '"' || ch == "'"
128
+ in_str = ch
129
+ elsif ch == "#"
130
+ return line[0...i]
131
+ end
132
+ end
133
+ line
134
+ end
135
+
136
+ # Parses `key: value, key2: value2` into a symbol-keyed hash.
137
+ def parse_options(src)
138
+ return {} if src.nil? || src.strip.empty?
139
+
140
+ split_top_level(src).each_with_object({}) do |part, h|
141
+ if (m = part.match(/\A\s*(\w+):\s*(.*)\z/m))
142
+ h[m[1].to_sym] = parse_value(m[2].strip)
143
+ elsif (m = part.match(/\A\s*["'](\w+)["']\s*=>\s*(.*)\z/m))
144
+ h[m[1].to_sym] = parse_value(m[2].strip)
145
+ end
146
+ end
147
+ end
148
+
149
+ def parse_value(src)
150
+ s = src.strip
151
+ case s
152
+ when "true" then true
153
+ when "false" then false
154
+ when "nil" then nil
155
+ when /\A-?\d+\z/ then s.to_i
156
+ when /\A-?\d+\.\d+\z/ then s.to_f
157
+ when /\A"(.*)"\z/m then Regexp.last_match(1).gsub('\\"', '"')
158
+ when /\A'(.*)'\z/m then Regexp.last_match(1)
159
+ when /\A:(\w+)\z/ then Regexp.last_match(1)
160
+ when /\A\[(.*)\]\z/m then split_top_level(Regexp.last_match(1)).map { |v| parse_value(v) }
161
+ else s # lambdas (-> { "now()" }), constants, etc. are kept verbatim
162
+ end
163
+ end
164
+
165
+ def split_top_level(src)
166
+ parts = []
167
+ depth = 0
168
+ in_str = nil
169
+ buf = +""
170
+ src.each_char.with_index do |ch, i|
171
+ if in_str
172
+ in_str = nil if ch == in_str && src[i - 1] != "\\"
173
+ buf << ch
174
+ next
175
+ end
176
+ case ch
177
+ when '"', "'" then in_str = ch
178
+ when "[", "{", "(" then depth += 1
179
+ when "]", "}", ")" then depth -= 1
180
+ when ","
181
+ if depth.zero?
182
+ parts << buf
183
+ buf = +""
184
+ next
185
+ end
186
+ end
187
+ buf << ch
188
+ end
189
+ parts << buf unless buf.strip.empty?
190
+ parts.map(&:strip)
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+
6
+ module RailsAgentKit
7
+ # Builds the MCP server (official `mcp` Ruby SDK) exposing the app's schema and routes.
8
+ module Server
9
+ module_function
10
+
11
+ def build(root:, routes_file: nil)
12
+ context = { root: File.expand_path(root), routes_file: routes_file }
13
+ MCP::Server.new(
14
+ name: "rails-agent-kit",
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.",
18
+ tools: [Tools::RailsSchema, Tools::RailsRoutes],
19
+ server_context: context
20
+ )
21
+ end
22
+
23
+ def run_stdio(root:, routes_file: nil)
24
+ server = build(root: root, routes_file: routes_file)
25
+ MCP::Server::Transports::StdioTransport.new(server).open
26
+ end
27
+ end
28
+
29
+ module Tools
30
+ # Shared helpers for tool classes.
31
+ module Helpers
32
+ def context_value(server_context, key)
33
+ server_context.respond_to?(:[]) ? server_context[key] : nil
34
+ end
35
+
36
+ def json_response(data, error: false)
37
+ MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(data) }], error: error)
38
+ end
39
+ end
40
+
41
+ class RailsSchema < MCP::Tool
42
+ extend Helpers
43
+
44
+ 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."
47
+ input_schema(
48
+ properties: {
49
+ table: { type: "string", description: "Only return this table (exact name). Omit for all tables." }
50
+ }
51
+ )
52
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
53
+
54
+ def self.call(server_context:, table: nil)
55
+ root = context_value(server_context, :root) || Dir.pwd
56
+ result = SchemaInspector.new(root).call(table: table)
57
+ json_response(result, error: result.key?(:error))
58
+ end
59
+ end
60
+
61
+ class RailsRoutes < MCP::Tool
62
+ extend Helpers
63
+
64
+ tool_name "rails_routes"
65
+ description "HTTP routes of the Rails app (verb, path, controller#action, route name). Runs " \
66
+ "`bin/rails routes --expanded` (boots the app); if that fails it explains why and returns config/routes.rb."
67
+ input_schema(
68
+ properties: {
69
+ filter: { type: "string", description: "Case-insensitive substring matched against name, path, verb and controller#action." }
70
+ }
71
+ )
72
+ annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
73
+
74
+ def self.call(server_context:, filter: nil)
75
+ root = context_value(server_context, :root) || Dir.pwd
76
+ routes_file = context_value(server_context, :routes_file)
77
+ json_response(RoutesInspector.new(root, routes_file: routes_file).call(filter: filter))
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module RailsAgentKit
6
+ # Copies the agent skills bundled with the gem (skills/<name>/SKILL.md) into a Rails app,
7
+ # e.g. <root>/.cursor/skills/<name>/SKILL.md for Cursor.
8
+ class SkillInstaller
9
+ SKILLS_DIR = File.expand_path("../../skills", __dir__)
10
+ TARGETS = {
11
+ "cursor" => ".cursor/skills",
12
+ "claude" => ".claude/skills"
13
+ }.freeze
14
+
15
+ Result = Struct.new(:skill, :path, :status, keyword_init: true)
16
+
17
+ def self.available_skills
18
+ Dir.glob(File.join(SKILLS_DIR, "*", "SKILL.md")).map { |f| File.basename(File.dirname(f)) }.sort
19
+ end
20
+
21
+ def initialize(root, agent: "cursor", force: false)
22
+ @root = File.expand_path(root)
23
+ @agent = agent.to_s
24
+ @force = force
25
+ raise Error, "unknown agent #{@agent.inspect} (supported: #{TARGETS.keys.join(', ')})" unless TARGETS.key?(@agent)
26
+ end
27
+
28
+ # Installs the named skills (default: all bundled skills). Existing files that differ are left
29
+ # untouched unless force: true. Returns one Result per skill with status :created, :updated,
30
+ # :unchanged or :skipped.
31
+ def install(skills = self.class.available_skills)
32
+ skills.map do |name|
33
+ source = File.join(SKILLS_DIR, name, "SKILL.md")
34
+ raise Error, "unknown skill #{name.inspect} (available: #{self.class.available_skills.join(', ')})" unless File.file?(source)
35
+
36
+ install_one(name, source)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def install_one(name, source)
43
+ dest = File.join(@root, TARGETS.fetch(@agent), name, "SKILL.md")
44
+ content = File.read(source)
45
+ status =
46
+ if !File.exist?(dest) then :created
47
+ elsif File.read(dest) == content then :unchanged
48
+ elsif @force then :updated
49
+ else :skipped
50
+ end
51
+
52
+ if %i[created updated].include?(status)
53
+ FileUtils.mkdir_p(File.dirname(dest))
54
+ File.write(dest, content)
55
+ end
56
+ Result.new(skill: name, path: dest, status: status)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentKit
4
+ # Best-effort parser for db/structure.sql (CREATE TABLE / CREATE INDEX).
5
+ # SQL dumps vary a lot between adapters, so results are approximate.
6
+ class StructureSqlParser
7
+ CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?((?:[\w"`]+\.)?[\w"`]+)\s*\((.*?)\)\s*;/mi
8
+ CREATE_INDEX = /CREATE\s+(UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w"`]+)\s+ON\s+((?:[\w"`]+\.)?[\w"`]+)(?:\s+USING\s+\w+)?\s*\((.*?)\)\s*(?:WHERE\s+(.*?))?;/mi
9
+ CONSTRAINT_WORDS = /\A(CONSTRAINT|PRIMARY|FOREIGN|UNIQUE|CHECK|KEY|INDEX|EXCLUDE)\b/i
10
+
11
+ def self.parse_file(path)
12
+ new(File.read(path)).parse
13
+ end
14
+
15
+ def initialize(source)
16
+ @source = source.gsub(/--[^\n]*/, "")
17
+ end
18
+
19
+ def parse
20
+ tables = @source.scan(CREATE_TABLE).map do |name, body|
21
+ columns = split_columns(body).filter_map do |defn|
22
+ next if defn.match?(CONSTRAINT_WORDS)
23
+
24
+ m = defn.match(/\A([\w"`]+)\s+(.+)\z/m) or next
25
+ rest = m[2]
26
+ type = rest.sub(/\s+(NOT\s+NULL|NULL|DEFAULT|PRIMARY|REFERENCES|UNIQUE|CHECK|COLLATE|GENERATED|CONSTRAINT|AUTO_INCREMENT)\b.*\z/mi, "").strip
27
+ {
28
+ name: unquote(m[1]),
29
+ type: type,
30
+ null: !rest.match?(/NOT\s+NULL/i) && !rest.match?(/PRIMARY\s+KEY/i),
31
+ primary_key: rest.match?(/PRIMARY\s+KEY/i) || nil
32
+ }.compact
33
+ end
34
+ { name: unquote(name.split(".").last), columns: columns, indexes: [] }
35
+ end
36
+
37
+ @source.scan(CREATE_INDEX).each do |unique, idx_name, table, cols, where|
38
+ t = tables.find { |tt| tt[:name] == unquote(table.split(".").last) }
39
+ next unless t
40
+
41
+ idx = { table: t[:name], name: unquote(idx_name), columns: cols.split(",").map { |c| unquote(c.strip.split(/\s+/).first) }, unique: !unique.nil? }
42
+ idx[:where] = where.strip if where
43
+ t[:indexes] << idx
44
+ end
45
+
46
+ { tables: tables }
47
+ end
48
+
49
+ private
50
+
51
+ def unquote(s)
52
+ s.to_s.delete('"`')
53
+ end
54
+
55
+ def split_columns(body)
56
+ parts = []
57
+ depth = 0
58
+ buf = +""
59
+ body.each_char do |ch|
60
+ depth += 1 if ch == "("
61
+ depth -= 1 if ch == ")"
62
+ if ch == "," && depth.zero?
63
+ parts << buf.strip
64
+ buf = +""
65
+ else
66
+ buf << ch
67
+ end
68
+ end
69
+ parts << buf.strip unless buf.strip.empty?
70
+ parts
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentKit
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_agent_kit/version"
4
+ require_relative "rails_agent_kit/schema_parser"
5
+ require_relative "rails_agent_kit/structure_sql_parser"
6
+ require_relative "rails_agent_kit/schema_inspector"
7
+ require_relative "rails_agent_kit/routes_parser"
8
+ require_relative "rails_agent_kit/routes_inspector"
9
+ require_relative "rails_agent_kit/skill_installer"
10
+
11
+ # Gives coding agents ground truth about a Rails app (schema, routes) over MCP.
12
+ module RailsAgentKit
13
+ class Error < StandardError; end
14
+
15
+ # The MCP server is loaded lazily so the parsers can be used without the `mcp` gem.
16
+ def self.server(root: Dir.pwd)
17
+ require_relative "rails_agent_kit/server"
18
+ Server.build(root: root)
19
+ end
20
+ end
@@ -0,0 +1,117 @@
1
+ ---
2
+ name: rails-8-conventions
3
+ description: Rails 8 conventions and a ground-truth-first workflow. Use when adding or changing models, migrations, controllers, routes, views, jobs, mailers or tests in a Rails 8 app, so changes follow the app's actual schema, routes and stack instead of guesses.
4
+ paths:
5
+ - "app/**"
6
+ - "config/**"
7
+ - "db/**"
8
+ - "lib/**"
9
+ - "test/**"
10
+ - "spec/**"
11
+ - "Gemfile"
12
+ metadata:
13
+ source: rails-agent-kit
14
+ ---
15
+
16
+ # Rails 8 conventions
17
+
18
+ Follow the app as it is, not Rails as you remember it. Check the facts first, then
19
+ change the code the way this app already does things.
20
+
21
+ ## 1. Check the facts before you edit
22
+
23
+ - **Schema:** call the `rails_schema` MCP tool (pass `table:` to get one table) before
24
+ you write a model attribute, query, validation, fixture or migration. Without the
25
+ tool, read `db/schema.rb` (or `db/structure.sql`). Never guess a column name, type,
26
+ nullability or default.
27
+ - **Routes:** call the `rails_routes` MCP tool (pass `filter:`) before you use a
28
+ `*_path`/`*_url` helper, link, form `url:` or redirect. Without it, read
29
+ `config/routes.rb` or run `bin/rails routes -g <term>`.
30
+ - **Stack:** read `Gemfile.lock` before you pick a library. Use what the app already
31
+ uses (RSpec or Minitest, Sidekiq or Solid Queue, Pundit, Devise or the built-in
32
+ auth generator, importmap or jsbundling). Don't add a gem when the app or Rails
33
+ already covers it.
34
+ - **Local patterns:** before you add a model, controller or job, open one or two
35
+ nearby files and copy their structure: concerns, base classes, naming, and how
36
+ they render and respond.
37
+
38
+ ## 2. Rails 8 defaults (unless the Gemfile says otherwise)
39
+
40
+ - **Strong params:** use `params.expect(post: [:title, :body])` (Rails 8). It returns
41
+ 400 on a malformed payload instead of raising a 500. Use
42
+ `params.require(...).permit(...)` only if the codebase still uses it.
43
+ - **Authentication:** `bin/rails generate authentication` gives you the `Session`
44
+ model, the `Authentication` concern, `Current.user`, `allow_unauthenticated_access`
45
+ and `require_authentication`. Use these when they're present. Don't add Devise
46
+ alongside them.
47
+ - **Rate limiting:** `rate_limit to: 10, within: 3.minutes, only: :create` in the
48
+ controller. You don't need Rack::Attack for simple cases.
49
+ - **Models:** `enum :status, { draft: 0, published: 1 }` (keyword-arg form),
50
+ `normalizes :email, with: ->(e) { e.strip.downcase }`, `generates_token_for`,
51
+ `has_secure_password`. Put validations in the model *and* constraints in the DB:
52
+ `null: false`, unique indexes, foreign keys.
53
+ - **Background jobs:** Solid Queue is the default (`config/queue.yml`). Recurring
54
+ work goes in `config/recurring.yml`, not cron. Inherit from `ApplicationJob`, pass
55
+ records or ids (GlobalID), and make `perform` idempotent. Use `perform_later`.
56
+ Call `perform_now` only in tests or consoles.
57
+ - **Cache and Cable:** Solid Cache and Solid Cable are database-backed and may use
58
+ their own databases (`db/cache_schema.rb`, `db/queue_schema.rb`,
59
+ `db/cable_schema.rb`). Don't move them to Redis on your own.
60
+ - **Frontend:** Hotwire is the default. Use Turbo Frames/Streams for partial updates
61
+ and Stimulus controllers in `app/javascript/controllers`. Assets go through
62
+ Propshaft, and JS through importmap (`bin/importmap pin <pkg>`) unless the app uses
63
+ jsbundling. Don't add a SPA framework into `app/`.
64
+ - **Deploy:** Kamal 2 (`config/deploy.yml`) and Thruster. Secrets live in
65
+ `config/credentials` or `.kamal/secrets`, never in code.
66
+
67
+ ## 3. Migrations
68
+
69
+ - Generate them: `bin/rails generate migration AddPublishedAtToPosts published_at:datetime:index`.
70
+ Never hand-pick a timestamp.
71
+ - Make them reversible (`change`, or `up`/`down`), and add indexes and foreign keys
72
+ (`t.references :user, null: false, foreign_key: true`).
73
+ - Never edit a migration that has already run on a shared branch. Add a new one.
74
+ - Run `bin/rails db:migrate` and commit the updated `db/schema.rb` (or
75
+ `structure.sql`) with the migration. After that, `rails_schema` is the source of
76
+ truth.
77
+
78
+ ## 4. Controllers, routes and views
79
+
80
+ - Prefer RESTful `resources` with the seven standard actions. When you need a custom
81
+ action, add a nested resource or a new controller instead
82
+ (`resource :publication, only: :create`).
83
+ - Keep controllers thin: load, authorize, call the model, respond. Make
84
+ `before_action :set_post` match the existing style.
85
+ - Respond to `format.html` and `format.turbo_stream` when the app uses Turbo. On a
86
+ failed form submit, render with `status: :unprocessable_entity`. Use
87
+ `redirect_to ..., status: :see_other` after a destroy.
88
+ - Use the route helpers confirmed in step 1. Don't hard-code a URL string.
89
+
90
+ ## 5. Tests
91
+
92
+ - Use the existing framework: `test/` (Minitest, fixtures) or `spec/` (RSpec,
93
+ factories). Don't add a second one.
94
+ - Add or update a test with every behaviour change: model tests for validations and
95
+ scopes, request/integration tests for endpoints, job tests for jobs.
96
+ - Run the narrowest test first (`bin/rails test test/models/post_test.rb`), then the
97
+ suite (`bin/rails test`, or `bin/rails test:all` with system tests). Run
98
+ `bin/rubocop` if it exists.
99
+
100
+ ## 6. Paired Next.js frontend (if there is one)
101
+
102
+ If the Rails app is an API for a separate Next.js app (`config.api_only = true`, JSON
103
+ controllers, a sibling `web/` or `frontend/` directory):
104
+
105
+ - Treat JSON responses as a contract. Add fields rather than rename or remove them,
106
+ and update the Next.js types and fetchers in the same change.
107
+ - Check `rails_routes` for the exact path and verb before you write a fetch call
108
+ on the frontend.
109
+ - Keep CORS (`config/initializers/cors.rb`) and auth (cookie vs token) consistent
110
+ with what the frontend already does.
111
+
112
+ ## Before you finish
113
+
114
+ - [ ] Every column, table and route helper you used is confirmed by `rails_schema` or `rails_routes` (or the files behind them).
115
+ - [ ] Migrations are reversible and `db/schema.rb` is updated.
116
+ - [ ] Tests were added or updated, and they pass.
117
+ - [ ] You added no new gem or framework that the app's existing stack already covers.
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-agent-kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andrii Furmanets
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mcp
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.8'
26
+ description: rails-agent-kit is a small Model Context Protocol (stdio) server. It
27
+ exposes a Rails app's database schema (parsed from db/schema.rb without booting
28
+ the app) and its routes to coding agents such as Cursor and Claude Code, and ships
29
+ a Rails 8 conventions skill for Cursor/Claude (installed with `rails-agent-kit install-skills`).
30
+ executables:
31
+ - rails-agent-kit
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - exe/rails-agent-kit
39
+ - lib/rails_agent_kit.rb
40
+ - lib/rails_agent_kit/routes_inspector.rb
41
+ - lib/rails_agent_kit/routes_parser.rb
42
+ - lib/rails_agent_kit/schema_inspector.rb
43
+ - lib/rails_agent_kit/schema_parser.rb
44
+ - lib/rails_agent_kit/server.rb
45
+ - lib/rails_agent_kit/skill_installer.rb
46
+ - lib/rails_agent_kit/structure_sql_parser.rb
47
+ - lib/rails_agent_kit/version.rb
48
+ - skills/rails-8-conventions/SKILL.md
49
+ homepage: https://github.com/afurm/rails-agent-kit
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/afurm/rails-agent-kit
54
+ source_code_uri: https://github.com/afurm/rails-agent-kit
55
+ changelog_uri: https://github.com/afurm/rails-agent-kit/blob/main/CHANGELOG.md
56
+ bug_tracker_uri: https://github.com/afurm/rails-agent-kit/issues
57
+ rubygems_mfa_required: 'true'
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '3.2'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.6.7
73
+ specification_version: 4
74
+ summary: MCP server and agent skills that give coding agents ground truth about a
75
+ Rails app's schema and routes.
76
+ test_files: []