coatepec 0.7.0 → 0.8.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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "model_resolver"
4
+
3
5
  module Coatepec
4
6
  module Introspection
5
7
  # Returns bounded ActiveRecord schema and class metadata for a single
@@ -7,45 +9,48 @@ module Coatepec
7
9
  # reflection, no method dispatch on the resolved class beyond pure
8
10
  # introspection APIs.
9
11
  class Model
10
- NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
12
+ FIELDS = %w[columns associations validators enums].freeze
11
13
  MAX_ITEMS = 200
14
+ MAX_OPTION_VALUES = 20
12
15
  EMPTY_TABLE_METADATA = { table_name: nil, primary_key: nil, columns: [] }.freeze
13
16
 
14
- def initialize(name)
17
+ # Counts are always returned; lists are opt-in, so an omitted fields costs a few dozen bytes.
18
+ def initialize(name, fields: nil)
15
19
  @name = name
20
+ @fields = validate_fields!(fields || [])
16
21
  end
17
22
 
18
23
  def call
19
- validate_name!
20
- klass = resolve!
21
- validate_active_record!(klass)
24
+ klass = ModelResolver.call(@name)
22
25
  build_metadata(klass)
23
26
  end
24
27
 
25
28
  private
26
29
 
27
- # An abstract class (e.g. ApplicationRecord) has no real table, so
28
- # table_name/primary_key/columns all raise if called against it --
29
- # report empty/nil table data instead of crashing. Validators aren't
30
- # table-dependent, so those are always attempted.
30
+ # An abstract class (e.g. ApplicationRecord) has no real table, so table_name/primary_key/columns all raise
31
+ # if called against it -- report empty/nil table data instead. abstract_class? is nil (not false) for a
32
+ # concrete class on Rails 7.1 and false on 8.1, so normalize it for version-stable JSON. enums, like
33
+ # validators, are in-memory class metadata needing no connection, so they are always collected.
31
34
  def build_metadata(klass)
32
- # abstract_class? is a plain attr_accessor-backed predicate that is
33
- # never assigned on concrete subclasses -- on Rails 7.1 it returns
34
- # nil (not false) in that case, while Rails 8.1 returns false.
35
- # Normalize to a genuine Boolean so JSON output is version-stable.
36
35
  abstract = klass.abstract_class? || false
37
36
  table = abstract ? EMPTY_TABLE_METADATA : table_metadata(klass)
38
- {
39
- name: klass.name, table_name: table[:table_name], primary_key: table[:primary_key],
40
- abstract_class: abstract, columns: table[:columns],
41
- associations: abstract ? [] : associations_for(klass),
42
- validators: validators_for(klass),
43
- # enum declarations are pure in-memory class metadata (populated
44
- # when the `enum` macro runs in the class body) -- unlike columns
45
- # and associations, they need no DB connection or real table, so
46
- # this is attempted unconditionally, the same way validators are.
47
- enums: enums_for(klass)
48
- }
37
+ sections = { columns: table[:columns], associations: abstract ? [] : associations_for(klass),
38
+ validators: validators_for(klass), enums: enums_for(klass) }
39
+ { name: klass.name, table_name: table[:table_name], primary_key: table[:primary_key], abstract_class: abstract,
40
+ counts: sections.transform_values(&:size) }.merge(sections.slice(*(FIELDS & @fields).map(&:to_sym)))
41
+ end
42
+
43
+ # The MCP schema enforces the enum; this guards the worker command against any other caller.
44
+ def validate_fields!(fields)
45
+ unless fields.is_a?(Array)
46
+ raise Coatepec::Error.new(:invalid_model_fields, "fields must be an array, got #{fields.inspect}")
47
+ end
48
+
49
+ unknown = fields - FIELDS
50
+ return fields if unknown.empty?
51
+
52
+ raise Coatepec::Error.new(:invalid_model_fields,
53
+ "fields must be among #{FIELDS.join(", ")}, got #{unknown.inspect}")
49
54
  end
50
55
 
51
56
  # A concrete model can still name a table that isn't there: a checkout
@@ -68,23 +73,6 @@ module Coatepec
68
73
  )
69
74
  end
70
75
 
