coatepec 0.4.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 503cdd08a244ae010df42c9698c214d4a1e916969bae0f96ddaa62dfed2c2157
4
+ data.tar.gz: 54672230e5e1905f78f53d5614be4dccbcf0b5a9392fd3d0caae22dcf7d8c719
5
+ SHA512:
6
+ metadata.gz: f33a3b662004ab4aaba6d80ade9176952c90730ebe44a009f4f3443db5888c56b7fd8094b87c17b7be8a29519f7f067c2c6efcdb796249a4da3bc81945b236af
7
+ data.tar.gz: 8bfe0dccc1861c8b8923a8e049898ad8ead99da9febc5b85274914dad61ac52f06c3d5726e14d05b097e425e9a6c98eba1e3bca3bc98a51b6ea1227fb4b96bf7
data/.rspec ADDED
@@ -0,0 +1,4 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
4
+ --exclude-pattern "spec/fixtures/**/*_spec.rb"
data/.rubocop.yml ADDED
@@ -0,0 +1,28 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.2
3
+ Exclude:
4
+ - "spec/fixtures/**/*"
5
+ # CI's `bundler-cache: true` installs gems into ./vendor/bundle (inside
6
+ # the repo tree, for caching) rather than the system gem home. Without
7
+ # this, RuboCop recursively scans installed gems too and can pick up a
8
+ # dependency's own dev-only .rubocop.yml (e.g. rbs ships one declaring
9
+ # plugins we don't have installed), crashing with a LoadError.
10
+ - "vendor/**/*"
11
+
12
+ Style/StringLiterals:
13
+ EnforcedStyle: double_quotes
14
+
15
+ Style/StringLiteralsInInterpolation:
16
+ EnforcedStyle: double_quotes
17
+
18
+ Metrics/BlockLength:
19
+ # RSpec's describe/context/it DSL naturally produces long blocks; this is the
20
+ # standard exclusion used by rubocop-rspec's own default config. inherit_mode
21
+ # merges this onto (rather than replacing) RuboCop's own default Exclude list
22
+ # for this cop, which already exempts **/*.gemspec -- a plain `Exclude:` here
23
+ # would silently drop that default exemption and flag coatepec.gemspec.
24
+ inherit_mode:
25
+ merge:
26
+ - Exclude
27
+ Exclude:
28
+ - "spec/**/*_spec.rb"
data/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0
4
+
5
+ - Add `enums` to `rails_model`'s output, sourced from ActiveRecord's own
6
+ `defined_enums` API (pure in-memory, no DB query, no eval). Attempted
7
+ unconditionally, unlike `columns`/`associations`, since enum declarations
8
+ don't need a real table.
9
+
10
+ ## 0.3.0
11
+
12
+ - Add `rails_routes` and `rails_model` MCP tools: bounded, filterable route
13
+ listing and ActiveRecord schema/association/validator introspection,
14
+ both running synchronously in the existing warm test worker (no
15
+ fork/spawn). `rails_model` is restricted to `ActiveRecord::Base`
16
+ descendants, resolved only via `safe_constantize` against a validated
17
+ constant-name pattern -- no eval, no arbitrary method dispatch.
18
+ - The warm worker now forces Rails' reload-checking on at boot (overriding
19
+ the target app's own `test.rb`, which disables it by default) and wraps
20
+ every dispatched command in `Rails.application.reloader.wrap`, so edited
21
+ model files no longer serve stale metadata without a worker restart.
22
+ Benefits `rails_runtime_status` and `rails_spec_run`'s fork path too.
23
+ Note that this applies process-wide: `rails_spec_run` also executes specs
24
+ under the forced `enable_reloading`/`cache_classes` settings rather than
25
+ the app's own `test.rb` values. The file watcher is pinned to the polling
26
+ `ActiveSupport::FileUpdateChecker` at the same time, so an app configuring
27
+ `ActiveSupport::EventedFileUpdateChecker` doesn't get `listen` threads
28
+ started inside the warm worker (and inside the fork guard's baseline).
29
+
30
+ ## 0.2.0
31
+
32
+ - Add an opt-in, guarded `Process.fork` strategy for macOS
33
+ (`macos_fork: true` in a project's `.coatepec.yml`), closing most of the
34
+ warm-worker performance gap that previously only benefited Linux. Guards
35
+ a live thread-count check and a loaded-gem denylist against fork-unsafety,
36
+ and transparently falls back to a fresh spawn -- with the remaining
37
+ timeout budget, and the crashed child's stderr for diagnosis -- if a
38
+ guard fails or the forked child crashes. `rails_spec_run` results gain
39
+ an `execution_mode` field when this strategy is in play.
40
+
41
+ ## 0.1.0
42
+
43
+ - Initial implementation: `rails_spec_run` and `rails_runtime_status` MCP tools,
44
+ a warm Rails test worker with fork (Linux) / spawn (macOS) isolation per
45
+ RSpec run, and a path-selector allowlist as the security boundary.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Enrique Mogollan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # Coatepec
2
+
3
+ Coatepec gives coding agents a bounded way to run targeted RSpec examples
4
+ against a Rails application, over MCP, without exposing a general Rails
5
+ console.
6
+
7
+ ## Quickstart
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ group :development, :test do
12
+ gem "coatepec", require: false
13
+ end
14
+ ```
15
+
16
+ ```bash
17
+ bundle install
18
+ bundle exec coatepec --version
19
+ ```
20
+
21
+ Configure your MCP client to run `bundle exec coatepec --root /absolute/path/to/app`
22
+ from the Rails application's own bundle.
23
+
24
+ With the [Claude Code CLI](https://docs.claude.com/en/docs/claude-code),
25
+ from the Rails app's own root:
26
+
27
+ ```bash
28
+ claude mcp add coatepec --scope project -- bundle exec coatepec --root .
29
+ ```
30
+
31
+ That writes a project-scoped `.mcp.json` you can commit so the whole team
32
+ gets it. For any other MCP-compatible client, or to write it by hand, the
33
+ same thing looks like:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "coatepec": {
39
+ "command": "bundle",
40
+ "args": ["exec", "coatepec", "--root", "."]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## Architecture
47
+
48
+ ```text
49
+ MCP client
50
+ |
51
+ | JSON-RPC over stdio
52
+ v
53
+ Coatepec parent (Rails-free)
54
+ `-- private NDJSON --> test worker (Rails "test", booted lazily, kept warm)
55
+ |-- Linux: Process.fork --> isolated RSpec child
56
+ `-- macOS: Process.spawn --> fresh RSpec process (default)
57
+ Process.fork, guarded --> opt-in, see Configuration
58
+ ```
59
+
60
+ ## Configuration
61
+
62
+ An optional `.coatepec.yml` at the target Rails app's root enables
63
+ per-project settings:
64
+
65
+ ```yaml
66
+ macos_fork: true # opt into forking on macOS (see below)
67
+ macos_fork_unsafe_gems: [some_gem] # extends the built-in fork-unsafe denylist
68
+ ```
69
+
70
+ A missing file means every setting takes its default -- this file is never
71
+ required.
72
+
73
+ ### macOS fork (experimental, opt-in)
74
+
75
+ On macOS, `rails_spec_run` normally spawns a fresh `bundle exec rspec`
76
+ process per call, re-booting Rails every time -- the warm-worker speedup
77
+ described above only applies on Linux by default. Setting `macos_fork:
78
+ true` lets Coatepec attempt `Process.fork` on macOS too, reusing the warm
79
+ boot the way Linux does.
80
+
81
+ This is opt-in because forking a process with native extensions loaded
82
+ isn't universally safe. Before each fork, Coatepec checks the worker's
83
+ live thread count against its post-boot baseline and its loaded gems
84
+ against a denylist, falling back to a fresh spawn for that one call if
85
+ either check looks risky. The built-in denylist ships empty -- no single
86
+ gem has been confirmed as the culprit yet -- so the guard is effectively
87
+ thread-count-only until a project adds its own
88
+ `macos_fork_unsafe_gems`. If a fork is attempted and the child crashes
89
+ anyway, Coatepec transparently retries via spawn and returns that result
90
+ -- fork stays enabled for later calls. Every `rails_spec_run` result
91
+ includes an `execution_mode` field (`fork`, `spawn_fallback`, or
92
+ `spawn_after_crash`) so you can see which path actually ran for a given
93
+ call; `spawn_after_crash` results also carry the crashed fork's own stderr
94
+ under `crashed_fork_stderr` so the crash can be diagnosed.
95
+
96
+ ## Tools
97
+
98
+ | Tool | Input | Notes |
99
+ |---|---|---|
100
+ | `rails_spec_run` | `paths: string[1..100]`, `example?`, `seed?`, `fail_fast?`, `timeout_seconds?` (1..900, default 120) | Isolated per run; output capped at 256 KiB per stream |
101
+ | `rails_runtime_status` | `{}` | Reports Ruby/Rails versions, worker PID, boot_id, lifecycle state |
102
+ | `rails_routes` | `query?`, `limit?` (1..200, default 50), `offset?` | Case-insensitive filter across name/verb/path/controller/action |
103
+ | `rails_model` | `name` (constant path, e.g. `Widget` or `Admin::Widget`) | ActiveRecord models only; columns, associations, validators, enums -- no row data |
104
+
105
+ The warm test worker forces Rails' reload-checking on for its own boot,
106
+ regardless of the target app's own `test.rb` setting (which disables it by
107
+ default) -- so editing a model file takes effect on the next tool call
108
+ without needing to restart Coatepec. This is not limited to metadata reads:
109
+ because the setting is applied to the whole worker process, `rails_spec_run`
110
+ also executes your specs with `enable_reloading = true` and `cache_classes =
111
+ false` rather than whatever your own `config/environments/test.rb` asks for
112
+ (the file watcher is additionally pinned to the polling
113
+ `ActiveSupport::FileUpdateChecker`, so no `listen` threads are started in the
114
+ worker). For most apps this is invisible, but if you ever see behavior differ
115
+ between Coatepec and your own `bundle exec rspec`, this is the first thing to
116
+ suspect.
117
+
118
+ ## Security boundary
119
+
120
+ No eval, console, SQL/record access, shell, Rake, or file-write tool. Spec
121
+ selectors must resolve inside an allowed spec root (`spec/`, `packs/*/spec/`,
122
+ `engines/*/spec/`, `gems/*/spec/`); absolute paths, `..`, symlink escapes,
123
+ non-`_spec.rb` files, and more than 100 selectors are rejected. RSpec still
124
+ executes application-controlled code; only run Coatepec against a trusted
125
+ checkout.
126
+
127
+ ### Compared to Rails Active MCP
128
+
129
+ [Rails Active MCP](https://github.com/GoodPie/rails-active-mcp) is a
130
+ different MCP server for Rails apps built around a `console_execute` tool:
131
+ arbitrary Ruby runs in your Rails console, gated by pattern-based
132
+ "dangerous operation" detection (blocking things like mass deletions,
133
+ `eval`, or raw SQL) rather than by not offering code execution at all --
134
+ sophisticated bypasses of a denylist like that are always possible in
135
+ principle.
136
+
137
+ Coatepec takes the opposite approach: there's no eval, console, or SQL
138
+ tool to begin with. `rails_spec_run` only ever executes RSpec files that
139
+ already exist under the app's own allowed spec roots, and `rails_routes`/
140
+ `rails_model` only ever call structured, read-only Rails APIs
141
+ (`Rails.application.routes.routes`, `ActiveRecord` reflection) -- never
142
+ `eval`, `const_get` on unvalidated input, or arbitrary method dispatch. If
143
+ you genuinely need a Rails console over MCP, Rails Active MCP is built for
144
+ that; Coatepec is for teams who want an agent to run specs and read
145
+ structure without ever handing it a REPL.
146
+
147
+ ## Compatibility
148
+
149
+ Ruby `>= 3.2`, Rails `>= 7.1, < 8.2`. CI tests three lanes: Rails 8.1 on
150
+ Linux (primary), Rails 7.1 on Linux (compat), and Rails 8.1 on macOS (which
151
+ is where the guarded-fork path above actually forks).
152
+
153
+ ## Development
154
+
155
+ ```bash
156
+ bundle install
157
+ bundle exec rake spec:unit # fast, no Rails boot
158
+ bundle exec rake spec:integration # boots the fixture Rails app
159
+ bundle exec rake # full suite + rubocop
160
+ ```
161
+
162
+ ## License
163
+
164
+ Coatepec is available under the MIT License. See `LICENSE.txt`.
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ namespace :spec do
9
+ desc "Run unit specs (fast, no Rails boot)"
10
+ RSpec::Core::RakeTask.new(:unit) do |t|
11
+ t.pattern = "spec/unit/**/*_spec.rb"
12
+ end
13
+
14
+ desc "Run integration specs (boots the fixture Rails app)"
15
+ RSpec::Core::RakeTask.new(:integration) do |t|
16
+ t.pattern = "spec/integration/**/*_spec.rb"
17
+ end
18
+ end
19
+
20
+ require "rubocop/rake_task"
21
+
22
+ RuboCop::RakeTask.new
23
+
24
+ task default: %i[spec rubocop]
data/exe/coatepec ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "coatepec/mcp"
5
+ require "coatepec/cli"
6
+
7
+ Coatepec::CLI.run(ARGV)
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "coatepec"
5
+
6
+ # Preserve a real handle to the OS stdout fd for the private NDJSON
7
+ # protocol before Rails boot has a chance to write banners/logs to it.
8
+ protocol_output = $stdout.dup
9
+ $stdout = $stderr
10
+
11
+ project_root = ARGV[0] || Dir.pwd
12
+ Coatepec::Worker::Server.new(project_root, input: $stdin, protocol_output: protocol_output).run
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ # Entry point for the `coatepec` executable: parses `--root`/`--debug`/
5
+ # `--version`/`--help`, then runs the MCP server over stdio.
6
+ class CLI
7
+ def self.run(argv)
8
+ options = parse(argv)
9
+ project = Coatepec::Project.new(options[:root] || Dir.pwd)
10
+ worker_manager = Coatepec::WorkerManager.new(project)
11
+ server = Coatepec::MCP.build_server(project: project, worker_manager: worker_manager)
12
+ ::MCP::Server::Transports::StdioTransport.new(server).open
13
+ rescue Coatepec::Error => e
14
+ warn "coatepec: #{e.code}: #{e.message}"
15
+ exit 1
16
+ end
17
+
18
+ def self.parse(argv)
19
+ options = { root: nil, debug: false }
20
+ remaining = argv.dup
21
+ while (arg = remaining.shift)
22
+ apply_flag!(options, arg, remaining)
23
+ end
24
+ options
25
+ end
26
+
27
+ def self.apply_flag!(options, arg, remaining)
28
+ case arg
29
+ when "--root" then options[:root] = remaining.shift
30
+ when "--debug" then (options[:debug] = true
31
+ ENV["COATEPEC_DEBUG"] = "1")
32
+ when "--version" then (puts Coatepec::VERSION
33
+ exit 0)
34
+ when "--help" then (puts "Usage: coatepec --root PATH [--debug]"
35
+ exit 0)
36
+ end
37
+ end
38
+ private_class_method :apply_flag!
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ # Structured error carrying a stable machine-readable `code` (and optional
5
+ # `details`) that MCP tool handlers translate into JSON error responses.
6
+ class Error < StandardError
7
+ attr_reader :code, :details
8
+
9
+ def initialize(code, message = nil, details: {})
10
+ @code = code
11
+ @details = details
12
+ super(message || code.to_s)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Introspection
5
+ # Returns bounded ActiveRecord schema and class metadata for a single
6
+ # model, for the rails_model MCP tool. No records, no SQL beyond schema
7
+ # reflection, no method dispatch on the resolved class beyond pure
8
+ # introspection APIs.
9
+ class Model
10
+ NAME_PATTERN = /\A[A-Z]\w*(?:::[A-Z]\w*)*\z/
11
+ MAX_ITEMS = 200
12
+ EMPTY_TABLE_METADATA = { table_name: nil, primary_key: nil, columns: [] }.freeze
13
+
14
+ def initialize(name)
15
+ @name = name
16
+ end
17
+
18
+ def call
19
+ validate_name!
20
+ klass = resolve!
21
+ validate_active_record!(klass)
22
+ build_metadata(klass)
23
+ end
24
+
25
+ private
26
+
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.
31
+ 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
+ abstract = klass.abstract_class? || false
37
+ 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
+ }
49
+ end
50
+
51
+ # A concrete model can still name a table that isn't there: a checkout
52
+ # whose test database is behind on migrations, a view-backed model, a
53
+ # model living in a database this process isn't connected to. That is
54
+ # deliberately *not* folded into the abstract-class handling above --
55
+ # an abstract class declares "I have no table", so empty column data is
56
+ # the truthful answer for it, whereas a missing table is a real
57
+ # mismatch the caller needs told about. Reporting it as a table with
58
+ # zero columns would read as "this model has no columns", which is a
59
+ # lie. It gets its own structured code instead of escaping as the
60
+ # server's catch-all :internal_error. The exception's own message is
61
+ # not interpolated: it carries driver-specific SQL text.
62
+ def table_metadata(klass)
63
+ { table_name: klass.table_name, primary_key: klass.primary_key, columns: columns_for(klass) }
64
+ rescue ::ActiveRecord::StatementInvalid
65
+ raise Coatepec::Error.new(
66
+ :table_not_found,
67
+ "#{@name}'s table (#{klass.table_name}) does not exist or could not be read"
68
+ )
69
+ end
70
+
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
+ def columns_for(klass)
89
+ klass.columns.first(MAX_ITEMS).map do |column|
90
+ { name: column.name, type: column.type.to_s, sql_type: column.sql_type, null: column.null,
91
+ default: column.default }
92
+ end
93
+ end
94
+
95
+ def associations_for(klass)
96
+ klass.reflect_on_all_associations.first(MAX_ITEMS).map { |assoc| build_association_data(assoc) }
97
+ end
98
+
99
+ def build_association_data(assoc)
100
+ {
101
+ name: assoc.name.to_s,
102
+ macro: assoc.macro.to_s,
103
+ class_name: association_class_name(assoc),
104
+ foreign_key: assoc.foreign_key.to_s,
105
+ through: assoc.through_reflection&.name&.to_s,
106
+ polymorphic: assoc.polymorphic? || false
107
+ }
108
+ end
109
+
110
+ # A polymorphic belongs_to has no single fixed target class (assoc.klass
111
+ # raises ArgumentError for those), and a reflection whose class_name
112
+ # points at a constant that doesn't exist raises NameError. Both are
113
+ # legitimate, introspectable states -- report class_name: nil rather
114
+ # than letting either crash the whole call.
115
+ def association_class_name(assoc)
116
+ return nil if assoc.polymorphic?
117
+
118
+ assoc.klass.name
119
+ rescue NameError, ArgumentError
120
+ nil
121
+ end
122
+
123
+ def validators_for(klass)
124
+ klass.validators.first(MAX_ITEMS).map do |validator|
125
+ {
126
+ name: validator.class.name,
127
+ attributes: validator.attributes.map(&:to_s),
128
+ options: SafeOptions.call(validator.options)
129
+ }
130
+ end
131
+ end
132
+
133
+ def enums_for(klass)
134
+ klass.defined_enums.first(MAX_ITEMS).map do |name, values|
135
+ { name: name, values: values.first(MAX_ITEMS).to_h }
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ 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.
8
+ class Routes
9
+ MAX_LIMIT = 200
10
+ DEFAULT_LIMIT = 50
11
+
12
+ def initialize(query: nil, limit: DEFAULT_LIMIT, offset: 0)
13
+ @query = query
14
+ @limit = [limit || DEFAULT_LIMIT, MAX_LIMIT].min
15
+ @offset = offset || 0
16
+ end
17
+
18
+ def call
19
+ matched = filtered_items
20
+ {
21
+ items: matched[@offset, @limit] || [],
22
+ matched: matched.size,
23
+ limit: @limit,
24
+ offset: @offset
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def filtered_items
31
+ items = all_items
32
+ return items unless @query
33
+
34
+ query_downcased = @query.downcase
35
+ items.select { |item| item.values.compact.any? { |v| v.to_s.downcase.include?(query_downcased) } }
36
+ end
37
+
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
+ }
48
+ end
49
+ end
50
+
51
+ # Isolated as a class method purely so unit tests can stub it without
52
+ # booting Rails -- Rails.application.routes.routes is otherwise only
53
+ # reachable with a real, booted application.
54
+ # rubocop:disable Lint/IneffectiveAccessModifier
55
+ def self.rails_routes
56
+ Rails.application.routes.routes
57
+ end
58
+ # rubocop:enable Lint/IneffectiveAccessModifier
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coatepec
4
+ module Introspection
5
+ # Filters an ActiveRecord validator's `options` hash down to genuinely
6
+ # primitive values before it's serialized as rails_model output.
7
+ # Validator options can carry arbitrary Ruby objects (Proc for `if:`/
8
+ # `unless:`, Regexp for `with:`, etc.) that must never be JSON-serialized
9
+ # as-is -- Proc#to_s leaks the target app's absolute source file path
10
+ # and line number. Only genuinely primitive values pass through;
11
+ # anything else is silently dropped.
12
+ module SafeOptions
13
+ module_function
14
+
15
+ def call(options)
16
+ options.filter_map do |key, value|
17
+ safe_value = value_for(value)
18
+ [key.to_s, safe_value] unless safe_value.nil? && !value.nil?
19
+ end.to_h
20
+ end
21
+
22
+ def value_for(value)
23
+ case value
24
+ when String, Numeric, TrueClass, FalseClass, NilClass
25
+ value
26
+ when Symbol
27
+ value.to_s
28
+ when Array
29
+ array_value_for(value)
30
+ end
31
+ end
32
+
33
+ # Symbol *elements* are stringified exactly as a bare Symbol value is
34
+ # above: `inclusion: { in: %i[draft published] }` is one of the most
35
+ # common option shapes in Rails, and dropping the whole `in:` key for it
36
+ # while keeping the single-Symbol equivalent would be arbitrary. An
37
+ # array still holding anything non-primitive after that is dropped
38
+ # whole -- a mixed array's non-primitive members can't be silently
39
+ # elided without misrepresenting the option.
40
+ def array_value_for(value)
41
+ stringified = value.map { |element| element.is_a?(Symbol) ? element.to_s : element }
42
+ stringified.all? { |element| element.is_a?(String) || element.is_a?(Numeric) } ? stringified : nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+
6
+ module Coatepec
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.
11
+ 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.
16
+ MAX_RESPONSE_BYTES = 1024 * 1024
17
+
18
+ module_function
19
+
20
+ def ok(data:, meta: {})
21
+ text = JSON.pretty_generate(ok: true, data: data, meta: meta)
22
+ return oversized_response if text.bytesize > MAX_RESPONSE_BYTES
23
+
24
+ ::MCP::Tool::Response.new([{ type: "text", text: text }])
25
+ end
26
+
27
+ def error(err)
28
+ payload = {
29
+ ok: false,
30
+ error: { code: err.code.to_s, message: err.message, details: err.details }
31
+ }
32
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }], error: true)
33
+ end
34
+
35
+ def oversized_response
36
+ error(Coatepec::Error.new(:response_too_large, "Result exceeds the 1 MiB response limit"))
37
+ end
38
+ end
39
+ end
40
+ end