Skip to main content

August 3, 2026

Rails MCP Server 1.6.0: Making "Read-Only" Actually Mean Read-Only

Mario Alberto Chávez Cárdenas
Rails MCP Server 1.6.0 release announcement showing execute_ruby sandbox hardening

The execute_ruby tool lets an AI model run Ruby in the context of your Rails application. That is genuinely useful—one call can answer a question that would otherwise take a dozen tool round-trips—and it is also the single most dangerous thing this server does. The tool advertises itself as read-only. Version 1.6.0 is about making that promise closer to true.

This release started with an uncomfortable review of the sandbox. What I found is that “read-only” had holes you could walk through without trying. So 1.6.0 closes them, adds several layers of defense behind them, and—importantly—is honest about what the sandbox is and isn’t.

What the sandbox actually is

Worth stating plainly, because it frames everything below: execute_ruby runs your code through bin/rails runner. That means real Ruby, with your full application loaded and a live database connection. The sandbox is a combination of static analysis (a scan for dangerous patterns) and runtime overrides of File, IO, Dir, and Kernel.

That is defense-in-depth, not a locked box. It raises the floor; it is not an isolation boundary. Keeping that distinction visible is part of what 1.6.0 fixes—the previous framing implied more safety than the implementation delivered.

The holes that were closed

File reads leaked through the siblings. The sandbox overrode File.read and File.open, but not File.readlines, File.binread, File.foreach, or any of the IO equivalents. Those are separate entry points, and they read arbitrary paths:

# Blocked before 1.6.0:
File.read("/etc/passwd")     # => PATH ERROR

# Wide open before 1.6.0:
IO.read("/etc/passwd")       # read anything
File.readlines("/etc/passwd")

Every read entry point now routes through the same path validation.

The raw readers were left on the shelf. The overrides kept the originals around as public aliases like File.original_read—which meant user code could just call the un-sandboxed method directly. Those are gone from the public surface now; the native handles live in private constants the sandbox uses internally.

Symlinks could point out of the project. Path validation expanded paths but never resolved symlinks, so a link inside the project that pointed at /etc/passwd passed the check and then read the target. Validation now resolves the real path first, and the allowlist for system data (the timezone directories Rails needs for Time.zone) is matched against canonical locations so it keeps working on macOS, where /usr/share/zoneinfo is itself a symlink.

ENV was only half-blocked. The scan rejected ENV[...] and ENV.fetch, but ENV.to_h, ENV.values_at, and ENV.each walked straight past it and handed over every secret in the process environment. The scan now rejects ENV access broadly—while still leaving Rails.env alone.

The layers added behind them

Closing bypasses is necessary but not sufficient. A blocklist can always be out-metaprogrammed. So 1.6.0 adds controls that don’t depend on catching every trick.

Database writes are rolled back. This is the big one, because “read-only” was never true for data. Nothing stopped User.delete_all or a raw DELETE. Now your code runs inside a transaction that is always rolled back:

# This executes, returns a count, and then is undone:
User.where(inactive: true).delete_all
# => 42   (and the 42 rows are still there)

It is harm reduction, not a guarantee—DDL auto-commits on some adapters like MySQL, and after_commit callbacks don’t fire—but the common accident is now a no-op instead of data loss.

The timeout actually stops the code. The previous timeout wrapped the subprocess call and, when it fired, stopped waiting—while the runaway rails runner kept going, orphaned. The command now runs in its own process group, and a timeout kills the whole group. A runaway query or an infinite loop is terminated, not abandoned.

Dual-use constructs ask first. Some things are legitimately useful and also the classic sandbox-escape tools: send, public_send, const_get, Kernel#open. Rather than silently allowing them or hard-blocking them, the tool now stops and explains:

CONFIRMATION REQUIRED: This code uses constructs that can bypass
the sandbox's static safety checks:
  - `send`: dynamic dispatch can invoke methods the static scan
    cannot see, e.g. reaching blocked system/file APIs indirectly.

