rails-mcp-server 1.6.0 → 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: 61b6de0c4ec67e88054b3fa1b91cfc3d0a9b6cb5d1c565c5086b1e88dfcd7dda
4
- data.tar.gz: 6af401f97462dd368072b223117fab10f49da0b873f7b2f29985da63ec4d8a98
3
+ metadata.gz: 380196bc8728daef0f20e005b40ef1464163062c4e871d60adc5a819da8f9e61
4
+ data.tar.gz: bc43ce8aa0a82c432def067b39394d9f3a1554484040fabc6bb4cf78b2268546
5
5
  SHA512:
6
- metadata.gz: 8cd00e7e51074c682b42ddcbd07dc7697547313c4712c69c0641f6357bbde84775bc8425fe5cad1b05807ca97c065e6d015a5d0aa03079dd371f39e3f664a331
7
- data.tar.gz: 20d054080683e9c8151075fcd6084eaaa868b549c99128768a18abe37bb1ec1ab96c32661273920fd03d100eea37c47c92e36ea6a11c266eff21239f84545d54
6
+ metadata.gz: f8025ec3e715e178650a138658fc4b8ebe9b81f49a4a1401d4b37efbde7a82c150c021e1737826536605f9c966e20b3ade4c5bf7879f85d383b9f28e047e9256
7
+ data.tar.gz: dfc2ce718b07936d8775a875bcf6f8979934151cf4e95827d7a41931ca5406b7b42671fbd17b7a8d312131f8f17679cb91457b6739f0ae41af0fe1acc235ceba
data/CHANGELOG.md CHANGED
@@ -7,6 +7,40 @@ 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
+
30
+ ## [1.6.1] - 2026-08-04
31
+
32
+ ### Security
33
+
34
+ - **`execute_ruby` process-execution hardening**: Closed a command-execution path and tightened the static filter.
35
+ - **`require` restricted to a tiny data-lib allowlist**: `require_relative` and dynamic `require`s are refused, and literal `require "lib"` is refused except for a small allowlist of pure-data libraries not always preloaded (`csv`, `tzinfo`, `date`, `time`). Under `bin/rails runner` the app's models, ActiveRecord, and the stdlib Rails loads on boot are already available, so inspection code needs almost no requires — and every dangerous stdlib escape has to be required first. This closes `require "pty"` (`PTY.spawn`/`PTY.getpty` started a child process outside the `Kernel#system` guard, giving arbitrary host command execution), along with `open3`, `fiddle`, `ffi`, and `socket`, at the source rather than by enumerating individual APIs. (`tzinfo` pairs with the existing system-timezone read-path allowlist so `Time.zone` code keeps working.)
36
+ - **Native/PTY patterns**: `PTY`, `Fiddle`, and `FFI` are added to the forbidden-pattern scan as defense in depth.
37
+ - **Dynamic dispatch to execution sinks hard-blocked**: `send`/`public_send`/`__send__`/`const_get` aimed by name at an execution or eval sink (`system`, `exec`, `spawn`, `fork`, `eval`, `popen`, `Open3`, `Process`, `PTY`, …) are now rejected outright instead of merely gated behind `confirm_risky`. Benign dynamic dispatch (e.g. `record.send(:name)`) is unaffected.
38
+ - **Honest framing**: the tool description and docs no longer call `execute_ruby` a read-only sandbox. It runs caller-supplied Ruby with the privileges of the server process; the controls are best-effort guardrails, not an isolation boundary.
39
+
40
+ ### Fixed
41
+
42
+ - **ReDoS in the `execute_ruby` static scan**: Rewrote the `require`/dynamic-dispatch matchers to remove an ambiguous `\s*\(?\s*` construct that backtracked in polynomial time on adversarial whitespace input (CodeQL alert). Matching is now linear.
43
+
10
44
  ## [1.6.0] - 2026-08-03
11
45
 
12
46
  ### Added
@@ -355,6 +389,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
355
389
 
356
390
  ## Version History Summary
357
391
 
392
+ - **v2.0.0** (2026-08-04): Removed the `execute_ruby` tool — the server is introspection-only via its dedicated analyzers (breaking)
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
358
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)
359
395
  - **v1.5.1** (2026-03-04): Relaxed dependency version constraints for better compatibility
360
396
  - **v1.4.0** (2025-12-10): Context-efficient architecture with progressive tool discovery (67% token reduction)
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 sandboxed Ruby code 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 sandboxed Ruby code 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,36 +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 sandboxed Ruby code in the Rails project context.
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:** 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.).
442
-
443
410
  ### Internal Analyzers (via execute_tool)
444
411
 
445
412
  #### `project_info`
@@ -598,7 +565,7 @@ This will:
598
565
 
