rails-mcp-server 1.1.4 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +216 -0
- data/README.md +156 -46
- data/config/resources.yml +203 -0
- data/docs/RESOURCES.md +339 -0
- data/exe/rails-mcp-server +8 -5
- data/exe/rails-mcp-server-download-resources +120 -0
- data/lib/rails-mcp-server/config.rb +7 -1
- data/lib/rails-mcp-server/extensions/resource_templating.rb +182 -0
- data/lib/rails-mcp-server/extensions/server_templating.rb +333 -0
- data/lib/rails-mcp-server/helpers/resource_base.rb +143 -0
- data/lib/rails-mcp-server/helpers/resource_downloader.rb +104 -0
- data/lib/rails-mcp-server/helpers/resource_importer.rb +113 -0
- data/lib/rails-mcp-server/resources/base_resource.rb +7 -0
- data/lib/rails-mcp-server/resources/custom_guides_resource.rb +54 -0
- data/lib/rails-mcp-server/resources/custom_guides_resources.rb +37 -0
- data/lib/rails-mcp-server/resources/guide_content_formatter.rb +130 -0
- data/lib/rails-mcp-server/resources/guide_error_handler.rb +85 -0
- data/lib/rails-mcp-server/resources/guide_file_finder.rb +100 -0
- data/lib/rails-mcp-server/resources/guide_framework_contract.rb +65 -0
- data/lib/rails-mcp-server/resources/guide_loader_template.rb +122 -0
- data/lib/rails-mcp-server/resources/guide_manifest_operations.rb +52 -0
- data/lib/rails-mcp-server/resources/kamal_guides_resource.rb +80 -0
- data/lib/rails-mcp-server/resources/kamal_guides_resources.rb +110 -0
- data/lib/rails-mcp-server/resources/rails_guides_resource.rb +29 -0
- data/lib/rails-mcp-server/resources/rails_guides_resources.rb +37 -0
- data/lib/rails-mcp-server/resources/stimulus_guides_resource.rb +29 -0
- data/lib/rails-mcp-server/resources/stimulus_guides_resources.rb +37 -0
- data/lib/rails-mcp-server/resources/turbo_guides_resource.rb +29 -0
- data/lib/rails-mcp-server/resources/turbo_guides_resources.rb +37 -0
- data/lib/rails-mcp-server/tools/analyze_models.rb +1 -1
- data/lib/rails-mcp-server/tools/load_guide.rb +370 -0
- data/lib/rails-mcp-server/version.rb +1 -1
- data/lib/rails_mcp_server.rb +51 -283
- metadata +49 -6
@@ -0,0 +1,104 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "uri"
|
3
|
+
require_relative "resource_base"
|
4
|
+
|
5
|
+
module RailsMcpServer
|
6
|
+
class ResourceDownloader < ResourceBase
|
7
|
+
class DownloadError < StandardError; end
|
8
|
+
|
9
|
+
def initialize(resource_name, config_dir:, force: false, verbose: false)
|
10
|
+
super
|
11
|
+
load_config
|
12
|
+
end
|
13
|
+
|
14
|
+
def download
|
15
|
+
setup_directories
|
16
|
+
load_manifest
|
17
|
+
|
18
|
+
log "Downloading #{@resource_name} resources..."
|
19
|
+
|
20
|
+
results = {downloaded: 0, skipped: 0, failed: 0}
|
21
|
+
|
22
|
+
@config["files"].each do |file|
|
23
|
+
result = download_file(file)
|
24
|
+
results[result] += 1
|
25
|
+
end
|
26
|
+
|
27
|
+
save_manifest
|
28
|
+
results
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.available_resources(config_dir)
|
32
|
+
config_file = File.join(File.dirname(__FILE__), "..", "..", "..", "config", "resources.yml")
|
33
|
+
return [] unless File.exist?(config_file)
|
34
|
+
|
35
|
+
YAML.load_file(config_file).keys
|
36
|
+
rescue => e
|
37
|
+
warn "Failed to load resource configuration: #{e.message}"
|
38
|
+
[]
|
39
|
+
end
|
40
|
+
|
41
|
+
protected
|
42
|
+
|
43
|
+
def create_manifest
|
44
|
+
{
|
45
|
+
"resource" => @resource_name,
|
46
|
+
"base_url" => @config["base_url"],
|
47
|
+
"description" => @config["description"],
|
48
|
+
"version" => @config["version"],
|
49
|
+
"files" => {},
|
50
|
+
"created_at" => Time.now.to_s,
|
51
|
+
"updated_at" => Time.now.to_s
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
def timestamp_key
|
56
|
+
"downloaded_at"
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def load_config
|
62
|
+
config_file = File.join(File.dirname(__FILE__), "..", "..", "..", "config", "resources.yml")
|
63
|
+
|
64
|
+
raise DownloadError, "Resource configuration file not found" unless File.exist?(config_file)
|
65
|
+
|
66
|
+
all_configs = YAML.load_file(config_file)
|
67
|
+
@config = all_configs[@resource_name]
|
68
|
+
|
69
|
+
raise DownloadError, "Unknown resource: #{@resource_name}" unless @config
|
70
|
+
end
|
71
|
+
|
72
|
+
def download_file(filename)
|
73
|
+
file_path = File.join(@resource_folder, filename)
|
74
|
+
url = "#{@config["base_url"]}/#{filename}"
|
75
|
+
|
76
|
+
# Skip if unchanged
|
77
|
+
if !@force && file_unchanged?(filename, file_path)
|
78
|
+
log "Skipping #{filename} (unchanged)"
|
79
|
+
return :skipped
|
80
|
+
end
|
81
|
+
|
82
|
+
log "Downloading #{filename}... ", newline: false
|
83
|
+
|
84
|
+
begin
|
85
|
+
uri = URI(url)
|
86
|
+
response = Net::HTTP.get_response(uri)
|
87
|
+
|
88
|
+
if response.code == "200"
|
89
|
+
FileUtils.mkdir_p(File.dirname(file_path))
|
90
|
+
File.write(file_path, response.body)
|
91
|
+
save_file_to_manifest(filename, file_path)
|
92
|
+
log "done"
|
93
|
+
:downloaded
|
94
|
+
else
|
95
|
+
log "failed (HTTP #{response.code})"
|
96
|
+
:failed
|
97
|
+
end
|
98
|
+
rescue => e
|
99
|
+
log "failed (#{e.message})"
|
100
|
+
:failed
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
require_relative "resource_base"
|
2
|
+
|
3
|
+
module RailsMcpServer
|
4
|
+
class ResourceImporter < ResourceBase
|
5
|
+
class ImportError < StandardError; end
|
6
|
+
|
7
|
+
def initialize(resource_name, config_dir:, source_path:, force: false, verbose: false)
|
8
|
+
@source_path = source_path
|
9
|
+
super(resource_name, config_dir: config_dir, force: force, verbose: verbose)
|
10
|
+
validate_source
|
11
|
+
end
|
12
|
+
|
13
|
+
def import
|
14
|
+
setup_directories
|
15
|
+
load_manifest
|
16
|
+
|
17
|
+
log "Importing custom files from #{@source_path}..."
|
18
|
+
|
19
|
+
results = {imported: 0, skipped: 0, failed: 0}
|
20
|
+
files = collect_files
|
21
|
+
|
22
|
+
if files.empty?
|
23
|
+
log "No markdown files found"
|
24
|
+
save_manifest
|
25
|
+
return results
|
26
|
+
end
|
27
|
+
|
28
|
+
files.each do |file_path|
|
29
|
+
result = import_file(file_path)
|
30
|
+
results[result] += 1
|
31
|
+
end
|
32
|
+
|
33
|
+
save_manifest
|
34
|
+
results
|
35
|
+
end
|
36
|
+
|
37
|
+
protected
|
38
|
+
|
39
|
+
def create_manifest
|
40
|
+
{
|
41
|
+
"resource" => @resource_name,
|
42
|
+
"base_url" => "local",
|
43
|
+
"description" => "Custom imported documentation",
|
44
|
+
"files" => {},
|
45
|
+
"created_at" => Time.now.to_s,
|
46
|
+
"updated_at" => Time.now.to_s
|
47
|
+
}
|
48
|
+
end
|
49
|
+
|
50
|
+
def timestamp_key
|
51
|
+
"imported_at"
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def validate_source
|
57
|
+
raise ImportError, "Source not found: #{@source_path}" unless File.exist?(@source_path)
|
58
|
+
raise ImportError, "Source not readable: #{@source_path}" unless File.readable?(@source_path)
|
59
|
+
end
|
60
|
+
|
61
|
+
def collect_files
|
62
|
+
if File.file?(@source_path)
|
63
|
+
markdown?(@source_path) ? [@source_path] : []
|
64
|
+
elsif File.directory?(@source_path)
|
65
|
+
Dir.glob(File.join(@source_path, "*.md")).select { |f| File.file?(f) }.sort # rubocop:disable Performance/ChainArrayAllocation
|
66
|
+
else
|
67
|
+
[]
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def markdown?(file_path)
|
72
|
+
File.extname(file_path).casecmp(".md").zero?
|
73
|
+
end
|
74
|
+
|
75
|
+
def import_file(file_path)
|
76
|
+
original_filename = File.basename(file_path)
|
77
|
+
normalized_filename = normalize_filename(original_filename)
|
78
|
+
destination_path = File.join(@resource_folder, normalized_filename)
|
79
|
+
|
80
|
+
# Skip if unchanged
|
81
|
+
if !@force && file_unchanged?(normalized_filename, file_path)
|
82
|
+
log "Skipping #{original_filename} (unchanged)"
|
83
|
+
return :skipped
|
84
|
+
end
|
85
|
+
|
86
|
+
log "Importing #{original_filename} -> #{normalized_filename}... ", newline: false
|
87
|
+
|
88
|
+
begin
|
89
|
+
FileUtils.cp(file_path, destination_path)
|
90
|
+
save_file_to_manifest(normalized_filename, destination_path,
|
91
|
+
{"original_filename" => original_filename})
|
92
|
+
log "done"
|
93
|
+
:imported
|
94
|
+
rescue => e
|
95
|
+
log "failed (#{e.message})"
|
96
|
+
:failed
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def normalize_filename(filename)
|
101
|
+
basename = File.basename(filename, ".md")
|
102
|
+
extension = File.extname(filename)
|
103
|
+
|
104
|
+
normalized = basename.downcase
|
105
|
+
.gsub(/[^a-z0-9_\-.]/, "_")
|
106
|
+
.squeeze("_")
|
107
|
+
.gsub(/^_+|_+$/, "")
|
108
|
+
|
109
|
+
normalized = "untitled" if normalized.empty?
|
110
|
+
"#{normalized}#{extension}"
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class CustomGuidesResource < BaseResource
|
3
|
+
include GuideLoaderTemplate
|
4
|
+
|
5
|
+
uri "custom://guides/{guide_name}"
|
6
|
+
resource_name "Custom Guides"
|
7
|
+
description "Access to specific custom imported documentation"
|
8
|
+
mime_type "text/markdown"
|
9
|
+
|
10
|
+
protected
|
11
|
+
|
12
|
+
def framework_name
|
13
|
+
"Custom"
|
14
|
+
end
|
15
|
+
|
16
|
+
def resource_directory
|
17
|
+
"custom"
|
18
|
+
end
|
19
|
+
|
20
|
+
def download_command
|
21
|
+
"rails-mcp-server-download-resources --file /path/to/files"
|
22
|
+
end
|
23
|
+
|
24
|
+
# Custom guides don't use handbook/reference sections (flat structure like Rails)
|
25
|
+
def supports_sections?
|
26
|
+
false
|
27
|
+
end
|
28
|
+
|
29
|
+
# Custom display name to show original filename
|
30
|
+
def customize_display_name(guide_name, guide_data)
|
31
|
+
guide_data["original_filename"] || guide_name
|
32
|
+
end
|
33
|
+
|
34
|
+
# Custom error message for imports
|
35
|
+
def customize_not_found_message(message, guide_name)
|
36
|
+
message + "\n**Note:** Make sure you've imported your custom guides with `#{download_command}`\n"
|
37
|
+
end
|
38
|
+
|
39
|
+
# Custom manifest error handling
|
40
|
+
def handle_manifest_error(error)
|
41
|
+
case error.message
|
42
|
+
when /No Custom guides found/
|
43
|
+
format_error_message(
|
44
|
+
"No custom guides found. Import guides with:\n" \
|
45
|
+
"`rails-mcp-server-download-resources --file /path/to/guide.md`\n" \
|
46
|
+
"or\n" \
|
47
|
+
"`rails-mcp-server-download-resources --file /path/to/guides/`"
|
48
|
+
)
|
49
|
+
else
|
50
|
+
super
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
class CustomGuidesResources < BaseResource
|
3
|
+
include GuideLoaderTemplate
|
4
|
+
|
5
|
+
uri "custom://guides"
|
6
|
+
resource_name "Custom Guides"
|
7
|
+
description "Access to available custom imported guides"
|
8
|
+
mime_type "text/markdown"
|
9
|
+
|
10
|
+
protected
|
11
|
+
|
12
|
+
def framework_name
|
13
|
+
"Custom"
|
14
|
+
end
|
15
|
+
|
16
|
+
def resource_directory
|
17
|
+
"custom"
|
18
|
+
end
|
19
|
+
|
20
|
+
def download_command
|
21
|
+
"rails-mcp-server-download-resources --file /path/to/files"
|
22
|
+
end
|
23
|
+
|
24
|
+
def example_guides
|
25
|
+
[
|
26
|
+
{guide: "api_documentation", comment: "Load API documentation"},
|
27
|
+
{guide: "setup_guide", comment: "Load setup instructions"},
|
28
|
+
{guide: "user_manual", comment: "Load user manual"}
|
29
|
+
]
|
30
|
+
end
|
31
|
+
|
32
|
+
# Custom guides don't use handbook/reference sections (flat structure like Rails)
|
33
|
+
def supports_sections?
|
34
|
+
false
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
# Module for formatting guide content and messages
|
3
|
+
module GuideContentFormatter
|
4
|
+
protected
|
5
|
+
|
6
|
+
# Format the guide content with appropriate headers
|
7
|
+
def format_guide_content(content, guide_name, guide_data, filename)
|
8
|
+
title = guide_data["title"] || generate_title_from_filename(filename)
|
9
|
+
|
10
|
+
header = if supports_sections?
|
11
|
+
section = determine_section(filename)
|
12
|
+
<<~HEADER
|
13
|
+
# #{title}
|
14
|
+
|
15
|
+
**Source:** #{framework_name} #{section}
|
16
|
+
**Guide:** #{guide_name}
|
17
|
+
**File:** #{filename}
|
18
|
+
|
19
|
+
---
|
20
|
+
|
21
|
+
HEADER
|
22
|
+
else
|
23
|
+
<<~HEADER
|
24
|
+
# #{title}
|
25
|
+
|
26
|
+
**Source:** #{framework_name} Guides
|
27
|
+
**Guide:** #{guide_name}
|
28
|
+
|
29
|
+
---
|
30
|
+
|
31
|
+
HEADER
|
32
|
+
end
|
33
|
+
|
34
|
+
header + content
|
35
|
+
end
|
36
|
+
|
37
|
+
# Format individual guide entry for listings
|
38
|
+
def format_guide_entry(title, short_name, full_name, description)
|
39
|
+
if supports_sections? && short_name != full_name
|
40
|
+
<<~GUIDE
|
41
|
+
### #{title}
|
42
|
+
**Guide name:** `#{short_name}` or `#{full_name}`
|
43
|
+
#{description.empty? ? "" : "**Description:** #{description}"}
|
44
|
+
GUIDE
|
45
|
+
else
|
46
|
+
<<~GUIDE
|
47
|
+
## #{title}
|
48
|
+
**Guide name:** `#{short_name}`
|
49
|
+
#{description.empty? ? "" : "**Description:** #{description}"}
|
50
|
+
GUIDE
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Format usage examples section
|
55
|
+
def format_usage_examples
|
56
|
+
examples = example_guides
|
57
|
+
|
58
|
+
usage = "\n## Example Usage:\n"
|
59
|
+
usage += "```\n"
|
60
|
+
|
61
|
+
examples.each do |example|
|
62
|
+
usage += "load_guide guides: \"#{framework_name.downcase}\", guide: \"#{example[:guide]}\"#{example[:comment] ? " # " + example[:comment] : ""}\n"
|
63
|
+
end
|
64
|
+
|
65
|
+
usage += "```\n"
|
66
|
+
usage
|
67
|
+
end
|
68
|
+
|
69
|
+
# Determine section from filename (handbook/reference/etc)
|
70
|
+
def determine_section(filename)
|
71
|
+
return "Handbook" if filename.start_with?("handbook/")
|
72
|
+
return "Reference" if filename.start_with?("reference/")
|
73
|
+
|
74
|
+
# Framework-specific section detection can be overridden
|
75
|
+
framework_specific_section(filename) if respond_to?(:framework_specific_section, true)
|
76
|
+
|
77
|
+
"Documentation"
|
78
|
+
end
|
79
|
+
|
80
|
+
# Format guides organized by sections (handbook/reference)
|
81
|
+
def format_sectioned_guides(guide_files)
|
82
|
+
sectioned = get_sectioned_guide_files(guide_files.keys.zip(guide_files.values).to_h)
|
83
|
+
guides = []
|
84
|
+
|
85
|
+
# Add handbook section
|
86
|
+
if sectioned[:handbook].any?
|
87
|
+
guides << "\n## Handbook (Main Documentation)\n"
|
88
|
+
sectioned[:handbook].each do |filename, file_data|
|
89
|
+
metadata = extract_guide_metadata(filename, file_data)
|
90
|
+
short_name = metadata[:guide_name].sub("handbook/", "")
|
91
|
+
guides << format_guide_entry(metadata[:title], short_name, metadata[:guide_name], metadata[:description])
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Add reference section
|
96
|
+
if sectioned[:reference].any?
|
97
|
+
guides << "\n## Reference (API Documentation)\n"
|
98
|
+
sectioned[:reference].each do |filename, file_data|
|
99
|
+
metadata = extract_guide_metadata(filename, file_data)
|
100
|
+
short_name = metadata[:guide_name].sub("reference/", "")
|
101
|
+
guides << format_guide_entry(metadata[:title], short_name, metadata[:guide_name], metadata[:description])
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
# Add other sections
|
106
|
+
if sectioned[:other].any?
|
107
|
+
guides << "\n## Other Guides\n"
|
108
|
+
sectioned[:other].each do |filename, file_data|
|
109
|
+
metadata = extract_guide_metadata(filename, file_data)
|
110
|
+
guides << format_guide_entry(metadata[:title], metadata[:guide_name], metadata[:guide_name], metadata[:description])
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
guides
|
115
|
+
end
|
116
|
+
|
117
|
+
# Format guides in a flat structure (no sections)
|
118
|
+
def format_flat_guides(guide_files)
|
119
|
+
guides = []
|
120
|
+
|
121
|
+
guide_files.each do |filename, file_data|
|
122
|
+
log(:debug, "Processing guide: #{filename}")
|
123
|
+
metadata = extract_guide_metadata(filename, file_data)
|
124
|
+
guides << format_guide_entry(metadata[:title], metadata[:guide_name], metadata[:guide_name], metadata[:description])
|
125
|
+
end
|
126
|
+
|
127
|
+
guides
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
# Module for handling guide-related errors and messages
|
3
|
+
module GuideErrorHandler
|
4
|
+
protected
|
5
|
+
|
6
|
+
# Format error messages consistently
|
7
|
+
def format_error_message(message)
|
8
|
+
"# Error\n\n#{message}"
|
9
|
+
end
|
10
|
+
|
11
|
+
# Create standardized not found message
|
12
|
+
def create_not_found_message(guide_name, available_guides)
|
13
|
+
normalized_guide_name = guide_name.gsub(/[^a-zA-Z0-9_\/.-]/, "").downcase
|
14
|
+
suggestions = find_suggestions(normalized_guide_name, available_guides)
|
15
|
+
|
16
|
+
message = "# Guide Not Found\n\n"
|
17
|
+
message += "Guide '#{guide_name}' not found in #{framework_name} guides.\n\n"
|
18
|
+
|
19
|
+
if suggestions.any?
|
20
|
+
message += "## Did you mean one of these?\n\n"
|
21
|
+
suggestions.each { |suggestion| message += "- #{suggestion}\n" }
|
22
|
+
message += "\n**Try:** `load_guide guides: \"#{framework_name.downcase}\", guide: \"#{suggestions.first}\"`\n"
|
23
|
+
else
|
24
|
+
message += format_available_guides_section(available_guides)
|
25
|
+
message += "Use `load_guide guides: \"#{framework_name.downcase}\"` to see all available guides with descriptions.\n"
|
26
|
+
end
|
27
|
+
|
28
|
+
message
|
29
|
+
end
|
30
|
+
|
31
|
+
# Format available guides section for error messages
|
32
|
+
def format_available_guides_section(available_guides)
|
33
|
+
return "\n" unless supports_sections?
|
34
|
+
|
35
|
+
handbook_guides = available_guides.select { |g| g.start_with?("handbook/") }
|
36
|
+
reference_guides = available_guides.select { |g| g.start_with?("reference/") }
|
37
|
+
|
38
|
+
message = "## Available #{framework_name} Guides:\n\n"
|
39
|
+
|
40
|
+
if handbook_guides.any?
|
41
|
+
message += "### Handbook:\n"
|
42
|
+
handbook_guides.each { |guide| message += "- #{guide.sub("handbook/", "")}\n" }
|
43
|
+
message += "\n"
|
44
|
+
end
|
45
|
+
|
46
|
+
if reference_guides.any?
|
47
|
+
message += "### Reference:\n"
|
48
|
+
reference_guides.each { |guide| message += "- #{guide.sub("reference/", "")}\n" }
|
49
|
+
message += "\n"
|
50
|
+
end
|
51
|
+
|
52
|
+
message
|
53
|
+
end
|
54
|
+
|
55
|
+
# Handle manifest loading errors with user-friendly messages
|
56
|
+
def handle_manifest_error(error)
|
57
|
+
case error.message
|
58
|
+
when /No .* guides found/
|
59
|
+
error.message
|
60
|
+
when /Permission denied/
|
61
|
+
format_error_message("Permission denied accessing guides. Check file permissions.")
|
62
|
+
when /No such file/
|
63
|
+
format_error_message("Guide files not found. Run '#{download_command}' to download guides.")
|
64
|
+
else
|
65
|
+
format_error_message("Error loading guides: #{error.message}")
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Handle guide loading errors
|
70
|
+
def handle_guide_loading_error(guide_name, error)
|
71
|
+
log(:error, "Error loading guide #{guide_name}: #{error.message}")
|
72
|
+
|
73
|
+
case error.message
|
74
|
+
when /Multiple guides found/
|
75
|
+
format_error_message(error.message)
|
76
|
+
when /Permission denied/
|
77
|
+
format_error_message("Permission denied reading guide '#{guide_name}'.")
|
78
|
+
when /No such file/
|
79
|
+
format_error_message("Guide file for '#{guide_name}' not found on disk.")
|
80
|
+
else
|
81
|
+
format_error_message("Error loading guide '#{guide_name}': #{error.message}")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
# Module for finding and matching guide files
|
3
|
+
module GuideFileFinder
|
4
|
+
protected
|
5
|
+
|
6
|
+
# Find guide file with exact and fuzzy matching
|
7
|
+
def find_guide_file(normalized_guide_name, manifest)
|
8
|
+
# Try exact matches first
|
9
|
+
possible_filenames = generate_possible_filenames(normalized_guide_name)
|
10
|
+
|
11
|
+
possible_filenames.each do |possible_filename|
|
12
|
+
if manifest["files"][possible_filename]
|
13
|
+
return [possible_filename, manifest["files"][possible_filename]]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# If not found, try fuzzy matching
|
18
|
+
matching_files = fuzzy_match_files(normalized_guide_name, manifest)
|
19
|
+
|
20
|
+
case matching_files.size
|
21
|
+
when 1
|
22
|
+
matching_files.first
|
23
|
+
when 0
|
24
|
+
[nil, nil]
|
25
|
+
else
|
26
|
+
matches = matching_files.map(&:first).map { |f| f.sub(".md", "") }.join(", ") # rubocop:disable Performance/ChainArrayAllocation
|
27
|
+
raise StandardError, "Multiple guides found matching '#{normalized_guide_name}': #{matches}. Please be more specific."
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Generate possible filename variations for exact matching
|
32
|
+
def generate_possible_filenames(normalized_guide_name)
|
33
|
+
possible_files = ["#{normalized_guide_name}.md"]
|
34
|
+
|
35
|
+
# Add section prefixes for frameworks that support them
|
36
|
+
if supports_sections?
|
37
|
+
possible_files += [
|
38
|
+
"handbook/#{normalized_guide_name}.md",
|
39
|
+
"reference/#{normalized_guide_name}.md"
|
40
|
+
]
|
41
|
+
end
|
42
|
+
|
43
|
+
# Framework-specific filename generation can be overridden
|
44
|
+
possible_files += framework_specific_filenames(normalized_guide_name) if respond_to?(:framework_specific_filenames, true)
|
45
|
+
|
46
|
+
possible_files.uniq
|
47
|
+
end
|
48
|
+
|
49
|
+
# Perform fuzzy matching on guide files
|
50
|
+
def fuzzy_match_files(normalized_guide_name, manifest)
|
51
|
+
search_terms = generate_search_terms(normalized_guide_name)
|
52
|
+
|
53
|
+
manifest["files"].select do |file, _|
|
54
|
+
next false unless file.end_with?(".md")
|
55
|
+
|
56
|
+
file_matches_any_search_term?(file, search_terms)
|
57
|
+
end.to_a
|
58
|
+
end
|
59
|
+
|
60
|
+
# Generate search terms for fuzzy matching
|
61
|
+
def generate_search_terms(normalized_guide_name)
|
62
|
+
base_term = normalized_guide_name.split("/").last.downcase
|
63
|
+
|
64
|
+
[
|
65
|
+
base_term,
|
66
|
+
base_term.gsub(/[_-]/, ""),
|
67
|
+
base_term.gsub(/[_-]/, "_"),
|
68
|
+
base_term.gsub(/[_-]/, "-")
|
69
|
+
].uniq
|
70
|
+
end
|
71
|
+
|
72
|
+
# Check if file matches any search term
|
73
|
+
def file_matches_any_search_term?(file, search_terms)
|
74
|
+
file_name_base = file.sub(".md", "").split("/").last.downcase
|
75
|
+
file_name_normalized = file_name_base.gsub(/[_-]/, "")
|
76
|
+
|
77
|
+
search_terms.any? do |term|
|
78
|
+
term_normalized = term.gsub(/[_-]/, "")
|
79
|
+
|
80
|
+
file_name_base.include?(term) ||
|
81
|
+
term.include?(file_name_base) ||
|
82
|
+
file_name_normalized.include?(term_normalized) ||
|
83
|
+
term_normalized.include?(file_name_normalized)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Generate suggestions for similar guide names
|
88
|
+
def find_suggestions(normalized_guide_name, available_guides)
|
89
|
+
search_base = normalized_guide_name.split("/").last.downcase
|
90
|
+
|
91
|
+
available_guides.select do |guide|
|
92
|
+
guide_base = guide.split("/").last.downcase
|
93
|
+
|
94
|
+
guide_base.include?(search_base) ||
|
95
|
+
search_base.include?(guide_base) ||
|
96
|
+
guide_base.gsub(/[_-]/, "").include?(search_base.gsub(/[_-]/, ""))
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
# Module defining the contract that guide resources must implement
|
3
|
+
module GuideFrameworkContract
|
4
|
+
def self.included(base)
|
5
|
+
base.extend(ClassMethods)
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
# Validate that required methods are implemented
|
10
|
+
def validate_contract!
|
11
|
+
required_methods = [:framework_name, :resource_directory, :download_command]
|
12
|
+
|
13
|
+
required_methods.each do |method|
|
14
|
+
unless method_defined?(method)
|
15
|
+
raise NotImplementedError, "#{self} must implement ##{method}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
# Abstract methods that must be implemented by including classes
|
24
|
+
def framework_name
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #framework_name"
|
26
|
+
end
|
27
|
+
|
28
|
+
def resource_directory
|
29
|
+
raise NotImplementedError, "#{self.class} must implement #resource_directory"
|
30
|
+
end
|
31
|
+
|
32
|
+
def download_command
|
33
|
+
raise NotImplementedError, "#{self.class} must implement #download_command"
|
34
|
+
end
|
35
|
+
|
36
|
+
# Optional methods with default implementations
|
37
|
+
def supports_sections?
|
38
|
+
false
|
39
|
+
end
|
40
|
+
|
41
|
+
# Optional method for list resources
|
42
|
+
def example_guides
|
43
|
+
[]
|
44
|
+
end
|
45
|
+
|
46
|
+
# Optional framework-specific methods (can be overridden)
|
47
|
+
def framework_specific_filenames(normalized_guide_name)
|
48
|
+
[]
|
49
|
+
end
|
50
|
+
|
51
|
+
def framework_specific_section(filename)
|
52
|
+
"Documentation"
|
53
|
+
end
|
54
|
+
|
55
|
+
# Utility method to check if this is a list resource
|
56
|
+
def list_resource?
|
57
|
+
respond_to?(:example_guides)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Utility method to check if this is a single guide resource
|
61
|
+
def single_guide_resource?
|
62
|
+
respond_to?(:params) && params.key?(:guide_name)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|