rails-mcp-server 1.0.1 → 1.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 +4 -4
- data/README.md +91 -169
- data/exe/rails-mcp-server +89 -17
- data/exe/rails-mcp-setup-claude +2 -1
- data/lib/rails-mcp-server/config.rb +72 -0
- data/lib/rails-mcp-server/tools/analyze_controller_views.rb +239 -0
- data/lib/rails-mcp-server/tools/analyze_environment_config.rb +427 -0
- data/lib/rails-mcp-server/tools/analyze_models.rb +116 -0
- data/lib/rails-mcp-server/tools/base_tool.rb +9 -0
- data/lib/rails-mcp-server/tools/get_file.rb +55 -0
- data/lib/rails-mcp-server/tools/get_model.rb +116 -0
- data/lib/rails-mcp-server/tools/get_routes.rb +24 -0
- data/lib/rails-mcp-server/tools/get_schema.rb +141 -0
- data/lib/rails-mcp-server/tools/list_files.rb +54 -0
- data/lib/rails-mcp-server/tools/project_info.rb +86 -0
- data/lib/rails-mcp-server/tools/switch_project.rb +25 -0
- data/lib/rails-mcp-server/utilities/run_process.rb +29 -0
- data/lib/rails-mcp-server/version.rb +1 -1
- data/lib/rails_mcp_server.rb +31 -864
- metadata +44 -3
@@ -0,0 +1,116 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class AnalyzeModels < BaseTool
|
3
|
+
tool_name "analize_models"
|
4
|
+
|
5
|
+
description "Retrieve detailed information about Active Record models in the project. When called without parameters, lists all model files. When a specific model is specified, returns its schema, associations (has_many, belongs_to, has_one), and complete source code."
|
6
|
+
|
7
|
+
arguments do
|
8
|
+
optional(:model_name).filled(:string).description("Class name of a specific model to get detailed information for (e.g., 'User', 'Product'). Use CamelCase, not snake_case. If omitted, returns a list of all models.")
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(model_name: nil)
|
12
|
+
unless current_project
|
13
|
+
message = "No active project. Please switch to a project first."
|
14
|
+
log(:warn, message)
|
15
|
+
|
16
|
+
return message
|
17
|
+
end
|
18
|
+
|
19
|
+
if model_name
|
20
|
+
log(:info, "Getting info for specific model: #{model_name}")
|
21
|
+
|
22
|
+
# Check if the model file exists
|
23
|
+
model_file = File.join(active_project_path, "app", "models", "#{underscore(model_name)}.rb")
|
24
|
+
unless File.exist?(model_file)
|
25
|
+
log(:warn, "Model file not found: #{model_name}")
|
26
|
+
message = "Model '#{model_name}' not found."
|
27
|
+
log(:warn, message)
|
28
|
+
|
29
|
+
return message
|
30
|
+
end
|
31
|
+
|
32
|
+
log(:debug, "Reading model file: #{model_file}")
|
33
|
+
|
34
|
+
# Get the model file content
|
35
|
+
model_content = File.read(model_file)
|
36
|
+
|
37
|
+
# Try to get schema information
|
38
|
+
log(:debug, "Executing Rails runner to get schema information")
|
39
|
+
schema_info = execute_rails_command(
|
40
|
+
active_project_path,
|
41
|
+
"runner \"puts #{model_name}.column_names\""
|
42
|
+
)
|
43
|
+
|
44
|
+
# Try to get associations
|
45
|
+
associations = []
|
46
|
+
if model_content.include?("has_many")
|
47
|
+
has_many = model_content.scan(/has_many\s+:(\w+)/).flatten
|
48
|
+
associations << "Has many: #{has_many.join(", ")}" unless has_many.empty?
|
49
|
+
end
|
50
|
+
|
51
|
+
if model_content.include?("belongs_to")
|
52
|
+
belongs_to = model_content.scan(/belongs_to\s+:(\w+)/).flatten
|
53
|
+
associations << "Belongs to: #{belongs_to.join(", ")}" unless belongs_to.empty?
|
54
|
+
end
|
55
|
+
|
56
|
+
if model_content.include?("has_one")
|
57
|
+
has_one = model_content.scan(/has_one\s+:(\w+)/).flatten
|
58
|
+
associations << "Has one: #{has_one.join(", ")}" unless has_one.empty?
|
59
|
+
end
|
60
|
+
|
61
|
+
log(:debug, "Found #{associations.size} associations for model: #{model_name}")
|
62
|
+
|
63
|
+
# Format the output
|
64
|
+
<<~INFO
|
65
|
+
Model: #{model_name}
|
66
|
+
|
67
|
+
Schema:
|
68
|
+
#{schema_info}
|
69
|
+
|
70
|
+
Associations:
|
71
|
+
#{associations.empty? ? "None found" : associations.join("\n")}
|
72
|
+
|
73
|
+
Model Definition:
|
74
|
+
```ruby
|
75
|
+
#{model_content}
|
76
|
+
```
|
77
|
+
INFO
|
78
|
+
else
|
79
|
+
log(:info, "Listing all models")
|
80
|
+
|
81
|
+
# List all models
|
82
|
+
models_dir = File.join(active_project_path, "app", "models")
|
83
|
+
unless File.directory?(models_dir)
|
84
|
+
message = "Models directory not found."
|
85
|
+
log(:warn, message)
|
86
|
+
|
87
|
+
return message
|
88
|
+
end
|
89
|
+
|
90
|
+
# Get all .rb files in the models directory and its subdirectories
|
91
|
+
model_files = Dir.glob(File.join(models_dir, "**", "*.rb"))
|
92
|
+
.map { |f| f.sub("#{models_dir}/", "").sub(/\.rb$/, "") }
|
93
|
+
.sort # rubocop:disable Performance/ChainArrayAllocation
|
94
|
+
|
95
|
+
log(:debug, "Found #{model_files.size} model files")
|
96
|
+
|
97
|
+
"Models in the project:\n\n#{model_files.join("\n")}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def execute_rails_command(project_path, command)
|
104
|
+
full_command = "cd #{project_path} && bin/rails #{command}"
|
105
|
+
`#{full_command}`
|
106
|
+
end
|
107
|
+
|
108
|
+
def underscore(string)
|
109
|
+
string.gsub("::", "/")
|
110
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
111
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
112
|
+
.tr("-", "_")
|
113
|
+
.downcase
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class BaseTool < FastMcp::Tool
|
3
|
+
extend Forwardable
|
4
|
+
|
5
|
+
def_delegators :RailsMcpServer, :log, :projects
|
6
|
+
def_delegators :RailsMcpServer, :current_project=, :current_project
|
7
|
+
def_delegators :RailsMcpServer, :active_project_path=, :active_project_path
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class GetFile < BaseTool
|
3
|
+
tool_name "get_file"
|
4
|
+
|
5
|
+
description "Retrieve the complete content of a specific file with syntax highlighting. Use this to examine implementation details, configurations, or any text file in the project."
|
6
|
+
|
7
|
+
arguments do
|
8
|
+
required(:path).filled(:string).description("File path relative to the project root (e.g., 'app/models/user.rb', 'config/routes.rb'). Use list_files first if you're not sure about the exact path.")
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(path:)
|
12
|
+
unless current_project
|
13
|
+
message = "No active project. Please switch to a project first."
|
14
|
+
log(:warn, message)
|
15
|
+
|
16
|
+
return message
|
17
|
+
end
|
18
|
+
|
19
|
+
full_path = File.join(active_project_path, path)
|
20
|
+
|
21
|
+
unless File.exist?(full_path)
|
22
|
+
message = "File '#{path}' not found in the project."
|
23
|
+
log(:warn, message)
|
24
|
+
|
25
|
+
return message
|
26
|
+
end
|
27
|
+
|
28
|
+
content = File.read(full_path)
|
29
|
+
log(:debug, "Read file: #{path} (#{content.size} bytes)")
|
30
|
+
|
31
|
+
"File: #{path}\n\n```#{get_file_extension(path)}\n#{content}\n```"
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def get_file_extension(path)
|
37
|
+
case File.extname(path).downcase
|
38
|
+
when ".rb"
|
39
|
+
"ruby"
|
40
|
+
when ".js"
|
41
|
+
"javascript"
|
42
|
+
when ".html", ".erb"
|
43
|
+
"html"
|
44
|
+
when ".css"
|
45
|
+
"css"
|
46
|
+
when ".json"
|
47
|
+
"json"
|
48
|
+
when ".yml", ".yaml"
|
49
|
+
"yaml"
|
50
|
+
else
|
51
|
+
""
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class GetModels < BaseTool
|
3
|
+
tool_name "get_models"
|
4
|
+
|
5
|
+
description "Retrieve detailed information about Active Record models in the project. When called without parameters, lists all model files. When a specific model is specified, returns its schema, associations (has_many, belongs_to, has_one), and complete source code."
|
6
|
+
|
7
|
+
arguments do
|
8
|
+
optional(:model_name).filled(:string).description("Class name of a specific model to get detailed information for (e.g., 'User', 'Product'). Use CamelCase, not snake_case. If omitted, returns a list of all models.")
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(model_name: nil)
|
12
|
+
unless current_project
|
13
|
+
message = "No active project. Please switch to a project first."
|
14
|
+
log(:warn, message)
|
15
|
+
|
16
|
+
return message
|
17
|
+
end
|
18
|
+
|
19
|
+
if model_name
|
20
|
+
log(:info, "Getting info for specific model: #{model_name}")
|
21
|
+
|
22
|
+
# Check if the model file exists
|
23
|
+
model_file = File.join(active_project_path, "app", "models", "#{underscore(model_name)}.rb")
|
24
|
+
unless File.exist?(model_file)
|
25
|
+
log(:warn, "Model file not found: #{model_name}")
|
26
|
+
message = "Model '#{model_name}' not found."
|
27
|
+
log(:warn, message)
|
28
|
+
|
29
|
+
return message
|
30
|
+
end
|
31
|
+
|
32
|
+
log(:debug, "Reading model file: #{model_file}")
|
33
|
+
|
34
|
+
# Get the model file content
|
35
|
+
model_content = File.read(model_file)
|
36
|
+
|
37
|
+
# Try to get schema information
|
38
|
+
log(:debug, "Executing Rails runner to get schema information")
|
39
|
+
schema_info = execute_rails_command(
|
40
|
+
active_project_path,
|
41
|
+
"runner \"puts #{model_name}.column_names\""
|
42
|
+
)
|
43
|
+
|
44
|
+
# Try to get associations
|
45
|
+
associations = []
|
46
|
+
if model_content.include?("has_many")
|
47
|
+
has_many = model_content.scan(/has_many\s+:(\w+)/).flatten
|
48
|
+
associations << "Has many: #{has_many.join(", ")}" unless has_many.empty?
|
49
|
+
end
|
50
|
+
|
51
|
+
if model_content.include?("belongs_to")
|
52
|
+
belongs_to = model_content.scan(/belongs_to\s+:(\w+)/).flatten
|
53
|
+
associations << "Belongs to: #{belongs_to.join(", ")}" unless belongs_to.empty?
|
54
|
+
end
|
55
|
+
|
56
|
+
if model_content.include?("has_one")
|
57
|
+
has_one = model_content.scan(/has_one\s+:(\w+)/).flatten
|
58
|
+
associations << "Has one: #{has_one.join(", ")}" unless has_one.empty?
|
59
|
+
end
|
60
|
+
|
61
|
+
log(:debug, "Found #{associations.size} associations for model: #{model_name}")
|
62
|
+
|
63
|
+
# Format the output
|
64
|
+
<<~INFO
|
65
|
+
Model: #{model_name}
|
66
|
+
|
67
|
+
Schema:
|
68
|
+
#{schema_info}
|
69
|
+
|
70
|
+
Associations:
|
71
|
+
#{associations.empty? ? "None found" : associations.join("\n")}
|
72
|
+
|
73
|
+
Model Definition:
|
74
|
+
```ruby
|
75
|
+
#{model_content}
|
76
|
+
```
|
77
|
+
INFO
|
78
|
+
else
|
79
|
+
log(:info, "Listing all models")
|
80
|
+
|
81
|
+
# List all models
|
82
|
+
models_dir = File.join(active_project_path, "app", "models")
|
83
|
+
unless File.directory?(models_dir)
|
84
|
+
message = "Models directory not found."
|
85
|
+
log(:warn, message)
|
86
|
+
|
87
|
+
return message
|
88
|
+
end
|
89
|
+
|
90
|
+
# Get all .rb files in the models directory and its subdirectories
|
91
|
+
model_files = Dir.glob(File.join(models_dir, "**", "*.rb"))
|
92
|
+
.map { |f| f.sub("#{models_dir}/", "").sub(/\.rb$/, "") }
|
93
|
+
.sort # rubocop:disable Performance/ChainArrayAllocation
|
94
|
+
|
95
|
+
log(:debug, "Found #{model_files.size} model files")
|
96
|
+
|
97
|
+
"Models in the project:\n\n#{model_files.join("\n")}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def execute_rails_command(project_path, command)
|
104
|
+
full_command = "cd #{project_path} && bin/rails #{command}"
|
105
|
+
`#{full_command}`
|
106
|
+
end
|
107
|
+
|
108
|
+
def underscore(string)
|
109
|
+
string.gsub("::", "/")
|
110
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
111
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
112
|
+
.tr("-", "_")
|
113
|
+
.downcase
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class GetRoutes < BaseTool
|
3
|
+
tool_name "get_routes"
|
4
|
+
|
5
|
+
description "Retrieve all HTTP routes defined in the Rails application with their associated controllers and actions. Equivalent to running 'rails routes' command. This helps understand the API endpoints or page URLs available in the application."
|
6
|
+
|
7
|
+
def call
|
8
|
+
unless current_project
|
9
|
+
message = "No active project. Please switch to a project first."
|
10
|
+
log(:warn, message)
|
11
|
+
|
12
|
+
return message
|
13
|
+
end
|
14
|
+
|
15
|
+
# Execute the Rails routes command
|
16
|
+
routes_output = RailsMcpServer::RunProcess.execute_rails_command(
|
17
|
+
active_project_path, "bin/rails routes"
|
18
|
+
)
|
19
|
+
log(:debug, "Routes command completed, output size: #{routes_output.size} bytes")
|
20
|
+
|
21
|
+
"Rails Routes:\n\n```\n#{routes_output}\n```"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class GetSchema < BaseTool
|
3
|
+
tool_name "get_schema"
|
4
|
+
|
5
|
+
description "Retrieve database schema information for the Rails application. Without parameters, returns all tables and the complete schema.rb. With a table name, returns detailed column information including data types, constraints, and foreign keys for that specific table."
|
6
|
+
|
7
|
+
arguments do
|
8
|
+
optional(:table_name).filled(:string).description("Database table name to get detailed schema information for (e.g., 'users', 'products'). Use snake_case, plural form. If omitted, returns complete database schema.")
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(table_name: nil)
|
12
|
+
unless current_project
|
13
|
+
message = "No active project. Please switch to a project first."
|
14
|
+
log(:warn, message)
|
15
|
+
|
16
|
+
return message
|
17
|
+
end
|
18
|
+
|
19
|
+
if table_name
|
20
|
+
log(:info, "Getting schema for table: #{table_name}")
|
21
|
+
|
22
|
+
# Execute the Rails schema command for a specific table
|
23
|
+
schema_output = RailsMcpServer::RunProcess.execute_rails_command(
|
24
|
+
active_project_path,
|
25
|
+
"bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.columns('#{table_name}').map{|c| [c.name, c.type, c.null, c.default].inspect}.join('\n')\""
|
26
|
+
)
|
27
|
+
|
28
|
+
if schema_output.strip.empty?
|
29
|
+
message = "Table '#{table_name}' not found or has no columns."
|
30
|
+
log(:warn, message)
|
31
|
+
|
32
|
+
return message
|
33
|
+
end
|
34
|
+
|
35
|
+
# Parse the column information
|
36
|
+
columns = schema_output.strip.split("\n").map do |column_info|
|
37
|
+
eval(column_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval
|
38
|
+
end
|
39
|
+
|
40
|
+
# Format the output
|
41
|
+
formatted_columns = columns.map do |name, type, nullable, default|
|
42
|
+
"#{name} (#{type})#{nullable ? ", nullable" : ""}#{default ? ", default: #{default}" : ""}"
|
43
|
+
end
|
44
|
+
|
45
|
+
output = <<~SCHEMA
|
46
|
+
Table: #{table_name}
|
47
|
+
|
48
|
+
Columns:
|
49
|
+
#{formatted_columns.join("\n")}
|
50
|
+
SCHEMA
|
51
|
+
|
52
|
+
# Try to get foreign keys
|
53
|
+
begin
|
54
|
+
fk_output = RailsMcpServer::RunProcess.execute_rails_command(
|
55
|
+
active_project_path,
|
56
|
+
"bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.foreign_keys('#{table_name}').map{|fk| [fk.from_table, fk.to_table, fk.column, fk.primary_key].inspect}.join('\n')\""
|
57
|
+
)
|
58
|
+
|
59
|
+
unless fk_output.strip.empty?
|
60
|
+
foreign_keys = fk_output.strip.split("\n").map do |fk_info|
|
61
|
+
eval(fk_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval
|
62
|
+
end
|
63
|
+
|
64
|
+
formatted_fks = foreign_keys.map do |from_table, to_table, column, primary_key|
|
65
|
+
"#{column} -> #{to_table}.#{primary_key}"
|
66
|
+
end
|
67
|
+
|
68
|
+
output += <<~FK
|
69
|
+
|
70
|
+
Foreign Keys:
|
71
|
+
#{formatted_fks.join("\n")}
|
72
|
+
FK
|
73
|
+
end
|
74
|
+
rescue => e
|
75
|
+
log(:warn, "Error fetching foreign keys: #{e.message}")
|
76
|
+
end
|
77
|
+
|
78
|
+
output
|
79
|
+
else
|
80
|
+
log(:info, "Getting full schema")
|
81
|
+
|
82
|
+
# Execute the Rails schema:dump command
|
83
|
+
# First, check if we need to create the schema file
|
84
|
+
schema_file = File.join(active_project_path, "db", "schema.rb")
|
85
|
+
unless File.exist?(schema_file)
|
86
|
+
log(:info, "Schema file not found, attempting to generate it")
|
87
|
+
RailsMcpServer::RunProcess.execute_rails_command(active_project_path, "db:schema:dump")
|
88
|
+
end
|
89
|
+
|
90
|
+
if File.exist?(schema_file)
|
91
|
+
# Read the schema file
|
92
|
+
schema_content = File.read(schema_file)
|
93
|
+
|
94
|
+
# Try to get table list
|
95
|
+
tables_output = RailsMcpServer::RunProcess.execute_rails_command(
|
96
|
+
active_project_path,
|
97
|
+
"bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\""
|
98
|
+
)
|
99
|
+
|
100
|
+
tables = tables_output.strip.split("\n")
|
101
|
+
|
102
|
+
<<~SCHEMA
|
103
|
+
Database Schema
|
104
|
+
|
105
|
+
Tables:
|
106
|
+
#{tables.join("\n")}
|
107
|
+
|
108
|
+
Schema Definition:
|
109
|
+
```ruby
|
110
|
+
#{schema_content}
|
111
|
+
```
|
112
|
+
SCHEMA
|
113
|
+
else
|
114
|
+
# If we can't get the schema file, try to get the table list
|
115
|
+
tables_output = RailsMcpServer::RunProcess.execute_rails_command(
|
116
|
+
active_project_path,
|
117
|
+
"bin/rails runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\""
|
118
|
+
)
|
119
|
+
|
120
|
+
if tables_output.strip.empty?
|
121
|
+
message = "Could not retrieve schema information. Try running 'rails db:schema:dump' in your project first."
|
122
|
+
log(:warn, message)
|
123
|
+
|
124
|
+
return message
|
125
|
+
end
|
126
|
+
|
127
|
+
tables = tables_output.strip.split("\n")
|
128
|
+
|
129
|
+
<<~SCHEMA
|
130
|
+
Database Schema
|
131
|
+
|
132
|
+
Tables:
|
133
|
+
#{tables.join("\n")}
|
134
|
+
|
135
|
+
Note: Full schema definition is not available. Run 'rails db:schema:dump' to generate the schema.rb file.
|
136
|
+
SCHEMA
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class ListFiles < BaseTool
|
3
|
+
tool_name "list_files"
|
4
|
+
|
5
|
+
description "List files in the Rails project matching specific criteria. Use this to explore project directories or locate specific file types. If no parameters are provided, lists files in the project root."
|
6
|
+
|
7
|
+
arguments do
|
8
|
+
optional(:directory).filled(:string).description("Directory path relative to the project root (e.g., 'app/models', 'config'). Leave empty to list files at the root.")
|
9
|
+
optional(:pattern).filled(:string).description("File pattern using glob syntax (e.g., '*.rb' for Ruby files, '*.erb' for ERB templates, '*_controller.rb' for controllers)")
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(directory: "", pattern: "*.rb")
|
13
|
+
unless current_project
|
14
|
+
message = "No active project. Please switch to a project first."
|
15
|
+
log(:warn, message)
|
16
|
+
|
17
|
+
return message
|
18
|
+
end
|
19
|
+
|
20
|
+
full_path = File.join(active_project_path, directory)
|
21
|
+
unless File.directory?(full_path)
|
22
|
+
message = "Directory '#{directory}' not found in the project."
|
23
|
+
log(:warn, message)
|
24
|
+
|
25
|
+
return message
|
26
|
+
end
|
27
|
+
|
28
|
+
# Check if this is a git repository
|
29
|
+
is_git_repo = system("cd #{active_project_path} && git rev-parse --is-inside-work-tree > /dev/null 2>&1")
|
30
|
+
|
31
|
+
if is_git_repo
|
32
|
+
log(:debug, "Project is a git repository, using git ls-files")
|
33
|
+
|
34
|
+
# Use git ls-files for tracked files
|
35
|
+
relative_dir = directory.empty? ? "" : "#{directory}/"
|
36
|
+
git_cmd = "cd #{active_project_path} && git ls-files --cached --others --exclude-standard #{relative_dir}#{pattern}"
|
37
|
+
|
38
|
+
files = `#{git_cmd}`.split("\n").map(&:strip).sort # rubocop:disable Performance/ChainArrayAllocation
|
39
|
+
else
|
40
|
+
log(:debug, "Project is not a git repository or git not available, using Dir.glob")
|
41
|
+
|
42
|
+
# Use Dir.glob as fallback
|
43
|
+
files = Dir.glob(File.join(full_path, pattern))
|
44
|
+
.map { |f| f.sub("#{active_project_path}/", "") }
|
45
|
+
.reject { |file| file.start_with?(".git/", ".ruby-lsp/", "node_modules/", "storage/", "public/assets/", "public/packs/", ".bundle/", "vendor/bundle/", "vendor/cache/", "tmp/", "log/") } # rubocop:disable Performance/ChainArrayAllocation
|
46
|
+
.sort # rubocop:disable Performance/ChainArrayAllocation
|
47
|
+
end
|
48
|
+
|
49
|
+
log(:debug, "Found #{files.size} files matching pattern (respecting .gitignore and ignoring node_modules)")
|
50
|
+
|
51
|
+
"Files in #{directory.empty? ? "project root" : directory} matching '#{pattern}':\n\n#{files.join("\n")}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class ProjectInfo < BaseTool
|
3
|
+
tool_name "project_info"
|
4
|
+
|
5
|
+
description "Retrieve comprehensive information about the current Rails project, including Rails version, directory structure, API-only status, and overall project organization. Useful for initial project exploration and understanding the codebase structure."
|
6
|
+
|
7
|
+
def call
|
8
|
+
unless current_project
|
9
|
+
message = "No active project. Please switch to a project first."
|
10
|
+
log(:warn, message)
|
11
|
+
|
12
|
+
return message
|
13
|
+
end
|
14
|
+
|
15
|
+
# Get additional project information
|
16
|
+
gemfile_path = File.join(active_project_path, "Gemfile")
|
17
|
+
gemfile_content = File.exist?(gemfile_path) ? File.read(gemfile_path) : "Gemfile not found"
|
18
|
+
|
19
|
+
# Get Rails version
|
20
|
+
rails_version = gemfile_content.match(/gem ['"]rails['"],\s*['"](.+?)['"]/)&.captures&.first || "Unknown"
|
21
|
+
|
22
|
+
# Check if it's an API-only app
|
23
|
+
config_application_path = File.join(active_project_path, "config", "application.rb")
|
24
|
+
is_api_only = File.exist?(config_application_path) &&
|
25
|
+
File.read(config_application_path).include?("config.api_only = true")
|
26
|
+
|
27
|
+
log(:info, "Project info: Rails v#{rails_version}, API-only: #{is_api_only}")
|
28
|
+
|
29
|
+
<<~INFO
|
30
|
+
Current project: #{current_project}
|
31
|
+
Path: #{active_project_path}
|
32
|
+
Rails version: #{rails_version}
|
33
|
+
API only: #{is_api_only ? "Yes" : "No"}
|
34
|
+
|
35
|
+
Project structure:
|
36
|
+
#{get_directory_structure(active_project_path, max_depth: 2)}
|
37
|
+
INFO
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Utility functions for Rails operations
|
43
|
+
def get_directory_structure(path, max_depth: 3, current_depth: 0, prefix: "")
|
44
|
+
return "" if current_depth > max_depth || !File.directory?(path)
|
45
|
+
|
46
|
+
# Define ignored directories
|
47
|
+
ignored_dirs = [
|
48
|
+
".git", "node_modules", "tmp", "log",
|
49
|
+
"storage", "coverage", "public/assets",
|
50
|
+
"public/packs", ".bundle", "vendor/bundle",
|
51
|
+
"vendor/cache", ".ruby-lsp"
|
52
|
+
]
|
53
|
+
|
54
|
+
output = ""
|
55
|
+
directories = []
|
56
|
+
files = []
|
57
|
+
|
58
|
+
Dir.foreach(path) do |entry|
|
59
|
+
next if entry == "." || entry == ".."
|
60
|
+
next if ignored_dirs.include?(entry) # Skip ignored directories
|
61
|
+
|
62
|
+
full_path = File.join(path, entry)
|
63
|
+
|
64
|
+
if File.directory?(full_path)
|
65
|
+
directories << entry
|
66
|
+
else
|
67
|
+
files << entry
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
directories.sort.each do |dir|
|
72
|
+
output << "#{prefix}└── #{dir}/\n"
|
73
|
+
full_path = File.join(path, dir)
|
74
|
+
output << get_directory_structure(full_path, max_depth: max_depth,
|
75
|
+
current_depth: current_depth + 1,
|
76
|
+
prefix: "#{prefix} ")
|
77
|
+
end
|
78
|
+
|
79
|
+
files.sort.each do |file|
|
80
|
+
output << "#{prefix}└── #{file}\n"
|
81
|
+
end
|
82
|
+
|
83
|
+
output
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class SwitchProject < BaseTool
|
3
|
+
tool_name "switch_project"
|
4
|
+
|
5
|
+
description "Change the active Rails project to interact with a different codebase. Must be called before using other tools. Available projects are defined in the projects.yml configuration file."
|
6
|
+
|
7
|
+
arguments do
|
8
|
+
required(:project_name).filled(:string).description("Name of the project as defined in the projects.yml file (case-sensitive)")
|
9
|
+
end
|
10
|
+
|
11
|
+
def call(project_name:)
|
12
|
+
if projects.key?(project_name)
|
13
|
+
self.current_project = project_name
|
14
|
+
self.active_project_path = File.expand_path(projects[project_name])
|
15
|
+
log(:info, "Switched to project: #{project_name} at path: #{active_project_path}")
|
16
|
+
|
17
|
+
"Switched to project: #{project_name} at path: #{active_project_path}"
|
18
|
+
else
|
19
|
+
log(:warn, "Project not found: #{project_name}")
|
20
|
+
|
21
|
+
"Project '#{project_name}' not found. Available projects: #{projects.keys.join(", ")}"
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class RunProcess
|
3
|
+
def self.execute_rails_command(project_path, command)
|
4
|
+
subprocess_env = ENV.to_h.merge(Bundler.original_env).merge(
|
5
|
+
"BUNDLE_GEMFILE" => File.join(project_path, "Gemfile")
|
6
|
+
)
|
7
|
+
|
8
|
+
RailsMcpServer.log(:debug, "Executing: #{command}")
|
9
|
+
|
10
|
+
# Execute the command and capture stdout, stderr, and status
|
11
|
+
stdout_str, stderr_str, status = Open3.capture3(subprocess_env, command, chdir: project_path)
|
12
|
+
|
13
|
+
if status.success?
|
14
|
+
RailsMcpServer.log(:debug, "Command succeeded")
|
15
|
+
stdout_str
|
16
|
+
else
|
17
|
+
# Log error details
|
18
|
+
RailsMcpServer.log(:error, "Command failed with status: #{status.exitstatus}")
|
19
|
+
RailsMcpServer.log(:error, "stderr: #{stderr_str}")
|
20
|
+
|
21
|
+
# Return error message
|
22
|
+
"Error executing Rails command: #{command}\n\n#{stderr_str}"
|
23
|
+
end
|
24
|
+
rescue => e
|
25
|
+
RailsMcpServer.log(:error, "Exception executing Rails command: #{e.message}")
|
26
|
+
"Exception executing command: #{e.message}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|