mcp-rails 0.4.11

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.
@@ -0,0 +1,39 @@
1
+ module MCP::Rails::ToolDescriptions
2
+ extend ActiveSupport::Concern
3
+
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+ base.class_eval do
7
+ # Initialize instance variables for each class
8
+ @action_descriptions = {}
9
+
10
+ # Define class-level accessors
11
+ def self.action_descriptions
12
+ @action_descriptions ||= {}
13
+ end
14
+
15
+ # Optionally, define setters if needed
16
+ def self.action_descriptions=(value)
17
+ @action_descriptions = value
18
+ end
19
+
20
+ # Ensure subclasses get their own fresh instances
21
+ def self.inherited(subclass)
22
+ super
23
+ subclass.instance_variable_set(:@action_descriptions, {})
24
+ end
25
+ end
26
+ end
27
+
28
+ class_methods do
29
+ # Define description for an action
30
+ def tool_description_for(action, description)
31
+ action_descriptions[action.to_sym] = description
32
+ end
33
+
34
+ # Get description for an action
35
+ def tool_description(action)
36
+ action_descriptions[action.to_sym]
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ require "securerandom"
2
+
3
+ module MCP
4
+ module Rails
5
+ class BypassKeyManager
6
+ class << self
7
+ def generate_key
8
+ SecureRandom.hex(32)
9
+ end
10
+
11
+ def save_key(key = nil)
12
+ key ||= generate_key
13
+ config = MCP::Rails.configuration
14
+ FileUtils.mkdir_p(File.dirname(config.bypass_key_path))
15
+ File.write(config.bypass_key_path, key)
16
+ key
17
+ end
18
+
19
+ def load_key
20
+ return nil unless File.exist?(MCP::Rails.configuration.bypass_key_path)
21
+ File.read(MCP::Rails.configuration.bypass_key_path).strip
22
+ end
23
+
24
+ def key
25
+ load_key || save_key
26
+ end
27
+
28
+ def create_new_key
29
+ save_key(generate_key)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,86 @@
1
+ require "pathname"
2
+
3
+ module MCP
4
+ module Rails
5
+ class Configuration
6
+ # Server configuration
7
+ attr_accessor :server_name, :server_version
8
+
9
+ # Output configuration
10
+ attr_writer :output_directory, :bypass_key_path
11
+
12
+ # Environment variables to include in tool calls
13
+ attr_accessor :env_vars
14
+
15
+ # Engine configurations
16
+ attr_reader :engine_configurations
17
+
18
+ def initialize
19
+ # Server defaults
20
+ @server_name = "mcp-server"
21
+ @server_version = "1.0.0"
22
+
23
+ # Output defaults
24
+ @output_directory = ::Rails.root.join("tmp", "mcp") if defined?(::Rails)
25
+ @bypass_key_path = ::Rails.root.join("tmp", "mcp", "bypass_key.txt") if defined?(::Rails)
26
+
27
+ # Environment variables to include in tool calls
28
+ @env_vars = []
29
+
30
+ # Initialize engine configurations hash
31
+ @engine_configurations = {}
32
+ end
33
+
34
+ def base_url
35
+ return @base_url if defined?(@base_url)
36
+
37
+ if defined?(::Rails)
38
+ mailer_options = ::Rails.application.config.action_mailer.default_url_options || {}
39
+ host = mailer_options[:host] || "localhost"
40
+ port = mailer_options[:port] || "3000"
41
+ protocol = mailer_options[:protocol] || "http"
42
+ "#{protocol}://#{host}:#{port}"
43
+ else
44
+ "http://localhost:3000"
45
+ end
46
+ end
47
+
48
+ def base_url=(url)
49
+ @base_url = url
50
+ end
51
+
52
+ def bypass_key_path
53
+ Pathname.new(@bypass_key_path || ::Rails.root.join("tmp", "mcp", "bypass_key.txt"))
54
+ end
55
+
56
+ def output_directory
57
+ Pathname.new(@output_directory || ::Rails.root.join("tmp", "mcp"))
58
+ end
59
+
60
+ # Register an engine's configuration
61
+ def register_engine(engine, settings = {})
62
+ engine_name = engine.respond_to?(:engine_name) ? engine.engine_name : engine
63
+ @engine_configurations[engine_name] = EngineConfiguration.new(settings)
64
+ end
65
+
66
+ # Get configuration for a specific engine
67
+ def for_engine(engine)
68
+ return self unless engine
69
+ engine_config = @engine_configurations[engine.engine_name]
70
+
71
+ dup.tap do |config|
72
+ config.server_name = engine.engine_name
73
+ config.instance_variable_set(:@env_vars, (self.env_vars + engine_config.env_vars).uniq)
74
+ end
75
+ end
76
+ end
77
+
78
+ class EngineConfiguration
79
+ attr_reader :env_vars
80
+
81
+ def initialize(settings = {})
82
+ @env_vars = Array(settings[:env_vars] || [])
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,49 @@
1
+ module Mcp
2
+ module Rails
3
+ class Railtie < ::Rails::Railtie
4
+ railtie_name "mcp-rails"
5
+ gem_root = Gem::Specification.find_by_name("mcp-rails").gem_dir
6
+
7
+ config.to_prepare do
8
+ require File.join(gem_root, "app/controllers/concerns/mcp/rails/parameters")
9
+ require File.join(gem_root, "app/controllers/concerns/mcp/rails/tool_descriptions")
10
+ require File.join(gem_root, "app/controllers/concerns/mcp/rails/error_handling")
11
+ require File.join(gem_root, "app/controllers/concerns/mcp/rails/renderer")
12
+ ActionController::Base.include(MCP::Rails::Parameters)
13
+ ActionController::Base.include(MCP::Rails::ToolDescriptions)
14
+ ActionController::Base.include(MCP::Rails::ErrorHandling)
15
+ ActionController::Base.include(MCP::Rails::Renderer)
16
+ end
17
+
18
+ initializer "mcp-rails.mime_type" do
19
+ Mime::Type.register "application/vnd.mcp+json", :mcp# , [ "application/json" ]
20
+ end
21
+
22
+ initializer "mcp-rails.renderer" do
23
+ ActionController::Renderers.add :mcp do |mcp_json, options|
24
+ self.content_type = Mime[:mcp] if media_type.nil?
25
+ mcp_json
26
+ end
27
+ end
28
+
29
+ initializer "mcp-rails.integration_test_request_encoding" do
30
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
31
+ # Support `as: :mcp`. Public `register_encoder` API is a little too strict.
32
+ class ActionDispatch::RequestEncoder
33
+ class McpEncoder < IdentityEncoder
34
+ header = [ Mime[:mcp], Mime[:json] ].join(",")
35
+ define_method(:accept_header) { header }
36
+ end
37
+
38
+ @encoders[:mcp] = McpEncoder.new
39
+ end
40
+ end
41
+ end
42
+
43
+ rake_tasks do
44
+ path = File.expand_path("#{gem_root}/tasks/mcp", __dir__)
45
+ Dir.glob("#{path}/*.rake").each { |f| load f }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,84 @@
1
+ module MCP
2
+ module Rails
3
+ class ServerGenerator::RouteCollector
4
+ def self.collect_routes(routes, prefix = "", engine = nil)
5
+ routes.map do |route|
6
+ app = route.app.app
7
+
8
+ if app.respond_to?(:routes) && app < ::Rails::Engine
9
+ new_prefix = [ prefix, route.path.spec.to_s ].join
10
+ collect_routes(app.app.routes.routes, new_prefix, app)
11
+ else
12
+ path = [ prefix, route.path.spec.to_s ].join
13
+ {
14
+ route: route,
15
+ path: path.present? ? path : route.path.spec.to_s,
16
+ engine: engine
17
+ }
18
+ end
19
+ end.flatten
20
+ end
21
+
22
+ def self.process_routes(routes)
23
+ candidate_routes = routes.select do |wrapped_route|
24
+ route = wrapped_route[:route]
25
+ action = route.defaults[:action]&.to_s
26
+ mcp = route.defaults[:mcp]
27
+ mcp == true || (mcp.is_a?(Array) && action&.in?(mcp.map(&:to_s)))
28
+ end
29
+
30
+ candidate_routes.map do |wrapped_route|
31
+ route = wrapped_route[:route]
32
+ next unless route.defaults[:controller] && route.defaults[:action]
33
+ next unless route.defaults[:action].to_s.in?(%w[create index show update destroy])
34
+ next if route.verb.downcase == "put"
35
+
36
+ begin
37
+ controller_class = "#{route.defaults[:controller].camelize}Controller".constantize
38
+ action = route.defaults[:action].to_sym
39
+ params_def = controller_class.permitted_params(action)
40
+ rescue NameError
41
+ ::Rails.logger.warn("Controller not found for route: #{route.defaults[:controller]}")
42
+ next
43
+ end
44
+
45
+ full_path = (wrapped_route[:path] || route.path.spec.to_s).sub(/\(\.:format\)$/, "") || ""
46
+ url_params = extract_url_params(full_path)
47
+ params_def += url_params unless params_def.any? { |p| url_params.map { |up| up[:name] }.include?(p[:name]) }
48
+
49
+ description = controller_class.tool_description(action) || "Handles #{action} for #{route.defaults[:controller]}"
50
+
51
+ {
52
+ tool_name: "#{action}_#{route.defaults[:controller].parameterize}",
53
+ description: escape_for_ruby_string(description),
54
+ method: route.verb.downcase.to_sym,
55
+ path: full_path,
56
+ url_parameters: url_params,
57
+ engine: wrapped_route[:engine],
58
+ accepted_parameters: params_def.map { |param| build_param_structure(param) }
59
+ }
60
+ end.compact
61
+ end
62
+
63
+ def self.build_param_structure(param)
64
+ structure = {
65
+ name: param[:name],
66
+ type: param[:type] || :string,
67
+ required: param[:required]
68
+ }
69
+ structure[:item_type] = param[:item_type] if param[:item_type] # Add item_type for scalar arrays
70
+ structure[:description] = escape_for_ruby_string(param[:example]) if param[:example]
71
+ structure[:nested] = param[:nested].map { |n| build_param_structure(n) } if param[:nested]
72
+ structure
73
+ end
74
+
75
+ def self.escape_for_ruby_string(str)
76
+ str.to_s.gsub(/[\\"]/) { |m| "\\#{m}" }
77
+ end
78
+
79
+ def self.extract_url_params(path)
80
+ path.scan(/:([a-zA-Z0-9_]+)/).flatten.map { |name| { name: name, type: "string", required: true } }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,242 @@
1
+ module MCP
2
+ module Rails
3
+ class ServerGenerator::ServerWriter
4
+ def self.write_server(routes_data, config, base_url, bypass_csrf_key, engine = nil)
5
+ # Get engine-specific configuration if available
6
+ config = config.for_engine(engine)
7
+
8
+ file_name = engine ? "#{config.server_name}_server.rb" : "server.rb"
9
+ file_path = File.join(config.output_directory.to_s, file_name)
10
+ FileUtils.mkdir_p(File.dirname(file_path))
11
+
12
+ File.open(file_path, "w") do |file|
13
+ file.puts ruby_invocation
14
+ file.puts
15
+ file.puts %(require "mcp")
16
+ file.puts %(require "httparty")
17
+ file.puts
18
+ file.puts helper_methods(base_url, bypass_csrf_key)
19
+ file.puts
20
+
21
+ file.puts %(name "#{config.server_name}")
22
+ file.puts %(version "#{config.server_version}")
23
+
24
+ routes_data.each do |route|
25
+ file.puts %(tool "#{route[:tool_name].underscore}" do)
26
+ file.puts " description \"#{route[:description].sub(/\//, ' ')}\""
27
+ route[:accepted_parameters].each do |param|
28
+ file.puts generate_parameter(param)
29
+ end
30
+ file.puts route_block(route, config).lines.map { |line| " #{line}" }.join
31
+ file.puts "end"
32
+ file.puts
33
+ end
34
+ end
35
+
36
+ current_mode = File.stat(file_path).mode
37
+ new_mode = current_mode | 0111 # Add execute (u+x, g+x, o+x)
38
+ File.chmod(new_mode, file_path)
39
+
40
+ file_path
41
+ end
42
+
43
+ def self.type_to_class(type)
44
+ case type
45
+ when :string then "String"
46
+ when :integer then "Integer"
47
+ when :number then "Float"
48
+ when :boolean then "TrueClass"
49
+ when :array then "Array"
50
+ else "String" # Default to String
51
+ end
52
+ end
53
+
54
+ def self.generate_parameter(param, indent_level = 1)
55
+ indent = " " * indent_level
56
+ name = param[:name].to_sym
57
+ required = param[:required] ? ", required: true" : ""
58
+ description = param[:description] ? ", description: \"#{param[:description]}\"" : ""
59
+
60
+ if param[:type] == :array
61
+ if param[:item_type]
62
+ # Scalar array: argument :name, Array, items: Type
63
+ type_str = "Array, items: #{type_to_class(param[:item_type])}"
64
+ "#{indent}argument :#{name}, #{type_str}#{required}#{description}"
65
+ elsif param[:nested]
66
+ # Array of objects: argument :name, Array do ... end
67
+ nested_params = param[:nested].map { |np| generate_parameter(np, indent_level + 1) }.join("\n")
68
+ "#{indent}argument :#{name}, Array#{required}#{description} do\n#{nested_params}\n#{indent}end"
69
+ else
70
+ raise "Array parameter must have either item_type or nested parameters"
71
+ end
72
+ elsif param[:type] == :object && param[:nested]
73
+ # Object: argument :name do ... end
74
+ nested_params = param[:nested].map { |np| generate_parameter(np, indent_level + 1) }.join("\n")
75
+ "#{indent}argument :#{name}#{required}#{description} do\n#{nested_params}\n#{indent}end"
76
+ else
77
+ # Scalar type: argument :name, Type
78
+ type_str = type_to_class(param[:type])
79
+ "#{indent}argument :#{name}, #{type_str}#{required}#{description}"
80
+ end
81
+ end
82
+
83
+ def self.ruby_invocation
84
+ <<~RUBY
85
+ #!/usr/bin/env ruby
86
+
87
+ # Find the nearest Gemfile by walking up the directory tree
88
+ def find_nearest_gemfile(start_dir)
89
+ current_dir = File.expand_path(start_dir)
90
+ loop do
91
+ gemfile = File.join(current_dir, "Gemfile")
92
+ return gemfile if File.exist?(gemfile)
93
+ parent_dir = File.dirname(current_dir)
94
+ break if parent_dir == current_dir # Reached root (e.g., "/")
95
+ current_dir = parent_dir
96
+ end
97
+ nil # No Gemfile found
98
+ end
99
+
100
+ # If not already running under bundle exec, re-execute with the nearest Gemfile
101
+ unless ENV["BUNDLE_GEMFILE"] # Check if already running under Bundler
102
+ gemfile = find_nearest_gemfile(__dir__) # __dir__ is the script's directory
103
+ if gemfile
104
+ ENV["BUNDLE_GEMFILE"] = gemfile
105
+ exec("bundle", "exec", "ruby", __FILE__, *ARGV) # Re-run with bundle exec
106
+ else
107
+ warn "Warning: No Gemfile found in any parent directory."
108
+ end
109
+ end
110
+ RUBY
111
+ end
112
+
113
+ def self.helper_methods(base_uri, bypass_csrf_key)
114
+ return test_helper_methods(base_uri, bypass_csrf_key) if ::Rails.env.test?
115
+ <<~RUBY
116
+ def transform_args(args)
117
+ if args.is_a?(Hash)
118
+ args.transform_keys { |key| key.to_s.gsub(/([a-z])([A-Z])/, '\\1_\\2').gsub(/([A-Z])([A-Z][a-z])/, '\\1_\\2').downcase }
119
+ .transform_values { |value| transform_args(value) }
120
+ else
121
+ args # Return non-hash values (e.g., strings, integers) unchanged
122
+ end
123
+ end
124
+
125
+ def parse_response(response)
126
+ response_body = JSON.parse(response.body)
127
+ case response_body
128
+ when Hash
129
+ if response_body["status"] == "error"
130
+ raise "From Rails Server: \#{response_body["message"]}"
131
+ else
132
+ response_body.dig("data").to_json
133
+ end
134
+ else
135
+ raise "None MCP response from Rails Server"
136
+ end
137
+ rescue => e
138
+ raise "Parsing JSON failed: \#{e.message}"
139
+ end
140
+
141
+ def get_resource(uri, arguments = {})
142
+ response = HTTParty.get("#{base_uri}\#{uri}", query: transform_args(arguments), headers: { "Accept" => "application/vnd.mcp+json, application/json" })
143
+ parse_response(response)
144
+ end
145
+
146
+ def post_resource(uri, payload = {})
147
+ headers = { "Accept" => "application/vnd.mcp+json, application/json" }
148
+ headers["X-Bypass-CSRF"] = "#{bypass_csrf_key}"
149
+ response = HTTParty.post("#{base_uri}\#{uri}", body: transform_args(payload), headers: headers)
150
+ parse_response(response)
151
+ end
152
+
153
+ def patch_resource(uri, payload = {})
154
+ headers = { "Accept" => "application/vnd.mcp+json, application/json" }
155
+ headers["X-Bypass-CSRF"] = "#{bypass_csrf_key}"
156
+ response = HTTParty.patch("#{base_uri}\#{uri}", body: transform_args(payload), headers: headers)
157
+ parse_response(response)
158
+ end
159
+
160
+ def delete_resource(uri, payload = {})
161
+ headers = { "Accept" => "application/vnd.mcp+json, application/json" }
162
+ headers["X-Bypass-CSRF"] = "#{bypass_csrf_key}"
163
+ response = HTTParty.delete("#{base_uri}\#{uri}", body: transform_args(payload), headers: headers)
164
+ parse_response(response)
165
+ end
166
+ RUBY
167
+ end
168
+
169
+ def self.route_block(route, config)
170
+ uri = route[:path]
171
+ route[:url_parameters].each do |url_parameter|
172
+ uri = uri.gsub(":#{url_parameter[:name]}", "\#{args[:#{url_parameter[:name]}]}")
173
+ end
174
+
175
+ env_vars = config.env_vars.map do |var|
176
+ "args[:#{var.downcase}] = ENV['#{var}']"
177
+ end.join("\n")
178
+
179
+ method = route[:method].to_s.downcase
180
+ helper_method = "#{method}_resource"
181
+
182
+ <<~RUBY
183
+ call do |args|
184
+ #{env_vars}
185
+ #{helper_method}("#{uri}", args)
186
+ end
187
+ RUBY
188
+ end
189
+
190
+ def self.test_helper_methods(base_uri, bypass_csrf_key)
191
+ <<~RUBY
192
+ def parse_response(test_context)
193
+ # Would be url: #{base_uri}
194
+ parsed_body = JSON.parse(test_context.response.body)
195
+ case parsed_body
196
+ when Hash
197
+ if parsed_body["status"] == "error"
198
+ raise "From Rails Server: \#{parsed_body["message"]}"
199
+ else
200
+ parsed_body.dig("data")
201
+ end
202
+ else
203
+ raise "None MCP response from Rails Server"
204
+ end
205
+ rescue => e
206
+ raise "Parsing JSON failed: \#{e.message}"
207
+ end
208
+
209
+ def get_resource(uri, arguments = {})
210
+ test_context = arguments.delete(:test_context)
211
+ test_context.get uri, params: arguments, headers: { "Accept" => "application/vnd.mcp+json, application/json" }, as: :mcp
212
+ parse_response(test_context)
213
+ end
214
+
215
+ def post_resource(uri, payload = {}, headers = {})
216
+ test_context = payload.delete(:test_context)
217
+ headers = { "Accept" => "application/vnd.mcp+json, application/json" }
218
+ headers["X-Bypass-CSRF"] = "#{bypass_csrf_key}"
219
+ test_context.post uri, params: payload, headers: headers, as: :mcp
220
+ parse_response(test_context)
221
+ end
222
+
223
+ def patch_resource(uri, payload = {})
224
+ test_context = payload.delete(:test_context)
225
+ headers = { "Accept" => "application/vnd.mcp+json, application/json" }
226
+ headers["X-Bypass-CSRF"] = "#{bypass_csrf_key}"
227
+ test_context.patch uri, params: payload.merge(headers: headers), as: :mcp
228
+ parse_response(test_context)
229
+ end
230
+
231
+ def delete_resource(uri, payload = {})
232
+ test_context = payload.delete(:test_context)
233
+ headers = { "Accept" => "application/vnd.mcp+json, application/json" }
234
+ headers["X-Bypass-CSRF"] = "#{bypass_csrf_key}"
235
+ test_context.delete uri, params: payload.merge(headers: headers), as: :mcp
236
+ parse_response(test_context)
237
+ end
238
+ RUBY
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,49 @@
1
+ module MCP
2
+ module Rails
3
+ class ServerGenerator
4
+ class << self
5
+ def generate_files(config = MCP::Rails.configuration)
6
+ base_url = config.base_url
7
+ bypass_csrf_key = BypassKeyManager.create_new_key
8
+
9
+ all_routes = RouteCollector.collect_routes(::Rails.application.routes.routes).flatten
10
+ grouped_routes = all_routes.group_by { |r| r[:engine] }
11
+
12
+ generated_files = []
13
+
14
+ # Process main app routes
15
+ main_app_routes = RouteCollector.process_routes(grouped_routes[nil] || [])
16
+ if main_app_routes.any?
17
+ file_path = ServerWriter.write_server(
18
+ main_app_routes,
19
+ config.for_engine(nil),
20
+ base_url,
21
+ bypass_csrf_key
22
+ )
23
+ generated_files << file_path
24
+ end
25
+
26
+ # Process each engine's routes
27
+ grouped_routes.each do |engine, routes|
28
+ next unless engine
29
+
30
+ engine_routes = RouteCollector.process_routes(routes)
31
+ next unless engine_routes.any?
32
+
33
+ engine_config = config.for_engine(engine)
34
+ file_path = ServerWriter.write_server(
35
+ engine_routes,
36
+ engine_config,
37
+ base_url,
38
+ bypass_csrf_key,
39
+ engine
40
+ )
41
+ generated_files << file_path
42
+ end
43
+
44
+ generated_files
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,5 @@
1
+ module Mcp
2
+ module Rails
3
+ VERSION = "0.4.11"
4
+ end
5
+ end
data/lib/mcp/rails.rb ADDED
@@ -0,0 +1,31 @@
1
+ require "mcp/rails/version"
2
+ require "mcp/rails/railtie"
3
+ require "mcp/rails/configuration"
4
+ require "mcp/rails/server_generator"
5
+ require "mcp/rails/bypass_key_manager"
6
+ require "mcp/rails/server_generator/server_writer"
7
+ require "mcp/rails/server_generator/route_collector"
8
+ require_relative "../../test/support/mcp/rails/test_helper"
9
+
10
+ module MCP
11
+ module Rails
12
+ class << self
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def configure
18
+ yield(configuration)
19
+ end
20
+
21
+ def reset_configuration!
22
+ @configuration = Configuration.new
23
+ end
24
+
25
+ def configuration=(configuration)
26
+ raise ArgumentError, "configuration must be an instance of MCP::Rails::Configuration" unless configuration.is_a?(Configuration)
27
+ @configuration = configuration
28
+ end
29
+ end
30
+ end
31
+ end