rails-mcp-server 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bb5fa61d56e0b92152a67a191cb7f0e0742496cf96d462467fefe40f201efec
4
- data.tar.gz: ba1ead2d4cecc46770c94e44704536453ce12ffada3882d71da8f47ef9ff0e43
3
+ metadata.gz: 61b6de0c4ec67e88054b3fa1b91cfc3d0a9b6cb5d1c565c5086b1e88dfcd7dda
4
+ data.tar.gz: 6af401f97462dd368072b223117fab10f49da0b873f7b2f29985da63ec4d8a98
5
5
  SHA512:
6
- metadata.gz: 86a0f66633dfce8adf6f3b929906a044502696bf0f2a14d054dfa8b8db7984f5962373e1fe2149557eccd149d9a31cbb2437fd52a75fa4f27e32d14099ba67b8
7
- data.tar.gz: 393d684c4695480de7111c27cad29bf3ef9e69d748d1c94212b1861576479bac34ba43f2955006d3510f30d1285a304ce5c84a30863893c21de97817eff0fa73
6
+ metadata.gz: 8cd00e7e51074c682b42ddcbd07dc7697547313c4712c69c0641f6357bbde84775bc8425fe5cad1b05807ca97c065e6d015a5d0aa03079dd371f39e3f664a331
7
+ data.tar.gz: 20d054080683e9c8151075fcd6084eaaa868b549c99128768a18abe37bb1ec1ab96c32661273920fd03d100eea37c47c92e36ea6a11c266eff21239f84545d54
data/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [1.6.0] - 2026-08-03
11
+
12
+ ### Added
13
+
14
+ - **Namespaced model resolution in `analyze_models`**: Module-namespaced models now resolve from every input form — `Namespace::Model`, the file path `namespace/model`, the flattened `NamespaceModel`, and the bare leaf `Model` — independent of the app's custom inflections. Previously namespaced models could be reported as "not found".
15
+
16
+ ### Changed
17
+
18
+ - **Dropped Ruby 3.2 support** (breaking): The minimum supported Ruby is now 3.3 (`required_ruby_version >= 3.3.0`), and the CI matrix tests Ruby 3.3 and 3.4. Dependency updates pull in transitive gems (`dry-configurable` 1.4.0, `parallel` 2.1.0) that require Ruby >= 3.3.
19
+ - **Dependency updates**: Bumped project dependencies, including major upgrades to `puma` (~> 8.0), `minitest` (~> 6.0) and `mocha` (~> 3.0), plus `activesupport` 8.1.3.1, `addressable` 2.9.0, `rubocop` 1.88.2, `standard` 1.56.0 and other transitive gems.
20
+ - **Deterministic linting**: Added `.standard.yml` pinning `ruby_version: 3.3` to match the gemspec's minimum supported Ruby, so Standard/RuboCop target the supported floor regardless of the local or CI Ruby.
21
+
22
+ ### Fixed
23
+
24
+ - **Version-manager Ruby resolution for Rails-runner tools** (mise/asdf/rbenv agnostic): Tools that shell out to `bin/rails` (`execute_ruby`, `get_schema`, and the introspection half of `analyze_models` / `analyze_controller_views`) no longer fall back to the system Ruby on machines managed by mise or asdf. The runner previously exported the rbenv-only `RBENV_VERSION` and used a login shell (`$SHELL -l -c`); on macOS `path_helper` then reordered `PATH` so `bin/rails` booted under system Ruby and failed. It now prepends the active manager's shims directory (mise/asdf/rbenv, honoring `MISE_DATA_DIR`/`XDG_DATA_HOME`/`ASDF_DATA_DIR`/`RBENV_ROOT`) to the subprocess `PATH` and runs a non-login shell, so the project's Ruby is used. rvm (which has no shims) is still sourced when present.
25
+ - **`analyze_models` introspection constant**: The introspection runner now derives the canonical constant from the resolved model file (loaded via `Object.const_get`) instead of interpolating the raw user input. This fixes invalid-Ruby / `NameError` failures for path and flattened inputs, degrades non-ActiveRecord constants to a clear message, and removes an unvalidated-input injection surface in the generated runner scripts.
26
+ - **Analyzer errors no longer swallowed**: The analyzer runner path dropped `2>/dev/null`, so a Rails boot failure now surfaces the real error instead of a blank "Error executing Rails command".
27
+ - **`execute_ruby` timezone data access**: The sandbox now allows read-only access to system timezone directories (`/usr/share/zoneinfo`, `/usr/share/lib/zoneinfo`, `/etc/zoneinfo`, `/var/db/timezone`). Previously, any code that touched `Time.zone` failed with `PATH ERROR: Access denied: path '/usr/share/zoneinfo/...' is outside project directory` because TZInfo lazily loads IANA timezone data on first use. Writes and all other out-of-project reads remain blocked.
28
+
29
+ ### Security
30
+
31
+ - **`execute_ruby` sandbox hardening**: Closed several read-path bypasses and added defense-in-depth layers to the sandbox.
32
+ - **File-read coverage**: `IO.read`/`readlines`/`binread`/`foreach` and `File.readlines`/`binread`/`foreach` are now sandboxed too (previously only `File.read`/`open` were, so `IO.read('/etc/passwd')` and `File.readlines` bypassed path validation). The raw native readers are no longer exposed as public `File.original_read`-style aliases.
33
+ - **Symlink resolution**: path validation now resolves symlinks (`realpath`) before checking, so a link inside the project can't point outside it. The system-timezone allowlist is matched against canonical (symlink-resolved) locations so it keeps working on macOS.
34
+ - **Broader `ENV` block**: the static scan now rejects all `ENV` access (`ENV.to_h`, `ENV.values_at`, `ENV.each`, …), not just `ENV[]`/`ENV.fetch`.
35
+ - **Database writes rolled back**: user code runs inside a transaction that is always rolled back, so accidental `delete_all`/`update`/`save`/raw DML are undone. (Harm reduction — DDL may auto-commit on some adapters and `after_commit` callbacks are suppressed.)
36
+ - **Timeout actually stops runaway code**: the execution timeout now kills the entire process group, so a runaway `bin/rails runner` is terminated instead of being orphaned while the parent stops waiting.
37
+ - **Confirmation for dual-use constructs**: `send`, `public_send`, `const_get`, and `Kernel#open` are no longer run implicitly. The tool returns a `CONFIRMATION REQUIRED` message; callers must opt in with the new `confirm_risky: true` parameter after a human reviews the code.
38
+ - **Puma advisories resolved**: Upgrading to `puma` 8.0.2 addresses CVE-2026-47736 and CVE-2026-47737 (both HIGH — PROXY Protocol v1 remote memory exhaustion and repeated-header handling). Dependency updates also clear the `concurrent-ruby` ReadWriteLock advisory (GHSA-6wx8-w4f5-wwcr). `bundler-audit` now reports no vulnerabilities.
39
+
8
40
  ## [1.5.1] - 2026-03-04
