Skip to main content
Glama
README.md
# local-code-mcp

A security-first Model Context Protocol (MCP) server for inspecting and editing multiple local repositories over STDIO. Repository roots are configured on the server and exposed to clients only through explicit project aliases.

[Tiếng Việt](README.vi.md) · [WSL + ChatGPT Secure MCP Tunnel runbook](docs/wsl-mcp-chatgpt-tunnel-setup.md)

## Design goals

- No arbitrary shell tool.
- No client-supplied repository roots.
- No mutable “current project” state.
- Independent path sandboxing for every configured project.
- Read-only Git access.
- Atomic text-file writes with secret and path protections.
- Project-specific, allowlisted test commands executed with `spawn()` and `shell: false`.

## Requirements

- Node.js 18 or newer
- npm
- Git for the Git tools
- Optional: [ripgrep](https://github.com/BurntSushi/ripgrep) for faster `search_code`; the server has a built-in literal-search fallback when `rg` is unavailable

## End-to-end flow

From a clean checkout to a client that can call tools:

| Step | Command | Result |
|---|---|---|
| 1. Install and verify | `npm install && npm run typecheck && npm test && npm run build` | `dist/index.js` exists |
| 2. Configure roots | copy `start-mcp.example.sh` to `start-mcp.sh`, edit `PROJECTS_CONFIG` | aliases mapped to absolute roots |
| 3. Smoke-test locally | `./start-mcp.sh` | `Configured projects: …` on stderr, then it waits for JSON-RPC; stop with `Ctrl+C` |
| 4a. Use from Codex CLI | `codex mcp add local-code -- /absolute/path/to/start-mcp.sh` | tools available in Codex |
| 4b. Use from ChatGPT | `tunnel-client run --profile <profile>` | tools available in a developer-mode app — full setup in the [Secure MCP Tunnel runbook](docs/wsl-mcp-chatgpt-tunnel-setup.md) |
| 5. Verify from the client | `list_projects` → `list_files` → `write_file` to a throwaway path, then delete it | reads and writes reach the right repository |
| 6. After changing this server | `npm run typecheck && npm test && npm run build`, then restart whatever spawns `dist/index.js` | clients pick up the new build |

Step 6 matters: `tunnel-client` and Codex spawn `dist/index.js` once and keep that process, so a rebuild alone changes nothing until the parent is restarted.

For ChatGPT specifically, the runbook covers the parts this README does not: [installing `tunnel-client`](docs/wsl-mcp-chatgpt-tunnel-setup.md#7-install-tunnel-client), [creating the tunnel and runtime key](docs/wsl-mcp-chatgpt-tunnel-setup.md#8-create-the-tunnel-and-supply-the-runtime-key), [initializing and running the profile](docs/wsl-mcp-chatgpt-tunnel-setup.md#9-initialize-and-run-the-tunnel-profile), [the developer-mode app](docs/wsl-mcp-chatgpt-tunnel-setup.md#10-create-the-chatgpt-developer-mode-app), [verification](docs/wsl-mcp-chatgpt-tunnel-setup.md#11-end-to-end-verification), and [troubleshooting](docs/wsl-mcp-chatgpt-tunnel-setup.md#12-troubleshooting).

## Install and verify

```bash
npm install
npm run typecheck
npm test
npm run build
```

The build output is `dist/`. The server entry point is `dist/index.js`.

## Configure projects

Set `PROJECTS_CONFIG` to a non-empty JSON object whose keys are aliases and whose values are absolute repository paths:

```bash
export PROJECTS_CONFIG='{
  "project-a": "/home/example/projects/project-a",
  "project-b": "/home/example/projects/project-b",
  "project-c": "/home/example/projects/project-c"
}'
```

At startup, the server:

- accepts aliases matching `[a-zA-Z0-9_-]+`;
- requires every root to be an existing absolute directory;
- canonicalizes every root with `realpath`;
- rejects duplicate canonical roots; and
- exits before accepting requests if the configuration is missing or invalid.

Only aliases are logged or returned to clients. Clients cannot submit raw repository roots.

## Run

```bash
node dist/index.js
```

This is a STDIO server. It normally waits silently for JSON-RPC input; operational logs go to `stderr`, while `stdout` is reserved for MCP traffic.

For Codex CLI, copy [start-mcp.example.sh](start-mcp.example.sh) to an untracked `start-mcp.sh`, replace the example paths, make it executable, and register it:

```bash
chmod 0755 /absolute/path/to/local-code-mcp/start-mcp.sh
codex mcp add local-code -- /absolute/path/to/local-code-mcp/start-mcp.sh
codex mcp list
```

To connect this private STDIO server to ChatGPT, follow the [Secure MCP Tunnel runbook](docs/wsl-mcp-chatgpt-tunnel-setup.md). In short: `tunnel-client init --profile <profile> …`, `tunnel-client doctor --profile <profile> --explain`, then keep `tunnel-client run --profile <profile>` in the foreground while ChatGPT discovers and calls tools.

### After changing server code

```bash
npm run typecheck && npm test && npm run build
```

Then restart the process that spawns `dist/index.js` — `tunnel-client run` (`Ctrl+C`, start it again) or the Codex session. Until that restart, clients keep talking to the previously loaded build.

## Tool reference

Except for `list_projects`, every tool requires a `project` alias.

| Tool | Inputs | Behavior and limits |
|---|---|---|
| `list_projects` | none | Returns configured aliases only. |
| `list_files` | `project`, optional `path`, optional `max_depth` | Lists from `path` (default `.`). Depth defaults to 4 and is capped at 20. Output is capped at 10,000 entries. Skips symlinks and every denylisted path, so blocked names are not disclosed. |
| `read_file` | `project`, `path`, optional `start_line`, optional `end_line` | Reads UTF-8 text with numbered lines. Rejects binary files and files larger than 2,000,000 bytes. Response text is capped at 500,000 characters. |
| `search_code` | `project`, `query`, optional `path`, optional `file_pattern`, optional `max_results` | Performs a literal search from `path` (default `.`). `query` and `file_pattern` are limited to 500 characters and cannot contain NUL or newlines. The requested match limit defaults to 100 and is capped at 1,000, and means the same total number of matching lines on both the `rg` path and the built-in fallback. Hits are reported relative to the project root, and denylisted files are excluded on both paths. Response text is capped at 500,000 characters. |
| `write_file` | `project`, `path`, `content` | Creates or replaces one UTF-8 text file, up to 2,000,000 bytes, using a same-directory temporary file and atomic rename. Replaced files keep their previous mode and ownership; new files are created `0644`. The parent directory must already exist. |
| `git_status` | `project` | Runs `git status --short` in the selected root. |
| `git_diff` | `project`, optional `staged` | Runs `git diff`, or `git diff --staged` when `staged` is `true`. |
| `git_log` | `project`, optional `limit` | Runs `git log --oneline`; the default limit is 20 and the maximum is 50. |
| `run_tests` | `project`, `test`, optional `args` | Runs only a server-defined command for the selected alias. Timeout: 120 seconds. Combined stdout/stderr capture is capped independently at 500,000 characters. |

### Test allowlist

The allowlist is the `TEST_COMMANDS` map in `src/git-tools.ts`. Each entry is a fixed argv, never a shell string, and deployments edit the map for their own aliases:

| Project alias | Test name | Command |
|---|---|---|
| `project-a` | `unit` | deployment-defined argv |
| `project-b` | `unit` | deployment-defined argv |
| `project-c` | `test` | deployment-defined argv |

Other aliases can use repository and Git tools, but `run_tests` returns `COMMAND_NOT_ALLOWED` until a command is explicitly added for that alias. Test arguments are passed as an argv array, limited to 20 strings, and reject shell metacharacters, newlines, absolute paths, and `..` path components.

## Security model

### Path containment

Every repository path is resolved relative to the selected canonical root. Lexical traversal, absolute paths outside the root, and symlink escapes are rejected with `PATH_OUTSIDE_PROJECT_ROOT`. Directory listings do not follow symlinks.

### Blocked content

Direct paths passed to `read_file` and `write_file` reject these names anywhere in the relative path:

- directories: `.git`, `.ssh`, `.aws`, `.gnupg`, `node_modules`, and `vendor`;
- files: `.env`, `.env.*`, `*.pem`, `*.key`, `*.p12`, `*.pfx`, `id_rsa`, `id_ed25519`, and `authorized_keys`.

`.git` is blocked in full rather than only `.git/objects`: git executes the values of `core.fsmonitor`, `core.pager`, and aliases from `.git/config`, and runs `.git/hooks/*`, so a writable `.git` would turn this server's own Git tools into arbitrary command execution.

The denylist is applied by `read_file`, `write_file`, `list_files`, and both `search_code` implementations, so blocked files are neither read, written, listed, nor matched. It is still a last line of defense, not a substitute for choosing narrow project roots and running the process with least privilege.

### Commands and Git

All child processes use `spawn(command, args, { shell: false })` with a fixed command and argument structure. There is no generic command-execution tool. Git exposure is intentionally limited to `status`, `diff`, and `log`; the server does not expose commit, push, reset, clean, checkout, merge, or rebase operations.

Child processes do not inherit the server environment. They receive only `PATH`, `HOME`, `LANG`, `LC_ALL`, `TZ`, `TERM`, `USER`, and `LOGNAME`, so `PROJECTS_CONFIG` and any tunnel credentials stay in the server process. A deployment that needs another variable (for example `COMPOSER_HOME`) lists its name in `MCP_CHILD_ENV_ALLOWLIST`, which can never re-add `PROJECTS_CONFIG`.

### Writes

`write_file` validates the destination before writing, refuses blocked paths and symlink escapes, writes a mode-`0600` temporary file in the destination directory, and atomically renames it into place. Because rename replaces the inode, the mode and ownership of the file being replaced are restored explicitly before the rename; otherwise an edit would narrow an existing `0644` repository file to `0600` and break the runtime user that serves it. New files are created `0644`. The file and its directory are synced so the replacement survives power loss, not only a process crash. It does not create parent directories. Filesystem errors are mapped to safe error codes without returning stack traces.

### Logging

Each tool call writes one JSON record to `stderr` containing timestamp, alias, tool, the requested path (or `.`), success status, and duration; failures also carry the returned code and an operator-only `detail` field. File contents, configured roots, and environment values are not included. Because rejected client input is still logged, operators must protect stderr logs appropriately.

## Error codes

Expected failures include:

- `PROJECT_CONFIG_INVALID`, `PROJECT_ALIAS_INVALID`, `PROJECT_ROOT_INVALID`
- `PROJECT_NOT_FOUND`, `INVALID_ARGUMENT`, `UNKNOWN_TOOL`
- `PATH_OUTSIDE_PROJECT_ROOT`, `BLOCKED_FILE`
- `FILE_TOO_LARGE`, `BINARY_FILE`
- `COMMAND_NOT_ALLOWED`, `COMMAND_FAILED`, `COMMAND_TIMEOUT`
- `WRITE_PERMISSION_DENIED`, `WRITE_FAILED`
- `INTERNAL_ERROR` for anything unmapped

MCP tool failures are returned with `isError: true` and a text payload in the form `CODE: message`. Only the codes above are ever returned: an unmapped failure becomes `INTERNAL_ERROR: Tool execution failed`, because a raw errno such as `EACCES` carries a Node message containing server-side absolute paths, and reporting an internal fault as `INVALID_ARGUMENT` sends the client into retrying arguments that were never wrong. Messages are redacted of configured roots and truncated to 2,000 characters; the full cause is written to `stderr` only.

## Development

```bash
npm run typecheck
npm test
npm run build
```

`npm run typecheck` checks `src/` and `tests/` against `tsconfig.json`; `npm run build` emits `dist/` from `tsconfig.build.json`.

Add or update tests whenever path handling, blocked content, command execution, writes, or project isolation changes. Keep Git access read-only and never add a generic shell interface.

## Files

- `src/index.ts` — MCP tool registration, dispatch, and client-safe error mapping
- `src/config.ts` — project-alias configuration
- `src/security.ts` — root containment, blocked-path policy, root redaction
- `src/fs-tools.ts` — `list_files`, `read_file`, `search_code`
- `src/git-tools.ts` — read-only Git tools and the `TEST_COMMANDS` allowlist
- `src/write-file.ts` — validated atomic writes
- `src/command.ts` — bounded `spawn()` wrapper and child-environment allowlist
- `src/errors.ts` — error-code allowlist and redaction
- `src/tool-input.ts`, `src/limits.ts` — argument validation and shared bounds
- `src/logger.ts` — structured `stderr` audit records
- `tests/` — security, write, filesystem-tool, error-mapping, command, and isolation tests
- `start-mcp.example.sh` — local launcher template

## Handling secrets

Never commit API keys, `.env` files, tunnel credentials, local tunnel profiles, private certificates, or a machine-specific `start-mcp.sh`. Review repository changes before committing.