Skip to main content
Glama
sonirico

mcp-shell

by sonirico
README.md
# mcp-shell

[![Trust Score](https://archestra.ai/mcp-catalog/api/badge/quality/sonirico/mcp-shell)](https://archestra.ai/mcp-catalog/sonirico__mcp-shell)
[![glama](https://glama.ai/mcp/servers/@sonirico/mcp-shell/badge)](https://glama.ai/mcp/servers/@sonirico/mcp-shell)

MCP server that runs shell commands. Your LLM gets a tool; you get control over what runs and how.

Built on [mark3labs/mcp-go](https://github.com/mark3labs/mcp-go). Written in Go.

---

## Run it

**Docker** (easiest):

```bash
docker run -it --rm -v /tmp/mcp-workspace:/tmp/mcp-workspace sonirico/mcp-shell:latest
```

**From source**:

```bash
git clone https://github.com/sonirico/mcp-shell && cd mcp-shell
make install
mcp-shell
```

---

## Configure it

**Secure mode is the default.** With no config file, `mcp-shell` boots in secure
mode and registers only typed tools: file reads, `grep`/`glob`, git inspection,
and (opt-in) file/git writes and operator-defined scripts. There is no raw
shell command. You only need a config file to change the defaults below. To
run fully unrestricted you must opt in explicitly:

```bash
MCP_SHELL_ALLOW_UNSAFE=1 mcp-shell   # disables secure mode; the only tool is shell_exec
```

To customize the policy, point to a YAML config:

```bash
export MCP_SHELL_SEC_CONFIG_FILE=/path/to/security.yaml
mcp-shell
```

**Secure mode** (default) — typed tools only, every path confined to `working_directory`:

```yaml
security:
  enabled: true
  working_directory: /tmp/mcp-workspace
  max_execution_time: 30s
  max_output_size: 1048576
  run_as_user: ""
  audit_log: true

  # Expose file and git write tools (write_file, edit_file, mkdir, move,
  # delete, git_add, git_commit, git_switch, git_restore, git_stash). Off by
  # default.
  writes_enabled: false

  # Operator-defined scripts exposed through the run_script tool. The client
  # picks a name; the argv is yours and cannot be altered.
  # scripts:
  #   test: ["go", "test", "./..."]
  #   lint: ["golangci-lint", "run"]
```

---

## Wire it up

**Claude Desktop** — add to your MCP config:

```json
{
  "mcpServers": {
    "shell": {
      "command": "docker",
      "args": ["run", "--rm", "-i", "sonirico/mcp-shell:latest"],
      "env": { "MCP_SHELL_LOG_LEVEL": "info" }
    }
  }
}
```

For custom config, mount the file and set the env:

```json
{
  "command": "docker",
  "args": ["run", "--rm", "-i", "-v", "/path/to/security.yaml:/etc/mcp-shell/security.yaml", "-e", "MCP_SHELL_SEC_CONFIG_FILE=/etc/mcp-shell/security.yaml", "sonirico/mcp-shell:latest"]
}
```

---

## Tools

Secure mode (the default) registers these typed tools. `*` marks a required
parameter.

| Tool | Parameters | Available |
|------|------------|-----------|
| `read_file` | path*, offset, limit, tail | always |
| `list_dir` | path, depth, include_hidden | always |
| `glob` | pattern*, path, newer_than, max_results | always |
| `grep` | pattern*, path, glob, ignore_case, context, files_only, count, max_results | always |
| `stat` | path* | always |
| `diff_files` | path_a*, path_b* | always |
| `system_info` | | always |
| `git_status` | | always |
| `git_log` | max_count, ref, path, author, grep, since, until, oneline, follow | always |
| `git_diff` | ref, ref_to, staged, path, stat_only, name_only | always |
| `git_show` | ref, path, stat_only | always |
| `git_blame` | path*, ref, line_start, line_end | always |
| `git_branches` | all, merged | always |
| `git_tags` | pattern | always |
| `git_rev_parse` | ref* | always |
| `git_ls_files` | path, untracked | always |
| `git_stash_list` | | always |
| `git_remotes` | | always |
| `write_file` | path*, content*, append | writes_enabled |
| `edit_file` | path*, old_string*, new_string*, replace_all | writes_enabled |
| `mkdir` | path* | writes_enabled |
| `move` | from*, to* | writes_enabled |
| `delete` | path*, recursive | writes_enabled |
| `git_add` | paths, all | writes_enabled |
| `git_commit` | message*, all | writes_enabled |
| `git_switch` | branch*, create | writes_enabled |
| `git_restore` | paths*, staged | writes_enabled |
| `git_stash` | action*, message | writes_enabled |
| `run_script` | name* | scripts |

Every path parameter is resolved against `working_directory` (symlinks
followed); anything outside it is rejected. Git paths and refs are passed
positionally and validated: a ref starting with `-` is rejected. There are no
network tools; push, fetch and clone are not offered.

**Unrestricted mode**: `shell_exec` exists only with `MCP_SHELL_ALLOW_UNSAFE=1`, runs the command through `bash -c` with no validation, by design, and it is the only tool registered in that mode.

---

## Environment variables

| Variable | Description |
|----------|-------------|
| `MCP_SHELL_SEC_CONFIG_FILE` | Path to security YAML (overrides built-in secure defaults) |
| `MCP_SHELL_ALLOW_UNSAFE` | Set `1` (or `true`) to disable secure mode and expose `shell_exec` instead of the typed tools (opt-in) |
| `MCP_SHELL_SERVER_NAME` | Server name (default: "mcp-shell 🐚") |
| `MCP_SHELL_LOG_LEVEL` | debug, info, warn, error, fatal |
| `MCP_SHELL_LOG_FORMAT` | json, console |
| `MCP_SHELL_LOG_OUTPUT` | stdout, stderr, file |

---

## Development

```bash
make install dev-tools   # deps + goimports, golines
make fmt test lint
make docker-build       # build image locally
make release            # binary + docker image
```

---

## Security

- **Default**: Secure mode. The server builds every command's argv itself; the
  client never supplies a shell string. Only typed tools are registered.
- **Path confinement**: every path parameter is resolved against
  `working_directory`, symlinks followed, and anything that resolves outside
  it is rejected.
- **Git hardening**: paths are passed after `--`, refs after
  `--end-of-options`, and a ref starting with `-` is rejected. Git runs with
  `GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_GLOBAL=/dev/null`, `core.fsmonitor`,
  `core.pager` and `core.hooksPath` neutralised, and `--no-ext-diff
  --no-textconv` on log/diff/show/blame.
- **Minimal environment**: child processes get only `PATH`, `HOME` and `LANG`,
  never the server's own environment or `.env` secrets.
- **Writes and scripts are opt-in**: `writes_enabled: true` exposes the
  file/git write tools; a non-empty `scripts` map exposes `run_script`. Both
  are off by default.
- **Unrestricted**: only via `MCP_SHELL_ALLOW_UNSAFE=1`. The only tool
  registered is `shell_exec`, which runs `bash -c` with no validation. Fine
  for local dev, dangerous otherwise.
- **Docker**: Runs as non-root, Alpine-based. Use it in production. Best paired with an OS sandbox (read-only FS, dropped caps) as defense-in-depth.

Threat model, guarantees, and the scope for vulnerability reports live in [SECURITY.md](SECURITY.md). Read it before opening an advisory.

---

## Migrating from 0.x

Secure mode no longer validates a `shell_exec` command string; it exposes
typed tools instead. A config file's `security:` block no longer accepts:

| Removed key | Replacement |
|-------------|-------------|
| `use_shell_execution` | not needed; typed tools never shell out |
| `allowed_executables` | not needed; each tool runs a fixed, server-built argv |
| `allowed_commands` | not needed; same as above |
| `blocked_commands` | not needed; same as above |
| `blocked_patterns` | not needed; same as above |

Loading a config file that still sets one of these fails at startup with an
error naming the key. There is no more "legacy mode" and no
`security-legacy.yaml` example. If you need raw shell access, set
`MCP_SHELL_ALLOW_UNSAFE=1` to get `shell_exec` back; it is no longer
constrained by the `security:` block at all.

---

## Contributing

Fork, branch, `make fmt test`, open a PR.