9
41
 
10
42
  ### Changed
@@ -323,6 +355,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
323
355
 
324
356
  ## Version History Summary
325
357
 
358
+ - **v1.6.0** (2026-08-03): Sandbox hardening for `execute_ruby`, version-manager Ruby resolution, namespaced model resolution, dependency + security updates (drops Ruby 3.2)
326
359
  - **v1.5.1** (2026-03-04): Relaxed dependency version constraints for better compatibility
327
360
  - **v1.4.0** (2025-12-10): Context-efficient architecture with progressive tool discovery (67% token reduction)
328
361
  - **v1.2.3** (2025-12-10): Setup script fix for readonly filesystems (NixOS compatibility)
data/README.md CHANGED
@@ -207,9 +207,11 @@ After running the script, restart Claude Desktop to apply the changes.
207
207
 
208
208
  ### Ruby Version Manager Users
209
209
 
210
- 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
232
+
233
+ Tools that boot your app — `execute_ruby`, `get_schema`, and the introspection half of `analyze_models` / `analyze_controller_views` — run `bin/rails` inside the project directory. The server selects the **project's** Ruby automatically and is agnostic to your version manager: it prepends the active manager's shims (**mise**, **asdf**, **rbenv**) to the subprocess `PATH` and sources **rvm** when present, then uses a non-login shell so macOS `path_helper` cannot substitute the system Ruby. The version is taken from the project's `.ruby-version` / `.tool-versions` / `.mise.toml`, so different projects can use different Rubies with no extra configuration.
226
234
 
