rails-mcp-server 1.2.3 → 1.4.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +168 -166
  3. data/docs/AGENT.md +345 -0
  4. data/exe/rails-mcp-config +1411 -0
  5. data/exe/rails-mcp-server +23 -10
  6. data/exe/rails-mcp-setup-claude +1 -1
  7. data/lib/rails-mcp-server/analyzers/analyze_controller_views.rb +253 -0
  8. data/lib/rails-mcp-server/analyzers/analyze_environment_config.rb +79 -0
  9. data/lib/rails-mcp-server/analyzers/analyze_models.rb +251 -0
  10. data/lib/rails-mcp-server/analyzers/base_analyzer.rb +42 -0
  11. data/lib/rails-mcp-server/analyzers/get_file.rb +40 -0
  12. data/lib/rails-mcp-server/analyzers/get_routes.rb +212 -0
  13. data/lib/rails-mcp-server/analyzers/get_schema.rb +216 -0
  14. data/lib/rails-mcp-server/analyzers/list_files.rb +43 -0
  15. data/lib/rails-mcp-server/analyzers/load_guide.rb +84 -0
  16. data/lib/rails-mcp-server/analyzers/project_info.rb +136 -0
  17. data/lib/rails-mcp-server/tools/base_tool.rb +2 -0
  18. data/lib/rails-mcp-server/tools/execute_ruby.rb +409 -0
  19. data/lib/rails-mcp-server/tools/execute_tool.rb +115 -0
  20. data/lib/rails-mcp-server/tools/search_tools.rb +186 -0
  21. data/lib/rails-mcp-server/tools/switch_project.rb +16 -1
  22. data/lib/rails-mcp-server/version.rb +1 -1
  23. data/lib/rails_mcp_server.rb +19 -53
  24. metadata +65 -18
  25. data/lib/rails-mcp-server/extensions/resource_templating.rb +0 -182
  26. data/lib/rails-mcp-server/extensions/server_templating.rb +0 -333
  27. data/lib/rails-mcp-server/tools/analyze_controller_views.rb +0 -239
  28. data/lib/rails-mcp-server/tools/analyze_environment_config.rb +0 -427
  29. data/lib/rails-mcp-server/tools/analyze_models.rb +0 -116
  30. data/lib/rails-mcp-server/tools/get_file.rb +0 -55
  31. data/lib/rails-mcp-server/tools/get_routes.rb +0 -24
  32. data/lib/rails-mcp-server/tools/get_schema.rb +0 -141
  33. data/lib/rails-mcp-server/tools/list_files.rb +0 -54
  34. data/lib/rails-mcp-server/tools/load_guide.rb +0 -370
  35. data/lib/rails-mcp-server/tools/project_info.rb +0 -86
