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 +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +28 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE.txt +21 -0
- data/README.md +164 -0
- data/Rakefile +24 -0
- data/exe/coatepec +7 -0
- data/exe/coatepec-worker +12 -0
- data/lib/coatepec/cli.rb +40 -0
- data/lib/coatepec/errors.rb +15 -0
- data/lib/coatepec/introspection/model.rb +140 -0
- data/lib/coatepec/introspection/routes.rb +61 -0
- data/lib/coatepec/introspection/safe_options.rb +46 -0
- data/lib/coatepec/mcp/response.rb +40 -0
- data/lib/coatepec/mcp/tools.rb +139 -0
- data/lib/coatepec/mcp.rb +22 -0
- data/lib/coatepec/project.rb +30 -0
- data/lib/coatepec/project_config.rb +44 -0
- data/lib/coatepec/protocol.rb +30 -0
- data/lib/coatepec/spec/fork_strategy.rb +39 -0
- data/lib/coatepec/spec/guarded_fork_strategy.rb +116 -0
- data/lib/coatepec/spec/path_policy.rb +92 -0
- data/lib/coatepec/spec/process_strategy.rb +107 -0
- data/lib/coatepec/spec/result.rb +79 -0
- data/lib/coatepec/spec/runner.rb +62 -0
- data/lib/coatepec/spec/spawn_strategy.rb +19 -0
- data/lib/coatepec/version.rb +5 -0
- data/lib/coatepec/worker/change_detector.rb +44 -0
- data/lib/coatepec/worker/client.rb +101 -0
- data/lib/coatepec/worker/rails_runtime.rb +140 -0
- data/lib/coatepec/worker/server.rb +65 -0
- data/lib/coatepec/worker_manager.rb +87 -0
- data/lib/coatepec.rb +28 -0
- data/sig/coatepec.rbs +4 -0
- metadata +116 -0
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
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
data/exe/coatepec-worker
ADDED
|
@@ -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
|
data/lib/coatepec/cli.rb
ADDED
|
@@ -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
|