227
- **Tip**: The `rails-mcp-config` tool automatically detects your Ruby path and uses the correct shim path when configuring Claude Desktop.
235
+ > No manual `PATH` workaround is needed. Previously these tools could fall back to the system Ruby on mise/asdf machines, where the app's Bundler then failed to boot.
228
236
 
229
237
  ### Using an MCP Proxy (Advanced)
230
238
 
@@ -410,6 +418,7 @@ After switching, you'll see a Quick Start guide with common commands.
410
418
 
411
419
  - `code`: (String, required) Ruby code to execute
412
420
  - `timeout`: (Integer, optional) Timeout in seconds (default: 30, max: 60)
421
+ - `confirm_risky`: (Boolean, optional) Set `true` only after you have explicitly approved code that uses dual-use constructs (`send`, `public_send`, `const_get`, `Kernel#open`). When false/absent, such code is not executed — the tool returns a `CONFIRMATION REQUIRED` message explaining the risk instead.
413
422
 
414
423
  **Available helper methods:**
415
424
 
@@ -420,7 +429,16 @@ After switching, you'll see a Quick Start guide with common commands.
420
429
 
421
430
  **Note:** Use `puts` to see output from your code.
422
431
 
423
- **Security:** The sandbox prevents file writes, system calls, network access, and reading sensitive files (.env, credentials, etc.).
432
+ **Security:** The sandbox is intended for read-only exploration and applies several layers of defense:
433
+
434
+ - **No writes / shell / network:** file writes, `system`/`exec`/backticks, and network libraries are blocked by both static analysis and runtime overrides.
435
+ - **Confined file reads:** reads are limited to the project directory (via all of `File`/`IO` `read`/`readlines`/`binread`/`foreach` and `File.open`), plus a small allowlist of read-only system timezone paths (e.g. `/usr/share/zoneinfo`) that Rails needs when code touches `Time.zone`. Paths are symlink-resolved (`realpath`) so a link inside the project cannot point outside it.
436
+ - **No sensitive files:** `.env`, credentials, keys, and any `.gitignore`d path are refused.
437
+ - **Database writes are rolled back:** user code runs inside a transaction that is always rolled back, so `delete_all`, `update`, `save`, and raw DML are undone. Treat the tool as read-only for data too. (Caveat: DDL may still commit on some adapters such as MySQL, and `after_commit` callbacks do not fire.)
438
+ - **Bounded execution:** a timeout (default 30s, max 60s) kills the whole process group, so a runaway `bin/rails runner` is terminated rather than orphaned.
439
+ - **Confirmation for dual-use constructs:** `send`, `public_send`, `const_get`, and `Kernel#open` are not run until you approve them via `confirm_risky: true`.
440
+
441
+ These controls are defense-in-depth, not a hard isolation boundary. `execute_ruby` executes real Ruby with full access to the Rails app, so only enable it for projects and clients you trust. For stronger isolation run the server against a database user with read-only grants and/or inside an OS-level sandbox (container, `sandbox-exec`, etc.).
424
442
 
425
443
  ### Internal Analyzers (via execute_tool)
426
444
 
@@ -604,6 +622,8 @@ To use with an MCP client:
604
622
  2. Connect your MCP-compatible client to the server
605
623
  3. The client will be able to use the available tools to interact with your Rails projects
606
624
 
625
+ For teams using a governed AI client or control plane for tool access, approvals, audit trails, and cost reporting, see [Governed MCP Clients](docs/GOVERNED_CLIENTS.md).
626
+
607
627
  ## Security
608
628
 
609
629
  For security concerns, please see [SECURITY.md](SECURITY.md).
data/docs/AGENT.md CHANGED
@@ -225,6 +225,10 @@ railsMcpServer:execute_ruby code: "read_file('Gemfile')"
225
225
  railsMcpServer:execute_ruby code: "puts read_file('Gemfile')"
226
226
  ```
227
227
 
228
+ **Read-only by design:** `execute_ruby` is for exploration, not mutation. File writes, shell/system calls, and network access are blocked, and any database writes run inside a transaction that is **always rolled back** — so `delete_all`, `update`, and `save` will not persist. Do not rely on it to change data.
229
+
230
+ **Confirmation for dual-use constructs:** if your code uses `send`, `public_send`, `const_get`, or `Kernel#open`, the tool returns a `CONFIRMATION REQUIRED` message instead of running. These can bypass the sandbox's safety scan, so ask the user to review the code and, only with their explicit approval, re-invoke with `confirm_risky: true`. Do not set `confirm_risky` on your own.
231
+
228
232
  ---