599
566
  In the MCP Inspector UI, you can:
600
567
 
601
- - See all available tools (you should see 4 registered tools)
568
+ - See all available tools (you should see 3 registered tools)
602
569
  - Execute tool calls interactively
603
570
  - View request and response details
604
571
  - Debug issues in real-time
@@ -609,8 +576,8 @@ The Inspector UI provides an intuitive interface to interact with your MCP serve
609
576
 
610
577
  1. **Switch to a project:** `switch_project` with your project name
611
578
  2. **Discover tools:** `search_tools` to see available analyzers
612
- 3. **Test analyzers:** `execute_tool` to invoke specific analyzers
613
- 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" }`
614
581
 
615
582
  ## Integration with LLM Clients
616
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
- **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
-
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 sandboxed Ruby code |
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 has read-only access to the repository.
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.0"
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.0
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,586 +0,0 @@
1
- module RailsMcpServer
2
- class ExecuteRuby < BaseTool
3
- tool_name "execute_ruby"
4
-
5
- description <<~DESC
6
- Execute read-only Ruby code in the context of the Rails project. Use this for:
7
- - Complex queries that would require multiple tool calls
8
- - Filtering/transforming data before returning
9
- - Custom exploration of the codebase
10
-
11
- RESTRICTIONS:
12
- - Cannot create, modify, or delete files
13
- - Cannot read .env, credentials, key files, or .gitignore'd files
14
- - Cannot access files outside the project directory (read-only system data
15
- such as timezone files under /usr/share/zoneinfo is allowed)
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)
20
-
21
- HELPER METHODS AVAILABLE:
22
- - read_file(path) - safely read a file
23
- - file_exists?(path) - check if file exists (false for sensitive files)
24
- - list_files(pattern) - glob files safely, e.g., list_files('app/models/**/*.rb')
25
- - project_root - returns the project root path
26
-
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.
33
- DESC
34
-
35
- arguments do
36
- required(:code).filled(:string).description("Ruby code to execute (read-only operations only)")
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.")
39
- end
40
-
41
- # Patterns that indicate dangerous operations
42
- FORBIDDEN_PATTERNS = [
43
- # File/IO writing
44
- /File\.(write|open|new)\s*\([^)]*['"][wa+]/i,
45
- /File\.(delete|unlink|rename|chmod|chown|truncate)/i,
46
- /FileUtils\./i,
47
- /IO\.(write|syswrite|popen|pipe)/i,
48
- /\.(write|puts|print|syswrite)\s*[(\s]/,
49
-
50
- # Directory modification
51
- /Dir\.(mkdir|rmdir|delete|chdir)/i,
52
-
53
- # System/shell execution
54
- /system\s*[(\s]/,
55
- /exec\s*[(\s]/,
56
- /`[^`]+`/,
57
- /%x[{(\[]/,
58
- /Kernel\.(system|exec|spawn|`)/,
59
- /Open3\./i,
60
- /IO\.popen/i,
61
- /Process\.(spawn|exec|fork)/i,
62
- /Shellwords/i,
63
-
64
- # Network access
65
- /Net::(HTTP|FTP|SMTP)/i,
66
- /URI\.(open|parse)/i,
67
- /HTTParty/i,
68
- /Faraday/i,
69
- /RestClient/i,
70
- /open-uri/i,
71
- /Socket/i,
72
- /TCPSocket/i,
73
- /UDPSocket/i,
74
-
75
- # Dangerous Ruby features
76
- /eval\s*[(\s]/,
77
- /instance_eval/i,
78
- /class_eval/i,
79
- /module_eval/i,
80
- /define_method/i,
81
- /send\s*[(\s]+[:'"]*(system|exec|`)/i,
82
- /__send__/,
83
- /ObjectSpace/i,
84
- /Binding/i,
85
- /set_trace_func/i,
86
-
87
- # Environment/credentials access
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/,
91
- /Rails\.application\.credentials/i,
92
- /Rails\.application\.secrets/i,
93
-
94
- # Load/require that could execute arbitrary code
95
- /load\s*[(\s]+[^)]*\$/i,
96
- /require\s+[^'"]/i
97
- ].freeze
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
-
114
- # Sensitive file patterns (in addition to .gitignore)
115
- SENSITIVE_PATTERNS = [
116
- /\.env(\..*)?$/i,
117
- /\.key$/i,
118
- /\.pem$/i,
119
- /\.crt$/i,
120
- /\.p12$/i,
121
- /credentials\.yml/i,
122
- /secrets\.yml/i,
123
- /master\.key/i,
124
- /config\/credentials/i,
125
- /config\/secrets/i,
126
- /\.secret$/i,
127
- /password/i,
128
- /\.ssh\//i,
129
- /id_rsa/i,
130
- /id_ed25519/i
131
- ].freeze
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
-
145
- NO_OUTPUT_MESSAGE = <<~MSG
146
- Code executed successfully (no output).
147
-
148
- Hint: Use `puts` to see results, e.g.:
149
- puts read_file('config/routes.rb')
150
- puts User.count
151
- puts Dir.glob('app/models/*.rb')
152
- MSG
153
-
154
- def call(code:, timeout: 30, confirm_risky: false)
155
- unless current_project
156
- return "No active project. Please switch to a project first."
157
- end
158
-
159
- timeout = [timeout.to_i, 60].min # Cap at 60 seconds
160
- timeout = 10 if timeout < 1
161
-
162
- # Step 1: Static analysis - reject outright-dangerous code
163
- validation_error = validate_code_safety(code)
164
- return validation_error if validation_error
165
-
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
173
- sandbox_code = build_sandbox(code)
174
-
175
- # Step 4: Execute with timeout
176
- execute_sandboxed(sandbox_code, timeout)
177
- end
178
-
179
- private
180
-
181
- def validate_code_safety(code)
182
- FORBIDDEN_PATTERNS.each do |pattern|
183
- if code.match?(pattern)
184
- return "REJECTED: Code contains forbidden pattern (#{pattern.source.split("\\").first}...). " \
185
- "This tool only allows read-only operations."
186
- end
187
- end
188
- nil
189
- end
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
-
208
- def build_sandbox(user_code)
209
- gitignore_patterns = parse_gitignore
210
- all_patterns = SENSITIVE_PATTERNS.map(&:source) + gitignore_patterns
211
- sensitive_patterns_ruby = all_patterns.map { |p| "Regexp.new(#{p.inspect}, Regexp::IGNORECASE)" }.join(",\n ")
212
-
213
- <<~RUBY
214
- require "stringio" # the File.open override below yields StringIO objects
215
-
216
- # Sandbox wrapper for safe execution
217
- module McpSandbox
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
231
-
232
- SENSITIVE_PATTERNS = [
233
- #{sensitive_patterns_ruby}
234
- ].freeze
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
-
253
- class PathViolation < StandardError; end
254
- class SensitiveFileViolation < StandardError; end
255
- class WriteViolation < StandardError; end
256
-
257
- module_function
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
-
272
- def validate_path!(path)
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
279
-
280
- unless resolved.start_with?(PROJECT_ROOT + "/") || resolved == PROJECT_ROOT
281
- raise PathViolation, "Access denied: path '\#{path}' is outside project directory"
282
- end
283
-
284
- relative_path = resolved.sub(PROJECT_ROOT + "/", "")
285
-
286
- SENSITIVE_PATTERNS.each do |pattern|
287
- if relative_path.match?(pattern)
288
- raise SensitiveFileViolation, "Access denied: '\#{relative_path}' matches sensitive file pattern"
289
- end
290
- end
291
-
292
- resolved
293
- end
294
-
295
- def safe_read(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)
312
- end
313
-
314
- def safe_exist?(path)
315
- ORIGINAL_FILE_EXIST.call(validate_path!(path))
316
- rescue PathViolation, SensitiveFileViolation
317
- false
318
- end
319
-
320
- def safe_directory?(path)
321
- ORIGINAL_FILE_DIRECTORY.call(validate_path!(path))
322
- rescue PathViolation, SensitiveFileViolation
323
- false
324
- end
325
-
326
- def safe_file?(path)
327
- ORIGINAL_FILE_FILE.call(validate_path!(path))
328
- rescue PathViolation, SensitiveFileViolation
329
- false
330
- end
331
-
332
- def safe_glob(pattern, base: PROJECT_ROOT)
333
- ORIGINAL_DIR_GLOB.call(File.join(base, pattern)).select do |path|
334
- validate_path!(path)
335
- true
336
- rescue PathViolation, SensitiveFileViolation
337
- false
338
- end
339
- end
340
-
341
- def safe_entries(path)
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
372
- end
373
- end
374
-
375
- # Override File class methods
376
- class File
377
- class << self
378
- def read(path, *args)
379
- McpSandbox.safe_read(path)
380
- end
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
-
394
- def exist?(path)
395
- McpSandbox.safe_exist?(path)
396
- end
397
-
398
- def directory?(path)
399
- McpSandbox.safe_directory?(path)
400
- end
401
-
402
- def file?(path)
403
- McpSandbox.safe_file?(path)
404
- end
405
-
406
- # Block all write operations
407
- [:write, :delete, :unlink, :rename, :chmod, :chown, :truncate].each do |method|
408
- define_method(method) do |*args, &block|
409
- raise McpSandbox::WriteViolation, "Write operations are not permitted: File.\#{method}"
410
- end
411
- end
412
-
413
- # Handle open specially - allow read-only mode
414
- def open(path, mode = "r", *args, &block)
415
- if mode.to_s =~ /[wa+]/
416
- raise McpSandbox::WriteViolation, "Write operations are not permitted: File.open with mode '\#{mode}'"
417
- end
418
- content = McpSandbox.safe_read(path)
419
- if block_given?
420
- yield StringIO.new(content)
421
- else
422
- StringIO.new(content)
423
- end
424
- end
425
- end
426
- end
427
-
428
- # Override Dir class methods
429
- class Dir
430
- class << self
431
- def glob(pattern, *args)
432
- McpSandbox.safe_glob(pattern)
433
- end
434
-
435
- def entries(path)
436
- McpSandbox.safe_entries(path)
437
- end
438
-
439
- [:mkdir, :rmdir, :delete, :chdir].each do |method|
440
- define_method(method) do |*args|
441
- raise McpSandbox::WriteViolation, "Directory modifications are not permitted: Dir.\#{method}"
442
- end
443
- end
444
- end
445
- end
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
-
470
- # Block FileUtils entirely
471
- if defined?(FileUtils)
472
- module FileUtils
473
- class << self
474
- def method_missing(method, *args)
475
- raise McpSandbox::WriteViolation, "FileUtils operations are not permitted"
476
- end
477
- end
478
- end
479
- end
480
-
481
- # Block system calls at Kernel level
482
- module Kernel
483
- def system(*args)
484
- raise McpSandbox::WriteViolation, "System calls are not permitted"
485
- end
486
-
487
- def exec(*args)
488
- raise McpSandbox::WriteViolation, "System calls are not permitted"
489
- end
490
-
491
- def spawn(*args)
492
- raise McpSandbox::WriteViolation, "System calls are not permitted"
493
- end
494
-
495
- def `(cmd)
496
- raise McpSandbox::WriteViolation, "Shell execution is not permitted"
497
- end
498
- end
499
-
500
- # Block backticks at Object level
501
- class Object
502
- def `(cmd)
503
- raise McpSandbox::WriteViolation, "Shell execution is not permitted"
504
- end
505
- end
506
-
507
- # Provide convenient aliases for sandboxed operations
508
- def read_file(path)
509
- McpSandbox.safe_read(path)
510
- end
511
-
512
- def file_exists?(path)
513
- McpSandbox.safe_exist?(path)
514
- end
515
-
516
- def list_files(pattern)
517
- McpSandbox.safe_glob(pattern)
518
- end
519
-
520
- def project_root
521
- McpSandbox::PROJECT_ROOT
522
- end
523
-
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.
528
- begin
529
- McpSandbox.readonly_guard do
530
- #{user_code}
531
- end
532
- rescue McpSandbox::PathViolation => e
533
- puts "PATH ERROR: \#{e.message}"
534
- rescue McpSandbox::SensitiveFileViolation => e
535
- puts "ACCESS DENIED: \#{e.message}"
536
- rescue McpSandbox::WriteViolation => e
537
- puts "WRITE ERROR: \#{e.message}"
538
- rescue => e
539
- puts "ERROR: \#{e.class} - \#{e.message}"
540
- end
541
- RUBY
542
- end
543
-
544
- def parse_gitignore
545
- gitignore_path = File.join(active_project_path, ".gitignore")
546
- return [] unless File.exist?(gitignore_path)
547
-
548
- File.readlines(gitignore_path)
549
- .map(&:strip)
550
- .reject { |line| line.empty? || line.start_with?("#") } # rubocop:disable Performance/ChainArrayAllocation
551
- .map { |pattern| convert_gitignore_to_regex(pattern) } # rubocop:disable Performance/ChainArrayAllocation
552
- end
553
-
554
- def convert_gitignore_to_regex(pattern)
555
- # Convert gitignore glob pattern to regex
556
- regex = Regexp.escape(pattern)
557
- .gsub('\*\*', ".*") # ** matches everything
558
- .gsub('\*', "[^/]*") # * matches within directory
559
- .gsub('\?', ".") # ? matches single char
560
- .gsub(/^\//, "^") # Leading / anchors to root
561
-
562
- # If pattern doesn't start with /, it can match anywhere
563
- regex = "(?:^|/)" + regex unless pattern.start_with?("/")
564
-
565
- regex
566
- end
567
-
568
- def execute_sandboxed(code, timeout)
569
- require "tempfile"
570
-
571
- Tempfile.create(["mcp_sandbox", ".rb"]) do |f|
572
- f.write(code)
573
- f.flush
574
-
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
583
- end
584
- end
585
- end
586
- end