Ask the user to review the code and confirm. If they approve,
re-invoke execute_ruby with confirm_risky: true.

The model can’t wave this through on its own. A human reviews the code, and only then does the call run with confirm_risky: true. It puts a person in the loop exactly where the static analysis runs out of road.

Being honest about the boundary

I want to be direct about the limits, because a security tool that oversells itself is worse than one that doesn’t. These controls are layered defense, not hard isolation. The tool still executes real Ruby with full application access, so a determined bypass is possible; DDL and writes on non-default connections can escape the rollback; there are no per-process CPU or memory caps beyond the timeout.

If you need stronger guarantees, the right moves are outside the Ruby layer: run the server against a database user with read-only grants, and/or run it inside an OS-level sandbox—a container, sandbox-exec, seccomp. The SECURITY.md in the repository now documents both the controls and these limitations so you can make an informed call.

Manager-agnostic Ruby resolution

Separate from the sandbox, 1.6.0 fixes a long-standing annoyance for anyone whose Ruby isn’t the system default. Tools that shell out to bin/railsexecute_ruby, get_schema, and the introspection halves of analyze_models and analyze_controller_views—were exporting the rbenv-only RBENV_VERSION and running a login shell. On macOS, path_helper then reshuffled PATH so bin/rails booted under system Ruby and failed.

The runner now prepends the active version manager’s shims directory to PATH—mise, asdf, or rbenv, honoring MISE_DATA_DIR, XDG_DATA_HOME, ASDF_DATA_DIR, and RBENV_ROOT—and uses a non-login shell so the project’s Ruby survives. rvm, which has no shims, is sourced when present. The version comes from your project’s .ruby-version, .tool-versions, or .mise.toml, so different projects can use different Rubies with no extra configuration.

While I was in there, the analyzer path stopped swallowing errors with 2>/dev/null, so a Rails boot failure now surfaces the real message instead of a blank “Error executing Rails command.”

Namespaced models resolve properly

analyze_models could report a module-namespaced model as “not found” depending on how you referred to it. It now resolves from every input form—Namespace::Model, the path namespace/model, the flattened NamespaceModel, and the bare leaf Model—independent of your app’s custom inflections. The introspection runner also derives the constant from the resolved file instead of interpolating raw input, which removes an injection surface in the generated scripts along the way.

Breaking change: Ruby 3.2 is dropped

The minimum supported Ruby is now 3.3 (required_ruby_version >= 3.3.0), and CI tests 3.3 and 3.4. The dependency updates in this release pull in transitive gems that require 3.3, so this was forced rather than chosen. If you’re on 3.2, upgrade your Ruby before updating the gem.

Security and dependencies

The dependency bump is also a security update. Upgrading to Puma 8.0.2 clears CVE-2026-47736 and CVE-2026-47737—both HIGH, covering PROXY Protocol v1 remote memory exhaustion and repeated-header handling—and the lockfile refresh clears a concurrent-ruby advisory. bundler-audit reports clean.

Upgrading

gem update rails-mcp-server

Make sure you’re on Ruby 3.3 or newer first. If you use Claude Desktop, restart it to pick up the new version; the binary path in your configuration doesn’t change.

For new installations:

gem install rails-mcp-server
rails-mcp-config

What’s Next

Giving a model a Ruby runtime inside your app is a sharp tool, and sharp tools deserve honest edges. This release is a step toward that: fewer accidental cuts, and a clear label on what the guard does and doesn’t cover. The durable next step is real isolation—read-only database roles and OS-level sandboxing as first-class, documented setups rather than footnotes.

If you find a gap, the issue tracker is open, and security reports have a private channel through the repository’s Security tab. Pull requests are welcome.


Need help with your Rails project?

I'm Mario Alberto Chávez—Rails architect available for consulting, AI integration, and code review.