229
233
 
230
234
  ## Tool Selection Summary
@@ -400,6 +404,14 @@ railsMcpServer:execute_ruby code: "User.count"
400
404
  railsMcpServer:execute_ruby code: "puts User.count"
401
405
  ```
402
406
 
407
+ ### `execute_ruby` / `get_schema` fail to boot the app (Bundler / wrong Ruby)
408
+
409
+ These tools run the project's `bin/rails`. The server auto-selects the project's Ruby via your version manager's shims (**mise**, **asdf**, **rbenv**; **rvm** is sourced), reading `.ruby-version` / `.tool-versions` / `.mise.toml`. If they still fail with a Bundler or boot error:
410
+
411
+ 1. Confirm the project has a `.ruby-version` (or `.tool-versions` / `.mise.toml`) and that Ruby is installed in your manager.
412
+ 2. Confirm your manager is one of mise, asdf, rbenv, or rvm — these are auto-detected.
413
+ 3. The underlying boot error is included in the tool output (no longer suppressed), so read it for the specific cause.
414
+
403
415
  ---
404
416
 
405
417
  ## Integration with Neovim MCP
@@ -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
 
@@ -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
@@ -11,8 +11,12 @@ module RailsMcpServer
11
11
  RESTRICTIONS:
12
12
  - Cannot create, modify, or delete files
13
13
  - Cannot read .env, credentials, key files, or .gitignore'd files
14
- - Cannot access files outside the project directory
14
+ - Cannot access files outside the project directory (read-only system data
15
+ such as timezone files under /usr/share/zoneinfo is allowed)
15
16
  - Cannot execute shell commands or system calls
17
+ - Database writes run inside a transaction that is always rolled back, so
18
+ treat this as read-only for data too (note: DDL may still commit on some
19
+ adapters, and after_commit callbacks do not fire)
16
20
 
17
21
  HELPER METHODS AVAILABLE:
18
22
  - read_file(path) - safely read a file
@@ -21,11 +25,17 @@ module RailsMcpServer
21
25
  - project_root - returns the project root path
22
26
 
23
27
  NOTE: Use `puts` to see output, e.g., puts read_file('Gemfile')
28
+
29
+ Some dual-use constructs (Kernel#open, send, public_send, const_get) are
30
+ not run immediately: the tool returns a CONFIRMATION REQUIRED message
31
+ explaining the risk. Re-invoke with confirm_risky: true only after the
32
+ user has reviewed the code and approved it.
24
33
  DESC
25
34
 
26
35
  arguments do
27
36
  required(:code).filled(:string).description("Ruby code to execute (read-only operations only)")
28
37
  optional(:timeout).filled(:integer).description("Timeout in seconds. Default: 30, Max: 60")
38
+ optional(:confirm_risky).filled(:bool).description("Set true ONLY after the user has explicitly approved running code that uses sandbox-bypass-capable constructs (send, public_send, const_get, Kernel#open). When false/absent, such code is not executed; the tool returns a CONFIRMATION REQUIRED message instead.")
29
39
  end
30
40
 
31
41
  # Patterns that indicate dangerous operations
@@ -75,8 +85,9 @@ module RailsMcpServer
75
85
  /set_trace_func/i,
76
86
 
77
87
  # Environment/credentials access
78
- /ENV\[/i,
79
- /ENV\.fetch/i,
88
+ # Match any ENV usage (ENV[, ENV.fetch, ENV.to_h, ENV.values_at, ENV.each,
89
+ # ...). Case-sensitive so it doesn't flag `Rails.env` or a local `env`.
90
+ /\bENV\b/,
80
91
  /Rails\.application\.credentials/i,
81
92
  /Rails\.application\.secrets/i,
82
93
 
@@ -85,6 +96,21 @@ module RailsMcpServer
85
96
  /require\s+[^'"]/i
86
97
  ].freeze
87
98
 
99
+ # Dual-use constructs that are NOT hard-blocked (they have legitimate
100
+ # read-only uses) but can defeat the static safety scan, so running them
101
+ # requires explicit user confirmation via confirm_risky: true.
102
+ # Each entry: [pattern, label, why-it-is-risky].
103
+ CONFIRMATION_REQUIRED_PATTERNS = [
104
+ [/(?<![.\w])open\s*\(/, "Kernel#open",
105
+ "`open(arg)` runs a shell command when arg begins with '|', and can open network/URI targets — both escape the sandbox."],
106
+ [/\bpublic_send\b/, "public_send",
107
+ "dynamic dispatch can invoke methods the static scan cannot see, e.g. reaching blocked system/file APIs indirectly."],
108
+ [/\bsend\s*[(\s]/, "send",
109
+ "dynamic dispatch can invoke methods the static scan cannot see, e.g. reaching blocked system/file APIs indirectly."],
110
+ [/\bconst_get\b/, "const_get",
111
+ "resolves constants by name at runtime, which can reach classes the static scan would otherwise block."]
112
+ ].freeze
113
+
88
114
  # Sensitive file patterns (in addition to .gitignore)
89
115
  SENSITIVE_PATTERNS = [
90
116
  /\.env(\..*)?$/i,
@@ -104,6 +130,18 @@ module RailsMcpServer
104
130
  /id_ed25519/i
105
131
  ].freeze
106
132
 
133
+ # Read-only system data directories the sandbox may read. TZInfo lazily
134
+ # loads IANA timezone data on first Time.zone use; these are its default
135
+ # search paths plus /var/db/timezone, the real location behind macOS's
136
+ # /usr/share/zoneinfo symlink. Writes remain blocked by the File/Dir/
137
+ # FileUtils overrides.
138
+ ALLOWED_READ_PATHS = %w[
139
+ /usr/share/zoneinfo
140
+ /usr/share/lib/zoneinfo
141
+ /etc/zoneinfo
142
+ /var/db/timezone
143
+ ].freeze
144
+
107
145
  NO_OUTPUT_MESSAGE = <<~MSG
108
146
  Code executed successfully (no output).
109
147
 
@@ -113,7 +151,7 @@ module RailsMcpServer
113
151
  puts Dir.glob('app/models/*.rb')
114
152
  MSG
115
153
 
116
- def call(code:, timeout: 30)
154
+ def call(code:, timeout: 30, confirm_risky: false)
117
155
  unless current_project
118
156
  return "No active project. Please switch to a project first."
119
157
  end
@@ -121,14 +159,20 @@ module RailsMcpServer
121
159
  timeout = [timeout.to_i, 60].min # Cap at 60 seconds
122
160
  timeout = 10 if timeout < 1
123
161
 
124
- # Step 1: Static analysis - reject dangerous code
162
+ # Step 1: Static analysis - reject outright-dangerous code
125
163
  validation_error = validate_code_safety(code)
126
164
  return validation_error if validation_error
127
165
 
128
- # Step 2: Build the sandboxed execution environment
166
+ # Step 2: Dual-use constructs require explicit user confirmation
167
+ unless confirm_risky
168
+ confirmation = confirmation_required(code)
169
+ return confirmation if confirmation
170
+ end
171
+
172
+ # Step 3: Build the sandboxed execution environment
129
173
  sandbox_code = build_sandbox(code)
130
174
 
131
- # Step 3: Execute with timeout
175
+ # Step 4: Execute with timeout
132
176
  execute_sandboxed(sandbox_code, timeout)
133
177
  end
134
178
 
@@ -144,34 +188,100 @@ module RailsMcpServer
144
188
  nil
145
189
  end
146
190
 
191
+ # Returns a message asking the model to confirm with the user when the code
192
+ # uses dual-use constructs, or nil when there is nothing to confirm.
193
+ def confirmation_required(code)
194
+ matched = CONFIRMATION_REQUIRED_PATTERNS.select { |pattern, _label, _reason| code.match?(pattern) }
195
+ return nil if matched.empty?
196
+
197
+ details = matched.map { |_pattern, label, reason| " - `#{label}`: #{reason}" }.join("\n")
198
+
199
+ <<~MSG
200
+ CONFIRMATION REQUIRED: This code uses constructs that can bypass the sandbox's static safety checks:
201
+
202
+ #{details}
203
+
204
+ These are not blocked outright because they have legitimate read-only uses, but they can reach APIs the safety scan would otherwise stop. Ask the user to review the code and confirm they want to run it. If they approve, re-invoke execute_ruby with confirm_risky: true. Do not set confirm_risky yourself without the user's explicit approval.
205
+ MSG
206
+ end
207
+
147
208
  def build_sandbox(user_code)
