rails-mcp-server 1.5.1 → 1.6.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 +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +25 -5
- data/docs/AGENT.md +12 -0
- data/docs/COPILOT_AGENT.md +1 -1
- data/docs/GOVERNED_CLIENTS.md +44 -0
- data/exe/rails-mcp-config +3 -2
- data/lib/rails-mcp-server/analyzers/analyze_models.rb +59 -17
- data/lib/rails-mcp-server/analyzers/base_analyzer.rb +4 -1
- data/lib/rails-mcp-server/tools/execute_ruby.rb +220 -43
- data/lib/rails-mcp-server/utilities/run_process.rb +102 -10
- data/lib/rails-mcp-server/version.rb +1 -1
- metadata +10 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61b6de0c4ec67e88054b3fa1b91cfc3d0a9b6cb5d1c565c5086b1e88dfcd7dda
|
|
4
|
+
data.tar.gz: 6af401f97462dd368072b223117fab10f49da0b873f7b2f29985da63ec4d8a98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8cd00e7e51074c682b42ddcbd07dc7697547313c4712c69c0641f6357bbde84775bc8425fe5cad1b05807ca97c065e6d015a5d0aa03079dd371f39e3f664a331
|
|
7
|
+
data.tar.gz: 20d054080683e9c8151075fcd6084eaaa868b549c99128768a18abe37bb1ec1ab96c32661273920fd03d100eea37c47c92e36ea6a11c266eff21239f84545d54
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.6.0] - 2026-08-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Namespaced model resolution in `analyze_models`**: Module-namespaced models now resolve from every input form — `Namespace::Model`, the file path `namespace/model`, the flattened `NamespaceModel`, and the bare leaf `Model` — independent of the app's custom inflections. Previously namespaced models could be reported as "not found".
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Dropped Ruby 3.2 support** (breaking): The minimum supported Ruby is now 3.3 (`required_ruby_version >= 3.3.0`), and the CI matrix tests Ruby 3.3 and 3.4. Dependency updates pull in transitive gems (`dry-configurable` 1.4.0, `parallel` 2.1.0) that require Ruby >= 3.3.
|
|
19
|
+
- **Dependency updates**: Bumped project dependencies, including major upgrades to `puma` (~> 8.0), `minitest` (~> 6.0) and `mocha` (~> 3.0), plus `activesupport` 8.1.3.1, `addressable` 2.9.0, `rubocop` 1.88.2, `standard` 1.56.0 and other transitive gems.
|
|
20
|
+
- **Deterministic linting**: Added `.standard.yml` pinning `ruby_version: 3.3` to match the gemspec's minimum supported Ruby, so Standard/RuboCop target the supported floor regardless of the local or CI Ruby.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **Version-manager Ruby resolution for Rails-runner tools** (mise/asdf/rbenv agnostic): Tools that shell out to `bin/rails` (`execute_ruby`, `get_schema`, and the introspection half of `analyze_models` / `analyze_controller_views`) no longer fall back to the system Ruby on machines managed by mise or asdf. The runner previously exported the rbenv-only `RBENV_VERSION` and used a login shell (`$SHELL -l -c`); on macOS `path_helper` then reordered `PATH` so `bin/rails` booted under system Ruby and failed. It now prepends the active manager's shims directory (mise/asdf/rbenv, honoring `MISE_DATA_DIR`/`XDG_DATA_HOME`/`ASDF_DATA_DIR`/`RBENV_ROOT`) to the subprocess `PATH` and runs a non-login shell, so the project's Ruby is used. rvm (which has no shims) is still sourced when present.
|
|
25
|
+
- **`analyze_models` introspection constant**: The introspection runner now derives the canonical constant from the resolved model file (loaded via `Object.const_get`) instead of interpolating the raw user input. This fixes invalid-Ruby / `NameError` failures for path and flattened inputs, degrades non-ActiveRecord constants to a clear message, and removes an unvalidated-input injection surface in the generated runner scripts.
|
|
26
|
+
- **Analyzer errors no longer swallowed**: The analyzer runner path dropped `2>/dev/null`, so a Rails boot failure now surfaces the real error instead of a blank "Error executing Rails command".
|
|
27
|
+
- **`execute_ruby` timezone data access**: The sandbox now allows read-only access to system timezone directories (`/usr/share/zoneinfo`, `/usr/share/lib/zoneinfo`, `/etc/zoneinfo`, `/var/db/timezone`). Previously, any code that touched `Time.zone` failed with `PATH ERROR: Access denied: path '/usr/share/zoneinfo/...' is outside project directory` because TZInfo lazily loads IANA timezone data on first use. Writes and all other out-of-project reads remain blocked.
|
|
28
|
+
|
|
29
|
+
### Security
|
|
30
|
+
|
|
31
|
+
- **`execute_ruby` sandbox hardening**: Closed several read-path bypasses and added defense-in-depth layers to the sandbox.
|
|
32
|
+
- **File-read coverage**: `IO.read`/`readlines`/`binread`/`foreach` and `File.readlines`/`binread`/`foreach` are now sandboxed too (previously only `File.read`/`open` were, so `IO.read('/etc/passwd')` and `File.readlines` bypassed path validation). The raw native readers are no longer exposed as public `File.original_read`-style aliases.
|
|
33
|
+
- **Symlink resolution**: path validation now resolves symlinks (`realpath`) before checking, so a link inside the project can't point outside it. The system-timezone allowlist is matched against canonical (symlink-resolved) locations so it keeps working on macOS.
|
|
34
|
+
- **Broader `ENV` block**: the static scan now rejects all `ENV` access (`ENV.to_h`, `ENV.values_at`, `ENV.each`, …), not just `ENV[]`/`ENV.fetch`.
|
|
35
|
+
- **Database writes rolled back**: user code runs inside a transaction that is always rolled back, so accidental `delete_all`/`update`/`save`/raw DML are undone. (Harm reduction — DDL may auto-commit on some adapters and `after_commit` callbacks are suppressed.)
|
|
36
|
+
- **Timeout actually stops runaway code**: the execution timeout now kills the entire process group, so a runaway `bin/rails runner` is terminated instead of being orphaned while the parent stops waiting.
|
|
37
|
+
- **Confirmation for dual-use constructs**: `send`, `public_send`, `const_get`, and `Kernel#open` are no longer run implicitly. The tool returns a `CONFIRMATION REQUIRED` message; callers must opt in with the new `confirm_risky: true` parameter after a human reviews the code.
|
|
38
|
+
- **Puma advisories resolved**: Upgrading to `puma` 8.0.2 addresses CVE-2026-47736 and CVE-2026-47737 (both HIGH — PROXY Protocol v1 remote memory exhaustion and repeated-header handling). Dependency updates also clear the `concurrent-ruby` ReadWriteLock advisory (GHSA-6wx8-w4f5-wwcr). `bundler-audit` now reports no vulnerabilities.
|
|
39
|
+
|
|
8
40
|
## [1.5.1] - 2026-03-04
|
|
9
41
|
|
|
10
42
|
### Changed
|
|
@@ -323,6 +355,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
323
355
|
|
|
324
356
|
## Version History Summary
|
|
325
357
|
|
|
358
|
+
- **v1.6.0** (2026-08-03): Sandbox hardening for `execute_ruby`, version-manager Ruby resolution, namespaced model resolution, dependency + security updates (drops Ruby 3.2)
|
|
326
359
|
- **v1.5.1** (2026-03-04): Relaxed dependency version constraints for better compatibility
|
|
327
360
|
- **v1.4.0** (2025-12-10): Context-efficient architecture with progressive tool discovery (67% token reduction)
|
|
328
361
|
- **v1.2.3** (2025-12-10): Setup script fix for readonly filesystems (NixOS compatibility)
|
data/README.md
CHANGED
|
@@ -207,9 +207,11 @@ After running the script, restart Claude Desktop to apply the changes.
|
|
|
207
207
|
|
|
208
208
|
### Ruby Version Manager Users
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
Two different Rubies are involved, and the server handles them differently.
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
#### 1. The Ruby that runs the MCP server
|
|
213
|
+
|
|
214
|
+
Your MCP client (e.g. Claude Desktop) launches the server using your system's default Ruby, bypassing version-manager initialization. The server must run on the Ruby where its gem is installed, or startup fails. Point the client's `command` at that Ruby's absolute path — for a version manager, its shim works:
|
|
213
215
|
|
|
214
216
|
```json
|
|
215
217
|
{
|
|
@@ -222,9 +224,15 @@ If you are using a Ruby version manager such as rbenv, you can use the Ruby shim
|
|
|
222
224
|
}
|
|
223
225
|
```
|
|
224
226
|
|
|
225
|
-
Replace
|
|
227
|
+
Replace `/home/your_user/.rbenv/shims/ruby` with your actual Ruby path (an rbenv/mise/asdf shim, or your `rvm`/`chruby` Ruby).
|
|
228
|
+
|
|
229
|
+
**Tip**: The `rails-mcp-config` tool detects this Ruby automatically (via `RbConfig.ruby`) and writes the correct absolute path when configuring Claude Desktop.
|
|
230
|
+
|
|
231
|
+
#### 2. The Ruby used to introspect each Rails project
|
|
232
|
+
|
|
233
|
+
Tools that boot your app — `execute_ruby`, `get_schema`, and the introspection half of `analyze_models` / `analyze_controller_views` — run `bin/rails` inside the project directory. The server selects the **project's** Ruby automatically and is agnostic to your version manager: it prepends the active manager's shims (**mise**, **asdf**, **rbenv**) to the subprocess `PATH` and sources **rvm** when present, then uses a non-login shell so macOS `path_helper` cannot substitute the system Ruby. The version is taken from the project's `.ruby-version` / `.tool-versions` / `.mise.toml`, so different projects can use different Rubies with no extra configuration.
|
|
226
234
|
|
|
227
|
-
|
|
235
|
+
> No manual `PATH` workaround is needed. Previously these tools could fall back to the system Ruby on mise/asdf machines, where the app's Bundler then failed to boot.
|
|
228
236
|
|
|
229
237
|
### Using an MCP Proxy (Advanced)
|
|
230
238
|
|
|
@@ -410,6 +418,7 @@ After switching, you'll see a Quick Start guide with common commands.
|
|
|
410
418
|
|
|
411
419
|
- `code`: (String, required) Ruby code to execute
|
|
412
420
|
- `timeout`: (Integer, optional) Timeout in seconds (default: 30, max: 60)
|
|
421
|
+
- `confirm_risky`: (Boolean, optional) Set `true` only after you have explicitly approved code that uses dual-use constructs (`send`, `public_send`, `const_get`, `Kernel#open`). When false/absent, such code is not executed — the tool returns a `CONFIRMATION REQUIRED` message explaining the risk instead.
|
|
413
422
|
|
|
414
423
|
**Available helper methods:**
|
|
415
424
|
|
|
@@ -420,7 +429,16 @@ After switching, you'll see a Quick Start guide with common commands.
|
|
|
420
429
|
|
|
421
430
|
**Note:** Use `puts` to see output from your code.
|
|
422
431
|
|
|
423
|
-
**Security:** The sandbox
|
|
432
|
+
**Security:** The sandbox is intended for read-only exploration and applies several layers of defense:
|
|
433
|
+
|
|
434
|
+
- **No writes / shell / network:** file writes, `system`/`exec`/backticks, and network libraries are blocked by both static analysis and runtime overrides.
|
|
435
|
+
- **Confined file reads:** reads are limited to the project directory (via all of `File`/`IO` `read`/`readlines`/`binread`/`foreach` and `File.open`), plus a small allowlist of read-only system timezone paths (e.g. `/usr/share/zoneinfo`) that Rails needs when code touches `Time.zone`. Paths are symlink-resolved (`realpath`) so a link inside the project cannot point outside it.
|
|
436
|
+
- **No sensitive files:** `.env`, credentials, keys, and any `.gitignore`d path are refused.
|
|
437
|
+
- **Database writes are rolled back:** user code runs inside a transaction that is always rolled back, so `delete_all`, `update`, `save`, and raw DML are undone. Treat the tool as read-only for data too. (Caveat: DDL may still commit on some adapters such as MySQL, and `after_commit` callbacks do not fire.)
|
|
438
|
+
- **Bounded execution:** a timeout (default 30s, max 60s) kills the whole process group, so a runaway `bin/rails runner` is terminated rather than orphaned.
|
|
439
|
+
- **Confirmation for dual-use constructs:** `send`, `public_send`, `const_get`, and `Kernel#open` are not run until you approve them via `confirm_risky: true`.
|
|
440
|
+
|
|
441
|
+
These controls are defense-in-depth, not a hard isolation boundary. `execute_ruby` executes real Ruby with full access to the Rails app, so only enable it for projects and clients you trust. For stronger isolation run the server against a database user with read-only grants and/or inside an OS-level sandbox (container, `sandbox-exec`, etc.).
|
|
424
442
|
|
|
425
443
|
### Internal Analyzers (via execute_tool)
|
|
426
444
|
|
|
@@ -604,6 +622,8 @@ To use with an MCP client:
|
|
|
604
622
|
2. Connect your MCP-compatible client to the server
|
|
605
623
|
3. The client will be able to use the available tools to interact with your Rails projects
|
|
606
624
|
|
|
625
|
+
For teams using a governed AI client or control plane for tool access, approvals, audit trails, and cost reporting, see [Governed MCP Clients](docs/GOVERNED_CLIENTS.md).
|
|
626
|
+
|
|
607
627
|
## Security
|
|
608
628
|
|
|
609
629
|
For security concerns, please see [SECURITY.md](SECURITY.md).
|
data/docs/AGENT.md
CHANGED
|
@@ -225,6 +225,10 @@ railsMcpServer:execute_ruby code: "read_file('Gemfile')"
|
|
|
225
225
|
railsMcpServer:execute_ruby code: "puts read_file('Gemfile')"
|
|
226
226
|
```
|
|
227
227
|
|
|
228
|
+
**Read-only by design:** `execute_ruby` is for exploration, not mutation. File writes, shell/system calls, and network access are blocked, and any database writes run inside a transaction that is **always rolled back** — so `delete_all`, `update`, and `save` will not persist. Do not rely on it to change data.
|
|
229
|
+
|
|
230
|
+
**Confirmation for dual-use constructs:** if your code uses `send`, `public_send`, `const_get`, or `Kernel#open`, the tool returns a `CONFIRMATION REQUIRED` message instead of running. These can bypass the sandbox's safety scan, so ask the user to review the code and, only with their explicit approval, re-invoke with `confirm_risky: true`. Do not set `confirm_risky` on your own.
|
|
231
|
+
|
|
228
232
|
---
|
|
229
233
|
|
|
230
234
|
## Tool Selection Summary
|
|
@@ -400,6 +404,14 @@ railsMcpServer:execute_ruby code: "User.count"
|
|
|
400
404
|
railsMcpServer:execute_ruby code: "puts User.count"
|
|
401
405
|
```
|
|
402
406
|
|
|
407
|
+
### `execute_ruby` / `get_schema` fail to boot the app (Bundler / wrong Ruby)
|
|
408
|
+
|
|
409
|
+
These tools run the project's `bin/rails`. The server auto-selects the project's Ruby via your version manager's shims (**mise**, **asdf**, **rbenv**; **rvm** is sourced), reading `.ruby-version` / `.tool-versions` / `.mise.toml`. If they still fail with a Bundler or boot error:
|
|
410
|
+
|
|
411
|
+
1. Confirm the project has a `.ruby-version` (or `.tool-versions` / `.mise.toml`) and that Ruby is installed in your manager.
|
|
412
|
+
2. Confirm your manager is one of mise, asdf, rbenv, or rvm — these are auto-detected.
|
|
413
|
+
3. The underlying boot error is included in the tool output (no longer suppressed), so read it for the specific cause.
|
|
414
|
+
|
|
403
415
|
---
|
|
404
416
|
|
|
405
417
|
## Integration with Neovim MCP
|
data/docs/COPILOT_AGENT.md
CHANGED
|
@@ -14,7 +14,7 @@ GitHub Copilot coding agent runs MCP servers in ephemeral GitHub Actions environ
|
|
|
14
14
|
|
|
15
15
|
- A Rails application repository on GitHub
|
|
16
16
|
- GitHub Copilot with coding agent enabled
|
|
17
|
-
- Ruby 3.
|
|
17
|
+
- Ruby 3.3+ (recommended: 3.4)
|
|
18
18
|
|
|
19
19
|
## Configuration
|
|
20
20
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Governed MCP Clients
|
|
2
|
+
|
|
3
|
+
Rails MCP Server exposes Rails project tools and documentation resources through the Model Context Protocol. Some teams connect those MCP tools to a governed AI client or control plane so tool access, audit trails, approvals, and cost controls can be managed centrally.
|
|
4
|
+
|
|
5
|
+
This guide describes the integration pattern. Rails MCP Server continues to own the Rails project tools and resources. The governed client or gateway owns model access, policy decisions, and cross-application reporting.
|
|
6
|
+
|
|
7
|
+
## Pattern
|
|
8
|
+
|
|
9
|
+
1. Start Rails MCP Server in STDIO or HTTP mode.
|
|
10
|
+
2. Register the server with an MCP-compatible client or control plane.
|
|
11
|
+
3. Let the client decide which users, roles, or agents can call Rails MCP tools.
|
|
12
|
+
4. Keep Rails project paths and credentials local to the Rails MCP Server environment.
|
|
13
|
+
|
|
14
|
+
## Example: Tuning Engines
|
|
15
|
+
|
|
16
|
+
Tuning Engines can be used as a governed AI control plane in front of model, agent, and MCP workflows. In this setup:
|
|
17
|
+
|
|
18
|
+
- Rails MCP Server provides tools such as `switch_project`, `search_tools`, `execute_tool`, and `load_guide`.
|
|
19
|
+
- The MCP client or control plane registers the Rails MCP Server and discovers its tools.
|
|
20
|
+
- Tuning Engines can enforce tenant, role, or policy-based access to MCP tools and record traces/costs for model and tool activity.
|
|
21
|
+
|
|
22
|
+
For local development, start Rails MCP Server normally:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
rails-mcp-server
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For HTTP/SSE testing or a local proxy:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
rails-mcp-server --mode http
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then configure your MCP-compatible client or control plane to connect to the STDIO command or HTTP/SSE endpoint.
|
|
35
|
+
|
|
36
|
+
## Security notes
|
|
37
|
+
|
|
38
|
+
- Do not expose Rails MCP Server on an untrusted network.
|
|
39
|
+
- Prefer STDIO or localhost HTTP mode unless you have a trusted network and explicit access controls.
|
|
40
|
+
- Keep Rails project paths, credentials, and `.env` files local to the server.
|
|
41
|
+
- Use the governed client to restrict high-risk tools such as code execution or broad project scans.
|
|
42
|
+
- Preserve request IDs or trace IDs in client metadata when available so tool calls can be correlated with model calls.
|
|
43
|
+
|
|
44
|
+
This pattern is useful when Rails MCP Server is part of a larger production AI workflow and the organization needs compliance, control, and cost reporting outside individual MCP clients.
|
data/exe/rails-mcp-config
CHANGED
|
@@ -219,8 +219,8 @@ module RailsMcpConfig
|
|
|
219
219
|
print Colors.mauve(Colors.bold("#{prompt} "))
|
|
220
220
|
print Colors.dim("[#{default_value}] ") unless default_value.empty?
|
|
221
221
|
result = gets&.strip
|
|
222
|
-
return default_value if result
|
|
223
|
-
result
|
|
222
|
+
return default_value if result == "" && !default_value.empty?
|
|
223
|
+
(result == "") ? nil : result
|
|
224
224
|
end
|
|
225
225
|
end
|
|
226
226
|
|
|
@@ -1151,6 +1151,7 @@ module RailsMcpConfig
|
|
|
1151
1151
|
|
|
1152
1152
|
ui.info("Ruby executable: #{ruby_path}")
|
|
1153
1153
|
ui.info("Server executable: #{server_path}")
|
|
1154
|
+
ui.info("Rails introspection uses each project's own Ruby (mise/asdf/rbenv/rvm) automatically.")
|
|
1154
1155
|
puts
|
|
1155
1156
|
|
|
1156
1157
|
return unless ui.confirm("Apply this configuration?", default: true)
|
|
@@ -60,24 +60,32 @@ module RailsMcpServer
|
|
|
60
60
|
|
|
61
61
|
Tips:
|
|
62
62
|
- Use CamelCase: 'User', 'BlogPost', 'OrderItem'
|
|
63
|
+
- For namespaced models use 'Namespace::Model' or the file path 'namespace/model'
|
|
63
64
|
- Use singular form: 'User' not 'Users'
|
|
64
65
|
- Run analyze_models without params to list all models
|
|
65
66
|
ERROR
|
|
66
67
|
end
|
|
67
68
|
|
|
69
|
+
# Resolve the canonical constant from the file we actually found, rather
|
|
70
|
+
# than trusting the raw input. This lets namespaced ("Base::FundHolding"),
|
|
71
|
+
# path ("base/fund_holding") and flattened ("BaseFundHolding") inputs all
|
|
72
|
+
# produce a valid Ruby constant for the introspection runner, and keeps
|
|
73
|
+
# unvalidated user input out of the interpolated script.
|
|
74
|
+
class_name = model_class_name(model_file)
|
|
75
|
+
|
|
68
76
|
case detail_level
|
|
69
77
|
when "names"
|
|
70
|
-
"Model: #{
|
|
78
|
+
"Model: #{class_name}\nFile: #{model_file.sub(active_project_path + "/", "")}"
|
|
71
79
|
when "associations"
|
|
72
|
-
format_associations_only(
|
|
80
|
+
format_associations_only(class_name, model_file)
|
|
73
81
|
else
|
|
74
|
-
build_full_analysis(
|
|
82
|
+
build_full_analysis(class_name, model_file, analysis_type)
|
|
75
83
|
end
|
|
76
84
|
end
|
|
77
85
|
|
|
78
|
-
def format_associations_only(
|
|
79
|
-
associations = get_associations_via_introspection(
|
|
80
|
-
output = ["Model: #{
|
|
86
|
+
def format_associations_only(class_name, model_file)
|
|
87
|
+
associations = get_associations_via_introspection(class_name)
|
|
88
|
+
output = ["Model: #{class_name}", "File: #{model_file.sub(active_project_path + "/", "")}", "", "Associations:"]
|
|
81
89
|
if associations&.any?
|
|
82
90
|
associations.each { |a| output << " #{a[:type]} :#{a[:name]}" }
|
|
83
91
|
else
|
|
@@ -86,11 +94,11 @@ module RailsMcpServer
|
|
|
86
94
|
output.join("\n")
|
|
87
95
|
end
|
|
88
96
|
|
|
89
|
-
def build_full_analysis(
|
|
90
|
-
output = ["=" * 60, "Model: #{
|
|
97
|
+
def build_full_analysis(class_name, model_file, analysis_type)
|
|
98
|
+
output = ["=" * 60, "Model: #{class_name}", "File: #{model_file.sub(active_project_path + "/", "")}", "=" * 60]
|
|
91
99
|
|
|
92
100
|
if %w[introspection full].include?(analysis_type)
|
|
93
|
-
output << "" << introspection_analysis(
|
|
101
|
+
output << "" << introspection_analysis(class_name)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
if %w[static full].include?(analysis_type)
|
|
@@ -101,8 +109,8 @@ module RailsMcpServer
|
|
|
101
109
|
output.join("\n")
|
|
102
110
|
end
|
|
103
111
|
|
|
104
|
-
def introspection_analysis(
|
|
105
|
-
script = build_introspection_script(
|
|
112
|
+
def introspection_analysis(class_name)
|
|
113
|
+
script = build_introspection_script(class_name)
|
|
106
114
|
raw_output = execute_rails_runner(script)
|
|
107
115
|
data = begin
|
|
108
116
|
JSON.parse(extract_json(raw_output))
|
|
@@ -113,12 +121,15 @@ module RailsMcpServer
|
|
|
113
121
|
format_introspection_result(data)
|
|
114
122
|
end
|
|
115
123
|
|
|
116
|
-
def build_introspection_script(
|
|
124
|
+
def build_introspection_script(class_name)
|
|
117
125
|
<<~RUBY
|
|
118
126
|
require 'json'
|
|
119
127
|
begin
|
|
120
|
-
model = #{
|
|
128
|
+
model = Object.const_get(#{class_name.inspect})
|
|
121
129
|
result = {}
|
|
130
|
+
unless model.is_a?(Class) && model.respond_to?(:reflect_on_all_associations)
|
|
131
|
+
raise "\#{model} is not an ActiveRecord model"
|
|
132
|
+
end
|
|
122
133
|
if model.respond_to?(:table_name) && model.table_exists?
|
|
123
134
|
result[:table_name] = model.table_name
|
|
124
135
|
result[:primary_key] = model.primary_key
|
|
@@ -238,8 +249,8 @@ module RailsMcpServer
|
|
|
238
249
|
output.join("\n")
|
|
239
250
|
end
|
|
240
251
|
|
|
241
|
-
def get_associations_via_introspection(
|
|
242
|
-
script = "require 'json'; puts (#{
|
|
252
|
+
def get_associations_via_introspection(class_name)
|
|
253
|
+
script = "require 'json'; puts (Object.const_get(#{class_name.inspect}).reflect_on_all_associations.map { |a| { name: a.name.to_s, type: a.macro.to_s } } rescue []).to_json"
|
|
243
254
|
begin
|
|
244
255
|
JSON.parse(extract_json(execute_rails_runner(script))).map { |a| a.transform_keys(&:to_sym) }
|
|
245
256
|
rescue
|
|
@@ -248,8 +259,39 @@ module RailsMcpServer
|
|
|
248
259
|
end
|
|
249
260
|
|
|
250
261
|
def find_model_file(model_name)
|
|
251
|
-
|
|
252
|
-
|
|
262
|
+
models_dir = File.join(active_project_path, "app", "models")
|
|
263
|
+
return nil unless File.directory?(models_dir)
|
|
264
|
+
|
|
265
|
+
# Fast path: conventional inflection (Base::FundHolding -> base/fund_holding.rb).
|
|
266
|
+
direct = File.join(models_dir, "#{underscore(model_name)}.rb")
|
|
267
|
+
return direct if File.exist?(direct)
|
|
268
|
+
|
|
269
|
+
# Fallback: match against the files that actually exist so namespaced,
|
|
270
|
+
# path, flattened and bare-leaf forms all resolve, independent of any
|
|
271
|
+
# custom inflections the app registers. Prefer a full relative-path
|
|
272
|
+
# match; only then fall back to a bare filename (leaf) match.
|
|
273
|
+
files = Dir.glob(File.join(models_dir, "**", "*.rb"))
|
|
274
|
+
target = model_key(model_name)
|
|
275
|
+
|
|
276
|
+
files.find { |file| model_key(relative_model_path(file, models_dir)) == target } ||
|
|
277
|
+
files.find { |file| model_key(File.basename(file, ".rb")) == target }
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Normalizes a model reference to a separator- and case-insensitive key so
|
|
281
|
+
# "Base::FundHolding", "base/fund_holding" and "BaseFundHolding" all collapse
|
|
282
|
+
# to the same value ("basefundholding") for matching against real files.
|
|
283
|
+
def model_key(name)
|
|
284
|
+
name.to_s.gsub("::", "/").split("/").map { |segment| segment.tr("-", "_").delete("_").downcase }.join
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def relative_model_path(file, models_dir)
|
|
288
|
+
file.sub("#{models_dir}/", "").sub(/\.rb$/, "")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Canonical Ruby constant name for a resolved model file.
|
|
292
|
+
def model_class_name(model_file)
|
|
293
|
+
models_dir = File.join(active_project_path, "app", "models")
|
|
294
|
+
classify_model_name(relative_model_path(model_file, models_dir))
|
|
253
295
|
end
|
|
254
296
|
|
|
255
297
|
def classify_model_name(model_file)
|
|
@@ -21,9 +21,12 @@ module RailsMcpServer
|
|
|
21
21
|
f.write(script)
|
|
22
22
|
f.flush
|
|
23
23
|
|
|
24
|
+
# No `2>/dev/null`: execute_rails_command captures stderr separately
|
|
25
|
+
# via Open3, keeps stdout (the JSON we parse) clean on success, and
|
|
26
|
+
# surfaces the real boot error on failure instead of a blank message.
|
|
24
27
|
RailsMcpServer::RunProcess.execute_rails_command(
|
|
25
28
|
active_project_path,
|
|
26
|
-
"bin/rails runner #{f.path}
|
|
29
|
+
"bin/rails runner #{f.path}"
|
|
27
30
|
)
|
|
28
31
|
end
|
|
29
32
|
end
|
|
@@ -11,8 +11,12 @@ module RailsMcpServer
|
|
|
11
11
|
RESTRICTIONS:
|
|
12
12
|
- Cannot create, modify, or delete files
|
|
13
13
|
- Cannot read .env, credentials, key files, or .gitignore'd files
|
|
14
|
-
- Cannot access files outside the project directory
|
|
14
|
+
- Cannot access files outside the project directory (read-only system data
|
|
15
|
+
such as timezone files under /usr/share/zoneinfo is allowed)
|
|
15
16
|
- Cannot execute shell commands or system calls
|
|
17
|
+
- Database writes run inside a transaction that is always rolled back, so
|
|
18
|
+
treat this as read-only for data too (note: DDL may still commit on some
|
|
19
|
+
adapters, and after_commit callbacks do not fire)
|
|
16
20
|
|
|
17
21
|
HELPER METHODS AVAILABLE:
|
|
18
22
|
- read_file(path) - safely read a file
|
|
@@ -21,11 +25,17 @@ module RailsMcpServer
|
|
|
21
25
|
- project_root - returns the project root path
|
|
22
26
|
|
|
23
27
|
NOTE: Use `puts` to see output, e.g., puts read_file('Gemfile')
|
|
28
|
+
|
|
29
|
+
Some dual-use constructs (Kernel#open, send, public_send, const_get) are
|
|
30
|
+
not run immediately: the tool returns a CONFIRMATION REQUIRED message
|
|
31
|
+
explaining the risk. Re-invoke with confirm_risky: true only after the
|
|
32
|
+
user has reviewed the code and approved it.
|
|
24
33
|
DESC
|
|
25
34
|
|
|
26
35
|
arguments do
|
|
27
36
|
required(:code).filled(:string).description("Ruby code to execute (read-only operations only)")
|
|
28
37
|
optional(:timeout).filled(:integer).description("Timeout in seconds. Default: 30, Max: 60")
|
|
38
|
+
optional(:confirm_risky).filled(:bool).description("Set true ONLY after the user has explicitly approved running code that uses sandbox-bypass-capable constructs (send, public_send, const_get, Kernel#open). When false/absent, such code is not executed; the tool returns a CONFIRMATION REQUIRED message instead.")
|
|
29
39
|
end
|
|
30
40
|
|
|
31
41
|
# Patterns that indicate dangerous operations
|
|
@@ -75,8 +85,9 @@ module RailsMcpServer
|
|
|
75
85
|
/set_trace_func/i,
|
|
76
86
|
|
|
77
87
|
# Environment/credentials access
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
# Match any ENV usage (ENV[, ENV.fetch, ENV.to_h, ENV.values_at, ENV.each,
|
|
89
|
+
# ...). Case-sensitive so it doesn't flag `Rails.env` or a local `env`.
|
|
90
|
+
/\bENV\b/,
|
|
80
91
|
/Rails\.application\.credentials/i,
|
|
81
92
|
/Rails\.application\.secrets/i,
|
|
82
93
|
|
|
@@ -85,6 +96,21 @@ module RailsMcpServer
|
|
|
85
96
|
/require\s+[^'"]/i
|
|
86
97
|
].freeze
|
|
87
98
|
|
|
99
|
+
# Dual-use constructs that are NOT hard-blocked (they have legitimate
|
|
100
|
+
# read-only uses) but can defeat the static safety scan, so running them
|
|
101
|
+
# requires explicit user confirmation via confirm_risky: true.
|
|
102
|
+
# Each entry: [pattern, label, why-it-is-risky].
|
|
103
|
+
CONFIRMATION_REQUIRED_PATTERNS = [
|
|
104
|
+
[/(?<![.\w])open\s*\(/, "Kernel#open",
|
|
105
|
+
"`open(arg)` runs a shell command when arg begins with '|', and can open network/URI targets — both escape the sandbox."],
|
|
106
|
+
[/\bpublic_send\b/, "public_send",
|
|
107
|
+
"dynamic dispatch can invoke methods the static scan cannot see, e.g. reaching blocked system/file APIs indirectly."],
|
|
108
|
+
[/\bsend\s*[(\s]/, "send",
|
|
109
|
+
"dynamic dispatch can invoke methods the static scan cannot see, e.g. reaching blocked system/file APIs indirectly."],
|
|
110
|
+
[/\bconst_get\b/, "const_get",
|
|
111
|
+
"resolves constants by name at runtime, which can reach classes the static scan would otherwise block."]
|
|
112
|
+
].freeze
|
|
113
|
+
|
|
88
114
|
# Sensitive file patterns (in addition to .gitignore)
|
|
89
115
|
SENSITIVE_PATTERNS = [
|
|
90
116
|
/\.env(\..*)?$/i,
|
|
@@ -104,6 +130,18 @@ module RailsMcpServer
|
|
|
104
130
|
/id_ed25519/i
|
|
105
131
|
].freeze
|
|
106
132
|
|
|
133
|
+
# Read-only system data directories the sandbox may read. TZInfo lazily
|
|
134
|
+
# loads IANA timezone data on first Time.zone use; these are its default
|
|
135
|
+
# search paths plus /var/db/timezone, the real location behind macOS's
|
|
136
|
+
# /usr/share/zoneinfo symlink. Writes remain blocked by the File/Dir/
|
|
137
|
+
# FileUtils overrides.
|
|
138
|
+
ALLOWED_READ_PATHS = %w[
|
|
139
|
+
/usr/share/zoneinfo
|
|
140
|
+
/usr/share/lib/zoneinfo
|
|
141
|
+
/etc/zoneinfo
|
|
142
|
+
/var/db/timezone
|
|
143
|
+
].freeze
|
|
144
|
+
|
|
107
145
|
NO_OUTPUT_MESSAGE = <<~MSG
|
|
108
146
|
Code executed successfully (no output).
|
|
109
147
|
|
|
@@ -113,7 +151,7 @@ module RailsMcpServer
|
|
|
113
151
|
puts Dir.glob('app/models/*.rb')
|
|
114
152
|
MSG
|
|
115
153
|
|
|
116
|
-
def call(code:, timeout: 30)
|
|
154
|
+
def call(code:, timeout: 30, confirm_risky: false)
|
|
117
155
|
unless current_project
|
|
118
156
|
return "No active project. Please switch to a project first."
|
|
119
157
|
end
|
|
@@ -121,14 +159,20 @@ module RailsMcpServer
|
|
|
121
159
|
timeout = [timeout.to_i, 60].min # Cap at 60 seconds
|
|
122
160
|
timeout = 10 if timeout < 1
|
|
123
161
|
|
|
124
|
-
# Step 1: Static analysis - reject dangerous code
|
|
162
|
+
# Step 1: Static analysis - reject outright-dangerous code
|
|
125
163
|
validation_error = validate_code_safety(code)
|
|
126
164
|
return validation_error if validation_error
|
|
127
165
|
|
|
128
|
-
# Step 2:
|
|
166
|
+
# Step 2: Dual-use constructs require explicit user confirmation
|
|
167
|
+
unless confirm_risky
|
|
168
|
+
confirmation = confirmation_required(code)
|
|
169
|
+
return confirmation if confirmation
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Step 3: Build the sandboxed execution environment
|
|
129
173
|
sandbox_code = build_sandbox(code)
|
|
130
174
|
|
|
131
|
-
# Step
|
|
175
|
+
# Step 4: Execute with timeout
|
|
132
176
|
execute_sandboxed(sandbox_code, timeout)
|
|
133
177
|
end
|
|
134
178
|
|
|
@@ -144,34 +188,100 @@ module RailsMcpServer
|
|
|
144
188
|
nil
|
|
145
189
|
end
|
|
146
190
|
|
|
191
|
+
# Returns a message asking the model to confirm with the user when the code
|
|
192
|
+
# uses dual-use constructs, or nil when there is nothing to confirm.
|
|
193
|
+
def confirmation_required(code)
|
|
194
|
+
matched = CONFIRMATION_REQUIRED_PATTERNS.select { |pattern, _label, _reason| code.match?(pattern) }
|
|
195
|
+
return nil if matched.empty?
|
|
196
|
+
|
|
197
|
+
details = matched.map { |_pattern, label, reason| " - `#{label}`: #{reason}" }.join("\n")
|
|
198
|
+
|
|
199
|
+
<<~MSG
|
|
200
|
+
CONFIRMATION REQUIRED: This code uses constructs that can bypass the sandbox's static safety checks:
|
|
201
|
+
|
|
202
|
+
#{details}
|
|
203
|
+
|
|
204
|
+
These are not blocked outright because they have legitimate read-only uses, but they can reach APIs the safety scan would otherwise stop. Ask the user to review the code and confirm they want to run it. If they approve, re-invoke execute_ruby with confirm_risky: true. Do not set confirm_risky yourself without the user's explicit approval.
|
|
205
|
+
MSG
|
|
206
|
+
end
|
|
207
|
+
|
|
147
208
|
def build_sandbox(user_code)
|
|
148
209
|
gitignore_patterns = parse_gitignore
|
|
149
210
|
all_patterns = SENSITIVE_PATTERNS.map(&:source) + gitignore_patterns
|
|
150
211
|
sensitive_patterns_ruby = all_patterns.map { |p| "Regexp.new(#{p.inspect}, Regexp::IGNORECASE)" }.join(",\n ")
|
|
151
212
|
|
|
152
213
|
<<~RUBY
|
|
214
|
+
require "stringio" # the File.open override below yields StringIO objects
|
|
215
|
+
|
|
153
216
|
# Sandbox wrapper for safe execution
|
|
154
217
|
module McpSandbox
|
|
155
|
-
|
|
218
|
+
# realpath-normalized so symlink resolution below compares against the
|
|
219
|
+
# canonical root (e.g. macOS /var -> /private/var) rather than a path
|
|
220
|
+
# that would never prefix-match a resolved target.
|
|
221
|
+
PROJECT_ROOT = File.realpath(#{active_project_path.inspect}).freeze
|
|
222
|
+
|
|
223
|
+
ALLOWED_READ_PATHS = #{ALLOWED_READ_PATHS.inspect}.freeze
|
|
224
|
+
|
|
225
|
+
# realpath-resolved forms of the allowlist, so a resolved target still
|
|
226
|
+
# matches when the allowed dir is itself a symlink (e.g. macOS
|
|
227
|
+
# /usr/share/zoneinfo -> /private/var/db/timezone/.../zoneinfo).
|
|
228
|
+
CANONICAL_ALLOWED_READ_PATHS = ALLOWED_READ_PATHS.map { |dir|
|
|
229
|
+
File.exist?(dir) ? File.realpath(dir) : dir
|
|
230
|
+
}.freeze
|
|
156
231
|
|
|
157
232
|
SENSITIVE_PATTERNS = [
|
|
158
233
|
#{sensitive_patterns_ruby}
|
|
159
234
|
].freeze
|
|
160
235
|
|
|
236
|
+
# Native method handles captured *before* the File/Dir overrides below
|
|
237
|
+
# replace them. Held in private constants so sandboxed user code has no
|
|
238
|
+
# public `File.original_read`-style alias to call the raw method back.
|
|
239
|
+
ORIGINAL_FILE_READ = File.method(:read)
|
|
240
|
+
ORIGINAL_FILE_READLINES = File.method(:readlines)
|
|
241
|
+
ORIGINAL_FILE_BINREAD = File.method(:binread)
|
|
242
|
+
ORIGINAL_FILE_EXIST = File.method(:exist?)
|
|
243
|
+
ORIGINAL_FILE_DIRECTORY = File.method(:directory?)
|
|
244
|
+
ORIGINAL_FILE_FILE = File.method(:file?)
|
|
245
|
+
ORIGINAL_FILE_REALPATH = File.method(:realpath)
|
|
246
|
+
ORIGINAL_DIR_GLOB = Dir.method(:glob)
|
|
247
|
+
ORIGINAL_DIR_ENTRIES = Dir.method(:entries)
|
|
248
|
+
private_constant :ORIGINAL_FILE_READ, :ORIGINAL_FILE_READLINES,
|
|
249
|
+
:ORIGINAL_FILE_BINREAD, :ORIGINAL_FILE_EXIST, :ORIGINAL_FILE_DIRECTORY,
|
|
250
|
+
:ORIGINAL_FILE_FILE, :ORIGINAL_FILE_REALPATH, :ORIGINAL_DIR_GLOB,
|
|
251
|
+
:ORIGINAL_DIR_ENTRIES
|
|
252
|
+
|
|
161
253
|
class PathViolation < StandardError; end
|
|
162
254
|
class SensitiveFileViolation < StandardError; end
|
|
163
255
|
class WriteViolation < StandardError; end
|
|
164
256
|
|
|
165
257
|
module_function
|
|
166
258
|
|
|
259
|
+
# Resolve symlinks so a link *inside* the project cannot be used to
|
|
260
|
+
# read a target outside it. realpath needs the path to exist, so for a
|
|
261
|
+
# not-yet-existing path resolve the deepest existing ancestor and
|
|
262
|
+
# re-append the remainder (which still catches a symlinked ancestor).
|
|
263
|
+
def resolve_symlinks(expanded)
|
|
264
|
+
return ORIGINAL_FILE_REALPATH.call(expanded) if ORIGINAL_FILE_EXIST.call(expanded)
|
|
265
|
+
|
|
266
|
+
parent = File.dirname(expanded)
|
|
267
|
+
return expanded if parent == expanded
|
|
268
|
+
|
|
269
|
+
File.join(resolve_symlinks(parent), File.basename(expanded))
|
|
270
|
+
end
|
|
271
|
+
|
|
167
272
|
def validate_path!(path)
|
|
168
273
|
expanded = File.expand_path(path, PROJECT_ROOT)
|
|
274
|
+
resolved = resolve_symlinks(expanded)
|
|
275
|
+
|
|
276
|
+
if (ALLOWED_READ_PATHS + CANONICAL_ALLOWED_READ_PATHS).any? { |dir| resolved == dir || resolved.start_with?(dir + "/") }
|
|
277
|
+
return resolved
|
|
278
|
+
end
|
|
169
279
|
|
|
170
|
-
unless
|
|
280
|
+
unless resolved.start_with?(PROJECT_ROOT + "/") || resolved == PROJECT_ROOT
|
|
171
281
|
raise PathViolation, "Access denied: path '\#{path}' is outside project directory"
|
|
172
282
|
end
|
|
173
283
|
|
|
174
|
-
relative_path =
|
|
284
|
+
relative_path = resolved.sub(PROJECT_ROOT + "/", "")
|
|
175
285
|
|
|
176
286
|
SENSITIVE_PATTERNS.each do |pattern|
|
|
177
287
|
if relative_path.match?(pattern)
|
|
@@ -179,37 +289,48 @@ module RailsMcpServer
|
|
|
179
289
|
end
|
|
180
290
|
end
|
|
181
291
|
|
|
182
|
-
|
|
292
|
+
resolved
|
|
183
293
|
end
|
|
184
294
|
|
|
185
295
|
def safe_read(path)
|
|
186
|
-
|
|
187
|
-
|
|
296
|
+
ORIGINAL_FILE_READ.call(validate_path!(path))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def safe_readlines(path)
|
|
300
|
+
ORIGINAL_FILE_READLINES.call(validate_path!(path))
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def safe_binread(path)
|
|
304
|
+
ORIGINAL_FILE_BINREAD.call(validate_path!(path))
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def safe_foreach(path, &block)
|
|
308
|
+
lines = safe_readlines(path)
|
|
309
|
+
return lines.each unless block
|
|
310
|
+
|
|
311
|
+
lines.each(&block)
|
|
188
312
|
end
|
|
189
313
|
|
|
190
314
|
def safe_exist?(path)
|
|
191
|
-
|
|
192
|
-
File.original_exist?(validated_path)
|
|
315
|
+
ORIGINAL_FILE_EXIST.call(validate_path!(path))
|
|
193
316
|
rescue PathViolation, SensitiveFileViolation
|
|
194
317
|
false
|
|
195
318
|
end
|
|
196
319
|
|
|
197
320
|
def safe_directory?(path)
|
|
198
|
-
|
|
199
|
-
File.original_directory?(validated_path)
|
|
321
|
+
ORIGINAL_FILE_DIRECTORY.call(validate_path!(path))
|
|
200
322
|
rescue PathViolation, SensitiveFileViolation
|
|
201
323
|
false
|
|
202
324
|
end
|
|
203
325
|
|
|
204
326
|
def safe_file?(path)
|
|
205
|
-
|
|
206
|
-
File.original_file?(validated_path)
|
|
327
|
+
ORIGINAL_FILE_FILE.call(validate_path!(path))
|
|
207
328
|
rescue PathViolation, SensitiveFileViolation
|
|
208
329
|
false
|
|
209
330
|
end
|
|
210
331
|
|
|
211
332
|
def safe_glob(pattern, base: PROJECT_ROOT)
|
|
212
|
-
|
|
333
|
+
ORIGINAL_DIR_GLOB.call(File.join(base, pattern)).select do |path|
|
|
213
334
|
validate_path!(path)
|
|
214
335
|
true
|
|
215
336
|
rescue PathViolation, SensitiveFileViolation
|
|
@@ -218,23 +339,58 @@ module RailsMcpServer
|
|
|
218
339
|
end
|
|
219
340
|
|
|
220
341
|
def safe_entries(path)
|
|
221
|
-
|
|
222
|
-
|
|
342
|
+
ORIGINAL_DIR_ENTRIES.call(validate_path!(path)).reject { |e| e.start_with?(".") }
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# True only when ActiveRecord is loaded *and* a connection can be
|
|
346
|
+
# obtained, so we never turn a pure-Ruby read-only snippet into a
|
|
347
|
+
# database connection error just to wrap it in a transaction.
|
|
348
|
+
def database_available?
|
|
349
|
+
return false unless defined?(ActiveRecord::Base)
|
|
350
|
+
|
|
351
|
+
ActiveRecord::Base.connection
|
|
352
|
+
true
|
|
353
|
+
rescue StandardError
|
|
354
|
+
false
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Run the block inside a transaction that is *always* rolled back, so
|
|
358
|
+
# accidental writes are undone. Harm reduction, not a guarantee: DDL
|
|
359
|
+
# auto-commits on some adapters (e.g. MySQL) and after_commit
|
|
360
|
+
# callbacks are suppressed. Falls back to a plain call when no
|
|
361
|
+
# database is available. Real exceptions still propagate (and also
|
|
362
|
+
# trigger the rollback).
|
|
363
|
+
def readonly_guard
|
|
364
|
+
return yield unless database_available?
|
|
365
|
+
|
|
366
|
+
result = nil
|
|
367
|
+
ActiveRecord::Base.transaction do
|
|
368
|
+
result = yield
|
|
369
|
+
raise ActiveRecord::Rollback
|
|
370
|
+
end
|
|
371
|
+
result
|
|
223
372
|
end
|
|
224
373
|
end
|
|
225
374
|
|
|
226
375
|
# Override File class methods
|
|
227
376
|
class File
|
|
228
377
|
class << self
|
|
229
|
-
alias_method :original_read, :read
|
|
230
|
-
alias_method :original_exist?, :exist?
|
|
231
|
-
alias_method :original_directory?, :directory?
|
|
232
|
-
alias_method :original_file?, :file?
|
|
233
|
-
|
|
234
378
|
def read(path, *args)
|
|
235
379
|
McpSandbox.safe_read(path)
|
|
236
380
|
end
|
|
237
381
|
|
|
382
|
+
def readlines(path, *args)
|
|
383
|
+
McpSandbox.safe_readlines(path)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def binread(path, *args)
|
|
387
|
+
McpSandbox.safe_binread(path)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def foreach(path, *args, &block)
|
|
391
|
+
McpSandbox.safe_foreach(path, &block)
|
|
392
|
+
end
|
|
393
|
+
|
|
238
394
|
def exist?(path)
|
|
239
395
|
McpSandbox.safe_exist?(path)
|
|
240
396
|
end
|
|
@@ -272,9 +428,6 @@ module RailsMcpServer
|
|
|
272
428
|
# Override Dir class methods
|
|
273
429
|
class Dir
|
|
274
430
|
class << self
|
|
275
|
-
alias_method :original_glob, :glob
|
|
276
|
-
alias_method :original_entries, :entries
|
|
277
|
-
|
|
278
431
|
def glob(pattern, *args)
|
|
279
432
|
McpSandbox.safe_glob(pattern)
|
|
280
433
|
end
|
|
@@ -291,6 +444,29 @@ module RailsMcpServer
|
|
|
291
444
|
end
|
|
292
445
|
end
|
|
293
446
|
|
|
447
|
+
# Override IO read entry points. File < IO, but IO.read / IO.readlines /
|
|
448
|
+
# IO.binread / IO.foreach are separate class methods that bypass the File
|
|
449
|
+
# overrides above, so they must be sandboxed independently.
|
|
450
|
+
class IO
|
|
451
|
+
class << self
|
|
452
|
+
def read(path, *args)
|
|
453
|
+
McpSandbox.safe_read(path)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def readlines(path, *args)
|
|
457
|
+
McpSandbox.safe_readlines(path)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def binread(path, *args)
|
|
461
|
+
McpSandbox.safe_binread(path)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def foreach(path, *args, &block)
|
|
465
|
+
McpSandbox.safe_foreach(path, &block)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
294
470
|
# Block FileUtils entirely
|
|
295
471
|
if defined?(FileUtils)
|
|
296
472
|
module FileUtils
|
|
@@ -346,8 +522,13 @@ module RailsMcpServer
|
|
|
346
522
|
end
|
|
347
523
|
|
|
348
524
|
# ============ USER CODE BELOW ============
|
|
525
|
+
# Wrapped in an always-rolled-back transaction so accidental DB writes
|
|
526
|
+
# (delete_all, update, save, raw DML) are undone. See McpSandbox
|
|
527
|
+
# .readonly_guard for the caveats; it's a no-op without a database.
|
|
349
528
|
begin
|
|
350
|
-
|
|
529
|
+
McpSandbox.readonly_guard do
|
|
530
|
+
#{user_code}
|
|
531
|
+
end
|
|
351
532
|
rescue McpSandbox::PathViolation => e
|
|
352
533
|
puts "PATH ERROR: \#{e.message}"
|
|
353
534
|
rescue McpSandbox::SensitiveFileViolation => e
|
|
@@ -386,23 +567,19 @@ module RailsMcpServer
|
|
|
386
567
|
|
|
387
568
|
def execute_sandboxed(code, timeout)
|
|
388
569
|
require "tempfile"
|
|
389
|
-
require "timeout"
|
|
390
570
|
|
|
391
571
|
Tempfile.create(["mcp_sandbox", ".rb"]) do |f|
|
|
392
572
|
f.write(code)
|
|
393
573
|
f.flush
|
|
394
574
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
rescue Timeout::Error
|
|
404
|
-
"TIMEOUT: Execution exceeded #{timeout} seconds"
|
|
405
|
-
end
|
|
575
|
+
# RunProcess enforces the timeout by killing the whole process group, so
|
|
576
|
+
# a runaway `rails runner` is actually terminated rather than orphaned.
|
|
577
|
+
result = RailsMcpServer::RunProcess.execute_rails_command(
|
|
578
|
+
active_project_path,
|
|
579
|
+
"bin/rails runner #{f.path} 2>&1",
|
|
580
|
+
timeout: timeout
|
|
581
|
+
)
|
|
582
|
+
result.to_s.empty? ? NO_OUTPUT_MESSAGE : result
|
|
406
583
|
end
|
|
407
584
|
end
|
|
408
585
|
end
|
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
require "bundler"
|
|
2
2
|
require "shellwords"
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
3
5
|
|
|
4
6
|
module RailsMcpServer
|
|
5
7
|
class RunProcess
|
|
6
|
-
|
|
8
|
+
# `timeout` (seconds) bounds execution. When set, the command runs in its
|
|
9
|
+
# own process group so a timeout kills the whole tree; nil keeps the
|
|
10
|
+
# original unbounded behavior.
|
|
11
|
+
def self.execute_rails_command(project_path, command, timeout: nil)
|
|
7
12
|
RailsMcpServer.log(:debug, "Executing: #{command}")
|
|
8
13
|
|
|
9
14
|
Bundler.with_unbundled_env do
|
|
10
15
|
subprocess_env = ENV.to_h
|
|
11
16
|
subprocess_env.delete("BUNDLE_GEMFILE")
|
|
12
17
|
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# Make `bin/rails` resolve the *project's* Ruby regardless of which
|
|
19
|
+
# version manager is in use. mise, asdf and rbenv each expose a "shims"
|
|
20
|
+
# directory whose wrappers pick the Ruby from the project's
|
|
21
|
+
# .ruby-version / .tool-versions / .mise.toml at run time. Prepending it
|
|
22
|
+
# to PATH is manager-agnostic and needs no manager-specific environment
|
|
23
|
+
# variables (the previous RBENV_VERSION handling only worked for rbenv).
|
|
24
|
+
prepend_version_manager_shims(subprocess_env)
|
|
20
25
|
|
|
21
26
|
shell = ENV.fetch("SHELL", "/bin/bash")
|
|
22
|
-
shell_command =
|
|
23
|
-
|
|
27
|
+
shell_command = build_shell_command(project_path, command)
|
|
28
|
+
|
|
29
|
+
# A *non-login* shell (`-c`, not `-l`). A login shell triggers macOS
|
|
30
|
+
# `path_helper` (via /etc/zprofile), which rebuilds PATH with /usr/bin
|
|
31
|
+
# ahead of the manager's shims — the exact reason the system Ruby leaked
|
|
32
|
+
# in. It also never sources ~/.zshrc, where mise/asdf activation usually
|
|
33
|
+
# lives. `-c` keeps the PATH we assembled above intact.
|
|
34
|
+
stdout_str, stderr_str, status =
|
|
35
|
+
if timeout
|
|
36
|
+
capture3_with_timeout(subprocess_env, shell, shell_command, timeout)
|
|
37
|
+
else
|
|
38
|
+
Open3.capture3(subprocess_env, shell, "-c", shell_command)
|
|
39
|
+
end
|
|
24
40
|
|
|
25
41
|
if status.success?
|
|
26
42
|
RailsMcpServer.log(:debug, "Command succeeded")
|
|
@@ -33,9 +49,85 @@ module RailsMcpServer
|
|
|
33
49
|
"Error executing Rails command: #{command}\n\n#{error_output}"
|
|
34
50
|
end
|
|
35
51
|
end
|
|
52
|
+
rescue Timeout::Error
|
|
53
|
+
RailsMcpServer.log(:error, "Command timed out after #{timeout} seconds")
|
|
54
|
+
"TIMEOUT: Execution exceeded #{timeout} seconds"
|
|
36
55
|
rescue => e
|
|
37
56
|
RailsMcpServer.log(:error, "Exception executing Rails command: #{e.message}")
|
|
38
57
|
"Exception executing command: #{e.message}"
|
|
39
58
|
end
|
|
59
|
+
|
|
60
|
+
# Run the command in its own process group so a timeout can kill the entire
|
|
61
|
+
# tree (the shell *and* its `rails runner` grandchild). Open3.capture3 gives
|
|
62
|
+
# no handle to signal the group, so drive popen3 directly and drain stdout/
|
|
63
|
+
# stderr on separate threads to avoid a full-pipe deadlock. Re-raises
|
|
64
|
+
# Timeout::Error after killing; the caller maps it to a user-facing message.
|
|
65
|
+
def self.capture3_with_timeout(env, shell, shell_command, timeout)
|
|
66
|
+
Open3.popen3(env, shell, "-c", shell_command, pgroup: true) do |stdin, stdout, stderr, wait_thr|
|
|
67
|
+
stdin.close
|
|
68
|
+
out = +""
|
|
69
|
+
err = +""
|
|
70
|
+
out_reader = Thread.new { out << stdout.read }
|
|
71
|
+
err_reader = Thread.new { err << stderr.read }
|
|
72
|
+
|
|
73
|
+
begin
|
|
74
|
+
status = Timeout.timeout(timeout) { wait_thr.value }
|
|
75
|
+
out_reader.join
|
|
76
|
+
err_reader.join
|
|
77
|
+
[out, err, status]
|
|
78
|
+
rescue Timeout::Error
|
|
79
|
+
kill_process_group(wait_thr.pid)
|
|
80
|
+
out_reader.join(1)
|
|
81
|
+
err_reader.join(1)
|
|
82
|
+
raise
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# KILL the process group led by `pid`. Negative pid targets the whole group.
|
|
88
|
+
def self.kill_process_group(pid)
|
|
89
|
+
Process.kill("KILL", -Process.getpgid(pid))
|
|
90
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
91
|
+
# Already exited or not signalable; nothing to clean up.
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Shim directories for the version managers installed on this machine,
|
|
95
|
+
# detected by their well-known locations so resolution works even in a
|
|
96
|
+
# non-login shell that never sourced the manager's activation. Honors the
|
|
97
|
+
# managers' own overrides (MISE_DATA_DIR / XDG_DATA_HOME, ASDF_DATA_DIR,
|
|
98
|
+
# RBENV_ROOT). A machine normally has just one.
|
|
99
|
+
def self.version_manager_shim_dirs(home: Dir.home, env: ENV)
|
|
100
|
+
mise_data = env["MISE_DATA_DIR"] ||
|
|
101
|
+
File.join(env["XDG_DATA_HOME"] || File.join(home, ".local", "share"), "mise")
|
|
102
|
+
|
|
103
|
+
[
|
|
104
|
+
File.join(mise_data, "shims"), # mise
|
|
105
|
+
File.join(env["ASDF_DATA_DIR"] || File.join(home, ".asdf"), "shims"), # asdf
|
|
106
|
+
File.join(env["RBENV_ROOT"] || File.join(home, ".rbenv"), "shims") # rbenv
|
|
107
|
+
].select { |dir| File.directory?(dir) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Prepend the detected shim directories to the subprocess PATH.
|
|
111
|
+
def self.prepend_version_manager_shims(env)
|
|
112
|
+
dirs = version_manager_shim_dirs(env: env)
|
|
113
|
+
return if dirs.empty?
|
|
114
|
+
|
|
115
|
+
path = env["PATH"].to_s
|
|
116
|
+
env["PATH"] = (path.empty? ? dirs : dirs + [path]).join(File::PATH_SEPARATOR)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# rvm has no shims — it activates through a shell function keyed off the
|
|
120
|
+
# working directory, so source it (before `cd`, so its chpwd hook is in
|
|
121
|
+
# place) when it is installed. Everything else just runs in the project dir.
|
|
122
|
+
def self.build_shell_command(project_path, command)
|
|
123
|
+
cd = "cd #{Shellwords.escape(project_path)}"
|
|
124
|
+
rvm_script = File.join(Dir.home, ".rvm", "scripts", "rvm")
|
|
125
|
+
|
|
126
|
+
if File.exist?(rvm_script)
|
|
127
|
+
"source #{Shellwords.escape(rvm_script)} && #{cd} && #{command}"
|
|
128
|
+
else
|
|
129
|
+
"#{cd} && #{command}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
40
132
|
end
|
|
41
133
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-mcp-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mario Alberto Chávez Cárdenas
|
|
@@ -71,14 +71,14 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: '
|
|
74
|
+
version: '8.0'
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: '
|
|
81
|
+
version: '8.0'
|
|
82
82
|
- !ruby/object:Gem::Dependency
|
|
83
83
|
name: logger
|
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -127,14 +127,14 @@ dependencies:
|
|
|
127
127
|
requirements:
|
|
128
128
|
- - "~>"
|
|
129
129
|
- !ruby/object:Gem::Version
|
|
130
|
-
version: '
|
|
130
|
+
version: '6.0'
|
|
131
131
|
type: :development
|
|
132
132
|
prerelease: false
|
|
133
133
|
version_requirements: !ruby/object:Gem::Requirement
|
|
134
134
|
requirements:
|
|
135
135
|
- - "~>"
|
|
136
136
|
- !ruby/object:Gem::Version
|
|
137
|
-
version: '
|
|
137
|
+
version: '6.0'
|
|
138
138
|
- !ruby/object:Gem::Dependency
|
|
139
139
|
name: minitest-reporters
|
|
140
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -155,14 +155,14 @@ dependencies:
|
|
|
155
155
|
requirements:
|
|
156
156
|
- - "~>"
|
|
157
157
|
- !ruby/object:Gem::Version
|
|
158
|
-
version: '
|
|
158
|
+
version: '3.0'
|
|
159
159
|
type: :development
|
|
160
160
|
prerelease: false
|
|
161
161
|
version_requirements: !ruby/object:Gem::Requirement
|
|
162
162
|
requirements:
|
|
163
163
|
- - "~>"
|
|
164
164
|
- !ruby/object:Gem::Version
|
|
165
|
-
version: '
|
|
165
|
+
version: '3.0'
|
|
166
166
|
description: A Ruby implementation of Model Context Protocol server for Rails projects
|
|
167
167
|
email:
|
|
168
168
|
- mario.chavez@gmail.com
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- config/resources.yml
|
|
181
181
|
- docs/AGENT.md
|
|
182
182
|
- docs/COPILOT_AGENT.md
|
|
183
|
+
- docs/GOVERNED_CLIENTS.md
|
|
183
184
|
- docs/RESOURCES.md
|
|
184
185
|
- exe/rails-mcp-config
|
|
185
186
|
- exe/rails-mcp-server
|
|
@@ -241,14 +242,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
241
242
|
requirements:
|
|
242
243
|
- - ">="
|
|
243
244
|
- !ruby/object:Gem::Version
|
|
244
|
-
version: 3.
|
|
245
|
+
version: 3.3.0
|
|
245
246
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
246
247
|
requirements:
|
|
247
248
|
- - ">="
|
|
248
249
|
- !ruby/object:Gem::Version
|
|
249
250
|
version: '0'
|
|
250
251
|
requirements: []
|
|
251
|
-
rubygems_version: 4.0.
|
|
252
|
+
rubygems_version: 4.0.17
|
|
252
253
|
specification_version: 4
|
|
253
254
|
summary: MCP server for Rails projects
|
|
254
255
|
test_files: []
|