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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +403 -0
- data/Rakefile +3 -0
- data/app/controllers/concerns/mcp/rails/error_handling.rb +25 -0
- data/app/controllers/concerns/mcp/rails/parameters.rb +163 -0
- data/app/controllers/concerns/mcp/rails/renderer.rb +69 -0
- data/app/controllers/concerns/mcp/rails/tool_descriptions.rb +39 -0
- data/lib/mcp/rails/bypass_key_manager.rb +34 -0
- data/lib/mcp/rails/configuration.rb +86 -0
- data/lib/mcp/rails/railtie.rb +49 -0
- data/lib/mcp/rails/server_generator/route_collector.rb +84 -0
- data/lib/mcp/rails/server_generator/server_writer.rb +242 -0
- data/lib/mcp/rails/server_generator.rb +49 -0
- data/lib/mcp/rails/version.rb +5 -0
- data/lib/mcp/rails.rb +31 -0
- metadata +126 -0
@@ -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
|
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
|