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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bb5fa61d56e0b92152a67a191cb7f0e0742496cf96d462467fefe40f201efec
4
- data.tar.gz: ba1ead2d4cecc46770c94e44704536453ce12ffada3882d71da8f47ef9ff0e43
3
+ metadata.gz: 950673f40d56ddea684d938e4e55c2e8db18c3ca44e94988f63565a9db950869
4
+ data.tar.gz: 00712156bdfdfbfc1ec33bd08e31ccb0ad4e7427e3e1e31282de62b4f7b5cc1b
5
5
  SHA512:
6
- metadata.gz: 86a0f66633dfce8adf6f3b929906a044502696bf0f2a14d054dfa8b8db7984f5962373e1fe2149557eccd149d9a31cbb2437fd52a75fa4f27e32d14099ba67b8
7
- data.tar.gz: 393d684c4695480de7111c27cad29bf3ef9e69d748d1c94212b1861576479bac34ba43f2955006d3510f30d1285a304ce5c84a30863893c21de97817eff0fa73
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 sandboxed Ruby code for custom queries
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
- Claude Desktop launches the MCP server using your system's default Ruby environment, bypassing version manager initialization (e.g., rbenv, RVM). The MCP server needs to use the same Ruby version where it was installed, as MCP server startup failures can occur when using an incompatible Ruby version.
210
+ Two different Rubies are involved, and the server handles them differently.
211
211
 
212
- If you are using a Ruby version manager such as rbenv, you can use the Ruby shim path to ensure the correct version is used:
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 "/home/your_user/.rbenv/shims/ruby" with your actual path for the Ruby shim.
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
- **Tip**: The `rails-mcp-config` tool automatically detects your Ruby path and uses the correct shim path when configuring Claude Desktop.
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 sandboxed Ruby code for custom queries
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 sandboxed Ruby code in the Rails project context.
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:** The sandbox prevents file writes, system calls, network access, and reading sensitive files (.env, credentials, etc.).
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
@@ -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.1+ (recommended: 3.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 sandboxed Ruby code |
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 has read-only access to the repository.
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&.empty? && !default_value.empty?
223
- result&.empty? ? nil : 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: #{model_name}\nFile: #{model_file.sub(active_project_path + "/", "")}"
78
+ "Model: #{class_name}\nFile: #{model_file.sub(active_project_path + "/", "")}"
71
79
  when "associations"
72
- format_associations_only(model_name, model_file)
80
+ format_associations_only(class_name, model_file)
73
81
  else
74
- build_full_analysis(model_name, model_file, analysis_type)
82
+ build_full_analysis(class_name, model_file, analysis_type)
75
83
  end
76
84
  end
77
85
 
78
- def format_associations_only(model_name, model_file)
79
- associations = get_associations_via_introspection(model_name)
80
- output = ["Model: #{model_name}", "File: #{model_file.sub(active_project_path + "/", "")}", "", "Associations:"]
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(model_name, model_file, analysis_type)
90
- output = ["=" * 60, "Model: #{model_name}", "File: #{model_file.sub(active_project_path + "/", "")}", "=" * 60]
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(model_name)
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(model_name)
105
- script = build_introspection_script(model_name)
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(model_name)
124
+ def build_introspection_script(class_name)
117
125
  <<~RUBY
118
126
  require 'json'
119
127
  begin
120
- model = #{model_name}
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(model_name)
242
- script = "require 'json'; puts (#{model_name}.reflect_on_all_associations.map { |a| { name: a.name.to_s, type: a.macro.to_s } } rescue []).to_json"
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
- path = File.join(active_project_path, "app", "models", "#{underscore(model_name)}.rb")
252
- File.exist?(path) ? path : Dir.glob(File.join(active_project_path, "app", "models", "**", "#{underscore(model_name).split("/").last}.rb")).first
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} 2>/dev/null"
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 read-only Ruby code in the context of the Rails project. Use this for:
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 (read-only operations only)")
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
- /ENV\[/i,
79
- /ENV\.fetch/i,
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 that could execute arbitrary code
84
- /load\s*[(\s]+[^)]*\$/i,
85
- /require\s+[^'"]/i
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: Build the sandboxed execution environment
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 3: Execute with timeout
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 read-only operations."
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
- PROJECT_ROOT = #{active_project_path.inspect}.freeze
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 expanded.start_with?(PROJECT_ROOT + "/") || expanded == PROJECT_ROOT
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 = expanded.sub(PROJECT_ROOT + "/", "")
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
- expanded
353
+ resolved
183
354
  end
184
355
 
185
356
  def safe_read(path)
186
- validated_path = validate_path!(path)
187
- File.original_read(validated_path)
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
- validated_path = validate_path!(path)
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
- validated_path = validate_path!(path)
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
- validated_path = validate_path!(path)
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
- Dir.original_glob(File.join(base, pattern)).select do |path|
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
- validated_path = validate_path!(path)
222
- Dir.original_entries(validated_path).reject { |e| e.start_with?(".") }
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
- #{user_code}
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
- begin
396
- Timeout.timeout(timeout) do
397
- result = RailsMcpServer::RunProcess.execute_rails_command(
398
- active_project_path,
399
- "bin/rails runner #{f.path} 2>&1"
400
- )
401
- result.empty? ? NO_OUTPUT_MESSAGE : result
402
- end
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
- def self.execute_rails_command(project_path, command)
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
- # Set RBENV_VERSION from project's .ruby-version if it exists
14
- ruby_version_file = File.join(project_path, ".ruby-version")
15
- if File.exist?(ruby_version_file)
16
- subprocess_env["RBENV_VERSION"] = File.read(ruby_version_file).strip
17
- else
18
- subprocess_env.delete("RBENV_VERSION")
19
- end
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 = "cd #{Shellwords.escape(project_path)} && #{command}"
23
- stdout_str, stderr_str, status = Open3.capture3(subprocess_env, shell, "-l", "-c", shell_command)
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
@@ -1,3 +1,3 @@
1
1
  module RailsMcpServer
2
- VERSION = "1.5.1"
2
+ VERSION = "1.6.1"
3
3
  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.5.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: '7.1'
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: '7.1'
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: '5.25'
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: '5.25'
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: '2.7'
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: '2.7'
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.2.0
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.3
252
+ rubygems_version: 4.0.17
252
253
  specification_version: 4
253
254
  summary: MCP server for Rails projects
254
255
  test_files: []