rails-mcp-server 1.6.1 → 2.0.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: 950673f40d56ddea684d938e4e55c2e8db18c3ca44e94988f63565a9db950869
4
- data.tar.gz: 00712156bdfdfbfc1ec33bd08e31ccb0ad4e7427e3e1e31282de62b4f7b5cc1b
3
+ metadata.gz: 380196bc8728daef0f20e005b40ef1464163062c4e871d60adc5a819da8f9e61
4
+ data.tar.gz: bc43ce8aa0a82c432def067b39394d9f3a1554484040fabc6bb4cf78b2268546
5
5
  SHA512:
6
- metadata.gz: 98ebb4452eaa610b1430c652c94a339022f10bc05ba7a00b867e8b8c9aaa04d033ea149d923bfe131be52455b044b0feb4d90af24a55670708610e2a57b198a1
7
- data.tar.gz: 8ecf71ec6b09ed73b35e6f9ea64ec88bfcbbf1f91e2bbb8f033216d0a19d0c6af1493c23fb67be14f99e7933cc6dcc7d64f09d173d20c8c2d6a53f4d25ef88ef
6
+ metadata.gz: f8025ec3e715e178650a138658fc4b8ebe9b81f49a4a1401d4b37efbde7a82c150c021e1737826536605f9c966e20b3ade4c5bf7879f85d383b9f28e047e9256
7
+ data.tar.gz: dfc2ce718b07936d8775a875bcf6f8979934151cf4e95827d7a41931ca5406b7b42671fbd17b7a8d312131f8f17679cb91457b6739f0ae41af0fe1acc235ceba
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.0] - 2026-08-04
11
+
12
+ ### Removed
13
+
14
+ - **`execute_ruby` tool removed** (breaking): The tool that executed caller-supplied Ruby via `bin/rails runner` is gone. It was originally intended for Rails introspection (routes, schema, model queries), but accepting arbitrary Ruby made it an arbitrary-code-execution surface that a regex denylist and in-process monkey-patching could not safely contain — the root cause behind the 1.6.x hardening series. The server is an **introspection tool**, and its dedicated analyzers already cover the intended uses:
15
+ - Reading files → `get_file`
16
+ - Finding files → `list_files`
17
+ - Routes / schema / models / controllers / env / structure → `get_routes`, `get_schema`, `analyze_models`, `analyze_controller_views`, `analyze_environment_config`, `project_info`
18
+ - The only capability dropped is running arbitrary live Ruby against the app (ad-hoc data queries), which is out of scope for an introspection server and was the source of the risk.
19
+
20
+ ### Changed
21
+
22
+ - **Bootstrap tools reduced from 4 to 3**: `switch_project`, `search_tools`, `execute_tool`. The internal analyzers are unchanged and still discovered via `search_tools` / invoked via `execute_tool`.
23
+ - **`switch_project` quick-start** now points to `execute_tool("get_file", …)` / `execute_tool("list_files", …)` instead of `execute_ruby`.
24
+ - **Docs** (`README.md`, `docs/AGENT.md`, `docs/COPILOT_AGENT.md`, `SECURITY.md`) rewritten to route file reads/finds through `get_file` / `list_files` and to describe the server as introspection-only. `SECURITY.md` drops the `execute_ruby` sandbox section; the remaining file tools are protected by `PathValidator` (path-traversal and sensitive-file checks) and the app-booting analyzers pass caller input as validated parameters, never as code.
25
+
26
+ ### Migration
27
+
28
+ Clients that listed `execute_ruby` in their tool config should remove it. Replace `execute_ruby` file reads with `get_file` (`{ path: ... }`) and file globs with `list_files` (`{ pattern: ... }`). Ad-hoc data queries (`User.count`, custom scopes) are no longer supported by design; use the dedicated analyzers for structural introspection. Users who still want free-form execution should pin to the `1.6.x` line, which retains the hardened `execute_ruby`.
29
+
10
30
  ## [1.6.1] - 2026-08-04
11
31
 
12
32
  ### Security
@@ -369,6 +389,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
369
389
 
370
390
  ## Version History Summary
371
391
 
392
+ - **v2.0.0** (2026-08-04): Removed the `execute_ruby` tool — the server is introspection-only via its dedicated analyzers (breaking)
372
393
  - **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
394
  - **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)
374
395
  - **v1.5.1** (2026-03-04): Relaxed dependency version constraints for better compatibility
data/README.md CHANGED
@@ -17,7 +17,6 @@ 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 Ruby code in the project context for custom queries
21
20
  - Access comprehensive Rails, Turbo, Stimulus, and Kamal documentation
22
21
  - Context-efficient architecture with progressive tool discovery
23
22
  - Seamless integration with LLM clients
@@ -230,7 +229,7 @@ Replace `/home/your_user/.rbenv/shims/ruby` with your actual Ruby path (an rbenv
230
229
 
231
230
  #### 2. The Ruby used to introspect each Rails project
232
231
 
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.
232
+ Tools that boot your app — `get_schema`, `get_routes`, 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
233
 
235
234
  > 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.
236
235
 
@@ -286,7 +285,7 @@ Rails MCP Server works with GitHub Copilot coding agent out of the box. The serv
286
285
  "type": "local",
287
286
  "command": "rails-mcp-server",
288
287
  "args": ["--single-project"],
289
- "tools": ["switch_project", "search_tools", "execute_tool", "execute_ruby"]
288
+ "tools": ["switch_project", "search_tools", "execute_tool"]
290
289
  }
