sftp-manager
by Gus2708
README.md
# sftp-manager
**Manage your game servers by talking to them.** An MCP server that gives any AI agent
safe, sandboxed SFTP access to your Minecraft / Pterodactyl / TaroHosting boxes — read configs,
edit them surgically, install addons, restore backups. No shell required.
[](#tests)
[](LICENSE)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io)
[](#install)
```
you> on the survival server, bump max players to 40
ai> Reading the file first.
-> leer_archivo({"ruta": "server.properties"})
-> reemplazar_en_archivo({"ruta": "server.properties",
"buscar": "max-players=20",
"reemplazar": "max-players=40"})
[!] This operation modifies the server. Approve? (y/N)
ai> Done. max-players is now 40.
Backup saved at .ai_backups/20260731-004512__server.properties
Restart the server from your panel to apply it.
```
---
## Why this exists
Game hosting panels (Pterodactyl, TaroHosting, and friends) expose **SFTP only — no shell**.
No `ssh`, no `unzip`, no `grep`, no `rm -rf`. Editing one line of a config becomes a
download / edit / re-upload round trip through a clunky web file manager, and installing a mod
pack is a manual unzip-and-drag ritual across hundreds of files.
`sftp-manager` hands that whole workflow to an AI agent through the
[Model Context Protocol](https://modelcontextprotocol.io) — with guardrails, so the agent
can't wander outside your server root, and with a parallel transfer engine, so operations that
take minutes over a naive SFTP client take seconds.
## Features
- **Self-installing** — hand the folder to any AI agent and say *"read AGENTS.md and install this as an MCP server"*. [`install_mcp.py`](install_mcp.py) diagnoses the environment, writes the config for nine MCP clients, and proves the result with a real MCP handshake. See [Install](#install).
- **20 MCP tools** covering the full lifecycle: browse, read, search, edit, upload, install, delete, restore — plus start / stop / restart through the panel API.
- **Multi-server registry** — declare every box you own in one `servers.json`, switch with a `servidor` parameter.
- **Host key verification** — trust-on-first-use by default, `strict` when you want it.
- **Parallel transfer engine** — bulk uploads and recursive deletes are spread across multiple SSH connections. See [How it works](#how-it-works).
- **Path sandbox** — every path resolves against the configured root before any network call.
- **Automatic backups** — every overwrite and delete copies the original into `.ai_backups/` on the server first, with a timestamp. Fully restorable.
- **Surgical edits** — `reemplazar_en_archivo` refuses to run when the search fragment is ambiguous, instead of guessing.
- **Destructive-operation hints** — write and delete tools carry MCP `destructiveHint`, so your client asks before executing and shows the exact arguments.
- **Self-healing connections** — keepalive packets plus transparent reconnect when the transport dies.
- **Per-tool timeouts** — no call hangs forever; on timeout the connection is recycled and a clear error is returned.
- **Secrets stay out of config** — passwords are written as `${ENV_VAR}` and resolved from the environment at load time.
- **Addon installer** — `.zip` / `.mcaddon` archives are extracted *locally* and the resulting tree uploaded, working around the missing remote shell. Includes Zip-Slip protection.
- **Bedrock world settings** — reads and edits `level.dat` (little-endian NBT), so gamerules and experiment toggles like **Beta APIs** can be flipped without opening the game. See [World settings](#world-settings-leveldat).
## Requirements
- Python 3.10 or newer
- An SFTP-capable host (Pterodactyl-based panels, TaroHosting, or any plain SFTP server)
## Access
This repository is **private**. Cloning needs an authenticated GitHub account that has been
added as a collaborator. Pick whichever you already have set up:
```bash
# GitHub CLI — handles auth for you
gh repo clone Gus2708/sftp-manager
# SSH key
git clone git@github.com:Gus2708/sftp-manager.git
# HTTPS — asks for a personal access token with `repo` scope, not your password
git clone https://github.com/Gus2708/sftp-manager.git
```
Being private changes nothing about how the server runs: it is launched locally over stdio and
never phones home.
## Install
### Hand it to an AI agent
This repo is built to install itself. Clone it, point your agent at the folder and say:
```
Install this repo as an MCP server. Read AGENTS.md and follow it.
```
[`AGENTS.md`](AGENTS.md) is the agent playbook — the [AGENTS.md convention](https://agents.md) is
read automatically by Codex, Cursor, Copilot, Jules and Zed, and [`CLAUDE.md`](CLAUDE.md) points
Claude Code at the same file. It tells the agent exactly which commands to run, in what order,
what to ask you for, and how to prove the result works.
The agent drives [`install_mcp.py`](install_mcp.py), which is the actual install surface. It uses
**only the standard library** — so it runs before anything is installed — and every subcommand
emits **JSON**, so an agent branches on fields instead of guessing at prose:
| Command | What it does |
|---|---|
| `python install_mcp.py check` | Python version, missing deps, config state, and a `siguiente_paso` telling you what to run next |
| `python install_mcp.py deps` | Installs `requirements.txt` with the right interpreter |
| `python install_mcp.py init` | Creates `servers.json` and `.env` from the examples, without overwriting |
| `python install_mcp.py clients` | Lists every MCP client on this machine, its config path, and whether it's already set up |
| `python install_mcp.py install <client>` | Merges the config into that client's file, after a timestamped backup |
| `python install_mcp.py config <client>` | Prints the block instead of writing it |
| `python install_mcp.py verify` | Boots the server, does a real MCP handshake, lists the 20 tools |
| `python install_mcp.py doctor` | `check` + `verify` in human-readable form |
Two details make this reliable rather than merely convenient:
- **`verify` is a genuine MCP handshake**, not a smoke test. It spawns `mcp_server.py`, sends
`initialize` and `tools/list` over stdio and reads the replies. It opens no SFTP connection, so
it works *before* you have credentials — proving the install is sound separately from proving
the credentials are.
- **The generated config carries the absolute interpreter path** (`sys.executable`), never a bare
`python`. MCP clients don't inherit your shell's `PATH`, and that single detail is the most
common reason a server silently fails to start.
### Do it yourself
```bash
pip install -r requirements.txt
python install_mcp.py init # or: cp servers.example.json servers.json
python install_mcp.py doctor
```
> Only `paramiko`, `python-dotenv` and `mcp` are needed for the MCP server. `nbtlib` unlocks the
> two `level.dat` tools. `anthropic` is required exclusively for the optional standalone CLI.
If your environment is externally managed (PEP 668), use a virtualenv and keep using **its**
interpreter for every later step — including `install_mcp.py`, so the config it writes points at
the right Python:
```bash
python -m venv .venv
.venv/bin/python install_mcp.py deps # Windows: .venv\Scripts\python.exe
.venv/bin/python install_mcp.py doctor
```
## Configure your servers
`servers.json` declares every server you manage. Passwords are written as `${VARIABLE}`
references resolved from the environment (or a `.env` file), so the config file itself never
holds a secret and is safe to commit or share.
```json
{
"default": "survival",
"servers": {
"survival": {
"descripcion": "Main survival server",
"juego": "Minecraft Bedrock 26.30",
"host": "node1.tarohosting.net",
"port": 2022,
"user": "abc12345.f00d",
"password": "${TARO_SURVIVAL_PASSWORD}",
"root": "/",
"max_read_bytes": 200000
},
"creative": {
"descripcion": "Testing and builds",
"host": "node2.tarohosting.net",
"port": 2022,
"user": "abc12345.beef",
"password": "${TARO_CREATIVE_PASSWORD}",
"root": "/"
}
}
}
```
Then put the actual secrets in `.env` (git-ignored):
```dotenv
TARO_SURVIVAL_PASSWORD=your-panel-password
TARO_CREATIVE_PASSWORD=your-other-password
```
Every tool accepts an optional `servidor` parameter. Omit it and the one marked `default` is used.
### Keys accepted per server
Connection and identity:
| Key | Required | Default | Notes |
|---|---|---|---|
| `host` | yes | — | Node address |
| `user` | yes | — | Alias: `username` |
| `password` | one of the two | — | Use `${ENV_VAR}` |
| `key_path` | one of the two | — | SSH key instead of a password |
| `root` | no | `/` | Sandbox root — nothing outside is reachable |
| `descripcion` | no | `""` | Shown by `listar_servidores`. Alias: `description` |
| `juego` | no | `""` | Free text. Alias: `game` |
Tuning — all optional, all with sane defaults. Reach for these when a node is slow, far away,
or strict about concurrent connections:
| Key | Env equivalent | Default | Purpose |
|---|---|---|---|
| `port` | `SFTP_PORT` | `2022` | Panels rarely use 22 |
| `max_read_bytes` | `SFTP_MAX_READ_BYTES` | `200000` | Per-file read cap |
| `canales` | `SFTP_CANALES` | `8` | Parallel SSH connections for bulk operations |
| `op_timeout` | `SFTP_OP_TIMEOUT` | `120` | Seconds per operation once connected |
| `umbral_paralelo` | `SFTP_UMBRAL_PARALELO` | `25` | Item count above which extra connections are opened. `0` = always |
| `keepalive` | `SFTP_KEEPALIVE` | `15` | Seconds between keepalive packets |
| `timeout` | `SFTP_TIMEOUT` | `20` | Initial handshake timeout |
| `backup_dir` | `SFTP_BACKUP_DIR` | `.ai_backups` | Remote folder holding automatic backups |
| `host_key_policy` | `SFTP_HOST_KEY_POLICY` | `accept-new` | `strict`, `accept-new` or `auto` — see [Host key verification](#host-key-verification) |
| `known_hosts` | `SFTP_KNOWN_HOSTS` | `~/.ssh/known_hosts` | Where host keys are read from and written to |
Optionally, a nested `panel` block enables power control (see
[Restarting the server](#restarting-the-server)):
```json
"panel": {
"url": "https://panel.tarohosting.net",
"api_key": "${TARO_PANEL_API_KEY}",
"server_id": "1a2b3c4d"
}
```
Every key is validated at load time: non-numeric or out-of-range values fail immediately with
the server name and the offending key, rather than surfacing as a confusing network error on
the first tool call. Unknown keys are rejected too, so a typo like `canaless` is caught at
startup instead of being silently ignored.
For a Pterodactyl-style panel, the connection values live under **Settings → SFTP Details**.
Note the username is usually `panel-user.server-id`, and the password is your **panel** password.
### Single-server fallback
If `servers.json` doesn't exist, the server falls back to one connection read entirely from the
environment, using the `SFTP_*` variable names in the tables above plus `SFTP_HOST`,
`SFTP_USER`, `SFTP_PASSWORD` / `SFTP_KEY_PATH` and `SFTP_ROOT`. Defaults and validation are
identical in both modes — there is exactly one source of truth for them, in `SFTPConfig`.
Point `SFTP_SERVERS_FILE` at another path to load the registry from somewhere else.
---
## Registering it with your MCP client
**The short version:**
```bash
python install_mcp.py clients # which ones are on this machine
python install_mcp.py install cursor # merge it in, after a backup
```
| Client id | Config file it writes |
|---|---|
| `claude-code` | `.mcp.json` in this repo (already shipped) |
| `claude-desktop` | `claude_desktop_config.json`, per-OS location |
| `cursor` | `~/.cursor/mcp.json` |
| `vscode` | `.vscode/mcp.json` |
| `windsurf` | `~/.codeium/windsurf/mcp_config.json` |
| `zed` | Zed `settings.json` |
| `gemini` | `~/.gemini/settings.json` |
| `cline` | Cline's `cline_mcp_settings.json` |
| `codex` | `~/.codex/config.toml` — printed, not written (TOML) |
The file is merged rather than replaced, a timestamped `.bak-*` copy is made first, and an
existing `sftp-manager` entry is left untouched unless you pass `--force`.
**The rest of this section is the manual equivalent**, for clients not on that list or when you
would rather see what you're pasting. `python install_mcp.py config <client>` prints the same
blocks with your real paths already filled in.
The server speaks **stdio**, the transport every MCP client supports. The command is always
the same:
```
command: python
args: ["/absolute/path/to/sftp-manager/mcp_server.py"]
```
On Windows, use double backslashes in JSON: `"G:\\Projects\\sftp-manager\\mcp_server.py"`.
If Python isn't on your PATH under that name, use `python3` or the absolute path to your
interpreter (`/path/to/.venv/bin/python`) — this is the single most common setup failure, and
the reason `install_mcp.py` always writes an absolute path.
Most clients share the same `mcpServers` JSON shape, so this is the block to copy:
```json
{
"mcpServers": {
"sftp-manager": {
"command": "python",
"args": ["/absolute/path/to/sftp-manager/mcp_server.py"]
}
}
}
```
### Claude Code
The repo ships a project-scoped `.mcp.json`, so cloning and opening the folder is enough — approve
the server when prompted and verify with `/mcp`. It also ships [`CLAUDE.md`](CLAUDE.md), so a
fresh session already knows what the project is and how to install it.
That `.mcp.json` uses a bare `python`, which is portable but relies on your `PATH`. If the server
fails to start, pin the interpreter:
```bash
python install_mcp.py install claude-code --force
```
To register it for every project instead of just this one:
```bash
claude mcp add sftp-manager --scope user -- /absolute/path/to/python /absolute/path/to/mcp_server.py
```
### Claude Desktop
Paste the standard `mcpServers` block into `claude_desktop_config.json`:
- **macOS** — `~/Library/Application Support/Claude/claude_desktop_config.json`
- **Windows** — `%APPDATA%\Claude\claude_desktop_config.json`
- **Linux** — `~/.config/Claude/claude_desktop_config.json`
Restart the app afterwards.
### Cursor
Standard `mcpServers` block in either:
- `~/.cursor/mcp.json` — available in every project
- `.cursor/mcp.json` — this project only
Confirm under **Settings → MCP**.
### VS Code (GitHub Copilot agent mode)
VS Code uses a `servers` key instead of `mcpServers`. Create `.vscode/mcp.json`:
```json
{
"servers": {
"sftp-manager": {
"type": "stdio",
"command": "python",
"args": ["/absolute/path/to/sftp-manager/mcp_server.py"]
}
}
}
```
### Windsurf
Standard `mcpServers` block in `~/.codeium/windsurf/mcp_config.json`.
### Cline / Roo Code
Open the MCP Servers panel → **Configure MCP Servers**, then add the standard `mcpServers`
block to the settings file it opens.
### Zed
Zed calls them context servers. In `settings.json`:
```json
{
"context_servers": {
"sftp-manager": {
"source": "custom",
"command": "python",
"args": ["/absolute/path/to/sftp-manager/mcp_server.py"]
}
}
}
```
### JetBrains IDEs (AI Assistant / Junie)
**Settings → Tools → AI Assistant → Model Context Protocol (MCP) → Add**, then either fill in
the command and arguments, or paste the standard `mcpServers` JSON block.
### OpenAI Codex CLI
`~/.codex/config.toml` uses TOML:
```toml
[mcp_servers.sftp-manager]
command = "python"
args = ["/absolute/path/to/sftp-manager/mcp_server.py"]
```
### Gemini CLI
Standard `mcpServers` block in `~/.gemini/settings.json`.
### Anything else
Any MCP-compatible client works — point it at `python mcp_server.py` over stdio.
### When it doesn't come up
Start here, because it answers the question without an editor in the way:
```bash
python install_mcp.py verify
```
`"ok": true` and 20 tools means the server is fine and the problem is in the client's config.
Otherwise the `stderr` field carries the real reason.
| Symptom | Cause | Fix |
|---|---|---|
| Client shows the server as failed, no logs | `command` is a bare `python` the client can't resolve | `python install_mcp.py install <client> --force` — it writes the absolute interpreter path |
| `configuracion invalida` on stderr | No `servers.json` and no `.env` | `python install_mcp.py init`, then fill them in |
| `verify` reports missing dependencies | Deps went into a different interpreter | Run `install_mcp.py` with the same Python listed in the client config |
| `El servidor 'x' necesita 'password' o 'key_path'` | A `${VAR}` resolved to empty | The variable is missing from `.env`; `check` lists it under `secretos_sin_resolver` |
| Tool calls hang, then time out | Node limits concurrent connections | Lower `canales` in `servers.json` (try `2`) |
For a full protocol-level trace, the official inspector:
```bash
npx @modelcontextprotocol/inspector python mcp_server.py
```
Once connected, the first thing to ask your agent is: *"list my servers"*.
---
## Tools
| Category | Tools |
|---|---|
| **Inventory** | `listar_servidores` |
| **Read** | `listar`, `info`, `leer_archivo`, `buscar`, `listar_backups`, `descargar`, `descargar_carpeta`, `estado_servidor`, `leer_config_mundo` |
| **Write** | `escribir_archivo`, `reemplazar_en_archivo`, `crear_carpeta`, `subir_archivo`, `subir_carpeta`, `instalar_addon`, `configurar_mundo` |
| **Destructive** | `mover`, `borrar`, `restaurar_backup`, `controlar_servidor` |
Tool names and parameters are in Spanish because that is the project's working language and
they form the stable public API. Descriptions and schemas guide the model, so you can prompt
in any language — the agent maps your request onto the right call.
Notable behaviors worth knowing:
- `leer_archivo` caps at 200 KB, reports `truncado: true` when it hits the limit, and detects
binaries instead of dumping bytes into the model's context.
- `buscar` walks at most 6 levels deep, returns at most 40 results, skips `.git`,
`node_modules` and `.ai_backups`, and when filtering by content only scans files under 2 MB
— reporting the first matching line number.
- `borrar` requires `recursivo: true` for directories and flat-out refuses to delete the
sandbox root.
- `instalar_addon` uploads non-`.zip` files as-is, and for archives extracts locally then
uploads the resulting tree.
- `configurar_mundo` only writes keys that already exist in `level.dat`, keeping their original
NBT type, and refuses to run while the panel reports the server as running.
---
## How it works
Four layers, each with one job:
```
mcp_server.py stdio MCP server: schemas, timeouts, destructive hints <- entry point
|
tools.py tool catalog + dispatcher (shared with standalone mode)
|
servers.py multi-server registry, ${ENV} expansion, connection pool
| \ \
sftp_client.py panel.py level_dat.py
SFTPManager: sandbox, backups, parallel engine
PanelClient: power control over the panel HTTP API
level_dat: Bedrock level.dat, little-endian NBT
```
`install_mcp.py` sits deliberately *outside* that stack. It imports nothing from the project and
nothing outside the standard library, because its whole job is to run in an environment where
the dependencies aren't installed yet and report why. It talks to `mcp_server.py` the same way a
real client does — as a subprocess over stdio — rather than importing it.
`mcp_server.py` is async but `paramiko` is blocking, so every call is dispatched to a worker
thread via `asyncio.to_thread` and wrapped in `asyncio.wait_for` with a per-tool budget
(45 s for `listar`, 300 s for `borrar`, 900 s for `instalar_addon`, and so on). On timeout the
connection is deliberately closed — a paramiko thread can't be killed, but killing its socket
makes it abort instead of quietly consuming the channel forever.
### One connection per channel, not one channel per connection
This is the non-obvious part, and it's the reason bulk operations are fast here.
SFTP has no recursive delete and no bulk upload: **every single file costs a network round
trip.** Deleting a 2,000-file addon serially over a 120 ms link is four minutes of waiting.
The obvious fix is to open several SFTP channels over the existing SSH transport.
That does not work. Pterodactyl-style panels run their own SFTP implementation which accepts
**exactly one channel per connection** — requesting a second one doesn't error, it *blocks
forever*. What they do accept is multiple simultaneous SSH connections.
So `_canales()` opens N full SSH connections (`canales`, default 8), and opens them
concurrently, because
each handshake costs ~2 s and doing ten in series would cost 20 s before any work begins. Each
worker thread then owns one connection exclusively, so tasks are dealt out in fixed batches
rather than pulled from a shared pool.
Extra connections are only worth their handshake above a threshold (`umbral_paralelo`, 25
items). Below that, everything runs on the primary connection.
### Other things the engine does
- **Recursive delete** walks the tree level by level, listing each level in parallel, then
deletes all files in parallel, then removes directories deepest-first (siblings at the same
depth are independent, so those go in parallel too). A cheap one-call probe short-circuits
small flat folders back to the serial path.
- **Directory uploads** build the complete file list before touching the network, then create
parent directories shallowest-first, then push files in parallel. Empty directories are
skipped entirely — some obfuscated addons ship thousands of decoy directories with no files,
and creating them one by one costs minutes of network for nothing.
- **A `_dirs_ok` set** caches directories already known to exist, so uploading a deep tree
doesn't re-`stat()` the same parents dozens of times.
- **Keepalive packets every 15 s** stop the transport from going zombie while idle — the
symptom is nasty, since the socket still looks alive but every operation hangs until the
timeout fires. On top of that, the `sftp` property checks transport liveness before each use
and reconnects transparently.
- **Pterodactyl quirks** are handled explicitly: it answers a generic `failure` instead of
`ENOENT` for missing paths, so those errors are translated into readable messages.
---
## A real walkthrough: installing a Bedrock addon
This is the workflow the project was actually built for, condensed. The full operational
guide lives in [`docs/bedrock-addons-shaders.md`](docs/bedrock-addons-shaders.md).
Bedrock doesn't activate a pack just because the files are on disk — it has to be registered
against the world. So it's a two-step job, and the second step is a JSON edit:
1. **Upload the pack** — `instalar_addon` with `remoto: "behavior_packs"` (or
`"resource_packs"`). A `.mcaddon` carries both a behavior and a resource pack; treat them as
two separate installs.
2. **Read the manifest** — `leer_archivo` on the uploaded `manifest.json` and take `uuid` and
`version` from the **`header`** section, never from `modules`. Mixing those up is the source
of the classic *"Pack with id not found"*.
3. **Register it against the world** — `worlds/<world name>/world_behavior_packs.json`. Use
`reemplazar_en_archivo` to find the closing `]` and splice the new entry in, rather than
rewriting the file: that preserves every pack already installed.
4. **Enable the experiments it needs** — a pack with a `script` module does nothing until Beta
APIs is on. Stop the server, then `configurar_mundo` with `experimentos: {"beta_apis": true}`.
See [World settings](#world-settings-leveldat).
5. **Restart** — `controlar_servidor` if a `panel` block is configured, otherwise from the panel
by hand. Plain SFTP can't do it.
### Troubleshooting with the tools
| Symptom | How to diagnose | Fix |
|---|---|---|
| `Pack with id not found` | `leer_archivo` the uploaded `manifest.json`, compare the `header` uuid against what's registered | `reemplazar_en_archivo` to correct the `pack_id` |
| Nested path (`behavior_packs/Pack/Pack/...`) | `listar` on `behavior_packs` to see the real uploaded structure | `mover` to flatten, or `borrar` + reinstall |
| Players don't download textures | `leer_archivo` on `server.properties`, check `texturepack-required` | Set it to `true`; if the pack was updated, bump `version` in both the manifest and the world JSON |
| Works in singleplayer, not on the server | `leer_config_mundo` to see which Experimental Features are off — a `script` module needs `gametest` (Beta APIs) | Stop the server and turn them on with `configurar_mundo` |
| Server won't boot after an install | Enable `content-log-file-enabled` in `server.properties`, restart, then `leer_archivo` the new log | Remove the entry from the world JSON and `borrar` the pack folder |
| Need to undo anything | — | `listar_backups`, then `restaurar_backup` |
### What it explicitly won't do
- **No remote command execution, no remote unzip.** `instalar_addon` extracts on *your*
machine and uploads the tree.
- **No shader management.** Shaders are 100% client-side (RenderDragon). There is no
server-side file to upload, so if someone asks for "install a shader on the server", the
correct answer is an explanation, not a folder.
- **No websocket setup.** `/wsserver` is a per-player client setting; there is nothing in the
world or the server to flip for it. Experiment toggles *are* editable — see
[World settings](#world-settings-leveldat).
---
## Restarting the server
SFTP genuinely cannot restart anything — but Pterodactyl-based panels expose a separate HTTP
API that can, and `sftp-manager` speaks it. Add a `panel` block to a server and two more tools
appear: `estado_servidor` (read-only) and `controlar_servidor` (destructive).
```json
"panel": {
"url": "https://panel.tarohosting.net",
"api_key": "${TARO_PANEL_API_KEY}",
"server_id": "1a2b3c4d"
}
```
The API key is a **client** key — Account → API Credentials in the panel, *not* an application
key. It can only touch servers belonging to the account that created it. The `server_id` is the
short id in the panel URL, not the display name — and on Pterodactyl it is also the suffix of
your SFTP username, so a `user` of `abc12345.f00d` means the id is `f00d`. You already have it
in the config file. In single-server mode the same thing is configured with `PANEL_URL`,
`PANEL_API_KEY` and `PANEL_SERVER_ID`.
`controlar_servidor` takes `encender`, `apagar`, `reiniciar` or `forzar_apagado` (the last one
kills the process without saving and can corrupt a world — it exists for when a normal stop
hangs). The signal is asynchronous: the panel accepts it and the state changes a few seconds
later, so the tool tells the model to confirm with `estado_servidor` rather than assuming.
Three details that matter in practice:
- **The SFTP connection is never opened for these two tools.** A stopped server usually refuses
SFTP, and `controlar_servidor` is exactly what you need in that situation — opening a
connection first would make it unusable precisely when it matters.
- **Panel config is optional and stays optional.** If you never add the block, nothing changes;
the two tools return an error explaining how to enable them, and every file tool works as
before. `listar_servidores` reports `control_de_energia` per server so the model knows
whether it can offer a restart at all.
- **Configuration is read once, at startup.** `load_dotenv()` and the server registry both run
when the MCP process starts, and there is no hot reload. Adding a `panel` block to a server
that is already running has no effect until you restart the MCP client. If the tools still
report power control as unconfigured right after you edited `servers.json`, that is why: the
file on disk is correct, the live process is holding the copy it read at boot.
## World settings (`level.dat`)
`server.properties` is a text file any editor can touch. The settings that matter most for
add-ons are *not* in it: gamerules and the **experiment toggles** live inside the world's
`level.dat`, and the one everybody needs is **Beta APIs** — without it, no behavior pack with a
`script` module ever runs. Two tools cover it: `leer_config_mundo` (read-only) and
`configurar_mundo` (write, with backup).
```
leer_config_mundo -> ajustes + experimentos + ruta
configurar_mundo experimentos={"beta_apis": true} -> flips gametest, leaves a backup
```
The path is deduced from `level-name` in `server.properties` (falling back to the single folder
under `worlds/`), so you rarely pass `ruta` by hand.
### Why this needed a parser and not a text edit
Bedrock's `level.dat` is NBT, but not the NBT most libraries assume:
1. An **8-byte header** before the data: storage version (int32) + payload length (int32), both
little-endian.
2. **No gzip.** Java Edition compresses its NBT; Bedrock does not.
3. **Little-endian** numbers throughout, not big-endian.
A plain `nbtlib.load()` assumes gzip + big-endian and doesn't fail — it silently parses an
*empty* compound. Writing that back would wipe the world's ~115 keys (spawn, seed, gamerules).
So [level_dat.py](level_dat.py) splits the header by hand, parses the payload with an explicit
`byteorder="little"`, recomputes the length on the way out, and re-parses the bytes it produced
before uploading them. Parsing an empty compound is treated as an error, not as an empty world.
### Guardrails
- **Existing keys only.** `configurar_mundo` refuses to invent keys, and coerces each new value
into the type the key already had (`Byte` stays `Byte`, out-of-range values are rejected with
the valid range). A key that differs only in case gets a "did you mean" hint.
- **Experiments are a nested compound**, not root keys — `experiments.gametest` is what the game
menu calls "Beta APIs" (the name is historical: the Scripting API was born inside the GameTest
Framework). Turning any experiment on also sets `experiments_ever_used` and
`saved_with_toggled_experiments`, without which the engine ignores the toggle on load.
- **The server must be stopped.** Bedrock keeps the world in memory and rewrites `level.dat`
when it saves, so a hot edit disappears with no error at all. If a `panel` block is
configured, the tool checks the state and refuses; `forzar: true` overrides it.
- **Unknown toggles are written with a warning**, not blocked — the list changes with every
Bedrock release.
### What it can't do
**Websockets are not a world setting.** `/wsserver` connections are configured per player in
their own client settings; there is nothing in `level.dat` to flip for them.
## Host key verification
Earlier versions accepted any host key without checking (`AutoAddPolicy`), which left the first
connection open to interception. There are now three policies, set with `host_key_policy`:
| Policy | Behavior |
|---|---|
| `accept-new` *(default)* | Trust on first use: record the key the first time, then require it to match. This is `ssh`'s TOFU model — it protects against a key changing under you without breaking a fresh install. |
| `strict` | The key must already be in `known_hosts`. Nothing is trusted implicitly. |
| `auto` | Accept anything, remember nothing. The old behavior, kept only for disposable environments. |
The default is deliberately not `strict`: that would break every existing setup on upgrade.
If you want the strong guarantee, set `strict` and seed the file first:
```bash
ssh-keyscan -p 2022 node1.tarohosting.net >> ~/.ssh/known_hosts
```
When a key changes, the error says so explicitly rather than failing with a generic handshake
message — a reinstalled node and an active MITM look identical to the client, and you should be
the one deciding which it was.
---
## Safety model
Handing an LLM write access to a live game server deserves more than good intentions.
| Layer | What it stops |
|---|---|
| **Path sandbox** | Every path is resolved and normalized against the root before any network call. `../../etc/passwd` is rejected locally, never sent. |
| **Root delete guard** | `borrar` refuses outright when the resolved path *is* the sandbox root. |
| **Directory delete guard** | Deleting a directory requires an explicit `recursivo: true`. |
| **Automatic backups** | Overwrites and deletes copy the original into `.ai_backups/` on the server, timestamped. `listar_backups` enumerates, `restaurar_backup` rolls back. |
| **Human confirmation** | Write and delete tools carry `destructiveHint`, so the client prompts with the exact arguments. Standalone mode prompts `y/N` in the terminal. |
| **Ambiguity refusal** | `reemplazar_en_archivo` fails when the fragment matches more than once and `todas` is false, rather than picking one. |
| **Zip-Slip protection** | Archive members with absolute paths or `..` segments are rejected before extraction. |
| **NBT sanity checks** | `level.dat` writes refuse an empty or inconsistent parse, keep each key's original NBT type, reject keys that don't already exist, and re-parse the produced bytes before uploading them. |
| **Live-world guard** | `configurar_mundo` refuses to edit `level.dat` while the panel reports the server running — Bedrock would overwrite the change on save without any error. |
| **Read limits** | 200 KB per file plus binary detection, so a 40 MB `latest.log` can't blow up the agent's context. |
| **Per-tool timeouts** | No call hangs forever; the connection is recycled and an explicit error returned. |
| **Host key verification** | Trust-on-first-use by default, `strict` available. A changed key aborts the connection with an explanation. |
Every row above is covered by a test — see [Tests](#tests).
---
## Standalone mode (optional)
Prefer a terminal over an MCP client? `cli.py` runs its own tool-use loop against the
Anthropic API, with the same tools and a `y/N` prompt on anything destructive. It needs
`ANTHROPIC_API_KEY` in your `.env` and uses the single-server environment config.
```bash
python cli.py
```
Commands: `/salir`, `/reset`, `/backups`, `/ayuda`.
| Mode | Who drives the agent | When to use it |
|---|---|---|
| **MCP** (`mcp_server.py`) | Your MCP client | The normal path. No API key of your own. |
| **Standalone** (`cli.py`) | The loop in `agent.py` | No MCP client available. Requires `ANTHROPIC_API_KEY`. |
---
## Tests
```bash
pip install -r requirements-dev.txt
python -m pytest
```
206 tests. No network and no real server involved: `tests/conftest.py` provides an in-memory
double of paramiko's SFTP client, and the panel API is exercised by intercepting `urlopen`. The
suite runs on Linux and Windows across Python 3.10 and 3.13 in CI — Windows is there on purpose,
since the client juggles local and remote path separators and that is exactly where it breaks.
The one exception to "no real server" is `test_install_mcp.py`, which launches `mcp_server.py`
as a subprocess and speaks MCP to it over stdio. It still touches no network: SFTP connections
are lazy, so the handshake and the tool listing complete without any credentials.
What's covered:
| File | Focus |
|---|---|
| `test_sandbox.py` | Path traversal in every shape, including lookalike prefixes like `/home/container2` |
| `test_client.py` | Backups, surgical edits, ambiguity refusal, recursive delete, Zip-Slip, empty-directory skipping, the directory cache |
| `test_config.py` | Parity between `servers.json` and `.env` for every tunable, validation, unknown-key rejection |
| `test_panel.py` | Every power signal, HTTP error translation, response parsing |
| `test_tools.py` | Wiring: no tool can ship without a dispatcher, a timeout and an MCP function |
| `test_level_dat.py` | `level.dat`: header round-trip, empty-NBT refusal, NBT type coercion, experiment bookkeeping, the server-is-running guard |
| `test_install_mcp.py` | Install: absolute interpreter paths, merging a client config without losing entries, no secret ever reaching the diagnostic output, and a live MCP handshake |
That last file is worth calling out: the "easy to forget" steps in
[Adding a tool](#adding-a-tool) are now enforced by tests rather than by a warning in a README
nobody re-reads.
## Contributing
Issues and pull requests are welcome. Bug reports, new tools, support for other panel APIs and
translations are all welcome. CI runs the suite on every pull request.
If you're an AI agent working on this codebase, read [`AGENTS.md`](AGENTS.md) first — it carries
the conventions and the safety rules that aren't obvious from the code.
### Adding a tool
1. Add the method to `SFTPManager` in `sftp_client.py`.
2. Add an entry to `TOOLS` in `tools.py` with its `input_schema`.
3. Wire it into `build_dispatch`.
4. If it modifies anything, add it to the `DESTRUCTIVAS` set.
5. Add an `@app.tool(...)` function in `mcp_server.py` delegating to `_run("name", servidor, ...)`.
6. Give it a timeout budget in `TIMEOUTS`.
Steps 4 and 6 are easy to forget and both matter: skipping 4 means the standalone CLI won't ask
for confirmation, and skipping 6 silently falls back to a 60 s budget that a bulk operation will
blow through. `tests/test_tools.py` fails if you miss any of them, so CI catches it before a
reviewer has to.
### Ideas worth picking up
- Per-tool progress reporting for long uploads (MCP supports progress notifications).
- Support for panels that aren't Pterodactyl-compatible.
- Reading `world_behavior_packs.json` as a first-class operation instead of raw text edits.
### Using OpenRouter instead of the Anthropic API
The schemas are OpenAI-compatible by renaming `input_schema` → `parameters`. Swap the client
in `agent.py` for `openai.OpenAI(base_url="https://openrouter.ai/api/v1")` and adapt the loop
to `tool_calls` / `role: "tool"`.
## Security
Never commit `.env` or `servers.json` — both are git-ignored by default. If you think you've
leaked a credential, rotate the panel password immediately; deleting the file in a later commit
does not remove it from git history.
**A repository is not a secret store.** Visibility can change, forks keep their
copies, and every collaborator has full history. Treat a committed credential as compromised
regardless of who could see the repo at the time.
When an agent installs this for you, it should ask you for hosts and usernames and write those
into `servers.json` as `${VAR}` references — then let *you* put the actual passwords in `.env`.
Passwords should not travel through a chat transcript.
Found a security issue? Open an issue describing the impact, without including credentials.
## License
[MIT](LICENSE) © Gustavo Reyes
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues