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,182 @@
|
|
1
|
+
module RailsMcpServer
|
2
|
+
module Extensions
|
3
|
+
# Extension module to add URI templating capabilities to FastMcp::Resource
|
4
|
+
# Uses module prepending for clean method override behavior
|
5
|
+
module ResourceTemplating
|
6
|
+
# Class methods to be prepended to the singleton class
|
7
|
+
module ClassMethods
|
8
|
+
attr_reader :template_params
|
9
|
+
|
10
|
+
def variabilized_uri(params = {})
|
11
|
+
addressable_template.partial_expand(params).pattern
|
12
|
+
end
|
13
|
+
|
14
|
+
def addressable_template
|
15
|
+
@addressable_template ||= Addressable::Template.new(uri)
|
16
|
+
end
|
17
|
+
|
18
|
+
def template_variables
|
19
|
+
addressable_template.variables
|
20
|
+
end
|
21
|
+
|
22
|
+
def templated?
|
23
|
+
template_variables.any?
|
24
|
+
end
|
25
|
+
|
26
|
+
def non_templated?
|
27
|
+
!templated?
|
28
|
+
end
|
29
|
+
|
30
|
+
def match(uri)
|
31
|
+
addressable_template.match(uri)
|
32
|
+
end
|
33
|
+
|
34
|
+
def initialize_from_uri(uri)
|
35
|
+
new(params_from_uri(uri))
|
36
|
+
end
|
37
|
+
|
38
|
+
def params_from_uri(uri)
|
39
|
+
match(uri).mapping.transform_keys(&:to_sym)
|
40
|
+
end
|
41
|
+
|
42
|
+
def instance(uri = self.uri)
|
43
|
+
@instances ||= {}
|
44
|
+
@instances[uri] ||= begin
|
45
|
+
resource_class = Class.new(self)
|
46
|
+
params = params_from_uri(uri)
|
47
|
+
resource_class.instance_variable_set(:@params, params)
|
48
|
+
|
49
|
+
resource_class.define_singleton_method(:instance) do
|
50
|
+
@instance ||= begin
|
51
|
+
instance = new
|
52
|
+
instance.instance_variable_set(:@params, params)
|
53
|
+
instance
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
resource_class.instance
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def params
|
62
|
+
@params || {}
|
63
|
+
end
|
64
|
+
|
65
|
+
def name
|
66
|
+
return resource_name if resource_name
|
67
|
+
super
|
68
|
+
end
|
69
|
+
|
70
|
+
def metadata
|
71
|
+
if templated?
|
72
|
+
{
|
73
|
+
uriTemplate: uri,
|
74
|
+
name: resource_name,
|
75
|
+
description: description,
|
76
|
+
mimeType: mime_type
|
77
|
+
}.compact
|
78
|
+
else
|
79
|
+
super
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# Instance methods to be prepended
|
85
|
+
module InstanceMethods
|
86
|
+
def initialize
|
87
|
+
@params = self.class.params
|
88
|
+
super if defined?(super)
|
89
|
+
end
|
90
|
+
|
91
|
+
def params
|
92
|
+
@params || self.class.params
|
93
|
+
end
|
94
|
+
|
95
|
+
def name
|
96
|
+
self.class.resource_name
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Called when this module is prepended to a class
|
101
|
+
def self.prepended(base)
|
102
|
+
base.singleton_class.prepend(ClassMethods)
|
103
|
+
base.prepend(InstanceMethods)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Main setup class for resource extensions
|
108
|
+
class ResourceExtensionSetup
|
109
|
+
class << self
|
110
|
+
def setup!
|
111
|
+
return if @setup_complete
|
112
|
+
|
113
|
+
ensure_dependencies_loaded!
|
114
|
+
apply_extensions!
|
115
|
+
|
116
|
+
@setup_complete = true
|
117
|
+
RailsMcpServer.log(:info, "FastMcp::Resource extensions loaded successfully")
|
118
|
+
rescue => e
|
119
|
+
RailsMcpServer.log(:error, "Failed to setup resource extensions: #{e.message}")
|
120
|
+
raise
|
121
|
+
end
|
122
|
+
|
123
|
+
def reset!
|
124
|
+
@setup_complete = false
|
125
|
+
end
|
126
|
+
|
127
|
+
def setup_complete?
|
128
|
+
@setup_complete || false
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
def ensure_dependencies_loaded!
|
134
|
+
# Check that FastMcp::Resource exists
|
135
|
+
unless defined?(FastMcp::Resource)
|
136
|
+
begin
|
137
|
+
require "fast-mcp"
|
138
|
+
rescue LoadError => e
|
139
|
+
raise LoadError, "fast-mcp gem is required but not available. Ensure it's in your Gemfile: #{e.message}"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# Verify the expected interface exists
|
144
|
+
unless FastMcp::Resource.respond_to?(:uri)
|
145
|
+
raise "FastMcp::Resource doesn't have expected interface. Check fast-mcp gem version."
|
146
|
+
end
|
147
|
+
|
148
|
+
# Load addressable template dependency
|
149
|
+
begin
|
150
|
+
require "addressable/template"
|
151
|
+
rescue LoadError => e
|
152
|
+
raise LoadError, "addressable gem is required for URI templating: #{e.message}"
|
153
|
+
end
|
154
|
+
|
155
|
+
# Optional: Version checking
|
156
|
+
if defined?(FastMcp::VERSION)
|
157
|
+
version = Gem::Version.new(FastMcp::VERSION)
|
158
|
+
minimum_version = Gem::Version.new("1.4.0")
|
159
|
+
|
160
|
+
if version < minimum_version
|
161
|
+
RailsMcpServer.log(:warn, "FastMcp version #{FastMcp::VERSION} detected. Extensions tested with #{minimum_version}+")
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def apply_extensions!
|
167
|
+
# Apply extensions to FastMcp::Resource
|
168
|
+
FastMcp::Resource.prepend(ResourceTemplating)
|
169
|
+
|
170
|
+
# Also ensure our BaseResource gets the extensions
|
171
|
+
if defined?(RailsMcpServer::BaseResource)
|
172
|
+
# BaseResource already inherits from FastMcp::Resource, so it gets extensions automatically
|
173
|
+
RailsMcpServer.log(:debug, "BaseResource will inherit templating extensions")
|
174
|
+
end
|
175
|
+
|
176
|
+
# Setup server extensions as well
|
177
|
+
RailsMcpServer::Extensions::ServerExtensionSetup.setup!
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
@@ -0,0 +1,333 @@
|
|
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
|
@@ -0,0 +1,143 @@
|
|
1
|
+
require "fileutils"
|
2
|
+
require "digest"
|
3
|
+
require "yaml"
|
4
|
+
|
5
|
+
module RailsMcpServer
|
6
|
+
class ResourceBase
|
7
|
+
attr_reader :resource_name, :config_dir, :resource_folder, :manifest_file
|
8
|
+
|
9
|
+
def initialize(resource_name, config_dir:, force: false, verbose: false)
|
10
|
+
@resource_name = resource_name.to_s
|
11
|
+
@config_dir = config_dir
|
12
|
+
@force = force
|
13
|
+
@verbose = verbose
|
14
|
+
setup_paths
|
15
|
+
end
|
16
|
+
|
17
|
+
protected
|
18
|
+
|
19
|
+
def setup_paths
|
20
|
+
@resource_folder = File.join(@config_dir, "resources", @resource_name)
|
21
|
+
@manifest_file = File.join(@resource_folder, "manifest.yaml")
|
22
|
+
end
|
23
|
+
|
24
|
+
def setup_directories
|
25
|
+
FileUtils.mkdir_p(@resource_folder)
|
26
|
+
end
|
27
|
+
|
28
|
+
def load_manifest
|
29
|
+
@manifest = if File.exist?(@manifest_file)
|
30
|
+
YAML.load_file(@manifest_file)
|
31
|
+
else
|
32
|
+
create_manifest
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def save_manifest
|
37
|
+
@manifest["updated_at"] = Time.now.to_s
|
38
|
+
File.write(@manifest_file, @manifest.to_yaml)
|
39
|
+
end
|
40
|
+
|
41
|
+
def file_unchanged?(filename, file_path)
|
42
|
+
return false unless File.exist?(file_path)
|
43
|
+
current_hash = file_hash(file_path)
|
44
|
+
@manifest["files"][filename] && @manifest["files"][filename]["hash"] == current_hash
|
45
|
+
end
|
46
|
+
|
47
|
+
def save_file_to_manifest(filename, file_path, additional_data = {})
|
48
|
+
metadata = extract_metadata(File.read(file_path), filename)
|
49
|
+
|
50
|
+
@manifest["files"][filename] = {
|
51
|
+
"hash" => file_hash(file_path),
|
52
|
+
"size" => File.size(file_path)
|
53
|
+
}.merge(timestamp_key => Time.now.to_s)
|
54
|
+
.merge(additional_data)
|
55
|
+
.merge(metadata)
|
56
|
+
end
|
57
|
+
|
58
|
+
def extract_metadata(content, filename = nil)
|
59
|
+
metadata = {}
|
60
|
+
|
61
|
+
title = find_title(content) || (filename ? humanize_filename(filename) : nil)
|
62
|
+
metadata["title"] = title if title
|
63
|
+
|
64
|
+
description = find_description(content)
|
65
|
+
metadata["description"] = description if description && !description.empty?
|
66
|
+
|
67
|
+
metadata
|
68
|
+
end
|
69
|
+
|
70
|
+
def find_title(content)
|
71
|
+
lines = content.lines
|
72
|
+
|
73
|
+
# H1 header
|
74
|
+
lines.each do |line|
|
75
|
+
return $1.strip if line.strip =~ /^#\s+(.+)$/
|
76
|
+
end
|
77
|
+
|
78
|
+
# Underlined title
|
79
|
+
lines.each_with_index do |line, index|
|
80
|
+
next if index >= lines.length - 1
|
81
|
+
return line.strip if /^=+$/.match?(lines[index + 1].strip)
|
82
|
+
end
|
83
|
+
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
|
87
|
+
def find_description(content)
|
88
|
+
# Clean content
|
89
|
+
clean = content.dup
|
90
|
+
clean = clean.sub(/^---\s*\n.*?\n---\s*\n/m, "") # Remove YAML frontmatter
|
91
|
+
clean = clean.gsub(/^#\s+.*?\n/, "") # Remove H1 headers
|
92
|
+
clean = clean.gsub(/^.+\n=+\s*\n/, "") # Remove underlined titles
|
93
|
+
clean = clean.strip.gsub(/\n+/, " ").gsub(/\s+/, " ")
|
94
|
+
|
95
|
+
return "" if clean.empty?
|
96
|
+
|
97
|
+
if clean.length > 200
|
98
|
+
truncate_at = clean.rindex(" ", 200) || 200
|
99
|
+
clean[0...truncate_at] + "..."
|
100
|
+
else
|
101
|
+
clean
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def humanize_filename(filename)
|
106
|
+
base = File.basename(filename, File.extname(filename))
|
107
|
+
|
108
|
+
title = base.gsub(/[_-]/, " ")
|
109
|
+
.gsub(/^\d+[.\-_\s]*/, "")
|
110
|
+
.split(" ").map(&:capitalize).join(" ")
|
111
|
+
|
112
|
+
# Common abbreviations
|
113
|
+
replacements = {
|
114
|
+
/\bApi\b/ => "API", /\bHtml\b/ => "HTML", /\bCss\b/ => "CSS",
|
115
|
+
/\bJs\b/ => "JavaScript", /\bUi\b/ => "UI", /\bUrl\b/ => "URL",
|
116
|
+
/\bRest\b/ => "REST", /\bJson\b/ => "JSON", /\bXml\b/ => "XML",
|
117
|
+
/\bSql\b/ => "SQL"
|
118
|
+
}
|
119
|
+
|
120
|
+
replacements.each { |pattern, replacement| title = title.gsub(pattern, replacement) }
|
121
|
+
|
122
|
+
title.strip.empty? ? "Untitled Guide" : title
|
123
|
+
end
|
124
|
+
|
125
|
+
def file_hash(file_path)
|
126
|
+
Digest::SHA256.file(file_path).hexdigest
|
127
|
+
end
|
128
|
+
|
129
|
+
def log(message, newline: true)
|
130
|
+
return unless @verbose
|
131
|
+
newline ? puts(message) : print(message)
|
132
|
+
end
|
133
|
+
|
134
|
+
# Abstract methods to be implemented by subclasses
|
135
|
+
def create_manifest
|
136
|
+
raise NotImplementedError
|
137
|
+
end
|
138
|
+
|
139
|
+
def timestamp_key
|
140
|
+
raise NotImplementedError
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|