291
290
  }
292
291
  }
@@ -331,7 +330,7 @@ You can also use the `RAILS_MCP_PROJECT_PATH` environment variable:
331
330
  "env": {
332
331
  "RAILS_MCP_PROJECT_PATH": "."
333
332
  },
334
- "tools": ["switch_project", "search_tools", "execute_tool", "execute_ruby"]
333
+ "tools": ["switch_project", "search_tools", "execute_tool"]
335
334
  }
336
335
  }
337
336
  }
@@ -355,14 +354,13 @@ Each request includes a sequence number to match requests with responses, as def
355
354
 
356
355
  ### Context-Efficient Architecture
357
356
 
358
- The server uses a progressive tool discovery architecture to minimize context usage. Instead of exposing all tools upfront, it provides 4 bootstrap tools that allow LLMs to discover and invoke additional analyzers on-demand:
357
+ The server uses a progressive tool discovery architecture to minimize context usage. Instead of exposing all tools upfront, it provides 3 bootstrap tools that allow LLMs to discover and invoke the introspection analyzers on-demand:
359
358
 
360
359
  - **`switch_project`** - Select the active Rails project
361
360
  - **`search_tools`** - Discover available tools by category or keyword
362
361
  - **`execute_tool`** - Invoke internal analyzers with parameters
363
- - **`execute_ruby`** - Run Ruby code in the project context for custom queries
364
362
 
365
- This design reduces initial context from ~2,400 tokens to ~800 tokens while maintaining full functionality.
363
+ This design keeps initial context small while exposing the full set of analyzers on demand.
366
364
 
367
365
  ## AI Agent Guide
368
366
 
