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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +229 -0
  5. data/Rakefile +6 -0
  6. data/SECURITY.md +43 -0
  7. data/config/routes.rb +5 -0
  8. data/lib/generators/hyperdrive/content_sync_support.rb +54 -0
  9. data/lib/generators/hyperdrive/discover/discover_generator.rb +103 -0
  10. data/lib/generators/hyperdrive/gitignore_support.rb +28 -0
  11. data/lib/generators/hyperdrive/install/USAGE +22 -0
  12. data/lib/generators/hyperdrive/install/install_generator.rb +168 -0
  13. data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +3 -0
  14. data/lib/generators/hyperdrive/install_summary.rb +70 -0
  15. data/lib/generators/hyperdrive/sync/USAGE +21 -0
  16. data/lib/generators/hyperdrive/sync/sync_generator.rb +40 -0
  17. data/lib/generators/hyperdrive/sync_runner.rb +70 -0
  18. data/lib/rails/hyperdrive/artifact_status.rb +86 -0
  19. data/lib/rails/hyperdrive/audit_header.rb +83 -0
  20. data/lib/rails/hyperdrive/auto_install.rb +98 -0
  21. data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +320 -0
  22. data/lib/rails/hyperdrive/claude_md_import.rb +69 -0
  23. data/lib/rails/hyperdrive/companion_discovery.rb +232 -0
  24. data/lib/rails/hyperdrive/console_executor.rb +64 -0
  25. data/lib/rails/hyperdrive/data/gem_categories.yml +52 -0
  26. data/lib/rails/hyperdrive/drift_verdict.rb +42 -0
  27. data/lib/rails/hyperdrive/eager_footprint.rb +48 -0
  28. data/lib/rails/hyperdrive/engine.rb +22 -0
  29. data/lib/rails/hyperdrive/index_document.rb +49 -0
  30. data/lib/rails/hyperdrive/install_layout.rb +45 -0
  31. data/lib/rails/hyperdrive/install_pipeline.rb +415 -0
  32. data/lib/rails/hyperdrive/install_plan.rb +78 -0
  33. data/lib/rails/hyperdrive/install_shell.rb +43 -0
  34. data/lib/rails/hyperdrive/lock_file.rb +171 -0
  35. data/lib/rails/hyperdrive/mcp_server.rb +80 -0
  36. data/lib/rails/hyperdrive/resources/skill.rb +63 -0
  37. data/lib/rails/hyperdrive/resources/stack_profile.rb +32 -0
  38. data/lib/rails/hyperdrive/safety/rack_middleware.rb +54 -0
  39. data/lib/rails/hyperdrive/skill_template.rb +52 -0
  40. data/lib/rails/hyperdrive/sql_safety.rb +28 -0
  41. data/lib/rails/hyperdrive/stack_profile.rb +176 -0
  42. data/lib/rails/hyperdrive/tools/base.rb +39 -0
  43. data/lib/rails/hyperdrive/tools/describe_app.rb +21 -0
  44. data/lib/rails/hyperdrive/tools/list_models.rb +76 -0
  45. data/lib/rails/hyperdrive/tools/list_routes.rb +33 -0
  46. data/lib/rails/hyperdrive/tools/locate_source.rb +86 -0
  47. data/lib/rails/hyperdrive/tools/lookup_doc.rb +60 -0
  48. data/lib/rails/hyperdrive/tools/run_ruby.rb +31 -0
  49. data/lib/rails/hyperdrive/tools/run_sql.rb +49 -0
  50. data/lib/rails/hyperdrive/tools/tail_logs.rb +65 -0
  51. data/lib/rails/hyperdrive/version.rb +5 -0
  52. data/lib/rails/hyperdrive.rb +37 -0
  53. data/lib/rails-hyperdrive.rb +2 -0
  54. data/lib/tasks/hyperdrive.rake +22 -0
  55. 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