71
- def validate_name!
72
- return if @name.is_a?(String) && NAME_PATTERN.match?(@name)
73
-
74
- raise Coatepec::Error.new(:invalid_model_name, "#{@name.inspect} is not a valid constant name")
75
- end
76
-
77
- def resolve!
78
- ::ActiveSupport::Inflector.safe_constantize(@name) ||
79
- raise(Coatepec::Error.new(:model_not_found, "#{@name} could not be resolved"))
80
- end
81
-
82
- def validate_active_record!(klass)
83
- return if klass.is_a?(Class) && klass < ::ActiveRecord::Base
84
-
85
- raise Coatepec::Error.new(:not_active_record_model, "#{@name} is not an ActiveRecord model")
86
- end
87
-
88
76
  def columns_for(klass)
89
77
  klass.columns.first(MAX_ITEMS).map do |column|
90
78
  { name: column.name, type: column.type.to_s, sql_type: column.sql_type, null: column.null,
@@ -159,12 +147,14 @@ module Coatepec
159
147
  nil
160
148
  end
161
149
 
150
+ # Rails instantiates one validator per declaration; a concern and the model body often declare the same one.
151
+ # De-duplicate on the raw validator: SafeOptions drops the Procs and Regexps that tell two apart.
162
152
  def validators_for(klass)
163
- klass.validators.first(MAX_ITEMS).map do |validator|
153
+ klass.validators.uniq { |v| [v.class.name, v.attributes, v.options] }.first(MAX_ITEMS).map do |validator|
164
154
  {
165
155
  name: validator.class.name,
166
156
  attributes: validator.attributes.map(&:to_s),
167
- options: SafeOptions.call(validator.options)
157
+ options: BoundedOptions.call(SafeOptions.call(validator.options), MAX_OPTION_VALUES)
168
158
  }
169
159
  end
170
160
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Introspection
5
+ # Turns a rails_model name into an ActiveRecord class, or raises the structured error that says why not.
6
+ module ModelResolver
7
+ NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
8
+
9
+ module_function
10
+
11
+ def call(name)
12
+ validate_name!(name)
13
+ klass = ::ActiveSupport::Inflector.safe_constantize(name) ||
14
+ raise(Coatepec::Error.new(:model_not_found, "#{name} could not be resolved"))
15
+ validate_active_record!(name, klass)
16
+ klass
17
+ end
18
+
19
+ def validate_name!(name)
20
+ return if name.is_a?(String) && NAME_PATTERN.match?(name)
21
+
22
+ raise Coatepec::Error.new(:invalid_model_name, "#{name.inspect} is not a valid constant name")
23
+ end
24
+
25
+ def validate_active_record!(name, klass)
26
+ return if klass.is_a?(Class) && klass < ::ActiveRecord::Base
27
+
28
+ raise Coatepec::Error.new(:not_active_record_model, "#{name} is not an ActiveRecord model")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Introspection
5
+ # Flattens a route set into application routes followed by one level of mounted-engine
6
+ # routes with the mount path prefixed, the way `bin/rails routes` shows them.
7
+ class RouteEntries
8
+ Entry = Struct.new(:route, :path, :engine)
9
+
10
+ def self.call(routes)
11
+ new(routes).call
12
+ end
13
+
14
+ def initialize(routes)
15
+ @routes = routes
16
+ end
17
+
18
+ def call
19
+ visible = @routes.reject { |route| internal?(route) }
20
+
21
+ visible.map { |route| Entry.new(route, path_of(route), nil) } +
22
+ visible.flat_map { |route| engine_entries(route) }
23
+ end
24
+
25
+ private
26
+
27
+ # Rails' own /rails/info routes; `bin/rails routes` hides them too. Guarded so unit-spec structs work.
28
+ def internal?(route)
29
+ route.respond_to?(:internal) && route.internal
30
+ end
31
+
32
+ # One level only, like RoutesInspector; an engine mounted at two paths appears under both.
33
+ def engine_entries(route)
34
+ return [] unless engine_mount?(route)
35
+
36
+ engine = route.app.rack_app
37
+ prefix = mount_prefix(route)
38
+ name = engine_name(engine)
39
+ inner_routes(engine).reject { |inner| internal?(inner) }
40
+ .map { |inner| Entry.new(inner, prefix + path_of(inner), name) }
41
+ end
42
+
43
+ # `(.:format)` is on nearly every route and says nothing a caller acts on; `bin/rails routes` shows it, we don't.
44
+ def path_of(route)
45
+ route.path.spec.to_s.sub(/\(\.:format\)\z/, "")
46
+ end
47
+
48
+ # chomp only affects a mount at "/", whose junction is the one place a "//" could form.
49
+ def mount_prefix(route)
50
+ path_of(route).chomp("/")
51
+ end
52
+
53
+ # Only a ::Rails::Engine subclass is expanded or named; nothing else ever gets .name called on it.
54
+ def engine_mount?(route)
55
+ route.respond_to?(:app) && route.app.respond_to?(:engine?) && route.app.engine? &&
56
+ rails_engine?(route.app.rack_app)
57
+ end
58
+
59
+ def rails_engine?(rack_app)
60
+ defined?(::Rails::Engine) && rack_app.is_a?(Class) && rack_app < ::Rails::Engine
61
+ end
62
+
63
+ # Class.new(Rails::Engine) has a nil name; a fixed label keeps its routes tagged and boot-stable.
64
+ def engine_name(engine)
65
+ engine.name || "(anonymous engine)"
66
+ end
67
+
68
+ # engine.routes is a RouteSet whose .routes holds the Journey routes; anything else stays unflattened.
69
+ def inner_routes(engine)
70
+ return [] unless engine.respond_to?(:routes)
71
+
72
+ route_set = engine.routes
73
+ route_set.respond_to?(:routes) ? route_set.routes : []
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,33 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "route_entries"
4
+
3
5
  module Coatepec
4
6
  module Introspection
5
- # Returns a bounded, filterable list of the target Rails app's routes,
6
- # for the rails_routes MCP tool. Reads Rails.application.routes.routes
7
- # only -- no console, no request dispatch.
7
+ # Returns a bounded, filterable list of the target app's routes for the rails_routes MCP tool: application
8
+ # routes by default, mounted-engine routes on request. Reads route tables only -- no console, no dispatch.
8
9
  class Routes
9
10
  MAX_LIMIT = 200
10
- DEFAULT_LIMIT = 50
11
+ DEFAULT_LIMIT = 100
12
+ ENGINE_FILTERS = %w[include exclude only].freeze
13
+ COLUMNS = %w[name verb path controller action engine].freeze
14
+ COLUMN_KEYS = COLUMNS.map(&:to_sym).freeze
15
+ # Engine CRUD scaffolding was two thirds of a typical match; the payload states what the default withheld.
16
+ DEFAULT_ENGINES = "exclude"
11
17
 
12
- def initialize(query: nil, limit: DEFAULT_LIMIT, offset: 0)
18
+ def initialize(query: nil, limit: DEFAULT_LIMIT, offset: 0, engines: DEFAULT_ENGINES)
13
19
  @query = query
14
20
  @limit = [limit || DEFAULT_LIMIT, MAX_LIMIT].min
15
21
  @offset = offset || 0
22
+ @engines = validate_engines!(engines || DEFAULT_ENGINES)
16
23
  end
17
24
 
18
25
  def call
19
- matched = filtered_items
26
+ matched, excluded = partitioned_items
27
+ page(matched, excluded)
28
+ end
29
+
30
+ private
31
+
32
+ # Rows instead of one hash per route: the six key names were a third of every item's bytes.
33
+ def page(matched, excluded)
20
34
  {
21
- items: matched[@offset, @limit] || [],
35
+ columns: COLUMNS,
36
+ rows: (matched[@offset, @limit] || []).map { |item| item.values_at(*COLUMN_KEYS) },
22
37
  matched: matched.size,
23
38
  limit: @limit,
24
- offset: @offset
39
+ offset: @offset,
40
+ next_offset: next_offset_for(matched.size),
41
+ engines: @engines,
42
+ engines_excluded: excluded.size
25
43
  }
26
44
  end
27
45
 
28
- private
46
+ # The MCP schema already enforces the enum; this guards the worker command against any other caller.
47
+ def validate_engines!(value)
48
+ return value if ENGINE_FILTERS.include?(value)
49
+
50
+ raise Coatepec::Error.new(:invalid_engines_filter,
51
+ "engines must be one of #{ENGINE_FILTERS.join(", ")}, got #{value.inspect}")
52
+ end
53
+
54
+ # Spelled out so a caller never has to infer a follow-up page from matched > limit.
55
+ # candidate > @offset guards limit 0, which would otherwise hand back the same offset forever.
56
+ def next_offset_for(matched_count)
57
+ candidate = @offset + @limit
58
+ candidate > @offset && candidate < matched_count ? candidate : nil
59
+ end
29
60
 
30
- def filtered_items
61
+ # Query first, then the engine filter, so engines_excluded counts routes this exact query would have shown.
62
+ def partitioned_items
63
+ query_matches.partition { |item| engine_selected?(item) }
64
+ end
65
+
66
+ def query_matches
31
67
  items = all_items
32
68
  return items unless @query
33
69
 
@@ -35,19 +71,33 @@ module Coatepec
35
71
  items.select { |item| item.values.compact.any? { |v| v.to_s.downcase.include?(query_downcased) } }
36
72
  end
37
73
 
38
- def all_items
39
- self.class.rails_routes.map do |route|
40
- defaults = route.defaults
41
- {
42
- name: route.name&.to_s,
43
- verb: route.verb.to_s,
44
- path: route.path.spec.to_s,
45
- controller: defaults[:controller]&.to_s,
46
- action: defaults[:action]&.to_s
47
- }
74
+ # An engine's mount route has engine: nil -- it is an application route and stays under "exclude".
75
+ def engine_selected?(item)
76
+ case @engines
77
+ when "exclude" then item[:engine].nil?
78
+ when "only" then !item[:engine].nil?
79
+ else true
48
80
  end
49
81
  end
50
82
 
83
+ def all_items
84
+ RouteEntries.call(self.class.rails_routes).map { |entry| item_for(entry) }
85
+ end
86
+
87
+ # path comes off the entry: for an engine route it already carries the mount prefix.
88
+ def item_for(entry)
89
+ route = entry.route
90
+ defaults = route.defaults
91
+ {
92
+ name: route.name&.to_s,
93
+ verb: route.verb.to_s,
94
+ path: entry.path,
95
+ controller: defaults[:controller]&.to_s,
96
+ action: defaults[:action]&.to_s,
97
+ engine: entry.engine
98
+ }
99
+ end
100
+
51
101
  # Isolated as a class method purely so unit tests can stub it without
52
102
  # booting Rails -- Rails.application.routes.routes is otherwise only
53
103
  # reachable with a real, booted application.
@@ -5,20 +5,17 @@ require "mcp"
5
5
 
6
6
  module Coatepec
7
7
  module MCP
8
- # Builds the pretty-printed JSON `{ok: true, data:, meta:}` / `{ok: false,
9
- # error:}` envelope that every Coatepec tool response wraps in a single
10
- # text content block.
8
+ # Builds the compact JSON `{ok: true, data:, meta:}` / `{ok: false, error:}`
9
+ # envelope every tool response wraps in one text block.
11
10
  module Response
12
- # Per-stream caps in Spec::Result bound stdout/stderr, but the fully
13
- # assembled envelope (500 examples with long descriptions) can still
14
- # exceed what an MCP client will accept, so cap it here -- the single
15
- # place every tool response is built.
11
+ # Per-stream caps in Spec::Result bound stdout/stderr, but a fully assembled
12
+ # envelope can still exceed what an MCP client accepts, so cap it here.
16
13
  MAX_RESPONSE_BYTES = 1024 * 1024
17
14
 
18
15
  module_function
19
16
 
20
17
  def ok(data:, meta: {})
21
- text = JSON.pretty_generate(ok: true, data: data, meta: meta)
18
+ text = generate(ok: true, data: data, meta: meta)
22
19
  return oversized_response if text.bytesize > MAX_RESPONSE_BYTES
23
20
 
24
21
  ::MCP::Tool::Response.new([{ type: "text", text: text }])
@@ -29,12 +26,22 @@ module Coatepec
29
26
  ok: false,
30
27
  error: { code: err.code.to_s, message: err.message, details: err.details }
31
28
  }
32
- ::MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }], error: true)
29
+ ::MCP::Tool::Response.new([{ type: "text", text: generate(payload) }], error: true)
33
30
  end
34
31
 
35
32
  def oversized_response
36
33
  error(Coatepec::Error.new(:response_too_large, "Result exceeds the 1 MiB response limit"))
37
34
  end
35
+
36
+ # Compact by default: nothing downstream reads the indentation. COATEPEC_PRETTY=1 restores it for hand debugging.
37
+ def generate(payload)
38
+ ENV["COATEPEC_PRETTY"] == "1" ? JSON.pretty_generate(payload) : JSON.generate(payload)
39
+ end
40
+
41
+ # Per-call timing is the only per-call fact; project_root and environment live on rails_runtime_status.
42
+ def meta(started_at)
43
+ { duration_ms: ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round }
44
+ end
38
45
  end
39
46
  end
40
47
  end