rails-mcp-server 1.5.1 → 1.6.1
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 +48 -0
- data/README.md +33 -8
- data/docs/AGENT.md +12 -0
- data/docs/COPILOT_AGENT.md +3 -3
- 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 +287 -49
- 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: 950673f40d56ddea684d938e4e55c2e8db18c3ca44e94988f63565a9db950869
|
|
4
|
+
data.tar.gz: 00712156bdfdfbfc1ec33bd08e31ccb0ad4e7427e3e1e31282de62b4f7b5cc1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 98ebb4452eaa610b1430c652c94a339022f10bc05ba7a00b867e8b8c9aaa04d033ea149d923bfe131be52455b044b0feb4d90af24a55670708610e2a57b198a1
|
|
7
|
+
data.tar.gz: 8ecf71ec6b09ed73b35e6f9ea64ec88bfcbbf1f91e2bbb8f033216d0a19d0c6af1493c23fb67be14f99e7933cc6dcc7d64f09d173d20c8c2d6a53f4d25ef88ef
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,52 @@ 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.1] - 2026-08-04
|
|
11
|
+
|
|
12
|
+
### Security
|
|
13
|
+
|
|
14
|
+
- **`execute_ruby` process-execution hardening**: Closed a command-execution path and tightened the static filter.
|
|
15
|
+
- **`require` restricted to a tiny data-lib allowlist**: `require_relative` and dynamic `require`s are refused, and literal `require "lib"` is refused except for a small allowlist of pure-data libraries not always preloaded (`csv`, `tzinfo`, `date`, `time`). Under `bin/rails runner` the app's models, ActiveRecord, and the stdlib Rails loads on boot are already available, so inspection code needs almost no requires — and every dangerous stdlib escape has to be required first. This closes `require "pty"` (`PTY.spawn`/`PTY.getpty` started a child process outside the `Kernel#system` guard, giving arbitrary host command execution), along with `open3`, `fiddle`, `ffi`, and `socket`, at the source rather than by enumerating individual APIs. (`tzinfo` pairs with the existing system-timezone read-path allowlist so `Time.zone` code keeps working.)
|
|
16
|
+
- **Native/PTY patterns**: `PTY`, `Fiddle`, and `FFI` are added to the forbidden-pattern scan as defense in depth.
|
|
17
|
+
- **Dynamic dispatch to execution sinks hard-blocked**: `send`/`public_send`/`__send__`/`const_get` aimed by name at an execution or eval sink (`system`, `exec`, `spawn`, `fork`, `eval`, `popen`, `Open3`, `Process`, `PTY`, …) are now rejected outright instead of merely gated behind `confirm_risky`. Benign dynamic dispatch (e.g. `record.send(:name)`) is unaffected.
|
|
18
|
+
- **Honest framing**: the tool description and docs no longer call `execute_ruby` a read-only sandbox. It runs caller-supplied Ruby with the privileges of the server process; the controls are best-effort guardrails, not an isolation boundary.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **ReDoS in the `execute_ruby` static scan**: Rewrote the `require`/dynamic-dispatch matchers to remove an ambiguous `\s*\(?\s*` construct that backtracked in polynomial time on adversarial whitespace input (CodeQL alert). Matching is now linear.
|
|
23
|
+
|
|
24
|
+
## [1.6.0] - 2026-08-03
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **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".
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **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.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **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.
|
|
39
|
+
- **`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.
|
|
40
|
+
- **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".
|
|
41
|
+
- **`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.
|
|
42
|
+
|
|
43
|
+
### Security
|
|
44
|
+
|
|
45
|
+
- **`execute_ruby` sandbox hardening**: Closed several read-path bypasses and added defense-in-depth layers to the sandbox.
|
|
46
|
+
- **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.
|
|
47
|
+
- **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.
|
|
48
|
+
- **Broader `ENV` block**: the static scan now rejects all `ENV` access (`ENV.to_h`, `ENV.values_at`, `ENV.each`, …), not just `ENV[]`/`ENV.fetch`.
|
|
49
|
+
- **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.)
|
|
50
|
+
- **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.
|
|
51
|
+
- **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.
|
|
52
|
+
- **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.
|
|
53
|
+
|
|
8
54
|
## [1.5.1] - 2026-03-04
|
|
9
55
|
|
|
10
56
|
### Changed
|
|
@@ -323,6 +369,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
323
369
|
|
|
324
370
|
## Version History Summary
|
|
325
371
|
|
|
372
|
+
- **v1.6.1** (2026-08-04): `execute_ruby` process-execution hardening (blocks the `require "pty"` → `PTY.spawn` command-execution path, restricts `require` to a data-lib allowlist, hard-blocks dynamic dispatch to execution sinks) and a ReDoS fix in the static scan
|
|
373
|
+
- **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
374
|
- **v1.5.1** (2026-03-04): Relaxed dependency version constraints for better compatibility
|
|
327
375
|
- **v1.4.0** (2025-12-10): Context-efficient architecture with progressive tool discovery (67% token reduction)
|
|
328
376
|
- **v1.2.3** (2025-12-10): Setup script fix for readonly filesystems (NixOS compatibility)
|
data/README.md
CHANGED
|
@@ -17,7 +17,7 @@ This Rails MCP Server implements the MCP specification to give AI models access
|
|
|
17
17
|
- Get database schema information
|
|
18
18
|
- Analyze controller-view relationships
|
|
19
19
|
- Analyze environment configurations
|
|
20
|
-
- Execute
|
|
20
|
+
- Execute Ruby code in the project context for custom queries
|
|
21
21
|
- Access comprehensive Rails, Turbo, Stimulus, and Kamal documentation
|
|
22
22
|
- Context-efficient architecture with progressive tool discovery
|
|
23
23
|
- Seamless integration with LLM clients
|
|
@@ -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
|
|
226
232
|
|
|
227
|
-
|
|
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.
|
|
234
|
+
|
|
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
|
|
|
@@ -352,7 +360,7 @@ The server uses a progressive tool discovery architecture to minimize context us
|
|
|
352
360
|
- **`switch_project`** - Select the active Rails project
|
|
353
361
|
- **`search_tools`** - Discover available tools by category or keyword
|
|
354
362
|
- **`execute_tool`** - Invoke internal analyzers with parameters
|
|
355
|
-
- **`execute_ruby`** - Run
|
|
363
|
+
- **`execute_ruby`** - Run Ruby code in the project context for custom queries
|
|
356
364
|
|
|
357
365
|
This design reduces initial context from ~2,400 tokens to ~800 tokens while maintaining full functionality.
|
|
358
366
|
|
|
@@ -404,12 +412,13 @@ After switching, you'll see a Quick Start guide with common commands.
|
|
|
404
412
|
|
|
405
413
|
#### 4. `execute_ruby`
|
|
406
414
|
|
|
407
|
-
**Description:** Execute
|
|
415
|
+
**Description:** Execute Ruby code in the Rails project context, for inspection and exploration. Runs with the privileges of the server process — see the Security note below; this is not a sandbox for untrusted code.
|
|
408
416
|
|
|
409
417
|
**Parameters:**
|
|
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,21 @@ 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:**
|
|
432
|
+
**Security:** `execute_ruby` runs Ruby that you — or your coding agent — supply, inside your Rails application, with the privileges of the process that started the server. **It is not a security sandbox for untrusted code.** The guardrails below reduce accidental damage and block the obvious escapes, but real Ruby is expressive enough that a determined caller can work around a pattern-based filter; treat the controls as defense-in-depth, not an isolation boundary.
|
|
433
|
+
|
|
434
|
+
Because you start the server yourself — normally locally, against your own project — the realistic risk is *running code you didn't intend to*, for example when a coding agent is steered by prompt injection into calling `execute_ruby` with a hostile payload. That payload would run as you. So: only enable this tool for projects and clients you trust, and actually review code before approving a `confirm_risky` re-run.
|
|
435
|
+
|
|
436
|
+
Guardrails applied:
|
|
437
|
+
|
|
438
|
+
- **No writes / shell / network:** file writes, `system`/`exec`/backticks/`spawn`, and network libraries are blocked by both static analysis and runtime overrides.
|
|
439
|
+
- **Almost no `require`:** `require_relative` and dynamic `require`s are refused, and `require "lib"` is refused for everything except a tiny allowlist of pure-data libraries (`csv` and the timezone libs) that Rails doesn't always preload. Under `bin/rails runner` the app's models, ActiveRecord, and the stdlib Rails loads on boot (`json`, `yaml`, `set`, `date`, …) are already available, so inspection code needs no requires anyway — and every dangerous stdlib escape (`pty`, `open3`, `fiddle`, `ffi`, `socket`) has to be required first, so refusing them removes that whole class of bypass at the source.
|
|
440
|
+
- **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.
|
|
441
|
+
- **No sensitive files:** `.env`, credentials, keys, and any `.gitignore`d path are refused.
|
|
442
|
+
- **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 — a safety net against accidental mutation, not a data-access guarantee. (Caveat: DDL may still commit on some adapters such as MySQL, and `after_commit` callbacks do not fire.)
|
|
443
|
+
- **Bounded execution:** a timeout (default 30s, max 60s) kills the whole process group, so a runaway `bin/rails runner` is terminated rather than orphaned.
|
|
444
|
+
- **Dynamic dispatch to execution sinks is hard-blocked:** `send`/`public_send`/`const_get` aimed by name at `system`/`exec`/`spawn`/`eval`/`Open3`/`Process`/`PTY`/… are rejected outright; the remaining dual-use forms of `send`, `public_send`, `const_get`, and `Kernel#open` are not run until you approve them via `confirm_risky: true`.
|
|
445
|
+
|
|
446
|
+
For a real boundary, run the server against a database user with read-only grants and/or inside OS-level isolation (a container, `sandbox-exec`, seccomp, a dedicated low-privilege user with no ambient credentials or network) rather than relying on these in-process checks.
|
|
424
447
|
|
|
425
448
|
### Internal Analyzers (via execute_tool)
|
|
426
449
|
|
|
@@ -604,6 +627,8 @@ To use with an MCP client:
|
|
|
604
627
|
2. Connect your MCP-compatible client to the server
|
|
605
628
|
3. The client will be able to use the available tools to interact with your Rails projects
|
|
606
629
|
|
|
630
|
+
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).
|
|
631
|
+
|
|
607
632
|
## Security
|
|
608
633
|
|
|
609
634
|
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
|
+
**For inspection, not mutation:** `execute_ruby` is meant for exploring the app, not changing it. File writes, shell/system calls, process spawning, 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. These are guardrails, not a security sandbox: the code you send runs with the privileges of the server process, so send only code you would run yourself, and never code from an untrusted source (e.g. copied out of an issue, PR, or file you're inspecting).
|
|
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 static 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
|
|
|
@@ -133,7 +133,7 @@ GitHub Copilot Agent only supports MCP **tools**. The following are available:
|
|
|
133
133
|
| `switch_project` | Change active project (optional in single-project mode) |
|
|
134
134
|
| `search_tools` | Discover available analyzers |
|
|
135
135
|
| `execute_tool` | Invoke internal analyzers |
|
|
136
|
-
| `execute_ruby` | Run
|
|
136
|
+
| `execute_ruby` | Run Ruby code in the project context |
|
|
137
137
|
|
|
138
138
|
### Internal Analyzers (via `execute_tool`)
|
|
139
139
|
|
|
@@ -171,7 +171,7 @@ The `load_guide` analyzer requires guides to be downloaded. To include guides:
|
|
|
171
171
|
|
|
172
172
|
### Network Restrictions
|
|
173
173
|
|
|
174
|
-
GitHub Copilot Agent runs in a sandboxed environment with firewall restrictions. The MCP server
|
|
174
|
+
GitHub Copilot Agent runs in a sandboxed environment with firewall restrictions. The MCP server is used here to inspect the repository and runs with the permissions of that agent environment; note that `execute_ruby` executes real Ruby with those permissions and is not itself an isolation boundary (see the [security notes](../README.md#4-execute_ruby)).
|
|
175
175
|
|
|
176
176
|
## Troubleshooting
|
|
177
177
|
|
|
@@ -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
|
|
@@ -3,16 +3,30 @@ module RailsMcpServer
|
|
|
3
3
|
tool_name "execute_ruby"
|
|
4
4
|
|
|
5
5
|
description <<~DESC
|
|
6
|
-
Execute
|
|
6
|
+
Execute Ruby code in the context of the Rails project, for inspection and
|
|
7
|
+
exploration. Use this for:
|
|
7
8
|
- Complex queries that would require multiple tool calls
|
|
8
9
|
- Filtering/transforming data before returning
|
|
9
10
|
- Custom exploration of the codebase
|
|
10
11
|
|
|
12
|
+
This runs with the privileges of the rails-mcp-server process. The
|
|
13
|
+
restrictions below are best-effort guardrails against accidental writes
|
|
14
|
+
and obvious escapes, not a security boundary for untrusted code; only run
|
|
15
|
+
code you would run yourself.
|
|
16
|
+
|
|
11
17
|
RESTRICTIONS:
|
|
12
18
|
- Cannot create, modify, or delete files
|
|
13
19
|
- Cannot read .env, credentials, key files, or .gitignore'd files
|
|
14
|
-
- Cannot access files outside the project directory
|
|
20
|
+
- Cannot access files outside the project directory (read-only system data
|
|
21
|
+
such as timezone files under /usr/share/zoneinfo is allowed)
|
|
15
22
|
- Cannot execute shell commands or system calls
|
|
23
|
+
- Cannot `require` arbitrary libraries or `require_relative` project files.
|
|
24
|
+
Rails and the stdlib it loads (json, yaml, set, ...) are already
|
|
25
|
+
available under `bin/rails runner`; only a few pure-data libraries not
|
|
26
|
+
always preloaded (csv, and the timezone libs) may be required
|
|
27
|
+
- Database writes run inside a transaction that is always rolled back, so
|
|
28
|
+
treat this as read-only for data too (note: DDL may still commit on some
|
|
29
|
+
adapters, and after_commit callbacks do not fire)
|
|
16
30
|
|
|
17
31
|
HELPER METHODS AVAILABLE:
|
|
18
32
|
- read_file(path) - safely read a file
|
|
@@ -21,11 +35,17 @@ module RailsMcpServer
|
|
|
21
35
|
- project_root - returns the project root path
|
|
22
36
|
|
|
23
37
|
NOTE: Use `puts` to see output, e.g., puts read_file('Gemfile')
|
|
38
|
+
|
|
39
|
+
Some dual-use constructs (Kernel#open, send, public_send, const_get) are
|
|
40
|
+
not run immediately: the tool returns a CONFIRMATION REQUIRED message
|
|
41
|
+
explaining the risk. Re-invoke with confirm_risky: true only after the
|
|
42
|
+
user has reviewed the code and approved it.
|
|
24
43
|
DESC
|
|
25
44
|
|
|
26
45
|
arguments do
|
|
27
|
-
required(:code).filled(:string).description("Ruby code to execute (
|
|
46
|
+
required(:code).filled(:string).description("Ruby code to execute (inspection operations only)")
|
|
28
47
|
optional(:timeout).filled(:integer).description("Timeout in seconds. Default: 30, Max: 60")
|
|
48
|
+
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
49
|
end
|
|
30
50
|
|
|
31
51
|
# Patterns that indicate dangerous operations
|
|
@@ -51,6 +71,22 @@ module RailsMcpServer
|
|
|
51
71
|
/Process\.(spawn|exec|fork)/i,
|
|
52
72
|
/Shellwords/i,
|
|
53
73
|
|
|
74
|
+
# Pseudo-terminals and native/syscall bridges. PTY.spawn / PTY.getpty
|
|
75
|
+
# start a child process outside the Kernel#system guard; Fiddle and FFI
|
|
76
|
+
# can call libc (e.g. system(3), execve(2)) directly. None of these are
|
|
77
|
+
# needed for read-only inspection.
|
|
78
|
+
/\bPTY\b/,
|
|
79
|
+
/\bFiddle\b/,
|
|
80
|
+
/\bFFI\b/,
|
|
81
|
+
|
|
82
|
+
# Dynamic dispatch aimed at an execution/eval sink *by name* is hard
|
|
83
|
+
# blocked. The general send/public_send/const_get forms stay in the
|
|
84
|
+
# confirmation tier below; only a dangerous literal target is rejected
|
|
85
|
+
# outright, so `record.send(:name)` still works while
|
|
86
|
+
# `Process.send(:spawn, ...)` or `const_get("Open3")` do not.
|
|
87
|
+
/\b(?:public_send|__send__|send)\s*(?:\(\s*)?[:'"](?:system|exec|spawn|fork|eval|popen|syscall|`)/i,
|
|
88
|
+
/\bconst_get\s*(?:\(\s*)?['"](?:Open3|Process|PTY|Kernel|Socket|Fiddle|FFI|Binding|ObjectSpace|TCPSocket|UDPSocket)\b/i,
|
|
89
|
+
|
|
54
90
|
# Network access
|
|
55
91
|
/Net::(HTTP|FTP|SMTP)/i,
|
|
56
92
|
/URI\.(open|parse)/i,
|
|
@@ -75,14 +111,50 @@ module RailsMcpServer
|
|
|
75
111
|
/set_trace_func/i,
|
|
76
112
|
|
|
77
113
|
# Environment/credentials access
|
|
78
|
-
|
|
79
|
-
|
|
114
|
+
# Match any ENV usage (ENV[, ENV.fetch, ENV.to_h, ENV.values_at, ENV.each,
|
|
115
|
+
# ...). Case-sensitive so it doesn't flag `Rails.env` or a local `env`.
|
|
116
|
+
/\bENV\b/,
|
|
80
117
|
/Rails\.application\.credentials/i,
|
|
81
118
|
/Rails\.application\.secrets/i,
|
|
82
119
|
|
|
83
|
-
# Load/require
|
|
84
|
-
|
|
85
|
-
|
|
120
|
+
# Load/require. Under `bin/rails runner` Rails, the app's models/gems, and
|
|
121
|
+
# the stdlib Rails loads on boot are already available, so inspection code
|
|
122
|
+
# almost never needs `require`. Dynamic requires and require_relative
|
|
123
|
+
# (loads/executes arbitrary project files) are refused outright; literal
|
|
124
|
+
# `require "lib"` is refused unless the lib is on REQUIRE_ALLOWLIST. This
|
|
125
|
+
# keeps dangerous stdlib escapes (`pty`, `open3`, `fiddle`, `ffi`,
|
|
126
|
+
# `socket`) out while still allowing the few pure-data libs that aren't
|
|
127
|
+
# always preloaded (e.g. csv, the timezone libs).
|
|
128
|
+
/\brequire_relative\b/i,
|
|
129
|
+
/require\s+[^'"]/i,
|
|
130
|
+
/load\s*[(\s]+[^)]*\$/i
|
|
131
|
+
].freeze
|
|
132
|
+
|
|
133
|
+
# The only libraries a literal `require` may name. All are pure-Ruby, with
|
|
134
|
+
# no process/network/native-call surface: csv (not always preloaded) and
|
|
135
|
+
# the timezone libs Rails uses when code touches Time.zone. Everything else
|
|
136
|
+
# — notably any process/native bridge — is rejected. Matched
|
|
137
|
+
# case-insensitively; a trailing ".rb" is ignored.
|
|
138
|
+
REQUIRE_ALLOWLIST = %w[csv tzinfo date time].freeze
|
|
139
|
+
|
|
140
|
+
# Extracts a literal require target from `require "x"`, `require'x'`, or
|
|
141
|
+
# `require("x")`. Dynamic (non-literal) requires are already rejected by the
|
|
142
|
+
# /require\s+[^'"]/ pattern above.
|
|
143
|
+
REQUIRE_STATEMENT = /\brequire\b\s*(?:\(\s*)?(['"])([^'"]+)\1/
|
|
144
|
+
|
|
145
|
+
# Dual-use constructs that are NOT hard-blocked (they have legitimate
|
|
146
|
+
# read-only uses) but can defeat the static safety scan, so running them
|
|
147
|
+
# requires explicit user confirmation via confirm_risky: true.
|
|
148
|
+
# Each entry: [pattern, label, why-it-is-risky].
|
|
149
|
+
CONFIRMATION_REQUIRED_PATTERNS = [
|
|
150
|
+
[/(?<![.\w])open\s*\(/, "Kernel#open",
|
|
151
|
+
"`open(arg)` runs a shell command when arg begins with '|', and can open network/URI targets — both escape the sandbox."],
|
|
152
|
+
[/\bpublic_send\b/, "public_send",
|
|
153
|
+
"dynamic dispatch can invoke methods the static scan cannot see, e.g. reaching blocked system/file APIs indirectly."],
|
|
154
|
+
[/\bsend\s*[(\s]/, "send",
|
|
155
|
+
"dynamic dispatch can invoke methods the static scan cannot see, e.g. reaching blocked system/file APIs indirectly."],
|
|
156
|
+
[/\bconst_get\b/, "const_get",
|
|
157
|
+
"resolves constants by name at runtime, which can reach classes the static scan would otherwise block."]
|
|
86
158
|
].freeze
|
|
87
159
|
|
|
88
160
|
# Sensitive file patterns (in addition to .gitignore)
|
|
@@ -104,6 +176,18 @@ module RailsMcpServer
|
|
|
104
176
|
/id_ed25519/i
|
|
105
177
|
].freeze
|
|
106
178
|
|
|
179
|
+
# Read-only system data directories the sandbox may read. TZInfo lazily
|
|
180
|
+
# loads IANA timezone data on first Time.zone use; these are its default
|
|
181
|
+
# search paths plus /var/db/timezone, the real location behind macOS's
|
|
182
|
+
# /usr/share/zoneinfo symlink. Writes remain blocked by the File/Dir/
|
|
183
|
+
# FileUtils overrides.
|
|
184
|
+
ALLOWED_READ_PATHS = %w[
|
|
185
|
+
/usr/share/zoneinfo
|
|
186
|
+
/usr/share/lib/zoneinfo
|
|
187
|
+
/etc/zoneinfo
|
|
188
|
+
/var/db/timezone
|
|
189
|
+
].freeze
|
|
190
|
+
|
|
107
191
|
NO_OUTPUT_MESSAGE = <<~MSG
|
|
108
192
|
Code executed successfully (no output).
|
|
109
193
|
|
|
@@ -113,7 +197,7 @@ module RailsMcpServer
|
|
|
113
197
|
puts Dir.glob('app/models/*.rb')
|
|
114
198
|
MSG
|
|
115
199
|
|
|
116
|
-
def call(code:, timeout: 30)
|
|
200
|
+
def call(code:, timeout: 30, confirm_risky: false)
|
|
117
201
|
unless current_project
|
|
118
202
|
return "No active project. Please switch to a project first."
|
|
119
203
|
end
|
|
@@ -121,14 +205,20 @@ module RailsMcpServer
|
|
|
121
205
|
timeout = [timeout.to_i, 60].min # Cap at 60 seconds
|
|
122
206
|
timeout = 10 if timeout < 1
|
|
123
207
|
|
|
124
|
-
# Step 1: Static analysis - reject dangerous code
|
|
208
|
+
# Step 1: Static analysis - reject outright-dangerous code
|
|
125
209
|
validation_error = validate_code_safety(code)
|
|
126
210
|
return validation_error if validation_error
|
|
127
211
|
|
|
128
|
-
# Step 2:
|
|
212
|
+
# Step 2: Dual-use constructs require explicit user confirmation
|
|
213
|
+
unless confirm_risky
|
|
214
|
+
confirmation = confirmation_required(code)
|
|
215
|
+
return confirmation if confirmation
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Step 3: Build the sandboxed execution environment
|
|
129
219
|
sandbox_code = build_sandbox(code)
|
|
130
220
|
|
|
131
|
-
# Step
|
|
221
|
+
# Step 4: Execute with timeout
|
|
132
222
|
execute_sandboxed(sandbox_code, timeout)
|
|
133
223
|
end
|
|
134
224
|
|
|
@@ -138,40 +228,121 @@ module RailsMcpServer
|
|
|
138
228
|
FORBIDDEN_PATTERNS.each do |pattern|
|
|
139
229
|
if code.match?(pattern)
|
|
140
230
|
return "REJECTED: Code contains forbidden pattern (#{pattern.source.split("\\").first}...). " \
|
|
141
|
-
"This tool only allows
|
|
231
|
+
"This tool only allows a restricted set of inspection operations."
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
validate_requires(code)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Rejects any literal `require` of a library outside REQUIRE_ALLOWLIST.
|
|
239
|
+
# (require_relative and dynamic requires are already rejected by the
|
|
240
|
+
# forbidden patterns.) Returns an error string, or nil when permitted.
|
|
241
|
+
def validate_requires(code)
|
|
242
|
+
code.scan(REQUIRE_STATEMENT).each do |_quote, lib|
|
|
243
|
+
normalized = lib.downcase.sub(/\.rb\z/, "")
|
|
244
|
+
unless REQUIRE_ALLOWLIST.include?(normalized)
|
|
245
|
+
return "REJECTED: require of '#{lib}' is not permitted. " \
|
|
246
|
+
"Only these libraries may be required: #{REQUIRE_ALLOWLIST.join(", ")}."
|
|
142
247
|
end
|
|
143
248
|
end
|
|
144
249
|
nil
|
|
145
250
|
end
|
|
146
251
|
|
|
252
|
+
# Returns a message asking the model to confirm with the user when the code
|
|
253
|
+
# uses dual-use constructs, or nil when there is nothing to confirm.
|
|
254
|
+
def confirmation_required(code)
|
|
255
|
+
matched = CONFIRMATION_REQUIRED_PATTERNS.select { |pattern, _label, _reason| code.match?(pattern) }
|
|
256
|
+
return nil if matched.empty?
|
|
257
|
+
|
|
258
|
+
details = matched.map { |_pattern, label, reason| " - `#{label}`: #{reason}" }.join("\n")
|
|
259
|
+
|
|
260
|
+
<<~MSG
|
|
261
|
+
CONFIRMATION REQUIRED: This code uses constructs that can bypass the sandbox's static safety checks:
|
|
262
|
+
|
|
263
|
+
#{details}
|
|
264
|
+
|
|
265
|
+
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.
|
|
266
|
+
MSG
|
|
267
|
+
end
|
|
268
|
+
|
|
147
269
|
def build_sandbox(user_code)
|
|
148
270
|
gitignore_patterns = parse_gitignore
|
|
149
271
|
all_patterns = SENSITIVE_PATTERNS.map(&:source) + gitignore_patterns
|
|
150
272
|
sensitive_patterns_ruby = all_patterns.map { |p| "Regexp.new(#{p.inspect}, Regexp::IGNORECASE)" }.join(",\n ")
|
|
151
273
|
|
|
152
274
|
<<~RUBY
|
|
275
|
+
require "stringio" # the File.open override below yields StringIO objects
|
|
276
|
+
|
|
153
277
|
# Sandbox wrapper for safe execution
|
|
154
278
|
module McpSandbox
|
|
155
|
-
|
|
279
|
+
# realpath-normalized so symlink resolution below compares against the
|
|
280
|
+
# canonical root (e.g. macOS /var -> /private/var) rather than a path
|
|
281
|
+
# that would never prefix-match a resolved target.
|
|
282
|
+
PROJECT_ROOT = File.realpath(#{active_project_path.inspect}).freeze
|
|
283
|
+
|
|
284
|
+
ALLOWED_READ_PATHS = #{ALLOWED_READ_PATHS.inspect}.freeze
|
|
285
|
+
|
|
286
|
+
# realpath-resolved forms of the allowlist, so a resolved target still
|
|
287
|
+
# matches when the allowed dir is itself a symlink (e.g. macOS
|
|
288
|
+
# /usr/share/zoneinfo -> /private/var/db/timezone/.../zoneinfo).
|
|
289
|
+
CANONICAL_ALLOWED_READ_PATHS = ALLOWED_READ_PATHS.map { |dir|
|
|
290
|
+
File.exist?(dir) ? File.realpath(dir) : dir
|
|
291
|
+
}.freeze
|
|
156
292
|
|
|
157
293
|
SENSITIVE_PATTERNS = [
|
|
158
294
|
#{sensitive_patterns_ruby}
|
|
159
295
|
].freeze
|
|
160
296
|
|
|
297
|
+
# Native method handles captured *before* the File/Dir overrides below
|
|
298
|
+
# replace them. Held in private constants so sandboxed user code has no
|
|
299
|
+
# public `File.original_read`-style alias to call the raw method back.
|
|
300
|
+
ORIGINAL_FILE_READ = File.method(:read)
|
|
301
|
+
ORIGINAL_FILE_READLINES = File.method(:readlines)
|
|
302
|
+
ORIGINAL_FILE_BINREAD = File.method(:binread)
|
|
303
|
+
ORIGINAL_FILE_EXIST = File.method(:exist?)
|
|
304
|
+
ORIGINAL_FILE_DIRECTORY = File.method(:directory?)
|
|
305
|
+
ORIGINAL_FILE_FILE = File.method(:file?)
|
|
306
|
+
ORIGINAL_FILE_REALPATH = File.method(:realpath)
|
|
307
|
+
ORIGINAL_DIR_GLOB = Dir.method(:glob)
|
|
308
|
+
ORIGINAL_DIR_ENTRIES = Dir.method(:entries)
|
|
309
|
+
private_constant :ORIGINAL_FILE_READ, :ORIGINAL_FILE_READLINES,
|
|
310
|
+
:ORIGINAL_FILE_BINREAD, :ORIGINAL_FILE_EXIST, :ORIGINAL_FILE_DIRECTORY,
|
|
311
|
+
:ORIGINAL_FILE_FILE, :ORIGINAL_FILE_REALPATH, :ORIGINAL_DIR_GLOB,
|
|
312
|
+
:ORIGINAL_DIR_ENTRIES
|
|
313
|
+
|
|
161
314
|
class PathViolation < StandardError; end
|
|
162
315
|
class SensitiveFileViolation < StandardError; end
|
|
163
316
|
class WriteViolation < StandardError; end
|
|
164
317
|
|
|
165
318
|
module_function
|
|
166
319
|
|
|
320
|
+
# Resolve symlinks so a link *inside* the project cannot be used to
|
|
321
|
+
# read a target outside it. realpath needs the path to exist, so for a
|
|
322
|
+
# not-yet-existing path resolve the deepest existing ancestor and
|
|
323
|
+
# re-append the remainder (which still catches a symlinked ancestor).
|
|
324
|
+
def resolve_symlinks(expanded)
|
|
325
|
+
return ORIGINAL_FILE_REALPATH.call(expanded) if ORIGINAL_FILE_EXIST.call(expanded)
|
|
326
|
+
|
|
327
|
+
parent = File.dirname(expanded)
|
|
328
|
+
return expanded if parent == expanded
|
|
329
|
+
|
|
330
|
+
File.join(resolve_symlinks(parent), File.basename(expanded))
|
|
331
|
+
end
|
|
332
|
+
|
|
167
333
|
def validate_path!(path)
|
|
168
334
|
expanded = File.expand_path(path, PROJECT_ROOT)
|
|
335
|
+
resolved = resolve_symlinks(expanded)
|
|
336
|
+
|
|
337
|
+
if (ALLOWED_READ_PATHS + CANONICAL_ALLOWED_READ_PATHS).any? { |dir| resolved == dir || resolved.start_with?(dir + "/") }
|
|
338
|
+
return resolved
|
|
339
|
+
end
|
|
169
340
|
|
|
170
|
-
unless
|
|
341
|
+
unless resolved.start_with?(PROJECT_ROOT + "/") || resolved == PROJECT_ROOT
|
|
171
342
|
raise PathViolation, "Access denied: path '\#{path}' is outside project directory"
|
|
172
343
|
end
|
|
173
344
|
|
|
174
|
-
relative_path =
|
|
345
|
+
relative_path = resolved.sub(PROJECT_ROOT + "/", "")
|
|
175
346
|
|
|
176
347
|
SENSITIVE_PATTERNS.each do |pattern|
|
|
177
348
|
if relative_path.match?(pattern)
|
|
@@ -179,37 +350,48 @@ module RailsMcpServer
|
|
|
179
350
|
end
|
|
180
351
|
end
|
|
181
352
|
|
|
182
|
-
|
|
353
|
+
resolved
|
|
183
354
|
end
|
|
184
355
|
|
|
185
356
|
def safe_read(path)
|
|
186
|
-
|
|
187
|
-
|
|
357
|
+
ORIGINAL_FILE_READ.call(validate_path!(path))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def safe_readlines(path)
|
|
361
|
+
ORIGINAL_FILE_READLINES.call(validate_path!(path))
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def safe_binread(path)
|
|
365
|
+
ORIGINAL_FILE_BINREAD.call(validate_path!(path))
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def safe_foreach(path, &block)
|
|
369
|
+
lines = safe_readlines(path)
|
|
370
|
+
return lines.each unless block
|
|
371
|
+
|
|
372
|
+
lines.each(&block)
|
|
188
373
|
end
|
|
189
374
|
|
|
190
375
|
def safe_exist?(path)
|
|
191
|
-
|
|
192
|
-
File.original_exist?(validated_path)
|
|
376
|
+
ORIGINAL_FILE_EXIST.call(validate_path!(path))
|
|
193
377
|
rescue PathViolation, SensitiveFileViolation
|
|
194
378
|
false
|
|
195
379
|
end
|
|
196
380
|
|
|
197
381
|
def safe_directory?(path)
|
|
198
|
-
|
|
199
|
-
File.original_directory?(validated_path)
|
|
382
|
+
ORIGINAL_FILE_DIRECTORY.call(validate_path!(path))
|
|
200
383
|
rescue PathViolation, SensitiveFileViolation
|
|
201
384
|
false
|
|
202
385
|
end
|
|
203
386
|
|
|
204
387
|
def safe_file?(path)
|
|
205
|
-
|
|
206
|
-
File.original_file?(validated_path)
|
|
388
|
+
ORIGINAL_FILE_FILE.call(validate_path!(path))
|
|
207
389
|
rescue PathViolation, SensitiveFileViolation
|
|
208
390
|
false
|
|
209
391
|
end
|
|
210
392
|
|
|
211
393
|
def safe_glob(pattern, base: PROJECT_ROOT)
|
|
212
|
-
|
|
394
|
+
ORIGINAL_DIR_GLOB.call(File.join(base, pattern)).select do |path|
|
|
213
395
|
validate_path!(path)
|
|
214
396
|
true
|
|
215
397
|
rescue PathViolation, SensitiveFileViolation
|
|
@@ -218,23 +400,58 @@ module RailsMcpServer
|
|
|
218
400
|
end
|
|
219
401
|
|
|
220
402
|
def safe_entries(path)
|
|
221
|
-
|
|
222
|
-
|
|
403
|
+
ORIGINAL_DIR_ENTRIES.call(validate_path!(path)).reject { |e| e.start_with?(".") }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# True only when ActiveRecord is loaded *and* a connection can be
|
|
407
|
+
# obtained, so we never turn a pure-Ruby read-only snippet into a
|
|
408
|
+
# database connection error just to wrap it in a transaction.
|
|
409
|
+
def database_available?
|
|
410
|
+
return false unless defined?(ActiveRecord::Base)
|
|
411
|
+
|
|
412
|
+
ActiveRecord::Base.connection
|
|
413
|
+
true
|
|
414
|
+
rescue StandardError
|
|
415
|
+
false
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Run the block inside a transaction that is *always* rolled back, so
|
|
419
|
+
# accidental writes are undone. Harm reduction, not a guarantee: DDL
|
|
420
|
+
# auto-commits on some adapters (e.g. MySQL) and after_commit
|
|
421
|
+
# callbacks are suppressed. Falls back to a plain call when no
|
|
422
|
+
# database is available. Real exceptions still propagate (and also
|
|
423
|
+
# trigger the rollback).
|
|
424
|
+
def readonly_guard
|
|
425
|
+
return yield unless database_available?
|
|
426
|
+
|
|
427
|
+
result = nil
|
|
428
|
+
ActiveRecord::Base.transaction do
|
|
429
|
+
result = yield
|
|
430
|
+
raise ActiveRecord::Rollback
|
|
431
|
+
end
|
|
432
|
+
result
|
|
223
433
|
end
|
|
224
434
|
end
|
|
225
435
|
|
|
226
436
|
# Override File class methods
|
|
227
437
|
class File
|
|
228
438
|
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
439
|
def read(path, *args)
|
|
235
440
|
McpSandbox.safe_read(path)
|
|
236
441
|
end
|
|
237
442
|
|
|
443
|
+
def readlines(path, *args)
|
|
444
|
+
McpSandbox.safe_readlines(path)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def binread(path, *args)
|
|
448
|
+
McpSandbox.safe_binread(path)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def foreach(path, *args, &block)
|
|
452
|
+
McpSandbox.safe_foreach(path, &block)
|
|
453
|
+
end
|
|
454
|
+
|
|
238
455
|
def exist?(path)
|
|
239
456
|
McpSandbox.safe_exist?(path)
|
|
240
457
|
end
|
|
@@ -272,9 +489,6 @@ module RailsMcpServer
|
|
|
272
489
|
# Override Dir class methods
|
|
273
490
|
class Dir
|
|
274
491
|
class << self
|
|
275
|
-
alias_method :original_glob, :glob
|
|
276
|
-
alias_method :original_entries, :entries
|
|
277
|
-
|
|
278
492
|
def glob(pattern, *args)
|
|
279
493
|
McpSandbox.safe_glob(pattern)
|
|
280
494
|
end
|
|
@@ -291,6 +505,29 @@ module RailsMcpServer
|
|
|
291
505
|
end
|
|
292
506
|
end
|
|
293
507
|
|
|
508
|
+
# Override IO read entry points. File < IO, but IO.read / IO.readlines /
|
|
509
|
+
# IO.binread / IO.foreach are separate class methods that bypass the File
|
|
510
|
+
# overrides above, so they must be sandboxed independently.
|
|
511
|
+
class IO
|
|
512
|
+
class << self
|
|
513
|
+
def read(path, *args)
|
|
514
|
+
McpSandbox.safe_read(path)
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def readlines(path, *args)
|
|
518
|
+
McpSandbox.safe_readlines(path)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def binread(path, *args)
|
|
522
|
+
McpSandbox.safe_binread(path)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def foreach(path, *args, &block)
|
|
526
|
+
McpSandbox.safe_foreach(path, &block)
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
294
531
|
# Block FileUtils entirely
|
|
295
532
|
if defined?(FileUtils)
|
|
296
533
|
module FileUtils
|
|
@@ -346,8 +583,13 @@ module RailsMcpServer
|
|
|
346
583
|
end
|
|
347
584
|
|
|
348
585
|
# ============ USER CODE BELOW ============
|
|
586
|
+
# Wrapped in an always-rolled-back transaction so accidental DB writes
|
|
587
|
+
# (delete_all, update, save, raw DML) are undone. See McpSandbox
|
|
588
|
+
# .readonly_guard for the caveats; it's a no-op without a database.
|
|
349
589
|
begin
|
|
350
|
-
|
|
590
|
+
McpSandbox.readonly_guard do
|
|
591
|
+
#{user_code}
|
|
592
|
+
end
|
|
351
593
|
rescue McpSandbox::PathViolation => e
|
|
352
594
|
puts "PATH ERROR: \#{e.message}"
|
|
353
595
|
rescue McpSandbox::SensitiveFileViolation => e
|
|
@@ -386,23 +628,19 @@ module RailsMcpServer
|
|
|
386
628
|
|
|
387
629
|
def execute_sandboxed(code, timeout)
|
|
388
630
|
require "tempfile"
|
|
389
|
-
require "timeout"
|
|
390
631
|
|
|
391
632
|
Tempfile.create(["mcp_sandbox", ".rb"]) do |f|
|
|
392
633
|
f.write(code)
|
|
393
634
|
f.flush
|
|
394
635
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
rescue Timeout::Error
|
|
404
|
-
"TIMEOUT: Execution exceeded #{timeout} seconds"
|
|
405
|
-
end
|
|
636
|
+
# RunProcess enforces the timeout by killing the whole process group, so
|
|
637
|
+
# a runaway `rails runner` is actually terminated rather than orphaned.
|
|
638
|
+
result = RailsMcpServer::RunProcess.execute_rails_command(
|
|
639
|
+
active_project_path,
|
|
640
|
+
"bin/rails runner #{f.path} 2>&1",
|
|
641
|
+
timeout: timeout
|
|
642
|
+
)
|
|
643
|
+
result.to_s.empty? ? NO_OUTPUT_MESSAGE : result
|
|
406
644
|
end
|
|
407
645
|
end
|
|
408
646
|
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.1
|
|
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: []
|