rails-ai-context 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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +23 -0
- data/CHANGELOG.md +26 -0
- data/LICENSE +21 -0
- data/README.md +212 -0
- data/Rakefile +8 -0
- data/exe/rails-ai-context +67 -0
- data/lib/generators/rails_ai_context/install/install_generator.rb +85 -0
- data/lib/rails-ai-context.rb +5 -0
- data/lib/rails_ai_context/configuration.rb +52 -0
- data/lib/rails_ai_context/engine.rb +21 -0
- data/lib/rails_ai_context/introspector.rb +61 -0
- data/lib/rails_ai_context/introspectors/convention_detector.rb +125 -0
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +128 -0
- data/lib/rails_ai_context/introspectors/job_introspector.rb +82 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +163 -0
- data/lib/rails_ai_context/introspectors/route_introspector.rb +83 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +143 -0
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +60 -0
- data/lib/rails_ai_context/serializers/json_serializer.rb +19 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +158 -0
- data/lib/rails_ai_context/server.rb +90 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +89 -0
- data/lib/rails_ai_context/tools/base_tool.rb +37 -0
- data/lib/rails_ai_context/tools/get_conventions.rb +87 -0
- data/lib/rails_ai_context/tools/get_gems.rb +49 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +103 -0
- data/lib/rails_ai_context/tools/get_routes.rb +50 -0
- data/lib/rails_ai_context/tools/get_schema.rb +93 -0
- data/lib/rails_ai_context/tools/search_code.rb +111 -0
- data/lib/rails_ai_context/version.rb +5 -0
- data/lib/rails_ai_context.rb +74 -0
- data/rails-ai-context.gemspec +52 -0
- metadata +223 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# Extracts database schema information including tables, columns,
|
|
6
|
+
# indexes, and foreign keys from the Rails application.
|
|
7
|
+
class SchemaIntrospector
|
|
8
|
+
attr_reader :app
|
|
9
|
+
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash] database schema context
|
|
15
|
+
def call
|
|
16
|
+
return static_schema_parse unless active_record_connected?
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
adapter: adapter_name,
|
|
20
|
+
tables: extract_tables,
|
|
21
|
+
total_tables: table_names.size,
|
|
22
|
+
schema_version: current_schema_version
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def active_record_connected?
|
|
29
|
+
defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
|
|
30
|
+
rescue
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def adapter_name
|
|
35
|
+
ActiveRecord::Base.connection.adapter_name
|
|
36
|
+
rescue
|
|
37
|
+
"unknown"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def connection
|
|
41
|
+
ActiveRecord::Base.connection
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def table_names
|
|
45
|
+
@table_names ||= connection.tables.reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_tables
|
|
49
|
+
table_names.each_with_object({}) do |table, hash|
|
|
50
|
+
hash[table] = {
|
|
51
|
+
columns: extract_columns(table),
|
|
52
|
+
indexes: extract_indexes(table),
|
|
53
|
+
foreign_keys: extract_foreign_keys(table),
|
|
54
|
+
primary_key: connection.primary_key(table)
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def extract_columns(table)
|
|
60
|
+
connection.columns(table).map do |col|
|
|
61
|
+
{
|
|
62
|
+
name: col.name,
|
|
63
|
+
type: col.type.to_s,
|
|
64
|
+
null: col.null,
|
|
65
|
+
default: col.default,
|
|
66
|
+
limit: col.limit,
|
|
67
|
+
precision: col.precision,
|
|
68
|
+
scale: col.scale,
|
|
69
|
+
comment: col.comment
|
|
70
|
+
}.compact
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def extract_indexes(table)
|
|
75
|
+
connection.indexes(table).map do |idx|
|
|
76
|
+
{
|
|
77
|
+
name: idx.name,
|
|
78
|
+
columns: idx.columns,
|
|
79
|
+
unique: idx.unique,
|
|
80
|
+
where: idx.where
|
|
81
|
+
}.compact
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def extract_foreign_keys(table)
|
|
86
|
+
connection.foreign_keys(table).map do |fk|
|
|
87
|
+
{
|
|
88
|
+
from_table: fk.from_table,
|
|
89
|
+
to_table: fk.to_table,
|
|
90
|
+
column: fk.column,
|
|
91
|
+
primary_key: fk.primary_key,
|
|
92
|
+
on_delete: fk.on_delete,
|
|
93
|
+
on_update: fk.on_update
|
|
94
|
+
}.compact
|
|
95
|
+
end
|
|
96
|
+
rescue
|
|
97
|
+
[] # Some adapters don't support foreign_keys
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def current_schema_version
|
|
101
|
+
if File.exist?(schema_file_path)
|
|
102
|
+
content = File.read(schema_file_path)
|
|
103
|
+
match = content.match(/version:\s*(\d+)_?\d*/)
|
|
104
|
+
match ? match[1] : nil
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def schema_file_path
|
|
109
|
+
File.join(app.root, "db", "schema.rb")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Fallback: parse db/schema.rb as text when DB isn't connected
|
|
113
|
+
# This enables introspection in CI, Claude Code, etc.
|
|
114
|
+
def static_schema_parse
|
|
115
|
+
path = schema_file_path
|
|
116
|
+
return { error: "No schema.rb found at #{path}" } unless File.exist?(path)
|
|
117
|
+
|
|
118
|
+
content = File.read(path)
|
|
119
|
+
tables = {}
|
|
120
|
+
current_table = nil
|
|
121
|
+
|
|
122
|
+
content.each_line do |line|
|
|
123
|
+
if (match = line.match(/create_table\s+"(\w+)"/))
|
|
124
|
+
current_table = match[1]
|
|
125
|
+
next if current_table.start_with?("ar_internal_metadata", "schema_migrations")
|
|
126
|
+
tables[current_table] = { columns: [], indexes: [], foreign_keys: [] }
|
|
127
|
+
elsif current_table && (match = line.match(/t\.(\w+)\s+"(\w+)"/))
|
|
128
|
+
tables[current_table][:columns] << { name: match[2], type: match[1] }
|
|
129
|
+
elsif (match = line.match(/add_index\s+"(\w+)",\s+\[?"(\w+)"/))
|
|
130
|
+
tables[match[1]]&.dig(:indexes)&.push({ columns: match[2] })
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
adapter: "static_parse",
|
|
136
|
+
tables: tables,
|
|
137
|
+
total_tables: tables.size,
|
|
138
|
+
note: "Parsed from db/schema.rb (no DB connection)"
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Serializers
|
|
5
|
+
# Orchestrates writing context files to disk in various formats.
|
|
6
|
+
# Supports: CLAUDE.md, .cursorrules, .windsurfrules, .github/copilot-instructions.md, JSON
|
|
7
|
+
class ContextFileSerializer
|
|
8
|
+
attr_reader :context, :format
|
|
9
|
+
|
|
10
|
+
FORMAT_MAP = {
|
|
11
|
+
claude: "CLAUDE.md",
|
|
12
|
+
cursor: ".cursorrules",
|
|
13
|
+
windsurf: ".windsurfrules",
|
|
14
|
+
copilot: ".github/copilot-instructions.md",
|
|
15
|
+
json: ".ai-context.json"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(context, format: :all)
|
|
19
|
+
@context = context
|
|
20
|
+
@format = format
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Write context files and return list of files written
|
|
24
|
+
# @return [Array<String>] file paths written
|
|
25
|
+
def call
|
|
26
|
+
formats = format == :all ? FORMAT_MAP.keys : Array(format)
|
|
27
|
+
output_dir = RailsAiContext.configuration.output_dir_for(Rails.application)
|
|
28
|
+
written = []
|
|
29
|
+
|
|
30
|
+
formats.each do |fmt|
|
|
31
|
+
filename = FORMAT_MAP[fmt]
|
|
32
|
+
next unless filename
|
|
33
|
+
|
|
34
|
+
filepath = File.join(output_dir, filename)
|
|
35
|
+
|
|
36
|
+
# Ensure subdirectory exists (e.g. .github/)
|
|
37
|
+
FileUtils.mkdir_p(File.dirname(filepath))
|
|
38
|
+
|
|
39
|
+
content = serialize(fmt)
|
|
40
|
+
File.write(filepath, content)
|
|
41
|
+
written << filepath
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
written
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def serialize(fmt)
|
|
50
|
+
case fmt
|
|
51
|
+
when :json
|
|
52
|
+
JsonSerializer.new(context).call
|
|
53
|
+
else
|
|
54
|
+
# All markdown-based formats use the same content
|
|
55
|
+
MarkdownSerializer.new(context).call
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
module Serializers
|
|
7
|
+
class JsonSerializer
|
|
8
|
+
attr_reader :context
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
JSON.pretty_generate(context)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Serializers
|
|
5
|
+
# Generates AI-friendly markdown context files from introspection data.
|
|
6
|
+
# Outputs: CLAUDE.md (for Claude Code), .cursorrules, .windsurfrules, etc.
|
|
7
|
+
class MarkdownSerializer
|
|
8
|
+
attr_reader :context
|
|
9
|
+
|
|
10
|
+
def initialize(context)
|
|
11
|
+
@context = context
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [String] full markdown document
|
|
15
|
+
def call
|
|
16
|
+
sections = []
|
|
17
|
+
sections << header
|
|
18
|
+
sections << app_overview
|
|
19
|
+
sections << schema_section if context[:schema]
|
|
20
|
+
sections << models_section if context[:models]
|
|
21
|
+
sections << routes_section if context[:routes]
|
|
22
|
+
sections << jobs_section if context[:jobs]
|
|
23
|
+
sections << gems_section if context[:gems]
|
|
24
|
+
sections << conventions_section if context[:conventions]
|
|
25
|
+
sections << footer
|
|
26
|
+
sections.compact.join("\n\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def header
|
|
32
|
+
<<~MD
|
|
33
|
+
# #{context[:app_name]} — AI Context
|
|
34
|
+
|
|
35
|
+
> Auto-generated by rails-ai-context v#{RailsAiContext::VERSION}
|
|
36
|
+
> Generated: #{context[:generated_at]}
|
|
37
|
+
> Rails #{context[:rails_version]} | Ruby #{context[:ruby_version]}
|
|
38
|
+
|
|
39
|
+
This file gives AI assistants (Claude Code, Cursor, Copilot) deep context
|
|
40
|
+
about this Rails application's structure, patterns, and conventions.
|
|
41
|
+
MD
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def app_overview
|
|
45
|
+
conv = context[:conventions] || {}
|
|
46
|
+
arch = conv[:architecture] || []
|
|
47
|
+
patterns = conv[:patterns] || []
|
|
48
|
+
|
|
49
|
+
lines = ["## Overview"]
|
|
50
|
+
lines << "- **Architecture:** #{arch.join(', ')}" if arch.any?
|
|
51
|
+
lines << "- **Patterns:** #{patterns.join(', ')}" if patterns.any?
|
|
52
|
+
lines.join("\n")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def schema_section
|
|
56
|
+
schema = context[:schema]
|
|
57
|
+
return if schema[:error]
|
|
58
|
+
|
|
59
|
+
lines = ["## Database Schema (#{schema[:total_tables]} tables)"]
|
|
60
|
+
schema[:tables]&.each do |name, data|
|
|
61
|
+
cols = (data[:columns] || []).map { |c| "`#{c[:name]}` (#{c[:type]})" }.join(", ")
|
|
62
|
+
lines << "### #{name}"
|
|
63
|
+
lines << cols
|
|
64
|
+
end
|
|
65
|
+
lines.join("\n\n")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def models_section
|
|
69
|
+
models = context[:models]
|
|
70
|
+
return if models.is_a?(Hash) && models[:error]
|
|
71
|
+
|
|
72
|
+
lines = ["## Models (#{models.size})"]
|
|
73
|
+
models.each do |name, data|
|
|
74
|
+
next if data[:error]
|
|
75
|
+
assocs = (data[:associations] || []).map { |a| "#{a[:type]} :#{a[:name]}" }.join(", ")
|
|
76
|
+
lines << "### #{name}"
|
|
77
|
+
lines << "- Table: `#{data[:table_name]}`" if data[:table_name]
|
|
78
|
+
lines << "- Associations: #{assocs}" if assocs.present?
|
|
79
|
+
if data[:validations]&.any?
|
|
80
|
+
vals = data[:validations].map { |v| "#{v[:kind]} on #{v[:attributes].join(', ')}" }.join("; ")
|
|
81
|
+
lines << "- Validations: #{vals}"
|
|
82
|
+
end
|
|
83
|
+
lines << "- Enums: #{data[:enums].keys.join(', ')}" if data[:enums]&.any?
|
|
84
|
+
end
|
|
85
|
+
lines.join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def routes_section
|
|
89
|
+
routes = context[:routes]
|
|
90
|
+
return if routes[:error]
|
|
91
|
+
|
|
92
|
+
lines = ["## Routes (#{routes[:total_routes]} total)"]
|
|
93
|
+
routes[:by_controller]&.sort&.each do |ctrl, actions|
|
|
94
|
+
lines << "### #{ctrl}"
|
|
95
|
+
actions.each do |r|
|
|
96
|
+
lines << "- `#{r[:verb]} #{r[:path]}` → #{r[:action]}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
lines.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def jobs_section
|
|
103
|
+
jobs = context[:jobs]
|
|
104
|
+
parts = []
|
|
105
|
+
|
|
106
|
+
if jobs[:jobs]&.any?
|
|
107
|
+
parts << "## Background Jobs"
|
|
108
|
+
jobs[:jobs].each { |j| parts << "- `#{j[:name]}` (queue: #{j[:queue]})" }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if jobs[:mailers]&.any?
|
|
112
|
+
parts << "## Mailers"
|
|
113
|
+
jobs[:mailers].each { |m| parts << "- `#{m[:name]}`: #{m[:actions].join(', ')}" }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if jobs[:channels]&.any?
|
|
117
|
+
parts << "## Action Cable Channels"
|
|
118
|
+
jobs[:channels].each { |c| parts << "- `#{c[:name]}`" }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
parts.join("\n") if parts.any?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def gems_section
|
|
125
|
+
gems = context[:gems]
|
|
126
|
+
return if gems[:error]
|
|
127
|
+
|
|
128
|
+
notable = gems[:notable_gems] || []
|
|
129
|
+
return if notable.empty?
|
|
130
|
+
|
|
131
|
+
lines = ["## Notable Gems"]
|
|
132
|
+
notable.group_by { |g| g[:category] }.sort.each do |cat, group|
|
|
133
|
+
lines << "### #{cat.capitalize}"
|
|
134
|
+
group.each { |g| lines << "- **#{g[:name]}** (#{g[:version]}): #{g[:note]}" }
|
|
135
|
+
end
|
|
136
|
+
lines.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def conventions_section
|
|
140
|
+
conv = context[:conventions]
|
|
141
|
+
return unless conv[:directory_structure]&.any?
|
|
142
|
+
|
|
143
|
+
lines = ["## Project Structure"]
|
|
144
|
+
conv[:directory_structure].sort.each do |dir, count|
|
|
145
|
+
lines << "- `#{dir}/` — #{count} files"
|
|
146
|
+
end
|
|
147
|
+
lines.join("\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def footer
|
|
151
|
+
<<~MD
|
|
152
|
+
---
|
|
153
|
+
_This context file is auto-generated. Run `rails ai:context` to regenerate._
|
|
154
|
+
MD
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
# Configures and starts an MCP server using the official Ruby SDK.
|
|
7
|
+
# Registers all introspection tools and handles transport selection.
|
|
8
|
+
class Server
|
|
9
|
+
attr_reader :app, :transport_type
|
|
10
|
+
|
|
11
|
+
TOOLS = [
|
|
12
|
+
Tools::GetSchema,
|
|
13
|
+
Tools::GetRoutes,
|
|
14
|
+
Tools::GetModelDetails,
|
|
15
|
+
Tools::GetGems,
|
|
16
|
+
Tools::SearchCode,
|
|
17
|
+
Tools::GetConventions
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(app, transport: :stdio)
|
|
21
|
+
@app = app
|
|
22
|
+
@transport_type = transport
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Build and return the configured MCP::Server instance
|
|
26
|
+
def build
|
|
27
|
+
config = RailsAiContext.configuration
|
|
28
|
+
|
|
29
|
+
MCP::Server.new(
|
|
30
|
+
name: config.server_name,
|
|
31
|
+
version: config.server_version,
|
|
32
|
+
tools: TOOLS
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Start the MCP server with the configured transport
|
|
37
|
+
def start
|
|
38
|
+
server = build
|
|
39
|
+
|
|
40
|
+
case transport_type
|
|
41
|
+
when :stdio
|
|
42
|
+
start_stdio(server)
|
|
43
|
+
when :http, :streamable_http
|
|
44
|
+
start_http(server)
|
|
45
|
+
else
|
|
46
|
+
raise ConfigurationError, "Unknown transport: #{transport_type}. Use :stdio or :http"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def start_stdio(server)
|
|
53
|
+
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
54
|
+
# Log to stderr so we don't pollute the JSON-RPC channel on stdout
|
|
55
|
+
$stderr.puts "[rails-ai-context] MCP server started (stdio transport)"
|
|
56
|
+
$stderr.puts "[rails-ai-context] Tools: #{TOOLS.map { |t| t.tool_name }.join(', ')}"
|
|
57
|
+
transport.open
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def start_http(server)
|
|
61
|
+
config = RailsAiContext.configuration
|
|
62
|
+
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
|
63
|
+
|
|
64
|
+
# Build a minimal Rack app that delegates to the MCP transport
|
|
65
|
+
rack_app = build_rack_app(transport, config.http_path)
|
|
66
|
+
|
|
67
|
+
$stderr.puts "[rails-ai-context] MCP server starting on #{config.http_bind}:#{config.http_port}#{config.http_path}"
|
|
68
|
+
$stderr.puts "[rails-ai-context] Tools: #{TOOLS.map { |t| t.tool_name }.join(', ')}"
|
|
69
|
+
|
|
70
|
+
require "rackup"
|
|
71
|
+
Rackup::Handler.default.run(rack_app, Host: config.http_bind, Port: config.http_port)
|
|
72
|
+
rescue LoadError
|
|
73
|
+
# Fallback for older rack without rackup gem
|
|
74
|
+
require "rack/handler"
|
|
75
|
+
Rack::Handler.default.run(rack_app, Host: config.http_bind, Port: config.http_port)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_rack_app(transport, path)
|
|
79
|
+
lambda do |env|
|
|
80
|
+
# Only handle requests at the configured MCP path
|
|
81
|
+
unless env["PATH_INFO"] == path || env["PATH_INFO"] == "#{path}/"
|
|
82
|
+
return [404, { "Content-Type" => "application/json" }, ['{"error":"Not found"}']]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
request = Rack::Request.new(env)
|
|
86
|
+
transport.handle_request(request)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :ai do
|
|
4
|
+
desc "Generate AI context files (CLAUDE.md, .cursorrules, .windsurfrules, .github/copilot-instructions.md)"
|
|
5
|
+
task context: :environment do
|
|
6
|
+
require "rails_ai_context"
|
|
7
|
+
|
|
8
|
+
puts "🔍 Introspecting #{Rails.application.class.module_parent_name}..."
|
|
9
|
+
context = RailsAiContext.introspect
|
|
10
|
+
|
|
11
|
+
puts "📝 Writing context files..."
|
|
12
|
+
files = RailsAiContext.generate_context(format: :all)
|
|
13
|
+
|
|
14
|
+
files.each { |f| puts " ✅ #{f}" }
|
|
15
|
+
puts ""
|
|
16
|
+
puts "Done! Your AI assistants now understand your Rails app."
|
|
17
|
+
puts "Commit these files so your whole team benefits."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Generate AI context in a specific format (claude, cursor, windsurf, copilot, json)"
|
|
21
|
+
task :context_for, [:format] => :environment do |_t, args|
|
|
22
|
+
require "rails_ai_context"
|
|
23
|
+
|
|
24
|
+
format = (args[:format] || "claude").to_sym
|
|
25
|
+
puts "🔍 Introspecting #{Rails.application.class.module_parent_name}..."
|
|
26
|
+
context = RailsAiContext.introspect
|
|
27
|
+
|
|
28
|
+
puts "📝 Writing #{format} context file..."
|
|
29
|
+
files = RailsAiContext.generate_context(format: format)
|
|
30
|
+
|
|
31
|
+
files.each { |f| puts " ✅ #{f}" }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc "Start the MCP server (stdio transport, for Claude Code / Cursor)"
|
|
35
|
+
task serve: :environment do
|
|
36
|
+
require "rails_ai_context"
|
|
37
|
+
|
|
38
|
+
RailsAiContext.start_mcp_server(transport: :stdio)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
desc "Start the MCP server with HTTP transport"
|
|
42
|
+
task serve_http: :environment do
|
|
43
|
+
require "rails_ai_context"
|
|
44
|
+
|
|
45
|
+
RailsAiContext.start_mcp_server(transport: :http)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "Print introspection summary to stdout (useful for debugging)"
|
|
49
|
+
task inspect: :environment do
|
|
50
|
+
require "rails_ai_context"
|
|
51
|
+
require "json"
|
|
52
|
+
|
|
53
|
+
context = RailsAiContext.introspect
|
|
54
|
+
|
|
55
|
+
puts "=" * 60
|
|
56
|
+
puts " #{context[:app_name]} — AI Context Summary"
|
|
57
|
+
puts "=" * 60
|
|
58
|
+
puts ""
|
|
59
|
+
puts "Rails #{context[:rails_version]} | Ruby #{context[:ruby_version]}"
|
|
60
|
+
puts ""
|
|
61
|
+
|
|
62
|
+
if context[:schema] && !context[:schema][:error]
|
|
63
|
+
puts "📦 Database: #{context[:schema][:total_tables]} tables (#{context[:schema][:adapter]})"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if context[:models] && !context[:models].is_a?(Hash)
|
|
67
|
+
puts "🏗️ Models: #{context[:models].size}"
|
|
68
|
+
elsif context[:models].is_a?(Hash) && !context[:models][:error]
|
|
69
|
+
puts "🏗️ Models: #{context[:models].size}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if context[:routes] && !context[:routes][:error]
|
|
73
|
+
puts "🛤️ Routes: #{context[:routes][:total_routes]}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if context[:jobs]
|
|
77
|
+
puts "⚡ Jobs: #{context[:jobs][:jobs]&.size || 0}"
|
|
78
|
+
puts "📧 Mailers: #{context[:jobs][:mailers]&.size || 0}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if context[:conventions]
|
|
82
|
+
arch = context[:conventions][:architecture] || []
|
|
83
|
+
puts "🏛️ Architecture: #{arch.join(', ')}" if arch.any?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts ""
|
|
87
|
+
puts "Run `rails ai:context` to generate context files."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
module Tools
|
|
7
|
+
# Base class for all MCP tools exposed by rails-ai-context.
|
|
8
|
+
# Inherits from the official MCP::Tool to get schema validation,
|
|
9
|
+
# annotations, and protocol compliance for free.
|
|
10
|
+
class BaseTool < MCP::Tool
|
|
11
|
+
class << self
|
|
12
|
+
# Convenience: access the Rails app and cached introspection
|
|
13
|
+
def rails_app
|
|
14
|
+
Rails.application
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def config
|
|
18
|
+
RailsAiContext.configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Cache introspection results for the lifetime of the server process
|
|
22
|
+
def cached_context
|
|
23
|
+
@cached_context ||= RailsAiContext.introspect
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def reset_cache!
|
|
27
|
+
@cached_context = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Helper: wrap text in an MCP::Tool::Response
|
|
31
|
+
def text_response(text)
|
|
32
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Tools
|
|
5
|
+
class GetConventions < BaseTool
|
|
6
|
+
tool_name "rails_get_conventions"
|
|
7
|
+
description "Detect architectural patterns and conventions used in this Rails app. Returns info about architecture style (API-only, Hotwire, GraphQL), design patterns (service objects, STI, polymorphism), directory structure, and config files present."
|
|
8
|
+
|
|
9
|
+
input_schema(properties: {})
|
|
10
|
+
|
|
11
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
12
|
+
|
|
13
|
+
def self.call(server_context: nil)
|
|
14
|
+
conventions = cached_context[:conventions]
|
|
15
|
+
return text_response("Convention detection failed: #{conventions[:error]}") if conventions.is_a?(Hash) && conventions[:error]
|
|
16
|
+
|
|
17
|
+
lines = ["# App Conventions & Architecture", ""]
|
|
18
|
+
|
|
19
|
+
# Architecture
|
|
20
|
+
if conventions[:architecture]&.any?
|
|
21
|
+
lines << "## Architecture"
|
|
22
|
+
conventions[:architecture].each { |a| lines << "- #{humanize_arch(a)}" }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Patterns
|
|
26
|
+
if conventions[:patterns]&.any?
|
|
27
|
+
lines << "" << "## Detected patterns"
|
|
28
|
+
conventions[:patterns].each { |p| lines << "- #{humanize_pattern(p)}" }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Directory structure
|
|
32
|
+
if conventions[:directory_structure]&.any?
|
|
33
|
+
lines << "" << "## Directory structure"
|
|
34
|
+
conventions[:directory_structure].sort_by { |k, _| k }.each do |dir, count|
|
|
35
|
+
lines << "- `#{dir}/` → #{count} files"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Config files
|
|
40
|
+
if conventions[:config_files]&.any?
|
|
41
|
+
lines << "" << "## Config files present"
|
|
42
|
+
conventions[:config_files].each { |f| lines << "- `#{f}`" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
text_response(lines.join("\n"))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
ARCH_LABELS = {
|
|
49
|
+
"api_only" => "API-only mode (no views/assets)",
|
|
50
|
+
"hotwire" => "Hotwire (Turbo + Stimulus)",
|
|
51
|
+
"graphql" => "GraphQL API (app/graphql/)",
|
|
52
|
+
"grape_api" => "Grape API framework (app/api/)",
|
|
53
|
+
"service_objects" => "Service objects pattern (app/services/)",
|
|
54
|
+
"form_objects" => "Form objects (app/forms/)",
|
|
55
|
+
"query_objects" => "Query objects (app/queries/)",
|
|
56
|
+
"presenters" => "Presenters/Decorators",
|
|
57
|
+
"view_components" => "ViewComponent (app/components/)",
|
|
58
|
+
"stimulus" => "Stimulus controllers (app/javascript/controllers/)",
|
|
59
|
+
"importmaps" => "Import maps (no JS bundler)",
|
|
60
|
+
"docker" => "Dockerized",
|
|
61
|
+
"kamal" => "Kamal deployment",
|
|
62
|
+
"ci_github_actions" => "GitHub Actions CI"
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
PATTERN_LABELS = {
|
|
66
|
+
"sti" => "Single Table Inheritance (STI)",
|
|
67
|
+
"polymorphic" => "Polymorphic associations",
|
|
68
|
+
"soft_delete" => "Soft deletes (paranoia/discard)",
|
|
69
|
+
"versioning" => "Model versioning/auditing",
|
|
70
|
+
"state_machine" => "State machines (AASM/workflow)",
|
|
71
|
+
"multi_tenancy" => "Multi-tenancy",
|
|
72
|
+
"searchable" => "Full-text search (Searchkick/pg_search/Ransack)",
|
|
73
|
+
"taggable" => "Tagging",
|
|
74
|
+
"sluggable" => "Friendly URLs/slugs",
|
|
75
|
+
"nested_set" => "Tree/nested set structures"
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
private_class_method def self.humanize_arch(key)
|
|
79
|
+
ARCH_LABELS[key] || key.humanize
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method def self.humanize_pattern(key)
|
|
83
|
+
PATTERN_LABELS[key] || key.humanize
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|