@@ -1,333 +0,0 @@
1
- module RailsMcpServer
2
- module Extensions
3
- # Extension module to add any missing templated resource support to FastMcp::Server
4
- # This version of the server already has most templated resource functionality
5
- module ServerTemplating
6
- # Instance methods to be prepended
7
- module InstanceMethods
8
- # The target server already has most functionality, but we can add defensive checks
9
- def read_resource(uri)
10
- # Handle both hash-based and array-based resource storage
11
- if @resources.is_a?(Hash)
12
- # First try exact match (hash lookup)
13
- exact_match = @resources[uri]
14
- return exact_match if exact_match
15
-
16
- # Then try templated resource matching
17
- @resources.values.find { |r| r.respond_to?(:match) && r.match(uri) }
18
- else
19
- # Array-based storage (original target server behavior)
20
- resource = @resources.find { |r| r.respond_to?(:match) && r.match(uri) }
21
-
22
- # Fallback: if no templated match, try exact URI match for backward compatibility
23
- resource ||= @resources.find { |r| r.respond_to?(:uri) && r.uri == uri }
24
-
25
- resource
26
- end
27
- end
28
-
29
- # Add some defensive programming to handle_resources_read
30
- def handle_resources_read(params, id)
31
- uri = params["uri"]
32
-
33
- return send_error(-32_602, "Invalid params: missing resource URI", id) unless uri
34
-
35
- @logger.debug("Looking for resource with URI: #{uri}")
36
-
37
- begin
38
- resource = read_resource(uri)
39
- return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
40
-
41
- # Defensive check for templated method
42
- is_templated = resource.respond_to?(:templated?) ? resource.templated? : false
43
- @logger.debug("Found resource: #{resource.respond_to?(:resource_name) ? resource.resource_name : resource.name}, templated: #{is_templated}")
44
-
45
- base_content = {uri: uri}
46
- base_content[:mimeType] = resource.mime_type if resource.mime_type
47
-
48
- # Handle both templated and non-templated resources
49
- resource_instance = if is_templated && resource.respond_to?(:instance)
50
- resource.instance(uri)
51
- else
52
- # Fallback for non-templated resources or resources without instance method
53
- resource.respond_to?(:instance) ? resource.instance : resource
54
- end
55
-
56
- # Defensive check for params method
57
- if resource_instance.respond_to?(:params)
58
- @logger.debug("Resource instance params: #{resource_instance.params.inspect}")
59
- end
60
-
61
- result = if resource_instance.respond_to?(:binary?) && resource_instance.binary?
62
- {
63
- contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
64
- }
65
- else
66
- {
67
- contents: [base_content.merge(text: resource_instance.content)]
68
- }
69
- end
70
-
71
- send_result(result, id)
72
- rescue => e
73
- @logger.error("Error reading resource: #{e.message}")
74
- @logger.error(e.backtrace.join("\n"))
75
- send_error(-32_600, "Internal error reading resource: #{e.message}", id)
76
- end
77
- end
78
-
79
- # The target server already has these methods, but we can add defensive checks
80
- def handle_resources_list(id)
81
- # Handle both hash-based and array-based resource storage
82
- resources_collection = @resources.is_a?(Hash) ? @resources.values : @resources
83
-
84
- resources_list = resources_collection.select do |resource|
85
- !resource.respond_to?(:templated?) || resource.non_templated?
86
- end.map(&:metadata) # rubocop:disable Performance/ChainArrayAllocation
87
-
88
- send_result({resources: resources_list}, id)
89
- end
90
-
91
- def handle_resources_templates_list(id)
92
- @logger.debug("Handling resources/templates/list request")
93
-
94
- # Handle both hash-based and array-based resource storage
95
- resources_collection = @resources.is_a?(Hash) ? @resources.values : @resources
96
-
97
- templated_resources_list = resources_collection.select do |resource|
98
- resource.respond_to?(:templated?) && resource.templated?
99
- end.map do |resource| # rubocop:disable Performance/ChainArrayAllocation
100
- metadata = resource.metadata
101
- @logger.debug("Template resource metadata: #{metadata}")
102
- metadata
103
- end
104
-
105
- @logger.info("Returning #{templated_resources_list.length} templated resources")
106
- send_result({resourceTemplates: templated_resources_list}, id)
107
- end
108
-
109
- # Override handle_request to ensure resources/templates/list endpoint is available
110
- def handle_request(*args)
111
- # Extract arguments - handle different signatures
112
- if args.length == 2
113
- json_str, headers = args
114
- headers ||= {}
115
- else
116
- json_str = args[0]
117
- headers = {}
118
- end
119
-
120
- begin
121
- request = JSON.parse(json_str)
122
- rescue JSON::ParserError, TypeError
123
- return send_error(-32_600, "Invalid Request", nil)
124
- end
125
-
126
- @logger.debug("Received request: #{request.inspect}")
127
-
128
- # Check if it's a valid JSON-RPC 2.0 request
129
- unless request["jsonrpc"] == "2.0" && request["method"]
130
- return send_error(-32_600, "Invalid Request", request["id"])
131
- end
132
-
133
- method = request["method"]
134
- params = request["params"] || {}
135
- id = request["id"]
136
-
137
- # Handle the resources/templates/list endpoint specifically since it might not exist in original
138
- if method == "resources/templates/list"
139
- @logger.debug("Handling resources/templates/list via extension")
140
- return handle_resources_templates_list(id)
141
- end
142
-
143
- # For all other methods, call the original implementation
144
- begin
145
- super
146
- rescue NoMethodError => e
147
- # If super doesn't work, provide our own fallback
148
- @logger.debug("Original handle_request not available, using fallback: #{e.message}")
149
- handle_request_fallback(method, params, id, headers)
150
- end
151
- rescue => e
152
- @logger.error("Error handling request: #{e.message}, #{e.backtrace.join("\n")}")
153
- send_error(-32_600, "Internal error: #{e.message}", id)
154
- end
155
-
156
- private
157
-
158
- def handle_request_fallback(method, params, id, headers)
159
- @logger.debug("Using fallback handler for method: #{method}")
160
-
161
- case method
162
- when "ping"
163
- send_result({}, id)
164
- when "initialize"
165
- handle_initialize(params, id)
166
- when "notifications/initialized"
167
- handle_initialized_notification
168
- when "tools/list"
169
- handle_tools_list(id)
170
- when "tools/call"
171
- # Handle different method signatures for tools/call
172
- if method(:handle_tools_call).arity == 3
173
- handle_tools_call(params, headers, id)
174
- else
175
- handle_tools_call(params, id)
176
- end
177
- when "resources/list"
178
- handle_resources_list(id)
179
- when "resources/templates/list"
180
- handle_resources_templates_list(id)
181
- when "resources/read"
182
- handle_resources_read(params, id)
183
- when "resources/subscribe"
184
- handle_resources_subscribe(params, id)
185
- when "resources/unsubscribe"
186
- handle_resources_unsubscribe(params, id)
187
- else
188
- send_error(-32_601, "Method not found: #{method}", id)
189
- end
190
- end
191
-
192
- # Add defensive programming to resource subscription methods
193
- def handle_resources_subscribe(params, id)
194
- return unless @client_initialized
195
-
196
- uri = params["uri"]
197
-
198
- unless uri
199
- send_error(-32_602, "Invalid params: missing resource URI", id)
200
- return
201
- end
202
-
203
- # Use the read_resource method which supports templated resources
204
- resource = read_resource(uri)
205
- return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
206
-
207
- # Add to subscriptions
208
- @resource_subscriptions[uri] ||= []
209
- @resource_subscriptions[uri] << id
210
-
211
- send_result({subscribed: true}, id)
212
- end
213
-
214
- # Enhanced logging for resource registration
215
- def register_resource(resource)
216
- # Handle both hash-based and array-based resource storage
217
- if @resources.is_a?(Hash)
218
- @resources[resource.uri] = resource
219
- else
220
- @resources << resource
221
- end
222
-
223
- resource_name = if resource.respond_to?(:resource_name)
224
- resource.resource_name
225
- else
226
- (resource.respond_to?(:name) ? resource.name : "Unknown")
227
- end
228
- is_templated = resource.respond_to?(:templated?) ? resource.templated? : false
229
-
230
- @logger.debug("Registered resource: #{resource_name} (#{resource.uri}) - Templated: #{is_templated}")
231
- resource.server = self if resource.respond_to?(:server=)
232
-
233
- # Notify subscribers about the list change
234
- notify_resource_list_changed if @transport
235
-
236
- resource
237
- end
238
- end
239
-
240
- # Called when this module is prepended to a class
241
- def self.prepended(base)
242
- base.prepend(InstanceMethods)
243
- end
244
- end
245
-
246
- # Setup class for server extensions
247
- class ServerExtensionSetup
248
- class << self
249
- def setup!
250
- return if @setup_complete
251
-
252
- ensure_dependencies_loaded!
253
- check_server_compatibility!
254
- apply_extensions_if_needed!
255
-
256
- @setup_complete = true
257
- RailsMcpServer.log(:info, "FastMcp::Server extensions checked and applied if needed")
258
- rescue => e
259
- RailsMcpServer.log(:error, "Failed to setup server extensions: #{e.message}")
260
- raise
261
- end
262
-
263
- def reset!
264
- @setup_complete = false
265
- end
266
-
267
- def setup_complete?
268
- @setup_complete || false
269
- end
270
-
271
- private
272
-
273
- def ensure_dependencies_loaded!
274
- # Check that FastMcp::Server exists
275
- unless defined?(FastMcp::Server)
276
- begin
277
- require "fast-mcp"
278
- rescue LoadError => e
279
- raise LoadError, "fast-mcp gem is required but not available: #{e.message}"
280
- end
281
- end
282
-
283
- # Verify the expected interface exists
284
- unless FastMcp::Server.instance_methods.include?(:read_resource)
285
- raise "FastMcp::Server doesn't have expected read_resource method. Check fast-mcp gem version."
286
- end
287
-
288
- # Check handle_request method signature
289
- handle_request_method = FastMcp::Server.instance_method(:handle_request)
290
- arity = handle_request_method.arity
291
- RailsMcpServer.log(:debug, "FastMcp::Server#handle_request arity: #{arity}")
292
-
293
- # Check if resources/templates/list is already supported
294
- test_server = FastMcp::Server.new(name: "test", version: "1.0.0")
295
- has_templates_method = test_server.respond_to?(:handle_resources_templates_list)
296
- RailsMcpServer.log(:debug, "Original server has handle_resources_templates_list: #{has_templates_method}")
297
- end
298
-
299
- def check_server_compatibility!
300
- # Check if the server already has templated resource support
301
- server_instance = FastMcp::Server.new(name: "test", version: "1.0.0")
302
-
303
- @server_has_templates = server_instance.respond_to?(:handle_resources_templates_list)
304
- @server_has_advanced_read = begin
305
- # Check if read_resource method body includes 'match'
306
- method_source = FastMcp::Server.instance_method(:read_resource).source_location
307
- method_source ? true : false
308
- rescue
309
- false
310
- end
311
-
312
- RailsMcpServer.log(:debug, "Server template support detected: #{@server_has_templates}")
313
- RailsMcpServer.log(:debug, "Server advanced read support detected: #{@server_has_advanced_read}")
314
- end
315
-
316
- def apply_extensions_if_needed!
317
- # Always apply extensions to ensure resources/templates/list endpoint is available
318
- # The MCP inspector error shows this endpoint is missing
319
- RailsMcpServer.log(:info, "Applying server extensions to ensure full MCP compliance")
320
- FastMcp::Server.prepend(ServerTemplating)
321
-
322
- # Verify the extension was applied by checking if our methods are available
323
- test_server = FastMcp::Server.new(name: "test", version: "1.0.0")
324
- has_templates_list = test_server.respond_to?(:handle_resources_templates_list)
325
- RailsMcpServer.log(:info, "Server extension verification - handle_resources_templates_list available: #{has_templates_list}")
326
- rescue => e
327
- RailsMcpServer.log(:error, "Error applying server extensions: #{e.message}")
328
- raise
329
- end
330
- end
331
- end
332
- end
333
- end
@@ -1,239 +0,0 @@
1
- module RailsMcpServer
2
- class AnalyzeControllerViews < BaseTool
3
- tool_name "analyze_controller_views"
4
-
5
- description "Analyze the relationships between controllers, their actions, and corresponding views to understand the application's UI flow."
6
-
7
- arguments do
8
- optional(:controller_name).filled(:string).description("Name of a specific controller to analyze (e.g., 'UsersController' or 'users'). If omitted, all controllers will be analyzed.")
9
- end
10
-
11
- def call(controller_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
- # Find all controllers
20
- controllers_dir = File.join(active_project_path, "app", "controllers")
21
- unless File.directory?(controllers_dir)
22
- message = "Controllers directory not found at app/controllers."
23
- log(:warn, message)
24
-
25
- return message
26
- end
27
-
28
- # Get all controller files
29
- controller_files = Dir.glob(File.join(controllers_dir, "**", "*_controller.rb"))
30
-
31
- if controller_files.empty?
32
- message = "No controllers found in the project."
33
- log(:warn, message)
34
-
35
- return message
36
- end
37
-
38
- # If a specific controller was requested, filter the files
39
- if controller_name
40
- # Normalize controller name (allow both 'users' and 'UsersController')
41
- controller_name = "#{controller_name.sub(/_?controller$/i, "").downcase}_controller.rb"
42
- controller_files = controller_files.select { |f| File.basename(f).downcase == controller_name }
43
-
44
- if controller_files.empty?
45
- message = "Controller '#{controller_name}' not found."
46
- log(:warn, message)
47
-
48
- return message
49
- end
50
- end
51
-
52
- # Parse controllers to extract actions
53
- controllers_data = {}
54
-
55
- controller_files.each do |file_path|
56
- file_content = File.read(file_path)
57
- controller_class = File.basename(file_path, ".rb").gsub(/_controller$/i, "").then { |s| camelize(s) } + "Controller"
58
-
59
- # Extract controller actions (methods that are not private/protected)
60
- actions = []
61
- action_matches = file_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten
62
-
63
- # Find where private/protected begins
64
- private_index = file_content =~ /^\s*(private|protected)/
65
-
66
- if private_index
67
- # Get the actions defined before private/protected
68
- private_content = file_content[private_index..-1]
69
- private_methods = private_content.scan(/def\s+([a-zA-Z0-9_]+)/).flatten
70
- actions = action_matches - private_methods
71
- else
72
- actions = action_matches
73
- end
74
-
75
- # Remove Rails controller lifecycle methods
76
- lifecycle_methods = %w[initialize action_name controller_name params response]
77
- actions -= lifecycle_methods
78
-
79
- # Get routes mapped to this controller
80
- routes_output = RailsMcpServer::RunProcess.execute_rails_command(
81
- active_project_path,
82
- "bin/rails routes -c #{controller_class}"
83
- )
84
-
85
- routes = {}
86
- if routes_output && !routes_output.empty?
87
- routes_output.split("\n").each do |line|
88
- next if line.include?("(erb):") || line.include?("Prefix") || line.strip.empty?
89
- parts = line.strip.split(/\s+/)
90
- if parts.size >= 4
91
- # Get action name from the rails routes output
92
- action = parts[1].to_s.strip.downcase
93
- if actions.include?(action)
94
- verb = parts[0].to_s.strip
95
- path = parts[2].to_s.strip
96
- routes[action] = {verb: verb, path: path}
97
- end
98
- end
99
- end
100
- end
101
-
102
- # Find views for each action
103
- views_dir = File.join(active_project_path, "app", "views", File.basename(file_path, "_controller.rb"))
104
- views = {}
105
-
106
- if File.directory?(views_dir)
107
- actions.each do |action|
108
- # Look for view templates with various extensions
109
- view_files = Dir.glob(File.join(views_dir, "#{action}.*"))
110
- if view_files.any?
111
- views[action] = {
112
- templates: view_files.map { |f| f.sub("#{active_project_path}/", "") },
113
- partials: []
114
- }
115
-
116
- # Look for partials used in this template
117
- view_files.each do |view_file|
118
- if File.file?(view_file)
119
- view_content = File.read(view_file)
120
- # Find render calls with partials
121
- partial_matches = view_content.scan(/render\s+(?:partial:|:partial\s+=>\s+|:partial\s*=>|partial:)\s*["']([^"']+)["']/).flatten
122
- views[action][:partials] += partial_matches if partial_matches.any?
123
-
124
- # Find instance variables used in the view
125
- instance_vars = view_content.scan(/@([a-zA-Z0-9_]+)/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
126
- views[action][:instance_variables] = instance_vars if instance_vars.any?
127
-
128
- # Look for Stimulus controllers
129
- stimulus_controllers = view_content.scan(/data-controller="([^"]+)"/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
130
- views[action][:stimulus_controllers] = stimulus_controllers if stimulus_controllers.any?
131
- end
132
- end
133
- end
134
- end
135
- end
136
-
137
- # Extract instance variables set in the controller action
138
- instance_vars_in_controller = {}
139
- actions.each do |action|
140
- # Find the action method in the controller
141
- action_match = file_content.match(/def\s+#{action}\b(.*?)(?:(?:def|private|protected|public)\b|\z)/m)
142
- if action_match && action_match[1]
143
- action_body = action_match[1]
144
- # Find instance variable assignments
145
- vars = action_body.scan(/@([a-zA-Z0-9_]+)\s*=/).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
146
- instance_vars_in_controller[action] = vars if vars.any?
147
- end
148
- end
149
-
150
- controllers_data[controller_class] = {
151
- file: file_path.sub("#{active_project_path}/", ""),
152
- actions: actions,
153
- routes: routes,
154
- views: views,
155
- instance_variables: instance_vars_in_controller
156
- }
157
- rescue => e
158
- log(:error, "Error parsing controller #{file_path}: #{e.message}")
159
- end
160
-
161
- # Format the output
162
- output = []
163
-
164
- controllers_data.each do |controller, data|
165
- output << "Controller: #{controller}"
166
- output << " File: #{data[:file]}"
167
- output << " Actions: #{data[:actions].size}"
168
-
169
- data[:actions].each do |action|
170
- output << " Action: #{action}"
171
-
172
- # Show route if available
173
- if data[:routes] && data[:routes][action]
174
- route = data[:routes][action]
175
- output << " Route: [#{route[:verb]}] #{route[:path]}"
176
- else
177
- output << " Route: Not mapped to a route"
178
- end
179
-
180
- # Show view templates if available
181
- if data[:views] && data[:views][action]
182
- view_data = data[:views][action]
183
-
184
- output << " View Templates:"
185
- view_data[:templates].each do |template|
186
- output << " - #{template}"
187
- end
188
-
189
- # Show partials
190
- if view_data[:partials]&.any?
191
- output << " Partials Used:"
192
- view_data[:partials].uniq.each do |partial|
193
- output << " - #{partial}"
194
- end
195
- end
196
-
197
- # Show Stimulus controllers
198
- if view_data[:stimulus_controllers]&.any?
199
- output << " Stimulus Controllers:"
200
- view_data[:stimulus_controllers].each do |controller|
201
- output << " - #{controller}"
202
- end
203
- end
204
-
205
- # Show instance variables used in views
206
- if view_data[:instance_variables]&.any?
207
- output << " Instance Variables Used in View:"
208
- view_data[:instance_variables].sort.each do |var|
209
- output << " - @#{var}"
210
- end
211
- end
212
- else
213
- output << " View: No view template found"
214
- end
215
-
216
- # Show instance variables set in controller
217
- if data[:instance_variables] && data[:instance_variables][action]
218
- output << " Instance Variables Set in Controller:"
219
- data[:instance_variables][action].sort.each do |var|
220
- output << " - @#{var}"
221
- end
222
- end
223
-
224
- output << ""
225
- end
226
-
227
- output << "-------------------------"
228
- end
229
-
230
- output.join("\n")
231
- end
232
-
233
- private
234
-
235
- def camelize(string)
236
- string.split("_").map(&:capitalize).join
237
- end
238
- end
239
- end