coatepec 0.6.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.
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "route_entries"
4
+
5
+ module Coatepec
6
+ module Introspection
7
+ # Returns bounded ActionController class metadata for a single controller,
8
+ # for the rails_controller MCP tool. Pure reflection over an already-loaded,
9
+ # already-gated class -- no request dispatch, no action execution, and no
10
+ # evaluation of app-authored callback conditions.
11
+ # rubocop:disable Metrics/ClassLength -- the route cross-referencing methods serve this class's
12
+ # one responsibility (reflect on one controller); splitting them out would only satisfy a line count.
13
+ class Controller
14
+ NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
15
+ MAX_ITEMS = 200
16
+
17
+ # AbstractController::Callbacks::ActionFilter#match? reads exactly two
18
+ # things off the controller it is handed: `action_name`, and (Rails 7.1+)
19
+ # `raise_on_missing_callback_actions`. That second one must be false --
20
+ # when true, match? raises ActionNotFound for any action named in `only:`
21
+ # that the controller doesn't define, which is precisely the drift this
22
+ # tool exists to *report*, so it must never raise here. This Struct is
23
+ # the entire controller surface match? touches; nothing on it can
24
+ # execute application code.
25
+ CallbackProbe = Struct.new(:action_name, :raise_on_missing_callback_actions)
26
+
27
+ def initialize(name)
28
+ @name = name
29
+ end
30
+
31
+ def call
32
+ validate_name!
33
+ klass = resolve!
34
+ validate_action_controller!(klass)
35
+ build_metadata(klass)
36
+ end
37
+
38
+ private
39
+
40
+ def build_metadata(klass)
41
+ actions = action_names(klass)
42
+ routes = routes_by_action(klass)
43
+ {
44
+ name: klass.name, controller_path: klass.controller_path,
45
+ actions: actions.map { |action| { name: action, routes: routes.fetch(action, []) } },
46
+ unroutable_actions: actions.reject { |action| routes.key?(action) },
47
+ routes_without_action: routes_without_action(klass, routes),
48
+ callbacks: callbacks_for(klass, actions), concerns: concerns_for(klass)
49
+ }
50
+ end
51
+
52
+ def validate_name!
53
+ return if @name.is_a?(String) && NAME_PATTERN.match?(@name)
54
+
55
+ raise Coatepec::Error.new(:invalid_controller_name, "#{@name.inspect} is not a valid constant name")
56
+ end
57
+
58
+ def resolve!
59
+ ::ActiveSupport::Inflector.safe_constantize(@name) ||
60
+ raise(Coatepec::Error.new(:controller_not_found, "#{@name} could not be resolved"))
61
+ end
62
+
63
+ # Gate on Metal, not Base: an ActionController::API controller is not a
64
+ # Base descendant, so gating on Base would reject every API-only app.
65
+ def validate_action_controller!(klass)
66
+ unless defined?(::ActionController::Metal)
67
+ raise Coatepec::Error.new(:not_action_controller, "ActionController is not loaded in this app")
68
+ end
69
+ return if klass.is_a?(Class) && klass < ::ActionController::Metal
70
+
71
+ raise Coatepec::Error.new(:not_action_controller, "#{@name} is not an ActionController controller")
72
+ end
73
+
74
+ # action_methods is a Set of Strings. Sorted for stable output across
75
+ # runs. A public method contributed by a concern legitimately appears
76
+ # here -- Rails really would route to it, so surfacing it is the point,
77
+ # not a leak to filter out.
78
+ def action_names(klass)
79
+ klass.action_methods.to_a.map(&:to_s).sort.first(MAX_ITEMS)
80
+ end
81
+
82
+ # Slice the ancestor chain at the first ActionController::* class in it:
83
+ # ActionController::Base for a normal controller, ActionController::API
84
+ # for an API-only one. Everything before that point was inserted by the
85
+ # app, so this needs no denylist of the ~60 framework modules below it.
86
+ # Anonymous modules have a nil name and are dropped.
87
+ def concerns_for(klass)
88
+ base = framework_base(klass)
89
+ klass.ancestors
90
+ .take_while { |mod| mod != base }
91
+ .reject { |mod| mod.is_a?(Class) }
92
+ .filter_map(&:name)
93
+ .first(MAX_ITEMS)
94
+ end
95
+
96
+ def framework_base(klass)
97
+ klass.ancestors.find do |mod|
98
+ mod.is_a?(Class) && mod.name.to_s.start_with?("ActionController::")
99
+ end
100
+ end
101
+
102
+ # Matches on controller_path, Rails' own key in route defaults ("admin/reports"), through
103
+ # RouteEntries so engine controllers resolve too. Routes with no action (mounts, redirects) are skipped.
104
+ def routes_by_action(klass)
105
+ grouped_routes(klass).transform_values { |list| list.first(MAX_ITEMS).map { |entry| route_data(entry) } }
106
+ end
107
+
108
+ def grouped_routes(klass)
109
+ entries = controller_entries(klass.controller_path)
110
+ entries.group_by { |entry| entry.route.defaults[:action].to_s }.reject { |action, _| action.empty? }
111
+ end
112
+
113
+ def controller_entries(path)
114
+ RouteEntries.call(self.class.rails_routes)
115
+ .select { |entry| entry.route.defaults[:controller].to_s == path }
116
+ end
117
+
118
+ # Differenced against the controller's full action_methods set, not the
119
+ # (possibly truncated-to-MAX_ITEMS) displayed `actions` list -- a
120
+ # controller with more than MAX_ITEMS action methods would otherwise
121
+ # have every route whose action fell past the truncation point reported
122
+ # here as a false positive, in the field the README calls the tool's
123
+ # most actionable output. Bounded to MAX_ITEMS like every other
124
+ # collection in this payload; `routes_by_action` itself caps each route
125
+ # *list* but not its key count, so this is where that cap belongs.
126
+ def routes_without_action(klass, routes)
127
+ defined_actions = klass.action_methods.map(&:to_s)
128
+ (routes.keys - defined_actions).sort.first(MAX_ITEMS)
129
+ end
130
+
131
+ # path and engine come off the entry, so they are byte-identical to rails_routes' values for the
132
+ # same route. An engine route's name is engine-local: reach it as `<mount name>.<route_name>_path`.
133
+ def route_data(entry)
134
+ { verb: entry.route.verb.to_s, path: entry.path, route_name: entry.route.name&.to_s, engine: entry.engine }
135
+ end
136
+
137
+ # Isolated as a class method purely so unit tests can stub it without
138
+ # booting Rails -- Rails.application.routes.routes is otherwise only
139
+ # reachable with a real, booted application. Mirrors
140
+ # Introspection::Routes.rails_routes.
141
+ # rubocop:disable Lint/IneffectiveAccessModifier
142
+ def self.rails_routes
143
+ Rails.application.routes.routes
144
+ end
145
+ # rubocop:enable Lint/IneffectiveAccessModifier
146
+
147
+ # Gated on Metal, not Base/API, so a bare ActionController::Metal
148
+ # subclass -- which passes validate_action_controller!'s class gate but
149
+ # does not include AbstractController::Callbacks, unlike Base and API --
150
+ # gets an empty callback list instead of a NoMethodError.
151
+ def callbacks_for(klass, actions)
152
+ return [] unless klass.respond_to?(:_process_action_callbacks)
153
+
154
+ klass._process_action_callbacks.first(MAX_ITEMS).map do |callback|
155
+ conditions_for(callback, actions)
156
+ .merge(kind: callback.kind.to_s, filter: filter_description(callback.filter))
157
+ end
158
+ end
159
+
160
+ # only:/except: do not survive as readable options. Rails compiles both
161
+ # into an ActionFilter and distinguishes them purely by *placement*: the
162
+ # `only:` filter lands in the callback's @if chain, the `except:` one in
163
+ # its @unless chain. Intent is therefore recovered from which chain the
164
+ # object sits in, not from the object itself.
165
+ #
166
+ # Reaching those chains needs instance_variable_get: Callback exposes
167
+ # `kind` and `filter` publicly but has no reader for @if/@unless. That
168
+ # single private read is unavoidable; having taken it, the action set is
169
+ # then read through ActionFilter's *public* match? rather than a second
170
+ # private read of its @actions, so this keeps working if Rails changes
171
+ # how ActionFilter stores them.
172
+ def conditions_for(callback, actions)
173
+ ifs = Array(callback.instance_variable_get(:@if))
174
+ unlesses = Array(callback.instance_variable_get(:@unless))
175
+ {
176
+ only: matched_actions(ifs, actions, :all?),
177
+ except: matched_actions(unlesses, actions, :any?),
178
+ if: plain_conditions(ifs),
179
+ unless: plain_conditions(unlesses)
180
+ }
181
+ end
182
+
183
+ # nil (not []) when there is no ActionFilter at all: "this callback is
184
+ # unrestricted" and "this callback is restricted to no actions" are
185
+ # different facts and must not serialize identically.
186
+ #
187
+ # A chain routinely carries *more than one* ActionFilter: skip_callback
188
+ # (ActiveSupport::Callbacks::Callback#merge_conditional_options)
189
+ # concatenates a skip's normalized only:/except: onto the callback's
190
+ # existing @if/@unless chain rather than replacing it, so any
191
+ # `skip_before_action ..., only:`/`except:` leaves two ActionFilters
192
+ # behind. A callback only runs when *every* @if condition holds and
193
+ # *none* of its @unless conditions hold (ActiveSupport::Callbacks'
194
+ # run_callbacks ANDs @if and ANDs the negation of each @unless), so:
195
+ # only: is every @if ActionFilter's matches intersected (combinator
196
+ # :all? -- all must hold for the action to run the callback), and
197
+ # except: is every @unless ActionFilter's matches unioned (combinator
198
+ # :any? -- any one holding is enough to skip it). Keeping only the
199
+ # first ActionFilter in the chain (as a naive `find` would) silently
200
+ # drops every skip layered on top of it, which always biases toward
201
+ # over-reporting protection -- exactly backwards for a tool whose job is
202
+ # to answer "is this action protected?".
203
+ def matched_actions(conditions, actions, combinator)
204
+ filters = conditions.select { |condition| action_filter?(condition) }
205
+ return nil if filters.empty?
206
+
207
+ actions.select do |action|
208
+ filters.public_send(combinator) { |filter| filter.match?(CallbackProbe.new(action, false)) }
209
+ end
210
+ end
211
+
212
+ def action_filter?(condition)
213
+ defined?(::AbstractController::Callbacks::ActionFilter) &&
214
+ condition.is_a?(::AbstractController::Callbacks::ActionFilter)
215
+ end
216
+
217
+ # The conditions that are *not* only:/except: -- a real `if:`/`unless:`.
218
+ # A Symbol is a method reference and safe to name; a Proc must never be
219
+ # serialized (Proc#to_s leaks the app's absolute source path).
220
+ def plain_conditions(conditions)
221
+ conditions.reject { |condition| action_filter?(condition) }
222
+ .map { |condition| filter_description(condition) }
223
+ end
224
+
225
+ # A Symbol filter (the method-reference form, e.g.
226
+ # `before_action :require_login`) is a method reference and safe to
227
+ # report by name; a Proc must never be serialized, because Proc#to_s
228
+ # leaks the app's absolute source path, so it is reduced to "(block)"
229
+ # instead. Introspection::SafeOptions guards the same class of leak
230
+ # elsewhere, by silently dropping Procs from an options hash rather than
231
+ # substituting a placeholder -- a different mechanism for the same rule.
232
+ def filter_description(filter)
233
+ case filter
234
+ when Symbol then filter.to_s
235
+ when Proc then "(block)"
236
+ else filter.class.name || "(anonymous filter class)"
237
+ end
238
+ end
239
+ end
240
+ # rubocop:enable Metrics/ClassLength
241
+ end
242
+ end
@@ -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