@@ -370,14 +368,13 @@ For AI agents (Claude, GPT, etc.) using this server, see the comprehensive **[AI
370
368
 
371
369
  - Quick start workflow
372
370
  - Tool selection guide for common tasks
373
- - Helper methods available in `execute_ruby`
374
371
  - Common pitfalls and how to avoid them
375
372
  - Error handling and fallback strategies
376
373
  - Integration with other MCP servers (e.g., Neovim MCP)
377
374
 
378
375
  ## Available Tools
379
376
 
380
- The server provides 4 registered tools plus internal analyzers accessible via `execute_tool`.
377
+ The server provides 3 registered tools plus internal analyzers accessible via `execute_tool`.
381
378
 
382
379
  ### Registered Tools
383
380
 
@@ -410,41 +407,6 @@ After switching, you'll see a Quick Start guide with common commands.
410
407
  - `tool_name`: (String, required) Name of the analyzer (e.g., 'get_routes', 'analyze_models')
411
408
  - `params`: (Hash, optional) Parameters for the analyzer
412
409
 
413
- #### 4. `execute_ruby`
414
-
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.
416
-
417
- **Parameters:**
418
-
419
- - `code`: (String, required) Ruby code to execute
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.
422
-
423
- **Available helper methods:**
424
-
425
- - `read_file(path)` - Read a file safely
426
- - `file_exists?(path)` - Check if a file exists
427
- - `list_files(pattern)` - Glob files (e.g., `'app/models/**/*.rb'`)
428
- - `project_root` - Get the project root path
429
-
430
- **Note:** Use `puts` to see output from your code.
431
-
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.
447
-
448
410
  ### Internal Analyzers (via execute_tool)
449
411
 
450
412
  #### `project_info`
@@ -603,7 +565,7 @@ This will:
603
565
 
604
566
  In the MCP Inspector UI, you can:
605
567
 
606
- - See all available tools (you should see 4 registered tools)
568
+ - See all available tools (you should see 3 registered tools)
607
569
  - Execute tool calls interactively
608
570
  - View request and response details
609
571
  - Debug issues in real-time
@@ -614,8 +576,8 @@ The Inspector UI provides an intuitive interface to interact with your MCP serve
614
576
 
615
577
  1. **Switch to a project:** `switch_project` with your project name
616
578
  2. **Discover tools:** `search_tools` to see available analyzers
617
- 3. **Test analyzers:** `execute_tool` to invoke specific analyzers
618
- 4. **Test Ruby execution:** `execute_ruby` with code like `puts read_file('Gemfile')`
579
+ 3. **Test analyzers:** `execute_tool` to invoke specific analyzers (e.g. `get_routes`, `get_schema`)
580
+ 4. **Read a file:** `execute_tool` with `get_file`, e.g. `{ "path": "Gemfile" }`
619
581
 
620
582
  ## Integration with LLM Clients
621
583
 
data/docs/AGENT.md CHANGED
@@ -11,20 +11,17 @@ MCP Client (Claude, etc.)
11
11
 
12
12
 
13
13
  ┌─────────────────────────────────────────────┐
14
- 4 MCP-Registered Tools │
14
+ 3 MCP-Registered Tools │
15
15
  │ ┌─────────────┐ ┌─────────────────────┐ │
16
16
  │ │switch_project│ │search_tools │ │
17
17
  │ └─────────────┘ └─────────────────────┘ │
18
18
  │ ┌─────────────┐ ┌─────────────────────┐ │
19
19
  │ │execute_tool │──▶│ 9 Internal Analyzers│ │
20
20
  │ └─────────────┘ └─────────────────────┘ │
21
- │ ┌─────────────┐ │
22
- │ │execute_ruby │ │
23
- │ └─────────────┘ │
24
21
  └─────────────────────────────────────────────┘
25
22
  ```
26
23
 
27
- **Key concept:** Only 4 tools are registered with MCP. The 9 internal analyzers (`analyze_models`, `get_routes`, etc.) are discovered via `search_tools` and invoked via `execute_tool`.
24
+ **Key concept:** Only 3 tools are registered with MCP. The 9 internal analyzers (`analyze_models`, `get_routes`, `get_file`, etc.) are discovered via `search_tools` and invoked via `execute_tool`. The server is an introspection tool — it exposes this fixed set of analyzers and does not execute arbitrary Ruby.
28
25
 
29
26
  ---
30
27
 
@@ -54,50 +51,39 @@ railsMcpServer:search_tools query: "routes"
54
51
 
55
52
  ### Reading Files
56
53
 
57
- **Primary method** - Use `execute_ruby` with `read_file()`:
58
-
59
- ```
60
- railsMcpServer:execute_ruby code: "puts read_file('config/routes.rb')"
61
- railsMcpServer:execute_ruby code: "puts read_file('app/models/user.rb')"
62
- railsMcpServer:execute_ruby code: "puts read_file('app/controllers/users_controller.rb')"
63
- ```
64
-
65
- **Alternative** - Use `get_file` tool:
54
+ Use the `get_file` analyzer:
66
55
 
67
56
  ```
68
57
  railsMcpServer:execute_tool tool_name: "get_file" params: { path: "config/routes.rb" }
58
+ railsMcpServer:execute_tool tool_name: "get_file" params: { path: "app/models/user.rb" }
59
+ railsMcpServer:execute_tool tool_name: "get_file" params: { path: "app/controllers/users_controller.rb" }
69
60
  ```
70
61
 
62
+ Paths are relative to the project root. Reads are confined to the project directory, and sensitive files (`.env`, credentials, keys) are refused.
63
+
71
64
  > ⚠️ **Important:** Do NOT use Claude's built-in `view` tool for Rails project files. It cannot access the project directory. Always use Rails MCP tools.
72
65
 
73
66
  ---
74
67
 
75
68
  ### Finding Files
76
69
 
77
- **Use `execute_ruby` with `Dir.glob()`:**
70
+ Use the `list_files` analyzer with a glob `pattern` (and optional `directory`):
78
71
 
79
72
  ```
80
73
  # Find all models
81
- railsMcpServer:execute_ruby code: "puts Dir.glob('app/models/**/*.rb').join('\n')"
74
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/models/**/*.rb" }
82
75
 
83
76
  # Find all controllers
84
- railsMcpServer:execute_ruby code: "puts Dir.glob('app/controllers/**/*.rb').join('\n')"
77
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/controllers/**/*.rb" }
85
78
 
86
79
  # Find files by name pattern
87
- railsMcpServer:execute_ruby code: "puts Dir.glob('app/**/*user*').join('\n')"
80
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/**/*user*" }
88
81
 
89
82
  # Find all view templates
90
- railsMcpServer:execute_ruby code: "puts Dir.glob('app/views/**/*.erb').join('\n')"
83
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/views/**/*.erb" }
91
84
 
92
85
  # Find Stimulus controllers
93
- railsMcpServer:execute_ruby code: "puts Dir.glob('app/javascript/controllers/**/*.js').join('\n')"
94
- ```
95
-
96
- **Using `list_files` helper** (glob pattern):
97
-
98
- ```
99
- # List Ruby files in models directory
100
- railsMcpServer:execute_ruby code: "puts list_files('app/models/**/*.rb')"
86
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/javascript/controllers/**/*.js" }
101
87
  ```
102
88
 
103
89
  ---
@@ -160,10 +146,10 @@ railsMcpServer:execute_tool tool_name: "get_routes" params: { path_contains: "ap
160
146
  railsMcpServer:execute_tool tool_name: "get_routes" params: { named_only: true }
161
147
  ```
162
148
 
163
- **Fallback if `get_routes` fails:**
149
+ **Fallback if `get_routes` fails:** read the routes file directly.
164
150
 
165
151
  ```
166
- railsMcpServer:execute_ruby code: "puts read_file('config/routes.rb')"
152
+ railsMcpServer:execute_tool tool_name: "get_file" params: { path: "config/routes.rb" }
167
153
  ```
168
154
 
169
155
  ---
@@ -204,46 +190,19 @@ railsMcpServer:execute_tool tool_name: "analyze_environment_config"
204
190
 
205
191
  ---
206
192
 
207
- ## Helper Methods in `execute_ruby`
208
-
209
- When using `execute_ruby`, these helper methods are available:
210
-
211
- | Helper | Usage | Description |
212
- |--------|-------|-------------|
213
- | `read_file(path)` | `read_file('config/routes.rb')` | Read file contents (relative to project root) |
214
- | `file_exists?(path)` | `file_exists?('app/models/user.rb')` | Check if file exists (returns boolean) |
215
- | `list_files(pattern)` | `list_files('app/models/*.rb')` | Glob pattern to find files |
216
- | `project_root` | `project_root` | Returns the project root path |
217
-
218
- **Critical:** Always use `puts` to see output:
219
-
220
- ```
221
- # ❌ Bad - returns "Code executed successfully (no output)"
222
- railsMcpServer:execute_ruby code: "read_file('Gemfile')"
223
-
224
- # ✅ Good - returns file contents
225
- railsMcpServer:execute_ruby code: "puts read_file('Gemfile')"
226
- ```
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
-
232
- ---
233
-
234
193
  ## Tool Selection Summary
235
194
 
236
195
  | Task | Tool to Use |
237
196
  |------|-------------|
238
- | Read a project file | `execute_ruby` with `read_file()` or `get_file` |
239
- | Find files by pattern | `execute_ruby` with `Dir.glob()` |
197
+ | Read a project file | `get_file` (params: `path`) |
198
+ | Find files by pattern | `list_files` (params: `pattern`) |
240
199
  | Analyze models | `analyze_models` |
241
200
  | Get database schema | `get_schema` |
242
- | Get routes | `get_routes` (fallback: read routes.rb) |
201
+ | Get routes | `get_routes` (fallback: `get_file` on `config/routes.rb`) |
243
202
  | Analyze controllers | `analyze_controller_views` |
244
203
  | Compare environments | `analyze_environment_config` |
245
204
  | Load documentation | `load_guide` |
246
- | Custom Ruby queries | `execute_ruby` |
205
+ | Project overview | `project_info` |
247
206
 
248
207
  ---
249
208
 
@@ -272,7 +231,7 @@ railsMcpServer:execute_ruby code: "puts read_file('Gemfile')"
272
231
 
273
232
  | Task | Use This | NOT This |
274
233
  |------|----------|----------|
275
- | Read Rails project files | `railsMcpServer:execute_ruby` with `read_file()` | Claude's `view` tool |
234
+ | Read Rails project files | `railsMcpServer:execute_tool` with `get_file` | Claude's `view` tool |
276
235
  | Edit files in Neovim | `nvimMcpServer:update_buffer` | Claude's `str_replace` |
277
236
  | Create new files | Claude's `create_file` | — |
278
237
  | View images | Claude's `view` tool | — |
@@ -293,7 +252,7 @@ When starting work on an unfamiliar codebase:
293
252
  railsMcpServer:execute_tool tool_name: "project_info"
294
253
 
295
254
  # 2. Find relevant files
296
- railsMcpServer:execute_ruby code: "puts Dir.glob('app/**/*transaction*').join('\n')"
255
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/**/*transaction*" }
297
256
 
298
257
  # 3. Understand the data model
299
258
  railsMcpServer:execute_tool tool_name: "analyze_models" params: { model_name: "Transaction" }
@@ -303,10 +262,10 @@ railsMcpServer:execute_tool tool_name: "get_schema" params: { table_name: "trans
303
262
  railsMcpServer:execute_tool tool_name: "get_routes" params: { controller: "transactions" }
304
263
 
305
264
  # 5. Read the controller
306
- railsMcpServer:execute_ruby code: "puts read_file('app/controllers/transactions_controller.rb')"
265
+ railsMcpServer:execute_tool tool_name: "get_file" params: { path: "app/controllers/transactions_controller.rb" }
307
266
 
308
267
  # 6. Check existing views
309
- railsMcpServer:execute_ruby code: "puts Dir.glob('app/views/transactions/**/*').join('\n')"
268
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/views/transactions/**/*" }
310
269
  ```
311
270
 
312
271
  ---
@@ -317,10 +276,10 @@ railsMcpServer:execute_ruby code: "puts Dir.glob('app/views/transactions/**/*').
317
276
  What do you need to do?
318
277
 
319
278
  ├─► Read/analyze code?
320
- │ ├─► Single file? ──────────► execute_ruby with read_file()
279
+ │ ├─► Single file? ──────────► get_file (params: path)
321
280
  │ ├─► Model info? ───────────► analyze_models (params: model_name)
322
281
  │ ├─► Controller info? ──────► analyze_controller_views (params: controller_name)
323
- │ └─► Multiple files? ───────► execute_ruby with Dir.glob()
282
+ │ └─► Multiple files? ───────► list_files (params: pattern)
324
283
 
325
284
  ├─► Database info?
326
285
  │ ├─► Table structure? ──────► get_schema (params: table_name)
@@ -332,9 +291,7 @@ What do you need to do?
332
291
 
333
292
  ├─► Project overview? ─────────► project_info
334
293
 
335
- ├─► Documentation? ────────────► load_guide (params: library, guide)
336
-
337
- └─► Custom Ruby code? ─────────► execute_ruby
294
+ └─► Documentation? ────────────► load_guide (params: library, guide)
338
295
  ```
339
296
 
340
297
  ---
@@ -344,7 +301,6 @@ What do you need to do?
344
301
  ### ❌ Don't
345
302
 
346
303
  - Use Claude's `view` tool for Rails project files
347
- - Forget `puts` in `execute_ruby` calls
348
304
  - Use absolute paths (always use paths relative to project root)
349
305
  - Skip `switch_project` before using other tools
350
306
  - Use `users` (plural) for model names - use `User` (singular CamelCase)
@@ -353,9 +309,8 @@ What do you need to do?
353
309
  ### ✅ Do
354
310
 
355
311
  - Call `switch_project` before any other MCP tool
356
- - Use `execute_ruby` with `read_file()` as your primary file reading method
357
- - Use `puts` to output results in `execute_ruby`
358
- - Fall back to `execute_ruby` when specialized tools fail
312
+ - Use `get_file` to read files and `list_files` to find them
313
+ - Use the specialized analyzers (`analyze_models`, `get_routes`, `get_schema`) for structured info
359
314
  - Use `search_tools` when unsure what's available
360
315
  - Use CamelCase singular for models: `User`, `BlogPost`, `OrderItem`
361
316
  - Use snake_case plural for tables: `users`, `blog_posts`, `order_items`
@@ -366,45 +321,27 @@ What do you need to do?
366
321
 
367
322
  ### "undefined method" errors from analyzers
368
323
 
369
- Some analyzers may fail with certain Rails versions. Fall back to `execute_ruby`:
324
+ Some analyzers may fail with certain Rails versions. Fall back to reading the source directly:
370
325
 
371
326
  ```
372
327
  # If get_routes fails:
373
- railsMcpServer:execute_ruby code: "puts read_file('config/routes.rb')"
328
+ railsMcpServer:execute_tool tool_name: "get_file" params: { path: "config/routes.rb" }
374
329
 
375
330
  # If analyze_models fails:
376
- railsMcpServer:execute_ruby code: "puts read_file('app/models/user.rb')"
331
+ railsMcpServer:execute_tool tool_name: "get_file" params: { path: "app/models/user.rb" }
377
332
  ```
378
333
 
379
- ### "Path not found" errors
334
+ ### "Path not found" / "Access denied" errors
380
335
 
381
336
  1. Ensure you've called `switch_project` first
382
337
  2. Use relative paths, not absolute paths
383
- 3. Check if path exists:
338
+ 3. Check whether the file shows up in a listing:
384
339
  ```
385
- railsMcpServer:execute_ruby code: "puts file_exists?('app/models/user.rb')"
340
+ railsMcpServer:execute_tool tool_name: "list_files" params: { pattern: "app/models/*.rb" }
386
341
  ```
342
+ 4. Sensitive files (`.env`, credentials, keys) are intentionally refused by `get_file` / `list_files`.
387
343
 
388
- ### "wrong number of arguments" errors
389
-
390
- The `list_files()` helper takes a glob pattern as a single argument:
391
- ```
392
- # Correct usage
393
- railsMcpServer:execute_ruby code: "puts list_files('app/models/**/*.rb')"
394
- ```
395
-
396
- ### No output from `execute_ruby`
397
-
398
- Add `puts` before your expression:
399
- ```
400
- # Before (no output)
401
- railsMcpServer:execute_ruby code: "User.count"
402
-
403
- # After (shows result)
404
- railsMcpServer:execute_ruby code: "puts User.count"
405
- ```
406
-
407
- ### `execute_ruby` / `get_schema` fail to boot the app (Bundler / wrong Ruby)
344
+ ### `get_schema` / `get_routes` fail to boot the app (Bundler / wrong Ruby)
408
345
 
409
346
  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
347
 
@@ -434,3 +371,4 @@ nvimMcpServer:update_buffer project_name: "your-project" file_path: "/full/path/
434
371
  - You need to read/analyze project files
435
372
  - You need Rails-specific analysis (models, routes, schema)
436
373
  - The file isn't open in Neovim
374
+ ```
@@ -29,7 +29,7 @@ Create `.github/copilot/mcp.json` in your repository:
29
29
  "type": "local",
30
30
  "command": "rails-mcp-server",
31
31
  "args": ["--single-project"],
32
- "tools": ["switch_project", "search_tools", "execute_tool", "execute_ruby"]
32
+ "tools": ["switch_project", "search_tools", "execute_tool"]
33
33
  }
34
34
  }
35
35
  }
@@ -133,7 +133,6 @@ 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 Ruby code in the project context |
137
136
 
138
137
  ### Internal Analyzers (via `execute_tool`)
139
138
 
@@ -171,7 +170,7 @@ The `load_guide` analyzer requires guides to be downloaded. To include guides:
171
170
 
172
171
  ### Network Restrictions
173
172
 
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)).
173
+ 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. It exposes a fixed set of introspection tools and does not execute caller-supplied Ruby; the tools that boot the app run the project's environment, so use it with repositories you trust.
175
174
 
176
175
  ## Troubleshooting
177
176
 
@@ -205,7 +204,7 @@ Here's a complete example for a typical Rails project:
205
204
  "type": "local",
206
205
  "command": "rails-mcp-server",
207
206
  "args": ["--single-project"],
208
- "tools": ["switch_project", "search_tools", "execute_tool", "execute_ruby"]
207
+ "tools": ["switch_project", "search_tools", "execute_tool"]
209
208
  }
210
209
  }
211
210
  }
data/exe/rails-mcp-server CHANGED
@@ -83,15 +83,13 @@ RailsMcpServer.log(:info, "Starting Rails MCP Server in #{mode} mode...")
83
83
  # Workflow:
84
84
  # 1. switch_project - Select a Rails project to work with
85
85
  # 2. search_tools - Discover available tools and their parameters
86
- # 3. execute_tool - Invoke internal tools by name
87
- # 4. execute_ruby - Run custom Ruby code for complex queries
86
+ # 3. execute_tool - Invoke internal introspection tools by name
88
87
  #
89
88
  def setup_mcp_tools(server)
90
89
  server.register_tools(
91
90
  RailsMcpServer::SwitchProject,
92
91
  RailsMcpServer::SearchTools,
93
- RailsMcpServer::ExecuteTool,
94
- RailsMcpServer::ExecuteRuby
92
+ RailsMcpServer::ExecuteTool
95
93
  )
96
94
 
97
95
  server.register_resources(
@@ -21,15 +21,12 @@ module RailsMcpServer
21
21
 
22
22
  Quick Start:
23
23
  • Get project overview: execute_tool("project_info")
24
- • Read a file: execute_ruby("puts read_file('config/routes.rb')")
25
- • Find files: execute_ruby("puts Dir.glob('app/models/*.rb').join('\\n')")
24
+ • Read a file: execute_tool("get_file", { path: "config/routes.rb" })
25
+ • Find files: execute_tool("list_files", { pattern: "app/models/*.rb" })
26
26
  • Analyze models: execute_tool("analyze_models", { model_name: "User" })
27
27
  • Get routes: execute_tool("get_routes")
28
28
  • Get schema: execute_tool("get_schema", { table_name: "users" })
29
29
  • Search available tools: search_tools()
30
-
31
- Helpers in execute_ruby: read_file(path), file_exists?(path), list_files(pattern), project_root
32
- Note: Always use `puts` in execute_ruby to see output.
33
30
  GUIDE
34
31
 
35
32
  def call(project_name:)
@@ -1,3 +1,3 @@
1
1
  module RailsMcpServer
2
- VERSION = "1.6.1"
2
+ VERSION = "2.0.0"
3
3
  end
@@ -12,7 +12,6 @@ require_relative "rails-mcp-server/tools/base_tool"
12
12
  require_relative "rails-mcp-server/tools/switch_project"
13
13
  require_relative "rails-mcp-server/tools/search_tools"
14
14
  require_relative "rails-mcp-server/tools/execute_tool"
15
- require_relative "rails-mcp-server/tools/execute_ruby"
16
15
 
17
16
  # Analyzers (internal, invoked via execute_tool)
18
17
  require_relative "rails-mcp-server/analyzers/base_analyzer"
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.6.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mario Alberto Chávez Cárdenas
@@ -218,7 +218,6 @@ files:
218
218
  - lib/rails-mcp-server/resources/turbo_guides_resource.rb
219
219
  - lib/rails-mcp-server/resources/turbo_guides_resources.rb
220
220
  - lib/rails-mcp-server/tools/base_tool.rb
221
- - lib/rails-mcp-server/tools/execute_ruby.rb
222
221
  - lib/rails-mcp-server/tools/execute_tool.rb
223
222
  - lib/rails-mcp-server/tools/get_model.rb
224
223
  - lib/rails-mcp-server/tools/search_tools.rb
@@ -1,647 +0,0 @@
1
- module RailsMcpServer
2
- class ExecuteRuby < BaseTool
3
- tool_name "execute_ruby"
4
-
5
- description <<~DESC
6
- Execute Ruby code in the context of the Rails project, for inspection and
7
- exploration. Use this for:
8
- - Complex queries that would require multiple tool calls
9
- - Filtering/transforming data before returning
10
- - Custom exploration of the codebase
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
-
17
- RESTRICTIONS:
18
- - Cannot create, modify, or delete files
19
- - Cannot read .env, credentials, key files, or .gitignore'd files
20
- - Cannot access files outside the project directory (read-only system data
21
- such as timezone files under /usr/share/zoneinfo is allowed)
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)
30
-
31
- HELPER METHODS AVAILABLE:
32
- - read_file(path) - safely read a file
33
- - file_exists?(path) - check if file exists (false for sensitive files)
34
- - list_files(pattern) - glob files safely, e.g., list_files('app/models/**/*.rb')
35
- - project_root - returns the project root path
36
-
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.
43
- DESC
44
-
45
- arguments do
46
- required(:code).filled(:string).description("Ruby code to execute (inspection operations only)")
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.")
49
- end
50
-
51
- # Patterns that indicate dangerous operations
52
- FORBIDDEN_PATTERNS = [
53
- # File/IO writing
54
- /File\.(write|open|new)\s*\([^)]*['"][wa+]/i,
55
- /File\.(delete|unlink|rename|chmod|chown|truncate)/i,
56
- /FileUtils\./i,
57
- /IO\.(write|syswrite|popen|pipe)/i,
58
- /\.(write|puts|print|syswrite)\s*[(\s]/,
59
-
60
- # Directory modification
61
- /Dir\.(mkdir|rmdir|delete|chdir)/i,
62
-
63
- # System/shell execution
64
- /system\s*[(\s]/,
65
- /exec\s*[(\s]/,
66
- /`[^`]+`/,
67
- /%x[{(\[]/,
68
- /Kernel\.(system|exec|spawn|`)/,
69
- /Open3\./i,
70
- /IO\.popen/i,
71
- /Process\.(spawn|exec|fork)/i,
72
- /Shellwords/i,
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
-
90
- # Network access
91
- /Net::(HTTP|FTP|SMTP)/i,
92
- /URI\.(open|parse)/i,
93
- /HTTParty/i,
94
- /Faraday/i,
95
- /RestClient/i,
96
- /open-uri/i,
97
- /Socket/i,
98
- /TCPSocket/i,
99
- /UDPSocket/i,
100
-
101
- # Dangerous Ruby features
102
- /eval\s*[(\s]/,
103
- /instance_eval/i,
104
- /class_eval/i,
105
- /module_eval/i,
106
- /define_method/i,
107
- /send\s*[(\s]+[:'"]*(system|exec|`)/i,
108
- /__send__/,
109
- /ObjectSpace/i,
110
- /Binding/i,
111
- /set_trace_func/i,
112
-
113
- # Environment/credentials access
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/,
117
- /Rails\.application\.credentials/i,
118
- /Rails\.application\.secrets/i,
119
-
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."]
158
- ].freeze
159
-
160
- # Sensitive file patterns (in addition to .gitignore)
161
- SENSITIVE_PATTERNS = [
162
- /\.env(\..*)?$/i,
163
- /\.key$/i,
164
- /\.pem$/i,
165
- /\.crt$/i,
166
- /\.p12$/i,
167
- /credentials\.yml/i,
168
- /secrets\.yml/i,
169
- /master\.key/i,
170
- /config\/credentials/i,
171
- /config\/secrets/i,
172
- /\.secret$/i,
173
- /password/i,
174
- /\.ssh\//i,
175
- /id_rsa/i,
176
- /id_ed25519/i
177
- ].freeze
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
-
191
- NO_OUTPUT_MESSAGE = <<~MSG
192
- Code executed successfully (no output).
193
-
194
- Hint: Use `puts` to see results, e.g.:
195
- puts read_file('config/routes.rb')
196
- puts User.count
197
- puts Dir.glob('app/models/*.rb')
198
- MSG
199
-
200
- def call(code:, timeout: 30, confirm_risky: false)
201
- unless current_project
202
- return "No active project. Please switch to a project first."
203
- end
204
-
205
- timeout = [timeout.to_i, 60].min # Cap at 60 seconds
206
- timeout = 10 if timeout < 1
207
-
208
- # Step 1: Static analysis - reject outright-dangerous code
209
- validation_error = validate_code_safety(code)
210
- return validation_error if validation_error
211
-
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
219
- sandbox_code = build_sandbox(code)
220
-
221
- # Step 4: Execute with timeout
222
- execute_sandboxed(sandbox_code, timeout)
223
- end
224
-
225
- private
226
-
227
- def validate_code_safety(code)
228
- FORBIDDEN_PATTERNS.each do |pattern|
229
- if code.match?(pattern)
230
- return "REJECTED: Code contains forbidden pattern (#{pattern.source.split("\\").first}...). " \
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(", ")}."
247
- end
248
- end
249
- nil
250
- end
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
-
269
- def build_sandbox(user_code)
270
- gitignore_patterns = parse_gitignore
271
- all_patterns = SENSITIVE_PATTERNS.map(&:source) + gitignore_patterns
272
- sensitive_patterns_ruby = all_patterns.map { |p| "Regexp.new(#{p.inspect}, Regexp::IGNORECASE)" }.join(",\n ")
273
-
274
- <<~RUBY
275
- require "stringio" # the File.open override below yields StringIO objects
276
-
277
- # Sandbox wrapper for safe execution
278
- module McpSandbox
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
292
-
293
- SENSITIVE_PATTERNS = [
294
- #{sensitive_patterns_ruby}
295
- ].freeze
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
-
314
- class PathViolation < StandardError; end
315
- class SensitiveFileViolation < StandardError; end
316
- class WriteViolation < StandardError; end
317
-
318
- module_function
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
-
333
- def validate_path!(path)
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
340
-
341
- unless resolved.start_with?(PROJECT_ROOT + "/") || resolved == PROJECT_ROOT
342
- raise PathViolation, "Access denied: path '\#{path}' is outside project directory"
343
- end
344
-
345
- relative_path = resolved.sub(PROJECT_ROOT + "/", "")
346
-
347
- SENSITIVE_PATTERNS.each do |pattern|
348
- if relative_path.match?(pattern)
349
- raise SensitiveFileViolation, "Access denied: '\#{relative_path}' matches sensitive file pattern"
350
- end
351
- end
352
-
353
- resolved
354
- end
355
-
356
- def safe_read(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)
373
- end
374
-
375
- def safe_exist?(path)
376
- ORIGINAL_FILE_EXIST.call(validate_path!(path))
377
- rescue PathViolation, SensitiveFileViolation
378
- false
379
- end
380
-
381
- def safe_directory?(path)
382
- ORIGINAL_FILE_DIRECTORY.call(validate_path!(path))
383
- rescue PathViolation, SensitiveFileViolation
384
- false
385
- end
386
-
387
- def safe_file?(path)
388
- ORIGINAL_FILE_FILE.call(validate_path!(path))
389
- rescue PathViolation, SensitiveFileViolation
390
- false
391
- end
392
-
393
- def safe_glob(pattern, base: PROJECT_ROOT)
394
- ORIGINAL_DIR_GLOB.call(File.join(base, pattern)).select do |path|
395
- validate_path!(path)
396
- true
397
- rescue PathViolation, SensitiveFileViolation
398
- false
399
- end
400
- end
401
-
402
- def safe_entries(path)
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
433
- end
434
- end
435
-
436
- # Override File class methods
437
- class File
438
- class << self
439
- def read(path, *args)
440
- McpSandbox.safe_read(path)
441
- end
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
-
455
- def exist?(path)
456
- McpSandbox.safe_exist?(path)
457
- end
458
-
459
- def directory?(path)
460
- McpSandbox.safe_directory?(path)
461
- end
462
-
463
- def file?(path)
464
- McpSandbox.safe_file?(path)
465
- end
466
-
467
- # Block all write operations
468
- [:write, :delete, :unlink, :rename, :chmod, :chown, :truncate].each do |method|
469
- define_method(method) do |*args, &block|
470
- raise McpSandbox::WriteViolation, "Write operations are not permitted: File.\#{method}"
471
- end
472
- end
473
-
474
- # Handle open specially - allow read-only mode
475
- def open(path, mode = "r", *args, &block)
476
- if mode.to_s =~ /[wa+]/
477
- raise McpSandbox::WriteViolation, "Write operations are not permitted: File.open with mode '\#{mode}'"
478
- end
479
- content = McpSandbox.safe_read(path)
480
- if block_given?
481
- yield StringIO.new(content)
482
- else
483
- StringIO.new(content)
484
- end
485
- end
486
- end
487
- end
488
-
489
- # Override Dir class methods
490
- class Dir
491
- class << self
492
- def glob(pattern, *args)
493
- McpSandbox.safe_glob(pattern)
494
- end
495
-
496
- def entries(path)
497
- McpSandbox.safe_entries(path)
498
- end
499
-
500
- [:mkdir, :rmdir, :delete, :chdir].each do |method|
501
- define_method(method) do |*args|
502
- raise McpSandbox::WriteViolation, "Directory modifications are not permitted: Dir.\#{method}"
503
- end
504
- end
505
- end
506
- end
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
-
531
- # Block FileUtils entirely
532
- if defined?(FileUtils)
533
- module FileUtils
534
- class << self
535
- def method_missing(method, *args)
536
- raise McpSandbox::WriteViolation, "FileUtils operations are not permitted"
537
- end
538
- end
539
- end
540
- end
541
-
542
- # Block system calls at Kernel level
543
- module Kernel
544
- def system(*args)
545
- raise McpSandbox::WriteViolation, "System calls are not permitted"
546
- end
547
-
548
- def exec(*args)
549
- raise McpSandbox::WriteViolation, "System calls are not permitted"
550
- end
551
-
552
- def spawn(*args)
553
- raise McpSandbox::WriteViolation, "System calls are not permitted"
554
- end
555
-
556
- def `(cmd)
557
- raise McpSandbox::WriteViolation, "Shell execution is not permitted"
558
- end
559
- end
560
-
561
- # Block backticks at Object level
562
- class Object
563
- def `(cmd)
564
- raise McpSandbox::WriteViolation, "Shell execution is not permitted"
565
- end
566
- end
567
-
568
- # Provide convenient aliases for sandboxed operations
569
- def read_file(path)
570
- McpSandbox.safe_read(path)
571
- end
572
-
573
- def file_exists?(path)
574
- McpSandbox.safe_exist?(path)
575
- end
576
-
577
- def list_files(pattern)
578
- McpSandbox.safe_glob(pattern)
579
- end
580
-
581
- def project_root
582
- McpSandbox::PROJECT_ROOT
583
- end
584
-
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.
589
- begin
590
- McpSandbox.readonly_guard do
591
- #{user_code}
592
- end
593
- rescue McpSandbox::PathViolation => e
594
- puts "PATH ERROR: \#{e.message}"
595
- rescue McpSandbox::SensitiveFileViolation => e
596
- puts "ACCESS DENIED: \#{e.message}"
597
- rescue McpSandbox::WriteViolation => e
598
- puts "WRITE ERROR: \#{e.message}"
599
- rescue => e
600
- puts "ERROR: \#{e.class} - \#{e.message}"
601
- end
602
- RUBY
603
- end
604
-
605
- def parse_gitignore
606
- gitignore_path = File.join(active_project_path, ".gitignore")
607
- return [] unless File.exist?(gitignore_path)
608
-
609
- File.readlines(gitignore_path)
610
- .map(&:strip)
611
- .reject { |line| line.empty? || line.start_with?("#") } # rubocop:disable Performance/ChainArrayAllocation
612
- .map { |pattern| convert_gitignore_to_regex(pattern) } # rubocop:disable Performance/ChainArrayAllocation
613
- end
614
-
615
- def convert_gitignore_to_regex(pattern)
616
- # Convert gitignore glob pattern to regex
617
- regex = Regexp.escape(pattern)
618
- .gsub('\*\*', ".*") # ** matches everything
619
- .gsub('\*', "[^/]*") # * matches within directory
620
- .gsub('\?', ".") # ? matches single char
621
- .gsub(/^\//, "^") # Leading / anchors to root
622
-
623
- # If pattern doesn't start with /, it can match anywhere
624
- regex = "(?:^|/)" + regex unless pattern.start_with?("/")
625
-
626
- regex
627
- end
628
-
629
- def execute_sandboxed(code, timeout)
630
- require "tempfile"
631
-
632
- Tempfile.create(["mcp_sandbox", ".rb"]) do |f|
633
- f.write(code)
634
- f.flush
635
-
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
644
- end
645
- end
646
- end
647
- end