148
209
  gitignore_patterns = parse_gitignore
149
210
  all_patterns = SENSITIVE_PATTERNS.map(&:source) + gitignore_patterns
150
211
  sensitive_patterns_ruby = all_patterns.map { |p| "Regexp.new(#{p.inspect}, Regexp::IGNORECASE)" }.join(",\n ")
151
212
 
152
213
  <<~RUBY
214
+ require "stringio" # the File.open override below yields StringIO objects
215
+
153
216
  # Sandbox wrapper for safe execution
154
217
  module McpSandbox
155
- PROJECT_ROOT = #{active_project_path.inspect}.freeze
218
+ # realpath-normalized so symlink resolution below compares against the
219
+ # canonical root (e.g. macOS /var -> /private/var) rather than a path
220
+ # that would never prefix-match a resolved target.
221
+ PROJECT_ROOT = File.realpath(#{active_project_path.inspect}).freeze
222
+
223
+ ALLOWED_READ_PATHS = #{ALLOWED_READ_PATHS.inspect}.freeze
224
+
225
+ # realpath-resolved forms of the allowlist, so a resolved target still
226
+ # matches when the allowed dir is itself a symlink (e.g. macOS
227
+ # /usr/share/zoneinfo -> /private/var/db/timezone/.../zoneinfo).
228
+ CANONICAL_ALLOWED_READ_PATHS = ALLOWED_READ_PATHS.map { |dir|
229
+ File.exist?(dir) ? File.realpath(dir) : dir
230
+ }.freeze
156
231
 
157
232
  SENSITIVE_PATTERNS = [
158
233
  #{sensitive_patterns_ruby}
159
234
  ].freeze
160
235
 
236
+ # Native method handles captured *before* the File/Dir overrides below
237
+ # replace them. Held in private constants so sandboxed user code has no
238
+ # public `File.original_read`-style alias to call the raw method back.
239
+ ORIGINAL_FILE_READ = File.method(:read)
240
+ ORIGINAL_FILE_READLINES = File.method(:readlines)
241
+ ORIGINAL_FILE_BINREAD = File.method(:binread)
242
+ ORIGINAL_FILE_EXIST = File.method(:exist?)
243
+ ORIGINAL_FILE_DIRECTORY = File.method(:directory?)
244
+ ORIGINAL_FILE_FILE = File.method(:file?)
245
+ ORIGINAL_FILE_REALPATH = File.method(:realpath)
246
+ ORIGINAL_DIR_GLOB = Dir.method(:glob)
247
+ ORIGINAL_DIR_ENTRIES = Dir.method(:entries)
248
+ private_constant :ORIGINAL_FILE_READ, :ORIGINAL_FILE_READLINES,
249
+ :ORIGINAL_FILE_BINREAD, :ORIGINAL_FILE_EXIST, :ORIGINAL_FILE_DIRECTORY,
250
+ :ORIGINAL_FILE_FILE, :ORIGINAL_FILE_REALPATH, :ORIGINAL_DIR_GLOB,
251
+ :ORIGINAL_DIR_ENTRIES
252
+
161
253
  class PathViolation < StandardError; end
162
254
  class SensitiveFileViolation < StandardError; end
163
255
  class WriteViolation < StandardError; end
164
256
 
165
257
  module_function
166
258
 
259
+ # Resolve symlinks so a link *inside* the project cannot be used to
260
+ # read a target outside it. realpath needs the path to exist, so for a
261
+ # not-yet-existing path resolve the deepest existing ancestor and
262
+ # re-append the remainder (which still catches a symlinked ancestor).
263
+ def resolve_symlinks(expanded)
264
+ return ORIGINAL_FILE_REALPATH.call(expanded) if ORIGINAL_FILE_EXIST.call(expanded)
265
+
266
+ parent = File.dirname(expanded)
267
+ return expanded if parent == expanded
268
+
269
+ File.join(resolve_symlinks(parent), File.basename(expanded))
270
+ end
271
+
167
272
  def validate_path!(path)
168
273
  expanded = File.expand_path(path, PROJECT_ROOT)
274
+ resolved = resolve_symlinks(expanded)
275
+
276
+ if (ALLOWED_READ_PATHS + CANONICAL_ALLOWED_READ_PATHS).any? { |dir| resolved == dir || resolved.start_with?(dir + "/") }
277
+ return resolved
278
+ end
169
279
 
170
- unless expanded.start_with?(PROJECT_ROOT + "/") || expanded == PROJECT_ROOT
280
+ unless resolved.start_with?(PROJECT_ROOT + "/") || resolved == PROJECT_ROOT
171
281
  raise PathViolation, "Access denied: path '\#{path}' is outside project directory"
172
282
  end
173
283
 
174
- relative_path = expanded.sub(PROJECT_ROOT + "/", "")
284
+ relative_path = resolved.sub(PROJECT_ROOT + "/", "")
175
285
 
176
286
  SENSITIVE_PATTERNS.each do |pattern|
177
287
  if relative_path.match?(pattern)
@@ -179,37 +289,48 @@ module RailsMcpServer
179
289
  end
180
290
  end
181
291
 
182
- expanded
292
+ resolved
183
293
  end
184
294
 
185
295
  def safe_read(path)
186
- validated_path = validate_path!(path)
187
- File.original_read(validated_path)
296
+ ORIGINAL_FILE_READ.call(validate_path!(path))
297
+ end
298
+
299
+ def safe_readlines(path)
300
+ ORIGINAL_FILE_READLINES.call(validate_path!(path))
301
+ end
302
+
303
+ def safe_binread(path)
304
+ ORIGINAL_FILE_BINREAD.call(validate_path!(path))
305
+ end
306
+
307
+ def safe_foreach(path, &block)
308
+ lines = safe_readlines(path)
309
+ return lines.each unless block
310
+
311
+ lines.each(&block)
188
312
  end
189
313
 
190
314
  def safe_exist?(path)
191
- validated_path = validate_path!(path)
192
- File.original_exist?(validated_path)
315
+ ORIGINAL_FILE_EXIST.call(validate_path!(path))
193
316
  rescue PathViolation, SensitiveFileViolation
194
317
  false
195
318
  end
196
319
 
197
320
  def safe_directory?(path)
198
- validated_path = validate_path!(path)
199
- File.original_directory?(validated_path)
321
+ ORIGINAL_FILE_DIRECTORY.call(validate_path!(path))
200
322
  rescue PathViolation, SensitiveFileViolation
201
323
  false
202
324
  end
203
325
 
204
326
  def safe_file?(path)
205
- validated_path = validate_path!(path)
206
- File.original_file?(validated_path)
327
+ ORIGINAL_FILE_FILE.call(validate_path!(path))
207
328
  rescue PathViolation, SensitiveFileViolation
208
329
  false
209
330
  end
210
331
 
211
332
  def safe_glob(pattern, base: PROJECT_ROOT)
212
- Dir.original_glob(File.join(base, pattern)).select do |path|
333
+ ORIGINAL_DIR_GLOB.call(File.join(base, pattern)).select do |path|
213
334
  validate_path!(path)
214
335
  true
215
336
  rescue PathViolation, SensitiveFileViolation
@@ -218,23 +339,58 @@ module RailsMcpServer
218
339
  end
219
340
 
220
341
  def safe_entries(path)
221
- validated_path = validate_path!(path)
222
- Dir.original_entries(validated_path).reject { |e| e.start_with?(".") }
342
+ ORIGINAL_DIR_ENTRIES.call(validate_path!(path)).reject { |e| e.start_with?(".") }
343
+ end
344
+
345
+ # True only when ActiveRecord is loaded *and* a connection can be
346
+ # obtained, so we never turn a pure-Ruby read-only snippet into a
347
+ # database connection error just to wrap it in a transaction.
348
+ def database_available?
349
+ return false unless defined?(ActiveRecord::Base)
350
+
351
+ ActiveRecord::Base.connection
352
+ true
353
+ rescue StandardError
354
+ false
355
+ end
356
+
357
+ # Run the block inside a transaction that is *always* rolled back, so
358
+ # accidental writes are undone. Harm reduction, not a guarantee: DDL
359
+ # auto-commits on some adapters (e.g. MySQL) and after_commit
360
+ # callbacks are suppressed. Falls back to a plain call when no
361
+ # database is available. Real exceptions still propagate (and also
362
+ # trigger the rollback).
363
+ def readonly_guard
364
+ return yield unless database_available?
365
+
366
+ result = nil
367
+ ActiveRecord::Base.transaction do
368
+ result = yield
369
+ raise ActiveRecord::Rollback
370
+ end
371
+ result
223
372
  end
224
373
  end
225
374
 
226
375
  # Override File class methods
227
376
  class File
228
377
  class << self
229
- alias_method :original_read, :read
230
- alias_method :original_exist?, :exist?
231
- alias_method :original_directory?, :directory?
232
- alias_method :original_file?, :file?
233
-
234
378
  def read(path, *args)
235
379
  McpSandbox.safe_read(path)
236
380
  end
237
381
 
382
+ def readlines(path, *args)
383
+ McpSandbox.safe_readlines(path)
384
+ end
385
+
386
+ def binread(path, *args)
387
+ McpSandbox.safe_binread(path)
388
+ end
389
+
390
+ def foreach(path, *args, &block)
391
+ McpSandbox.safe_foreach(path, &block)
392
+ end
393
+
238
394
  def exist?(path)
239
395
  McpSandbox.safe_exist?(path)
240
396
  end
@@ -272,9 +428,6 @@ module RailsMcpServer
272
428
  # Override Dir class methods
273
429
  class Dir
274
430
  class << self
275
- alias_method :original_glob, :glob
276
- alias_method :original_entries, :entries
277
-
278
431
  def glob(pattern, *args)
279
432
  McpSandbox.safe_glob(pattern)
280
433
  end
@@ -291,6 +444,29 @@ module RailsMcpServer
291
444
  end
292
445
  end
293
446
 
447
+ # Override IO read entry points. File < IO, but IO.read / IO.readlines /
448
+ # IO.binread / IO.foreach are separate class methods that bypass the File
449
+ # overrides above, so they must be sandboxed independently.
450
+ class IO
451
+ class << self
452
+ def read(path, *args)
453
+ McpSandbox.safe_read(path)
454
+ end
455
+
456
+ def readlines(path, *args)
457
+ McpSandbox.safe_readlines(path)
458
+ end
459
+
460
+ def binread(path, *args)
461
+ McpSandbox.safe_binread(path)
462
+ end
463
+
464
+ def foreach(path, *args, &block)
465
+ McpSandbox.safe_foreach(path, &block)
466
+ end
467
+ end
468
+ end
469
+
294
470
  # Block FileUtils entirely
295
471
  if defined?(FileUtils)
296
472
  module FileUtils
@@ -346,8 +522,13 @@ module RailsMcpServer
346
522
  end
347
523
 
348
524
  # ============ USER CODE BELOW ============
525
+ # Wrapped in an always-rolled-back transaction so accidental DB writes
526
+ # (delete_all, update, save, raw DML) are undone. See McpSandbox
527
+ # .readonly_guard for the caveats; it's a no-op without a database.
349
528
  begin
350
- #{user_code}
529
+ McpSandbox.readonly_guard do
530
+ #{user_code}
531
+ end
351
532
  rescue McpSandbox::PathViolation => e
352
533
  puts "PATH ERROR: \#{e.message}"
353
534
  rescue McpSandbox::SensitiveFileViolation => e
@@ -386,23 +567,19 @@ module RailsMcpServer
386
567
 
387
568
  def execute_sandboxed(code, timeout)
388
569
  require "tempfile"
389
- require "timeout"
390
570
 
391
571
  Tempfile.create(["mcp_sandbox", ".rb"]) do |f|
392
572
  f.write(code)
393
573
  f.flush
394
574
 
395
- 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
575
+ # RunProcess enforces the timeout by killing the whole process group, so
576
+ # a runaway `rails runner` is actually terminated rather than orphaned.
577
+ result = RailsMcpServer::RunProcess.execute_rails_command(
578
+ active_project_path,
579
+ "bin/rails runner #{f.path} 2>&1",
580
+ timeout: timeout
581
+ )
582
+ result.to_s.empty? ? NO_OUTPUT_MESSAGE : result
406
583
  end
407
584
  end
408
585
  end
@@ -1,26 +1,42 @@
1
1
  require "bundler"
2
2
  require "shellwords"
3
+ require "open3"
4
+ require "timeout"
3
5
 
4
6
  module RailsMcpServer
5
7
  class RunProcess
6
- 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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez Cárdenas
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '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: []