rails-hyperdrive 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +295 -0
- data/LICENSE.txt +21 -0
- data/README.md +229 -0
- data/Rakefile +6 -0
- data/SECURITY.md +43 -0
- data/config/routes.rb +5 -0
- data/lib/generators/hyperdrive/content_sync_support.rb +54 -0
- data/lib/generators/hyperdrive/discover/discover_generator.rb +103 -0
- data/lib/generators/hyperdrive/gitignore_support.rb +28 -0
- data/lib/generators/hyperdrive/install/USAGE +22 -0
- data/lib/generators/hyperdrive/install/install_generator.rb +168 -0
- data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +3 -0
- data/lib/generators/hyperdrive/install_summary.rb +70 -0
- data/lib/generators/hyperdrive/sync/USAGE +21 -0
- data/lib/generators/hyperdrive/sync/sync_generator.rb +40 -0
- data/lib/generators/hyperdrive/sync_runner.rb +70 -0
- data/lib/rails/hyperdrive/artifact_status.rb +86 -0
- data/lib/rails/hyperdrive/audit_header.rb +83 -0
- data/lib/rails/hyperdrive/auto_install.rb +98 -0
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +320 -0
- data/lib/rails/hyperdrive/claude_md_import.rb +69 -0
- data/lib/rails/hyperdrive/companion_discovery.rb +232 -0
- data/lib/rails/hyperdrive/console_executor.rb +64 -0
- data/lib/rails/hyperdrive/data/gem_categories.yml +52 -0
- data/lib/rails/hyperdrive/drift_verdict.rb +42 -0
- data/lib/rails/hyperdrive/eager_footprint.rb +48 -0
- data/lib/rails/hyperdrive/engine.rb +22 -0
- data/lib/rails/hyperdrive/index_document.rb +49 -0
- data/lib/rails/hyperdrive/install_layout.rb +45 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +415 -0
- data/lib/rails/hyperdrive/install_plan.rb +78 -0
- data/lib/rails/hyperdrive/install_shell.rb +43 -0
- data/lib/rails/hyperdrive/lock_file.rb +171 -0
- data/lib/rails/hyperdrive/mcp_server.rb +80 -0
- data/lib/rails/hyperdrive/resources/skill.rb +63 -0
- data/lib/rails/hyperdrive/resources/stack_profile.rb +32 -0
- data/lib/rails/hyperdrive/safety/rack_middleware.rb +54 -0
- data/lib/rails/hyperdrive/skill_template.rb +52 -0
- data/lib/rails/hyperdrive/sql_safety.rb +28 -0
- data/lib/rails/hyperdrive/stack_profile.rb +176 -0
- data/lib/rails/hyperdrive/tools/base.rb +39 -0
- data/lib/rails/hyperdrive/tools/describe_app.rb +21 -0
- data/lib/rails/hyperdrive/tools/list_models.rb +76 -0
- data/lib/rails/hyperdrive/tools/list_routes.rb +33 -0
- data/lib/rails/hyperdrive/tools/locate_source.rb +86 -0
- data/lib/rails/hyperdrive/tools/lookup_doc.rb +60 -0
- data/lib/rails/hyperdrive/tools/run_ruby.rb +31 -0
- data/lib/rails/hyperdrive/tools/run_sql.rb +49 -0
- data/lib/rails/hyperdrive/tools/tail_logs.rb +65 -0
- data/lib/rails/hyperdrive/version.rb +5 -0
- data/lib/rails/hyperdrive.rb +37 -0
- data/lib/rails-hyperdrive.rb +2 -0
- data/lib/tasks/hyperdrive.rake +22 -0
- metadata +161 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
require "mcp"
|
|
2
|
+
require "mcp/server/transports/streamable_http_transport"
|
|
3
|
+
require_relative "safety/rack_middleware"
|
|
4
|
+
require_relative "tools/describe_app"
|
|
5
|
+
require_relative "tools/run_ruby"
|
|
6
|
+
require_relative "tools/run_sql"
|
|
7
|
+
require_relative "tools/tail_logs"
|
|
8
|
+
require_relative "tools/list_models"
|
|
9
|
+
require_relative "tools/locate_source"
|
|
10
|
+
require_relative "tools/lookup_doc"
|
|
11
|
+
require_relative "tools/list_routes"
|
|
12
|
+
require_relative "resources/stack_profile"
|
|
13
|
+
require_relative "resources/skill"
|
|
14
|
+
|
|
15
|
+
module Rails
|
|
16
|
+
module Hyperdrive
|
|
17
|
+
module McpServer
|
|
18
|
+
TOOLS = [
|
|
19
|
+
Tools::DescribeApp,
|
|
20
|
+
Tools::RunRuby,
|
|
21
|
+
Tools::RunSql,
|
|
22
|
+
Tools::TailLogs,
|
|
23
|
+
Tools::ListModels,
|
|
24
|
+
Tools::LocateSource,
|
|
25
|
+
Tools::LookupDoc,
|
|
26
|
+
Tools::ListRoutes
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def server
|
|
32
|
+
@server ||= build_server
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rack_app(allowed_hosts: Safety::RackMiddleware::DEFAULT_ALLOWED_HOSTS)
|
|
36
|
+
# The transport's own DNS-rebinding check requires same-origin (host:port);
|
|
37
|
+
# it is disabled because Safety::RackMiddleware enforces the localhost
|
|
38
|
+
# Origin allowlist, which accepts any port.
|
|
39
|
+
@rack_app ||= Safety::RackMiddleware.new(
|
|
40
|
+
::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
41
|
+
server, stateless: true, enable_json_response: true, dns_rebinding_protection: false
|
|
42
|
+
),
|
|
43
|
+
allowed_hosts: allowed_hosts
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset!
|
|
48
|
+
@server = nil
|
|
49
|
+
@rack_app = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_server
|
|
53
|
+
srv = ::MCP::Server.new(
|
|
54
|
+
name: "hyperdrive",
|
|
55
|
+
title: "Rails Hyperdrive",
|
|
56
|
+
version: Rails::Hyperdrive::VERSION,
|
|
57
|
+
instructions: "Rails Hyperdrive MCP server. Prefer locate_source/list_models before guessing.",
|
|
58
|
+
tools: TOOLS,
|
|
59
|
+
resources: [Resources::StackProfile.resource, *Resources::Skill.installed_resources],
|
|
60
|
+
resource_templates: [Resources::Skill.template]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
srv.resources_read_handler do |params|
|
|
64
|
+
uri = params[:uri] || params["uri"]
|
|
65
|
+
if uri == Resources::StackProfile::URI
|
|
66
|
+
Resources::StackProfile.read(params)
|
|
67
|
+
elsif uri.to_s.start_with?(Resources::Skill::URI_PREFIX)
|
|
68
|
+
Resources::Skill.read(params)
|
|
69
|
+
else
|
|
70
|
+
raise ::MCP::Server::RequestHandlerError.new(
|
|
71
|
+
"Resource not found: #{uri}", params, error_type: :invalid_params
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
srv
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require "mcp"
|
|
2
|
+
require "rails/hyperdrive/install_layout"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
module Resources
|
|
7
|
+
module Skill
|
|
8
|
+
URI_TEMPLATE = "hyperdrive://skills/{name}"
|
|
9
|
+
URI_PREFIX = "hyperdrive://skills/"
|
|
10
|
+
# Skill names follow the SKILL.md/.claude convention: lowercase letters,
|
|
11
|
+
# digits, hyphens. Anything else is rejected to prevent path traversal
|
|
12
|
+
# via `hyperdrive://skills/../../etc/passwd`.
|
|
13
|
+
NAME_PATTERN = /\A[a-z0-9][a-z0-9_\-]*\z/.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def template
|
|
18
|
+
::MCP::ResourceTemplate.new(
|
|
19
|
+
uri_template: URI_TEMPLATE,
|
|
20
|
+
name: "Installed skill",
|
|
21
|
+
description: "Markdown body of an installed #{InstallLayout::SKILLS_DIR}/<name>/SKILL.md",
|
|
22
|
+
mime_type: "text/markdown"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Enumerated once at server build time, so skills installed by a fresh
|
|
27
|
+
# `hyperdrive:init` only appear after a dev-server restart.
|
|
28
|
+
def installed_resources
|
|
29
|
+
return [] unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
30
|
+
skills_root = ::Rails.root.join(InstallLayout::SKILLS_DIR)
|
|
31
|
+
return [] unless Dir.exist?(skills_root)
|
|
32
|
+
Dir.glob(::Rails.root.join(InstallLayout.dest_for(:skill, "*")).to_s).sort.map do |path|
|
|
33
|
+
name = InstallLayout.installed_name(:skill, path)
|
|
34
|
+
next unless name.match?(NAME_PATTERN)
|
|
35
|
+
::MCP::Resource.new(
|
|
36
|
+
uri: "#{URI_PREFIX}#{name}",
|
|
37
|
+
name: name,
|
|
38
|
+
description: "Skill: #{name}",
|
|
39
|
+
mime_type: "text/markdown"
|
|
40
|
+
)
|
|
41
|
+
end.compact
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read(params)
|
|
45
|
+
uri = params[:uri] || params["uri"]
|
|
46
|
+
name = uri.to_s.sub(URI_PREFIX, "")
|
|
47
|
+
unless name.match?(NAME_PATTERN)
|
|
48
|
+
raise ::MCP::Server::RequestHandlerError.new(
|
|
49
|
+
"Resource not found: #{uri}", params, error_type: :invalid_params
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
path = ::Rails.root.join(InstallLayout.dest_for(:skill, name))
|
|
53
|
+
unless File.exist?(path)
|
|
54
|
+
raise ::MCP::Server::RequestHandlerError.new(
|
|
55
|
+
"Resource not found: #{uri}", params, error_type: :invalid_params
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
[{ uri: uri, mimeType: "text/markdown", text: File.read(path) }]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "mcp"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "../stack_profile"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Hyperdrive
|
|
7
|
+
module Resources
|
|
8
|
+
module StackProfile
|
|
9
|
+
URI = "hyperdrive://stack-profile"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def resource
|
|
14
|
+
::MCP::Resource.new(
|
|
15
|
+
uri: URI,
|
|
16
|
+
name: "Stack Profile",
|
|
17
|
+
description: "Parsed Gemfile.lock + categorization (rails, ruby, database, test, jobs, frontend, auth, authz, db_gems, gem_skills).",
|
|
18
|
+
mime_type: "application/json"
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read(_params)
|
|
23
|
+
[{
|
|
24
|
+
uri: URI,
|
|
25
|
+
mimeType: "application/json",
|
|
26
|
+
text: JSON.pretty_generate(Rails::Hyperdrive::StackProfile.current.to_h)
|
|
27
|
+
}]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "rack"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Hyperdrive
|
|
7
|
+
module Safety
|
|
8
|
+
# Defense-in-depth gate for every request that hits the MCP transport.
|
|
9
|
+
class RackMiddleware
|
|
10
|
+
DEFAULT_ALLOWED_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(app, allowed_hosts: DEFAULT_ALLOWED_HOSTS)
|
|
13
|
+
@app = app
|
|
14
|
+
@allowed_hosts = allowed_hosts
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
unless dev?
|
|
19
|
+
return forbid("hyperdrive is dev-only (Rails.env=#{rails_env})")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
origin = env["HTTP_ORIGIN"]
|
|
23
|
+
if origin && !origin_allowed?(origin)
|
|
24
|
+
return forbid("origin not allowed: #{origin}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@app.call(env)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def dev?
|
|
33
|
+
Rails::Hyperdrive.dev_mode?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def rails_env
|
|
37
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env : "unknown"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def origin_allowed?(origin)
|
|
41
|
+
host = URI.parse(origin).host
|
|
42
|
+
@allowed_hosts.include?(host)
|
|
43
|
+
rescue URI::InvalidURIError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def forbid(reason)
|
|
48
|
+
body = { error: "forbidden", reason: reason }.to_json
|
|
49
|
+
[403, { "content-type" => "application/json" }, [body]]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
# Renders a skill's *.md.erb sources against the app's resolved bundle.
|
|
6
|
+
# The template binding is sealed: exactly three helpers over the resolved
|
|
7
|
+
# {gem name => Gem::Version} map, no app context, no arbitrary lookup.
|
|
8
|
+
module SkillTemplate
|
|
9
|
+
class Context
|
|
10
|
+
def initialize(resolved)
|
|
11
|
+
@resolved = resolved
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def gem?(name, requirement = nil)
|
|
15
|
+
version = @resolved[name.to_s]
|
|
16
|
+
return false unless version
|
|
17
|
+
requirement.nil? || SkillTemplate.version_matches?(requirement, version)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def any_gem?(*names)
|
|
21
|
+
names.any? { |n| @resolved.key?(n.to_s) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def gem_version(name)
|
|
25
|
+
version = @resolved[name.to_s]
|
|
26
|
+
version&.to_s
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def template_binding
|
|
30
|
+
binding
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Errors (ERB SyntaxError included) propagate; the caller decides how to
|
|
37
|
+
# skip and warn.
|
|
38
|
+
def render(source, resolved:)
|
|
39
|
+
ERB.new(source, trim_mode: "-").result(Context.new(resolved).template_binding)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Gem::Requirement.new does not parse a single comma-separated string, so
|
|
43
|
+
# such strings are split into separate constraints first.
|
|
44
|
+
def version_matches?(requirement_str, version)
|
|
45
|
+
parts = Array(requirement_str).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
46
|
+
Gem::Requirement.new(*parts).satisfied_by?(Gem::Version.new(version.to_s))
|
|
47
|
+
rescue ArgumentError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Rails
|
|
2
|
+
module Hyperdrive
|
|
3
|
+
# Guardrail (not a sandbox) against an AI accidentally mutating the dev
|
|
4
|
+
# database. Rejects anything other than read-only statements at the regex
|
|
5
|
+
# level. A determined caller with `run_ruby` can trivially bypass it.
|
|
6
|
+
module SqlSafety
|
|
7
|
+
ALLOWED_LEADERS = /\A\s*(WITH\b.*?\bSELECT\b|SELECT\b|EXPLAIN\b|SHOW\b|PRAGMA\b)/im
|
|
8
|
+
FORBIDDEN_TOKEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|REPLACE|MERGE|RENAME|VACUUM|ATTACH|DETACH)\b/i
|
|
9
|
+
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def assert_read_only!(sql)
|
|
15
|
+
raise Error, "empty SQL" if sql.nil? || sql.strip.empty?
|
|
16
|
+
unless sql =~ ALLOWED_LEADERS
|
|
17
|
+
raise Error, "only SELECT / WITH...SELECT / EXPLAIN / SHOW / PRAGMA are allowed"
|
|
18
|
+
end
|
|
19
|
+
# Second pass catches a mutation smuggled inside a CTE body
|
|
20
|
+
# (e.g. `WITH x AS (DELETE ...) SELECT ...`).
|
|
21
|
+
if sql =~ FORBIDDEN_TOKEN
|
|
22
|
+
raise Error, "forbidden token detected: #{Regexp.last_match(1)}"
|
|
23
|
+
end
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
require "bundler"
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Rails
|
|
6
|
+
module Hyperdrive
|
|
7
|
+
class StackProfile
|
|
8
|
+
CATEGORIES_PATH = File.expand_path("data/gem_categories.yml", __dir__)
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def categories
|
|
12
|
+
@categories ||= YAML.load_file(CATEGORIES_PATH).each_with_object({}) do |(cat, gems), acc|
|
|
13
|
+
gems.each { |g| acc[g] = cat.to_sym }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def current
|
|
18
|
+
@current ||= from_lockfile(default_lockfile_path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset!
|
|
22
|
+
@current = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def from_lockfile(path, app_root: nil)
|
|
26
|
+
new(path: path, app_root: app_root).tap(&:parse!)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def default_lockfile_path
|
|
30
|
+
if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
31
|
+
::Rails.root.join("Gemfile.lock").to_s
|
|
32
|
+
else
|
|
33
|
+
File.expand_path("Gemfile.lock", Dir.pwd)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :path, :data
|
|
39
|
+
|
|
40
|
+
def initialize(path:, app_root: nil)
|
|
41
|
+
@path = path
|
|
42
|
+
@app_root = app_root
|
|
43
|
+
@data = empty_profile
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse!
|
|
47
|
+
unless File.exist?(@path)
|
|
48
|
+
@data = empty_profile.merge(error: "Gemfile.lock not found at #{@path}")
|
|
49
|
+
return self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
contents = File.read(@path)
|
|
53
|
+
parser = ::Bundler::LockfileParser.new(contents)
|
|
54
|
+
specs = parser.specs.each_with_object({}) { |s, h| h[s.name] = s.version.to_s }
|
|
55
|
+
|
|
56
|
+
@data = {
|
|
57
|
+
rails: rails_info(specs),
|
|
58
|
+
ruby: ruby_info(parser),
|
|
59
|
+
database: database_info(specs),
|
|
60
|
+
test: bucket(specs, :test),
|
|
61
|
+
jobs: bucket(specs, :jobs),
|
|
62
|
+
frontend: frontend_info(specs),
|
|
63
|
+
auth: bucket(specs, :auth),
|
|
64
|
+
authz: bucket(specs, :authz),
|
|
65
|
+
db_gems: bucket(specs, :db),
|
|
66
|
+
gem_skills: gem_skills_info
|
|
67
|
+
}
|
|
68
|
+
self
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
@data
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def empty_profile
|
|
78
|
+
{
|
|
79
|
+
rails: {}, ruby: {}, database: {},
|
|
80
|
+
test: [], jobs: [], frontend: [],
|
|
81
|
+
auth: [], authz: [], db_gems: [], gem_skills: []
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def rails_info(specs)
|
|
86
|
+
version = specs["rails"] || specs["railties"]
|
|
87
|
+
return {} unless version
|
|
88
|
+
{ version: version, major: version.to_s.split(".").first.to_i }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ruby_info(parser)
|
|
92
|
+
version = parser.ruby_version || (RUBY_VERSION if defined?(RUBY_VERSION))
|
|
93
|
+
version ? { version: version.to_s.sub(/^ruby /, "") } : {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# database.yml takes precedence: a Rails app can ship `pg` for a
|
|
97
|
+
# secondary store while running on sqlite3, and the declared adapter is
|
|
98
|
+
# what we want to report.
|
|
99
|
+
def database_info(specs)
|
|
100
|
+
if (yml_adapter = adapter_from_database_yml)
|
|
101
|
+
return { adapter: yml_adapter }
|
|
102
|
+
end
|
|
103
|
+
adapter = if specs["pg"] then "postgresql"
|
|
104
|
+
elsif specs["mysql2"] then "mysql2"
|
|
105
|
+
elsif specs["trilogy"] then "trilogy"
|
|
106
|
+
elsif specs["sqlite3"] then "sqlite3"
|
|
107
|
+
end
|
|
108
|
+
adapter ? { adapter: adapter } : {}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def adapter_from_database_yml
|
|
112
|
+
yml_path = database_yml_path
|
|
113
|
+
return nil unless yml_path && File.exist?(yml_path)
|
|
114
|
+
# ERB-rendered config is common; permissive parse keeps us out of the
|
|
115
|
+
# business of evaluating user code at generator time.
|
|
116
|
+
raw = File.read(yml_path)
|
|
117
|
+
raw = raw.gsub(/<%.*?%>/m, '""')
|
|
118
|
+
parsed = YAML.safe_load(raw, aliases: true) || {}
|
|
119
|
+
env = (defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.to_s) || "development"
|
|
120
|
+
section = parsed[env] || parsed["development"]
|
|
121
|
+
return nil unless section.is_a?(Hash)
|
|
122
|
+
adapter = section["adapter"] || section.values.grep(Hash).map { |h| h["adapter"] }.compact.first
|
|
123
|
+
adapter&.to_s
|
|
124
|
+
rescue StandardError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# An explicit app_root keeps the adapter resolvable with no Rails booted.
|
|
129
|
+
def database_yml_path
|
|
130
|
+
return File.join(@app_root.to_s, "config", "database.yml") if @app_root
|
|
131
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
132
|
+
::Rails.root.join("config", "database.yml").to_s
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def bucket(specs, category)
|
|
136
|
+
gem_names = self.class.categories.select { |_, c| c == category }.keys
|
|
137
|
+
gem_names.filter_map do |name|
|
|
138
|
+
version = specs[name]
|
|
139
|
+
next unless version
|
|
140
|
+
{ key: name.to_sym, version: version }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Frontend gets a special shape because Hotwire is actually two gems.
|
|
145
|
+
def frontend_info(specs)
|
|
146
|
+
bucket(specs, :frontend).map do |entry|
|
|
147
|
+
if entry[:key] == :"turbo-rails" || entry[:key] == :"stimulus-rails"
|
|
148
|
+
turbo = specs["turbo-rails"]
|
|
149
|
+
stim = specs["stimulus-rails"]
|
|
150
|
+
{ key: :hotwire, versions: { turbo: turbo, stimulus: stim }.compact }
|
|
151
|
+
else
|
|
152
|
+
entry
|
|
153
|
+
end
|
|
154
|
+
end.uniq
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Loaded lazily and rescued broadly so StackProfile stays usable where
|
|
158
|
+
# Bundler is absent or refuses to resolve.
|
|
159
|
+
def gem_skills_info
|
|
160
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
161
|
+
::Rails::Hyperdrive::BundlerArtifactDiscovery.discover.select(&:skill?).map do |skill|
|
|
162
|
+
{
|
|
163
|
+
name: skill.name,
|
|
164
|
+
gem: skill.target_gem,
|
|
165
|
+
source: skill.source_gem,
|
|
166
|
+
version: skill.spec_version,
|
|
167
|
+
path: skill.path,
|
|
168
|
+
sha256: Digest::SHA256.hexdigest(skill.body.to_s)
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
rescue StandardError
|
|
172
|
+
[]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "mcp"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
module Tools
|
|
7
|
+
class Base < ::MCP::Tool
|
|
8
|
+
class << self
|
|
9
|
+
def respond_text(text)
|
|
10
|
+
::MCP::Tool::Response.new([{ type: "text", text: text.to_s }])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def respond_json(object)
|
|
14
|
+
respond_text(JSON.pretty_generate(object))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def respond_error(message)
|
|
18
|
+
::MCP::Tool::Response.new(
|
|
19
|
+
[{ type: "text", text: message.to_s }],
|
|
20
|
+
error: true
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Defense in depth: the Rack middleware already gates the HTTP
|
|
25
|
+
# transport; this catches direct in-process invocations (specs, rake
|
|
26
|
+
# tasks) that bypass it.
|
|
27
|
+
def with_dev_guard
|
|
28
|
+
unless Rails::Hyperdrive.dev_mode?
|
|
29
|
+
return respond_error("hyperdrive tools are disabled outside Rails.env.development?")
|
|
30
|
+
end
|
|
31
|
+
yield
|
|
32
|
+
rescue => e
|
|
33
|
+
respond_error("#{e.class}: #{e.message}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
require_relative "../stack_profile"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
module Tools
|
|
7
|
+
class DescribeApp < Base
|
|
8
|
+
tool_name "describe_app"
|
|
9
|
+
description "Snapshot of Rails/Ruby/DB versions plus the full StackProfile (test, jobs, frontend, auth, authz, db_gems, gem_skills)."
|
|
10
|
+
|
|
11
|
+
input_schema(properties: {})
|
|
12
|
+
|
|
13
|
+
def self.call(server_context: nil)
|
|
14
|
+
with_dev_guard do
|
|
15
|
+
respond_json(Rails::Hyperdrive::StackProfile.current.to_h)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
module Tools
|
|
6
|
+
class ListModels < Base
|
|
7
|
+
tool_name "list_models"
|
|
8
|
+
description "List Active Record model classes with their columns, validations, and associations. Eager-loads the app first."
|
|
9
|
+
|
|
10
|
+
input_schema(properties: {})
|
|
11
|
+
|
|
12
|
+
def self.call(server_context: nil)
|
|
13
|
+
with_dev_guard do
|
|
14
|
+
::Rails.application.eager_load!
|
|
15
|
+
models = ActiveRecord::Base.descendants.reject(&:abstract_class?).sort_by(&:name)
|
|
16
|
+
described = models.map do |m|
|
|
17
|
+
describe(m)
|
|
18
|
+
rescue StandardError => e
|
|
19
|
+
# Collect per-model failures so one broken class doesn't blank out
|
|
20
|
+
# the whole listing.
|
|
21
|
+
{ class: m.name, errors: ["#{e.class}: #{e.message}"] }
|
|
22
|
+
end
|
|
23
|
+
respond_json(described)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.describe(model)
|
|
28
|
+
{
|
|
29
|
+
class: model.name,
|
|
30
|
+
table: safe(model, :table_name),
|
|
31
|
+
columns: safe_columns(model),
|
|
32
|
+
validators: safe_validators(model),
|
|
33
|
+
associations: safe_associations(model)
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.safe(model, method)
|
|
38
|
+
model.public_send(method)
|
|
39
|
+
rescue => e
|
|
40
|
+
"<error: #{e.class}: #{e.message}>"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.safe_columns(model)
|
|
44
|
+
return [] unless model.connected? || ActiveRecord::Base.connected?
|
|
45
|
+
model.columns.map { |c| { name: c.name, type: c.type.to_s, null: c.null, default: c.default } }
|
|
46
|
+
rescue => e
|
|
47
|
+
[{ error: "#{e.class}: #{e.message}" }]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.safe_validators(model)
|
|
51
|
+
model.validators.flat_map do |v|
|
|
52
|
+
v.attributes.map do |attr|
|
|
53
|
+
{ attribute: attr.to_s, kind: v.kind.to_s, options: v.options.transform_values(&:to_s) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
rescue
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.safe_associations(model)
|
|
61
|
+
model.reflect_on_all_associations.map do |a|
|
|
62
|
+
{ name: a.name.to_s, macro: a.macro.to_s, class_name: safe_class_name(a) }
|
|
63
|
+
end
|
|
64
|
+
rescue
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.safe_class_name(association)
|
|
69
|
+
association.class_name
|
|
70
|
+
rescue
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
module Tools
|
|
6
|
+
class ListRoutes < Base
|
|
7
|
+
tool_name "list_routes"
|
|
8
|
+
description "List all routes: {verb, path, controller_action, name}."
|
|
9
|
+
|
|
10
|
+
input_schema(properties: {})
|
|
11
|
+
|
|
12
|
+
def self.call(server_context: nil)
|
|
13
|
+
with_dev_guard do
|
|
14
|
+
list = ::Rails.application.routes.routes.map do |r|
|
|
15
|
+
reqs = r.requirements
|
|
16
|
+
controller = reqs[:controller]
|
|
17
|
+
action = reqs[:action]
|
|
18
|
+
# Engine mounts have no controller/action, so the pair is nil.
|
|
19
|
+
controller_action = (controller && action) ? "#{controller}##{action}" : nil
|
|
20
|
+
{
|
|
21
|
+
verb: r.verb.to_s,
|
|
22
|
+
path: r.path.spec.to_s.sub(/\(\.:format\)\z/, ""),
|
|
23
|
+
controller_action: controller_action,
|
|
24
|
+
name: r.name
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
respond_json(list)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|