sftp-mcp
by cordfuse
README.md
# sftp-mcp
[](https://github.com/cordfuse/sftp-mcp/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/@cordfuse/sftp-mcp)
**SFTP as a browsable, mutable filesystem for AI agents** — list, stat, lstat,
realpath, move, mkdir, delete, batch, symlink, tree upload, disk usage. Not just
upload/download. Node runtime, served over both **stdio** and **streamable
HTTP**, zero required native deps. Ships with a **read-only mode** and **path
jail** so it's safe to expose over a shared endpoint.
> Most SFTP/SSH MCP servers are SSH-first: their file story is upload/download of
> individual files bolted onto remote command execution. `sftp-mcp` treats the
> remote as a **filesystem** — the operations that make it browsable and mutable.
- [Quick start](#quick-start)
- [Tool reference](#tool-reference) — [connection params](#connection-parameters-every-tool) · [the 17 tools](#the-tools)
- [Credentials & security](#credentials--100-per-call-zero-config)
- [Hardening — read-only mode & path jail](#hardening--read-only-mode--path-jail)
- [Responses, limits & errors](#responses-limits--errors)
- [Transports](#transports) · [Docker](#docker--ghcr) · [Development](#development)
---
## Quick start
```bash
npx @cordfuse/sftp-mcp # stdio (default)
npx @cordfuse/sftp-mcp --http # streamable HTTP on :3901 (PORT to change)
```
MCP client config (stdio — Claude Code, Cursor, local agents):
```json
{
"mcpServers": {
"sftp": { "command": "npx", "args": ["-y", "@cordfuse/sftp-mcp"] }
}
}
```
Once wired, ask your agent to *"list `/var/www` on sftp.example.com as user
deploy"* and it will call `list_files` with the connection + path.
---
## Tool reference
Seventeen tools. Every tool is **self-contained**: each call carries its own
connection details (there is no server-side config — see
[Credentials](#credentials--100-per-call-zero-config)), so a single running
server can address any number of SFTP hosts, switched call-to-call.
### Connection parameters (every tool)
These fields are accepted by **all** tools. They are omitted from the per-tool
tables below to avoid repetition.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `host` | string | **yes** | — | SFTP server hostname or IP. |
| `username` | string | **yes** | — | SSH username. |
| `port` | number | no | `22` | TCP port. |
| `password` | string | no | — | Inline password. |
| `privateKey` | string | no | — | Inline private key (PEM / OpenSSH text). |
| `passphrase` | string | no | — | Passphrase for an encrypted `privateKey`. |
| `timeoutMs` | number | no | `15000` | Connection (ready) timeout in ms. |
**Secret resolution order:** `password` → `privateKey` (+ `passphrase`) → if
neither is given, the local **ssh-agent / default `~/.ssh` key** (stdio only).
See [Credentials](#credentials--100-per-call-zero-config).
**Authenticating a call.** Every tool takes exactly one of these three forms.
The same auth fields apply to *every* tool (shown here on `test_connection`):
```jsonc
// 1. Private key — preferred
{
"host": "sftp.example.com", "username": "deploy",
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----",
"passphrase": "only-if-the-key-is-encrypted"
}
// 2. Password
{ "host": "sftp.example.com", "username": "deploy", "password": "s3cr3t" }
// 3. No secret → local ssh-agent / ~/.ssh key (stdio only; keeps keys out of the model)
{ "host": "sftp.example.com", "username": "deploy" }
```
> The per-tool examples below **omit the secret for brevity** — they assume
> form 3 (local key). For explicit auth, add `privateKey` (form 1, preferred) or
> `password` (form 2) to any of them.
`FileType` (used below) is one of: `"file"`, `"directory"`, `"symlink"`,
`"other"`.
---
### The tools
#### `test_connection`
Connect and immediately disconnect — validates credentials and reachability.
*No parameters beyond the connection fields.*
**Returns:** `{ ok: true, message: string }`
```json
{ "host": "sftp.example.com", "username": "deploy" }
```
---
#### `list_files`
List a directory, with optional glob/type filtering, sorting, and recursion.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Remote directory to list. |
| `wildcard` | string | no | — | Glob on the entry name, e.g. `*.pdf` (`*` and `?`, case-insensitive). |
| `types` | `FileType[]` | no | all | Keep only these entry types. |
| `sortField` | `"name"` \| `"size"` \| `"modifyTime"` | no | — | Field to sort by. |
| `sortDirection` | `"asc"` \| `"desc"` | no | `asc` | Sort direction. |
| `recursive` | boolean | no | `false` | Descend into subdirectories. |
| `limit` | number | no | — | Cap the number of entries returned. |
**Returns:** `FileEntry[]` where `FileEntry = { name, path, type: FileType, size: number, modifyTime: number }` (`modifyTime` is epoch ms).
```json
{
"host": "sftp.example.com", "username": "deploy",
"path": "/var/www/releases",
"wildcard": "*.tar.gz", "types": ["file"],
"sortField": "modifyTime", "sortDirection": "desc"
}
```
---
#### `stat`
Metadata for a single path. Doubles as an existence check.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | **yes** | Remote path to stat. |
**Returns:** `{ exists: boolean, type?: FileType, size?: number, modifyTime?: number, accessTime?: number, mode?: string }` — `mode` is octal permission bits as a string (e.g. `"644"`). When `exists` is `false`, no other fields are present.
```json
{ "host": "sftp.example.com", "username": "deploy", "path": "/var/www/app.tar.gz" }
```
---
#### `lstat`
Like `stat`, but **does not follow a final symlink** — reports the link itself
(`type: "symlink"`) rather than its target. Use it to detect symlinks; use `stat`
to see what a link resolves to.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | **yes** | Remote path to lstat. |
**Returns:** same shape as `stat`. For a symlink, `type` is `"symlink"` and
`size` is the length of the target path string.
---
#### `realpath`
Canonicalize a path — resolve `.`/`..` segments **and** symlinks to an absolute path.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | **yes** | Remote path to resolve. |
**Returns:** `{ path: string, realpath: string }`
```json
{ "host": "sftp.example.com", "username": "deploy", "path": "/var/www/../www/current" }
```
---
#### `read_symlink`
Read the target a symbolic link points to (does not follow further).
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | **yes** | Symlink path to read. |
**Returns:** `{ path: string, target: string }`
---
#### `disk_usage`
Capacity of the filesystem holding a path — check free space before an upload.
Uses the OpenSSH `statvfs@openssh.com` extension; errors with code `ENOSYS` if
the server doesn't support it.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | no | `"."` | Any path on the target filesystem. |
**Returns:** `{ totalBytes: number, freeBytes: number, availableBytes: number }`
---
#### `download_file`
Download one file, base64-encoded.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Remote file path. |
| `maxBytes` | number | no | `33554432` (32 MiB) | Refuse files larger than this. |
**Returns:** `{ path: string, base64: string, size: number }`
```json
{ "host": "sftp.example.com", "username": "deploy", "path": "/var/www/config.json" }
```
---
#### `download_files`
Batch-download every **file** in a directory matching an optional wildcard.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Remote directory. |
| `wildcard` | string | no | — | Glob filter, e.g. `*.csv`. |
| `recursive` | boolean | no | `false` | Include matching files in subdirectories. |
| `maxTotalBytes` | number | no | `33554432` (32 MiB) | Cap on the **combined** size of the selection. |
**Returns:** `{ count: number, files: { path: string, base64: string, size: number }[] }`. Throws (with the offending path) if the running total exceeds `maxTotalBytes` — narrow the wildcard or raise the cap.
```json
{
"host": "sftp.example.com", "username": "deploy",
"path": "/exports", "wildcard": "*.csv", "recursive": true
}
```
---
#### `upload_file`
Upload base64 data to a remote path.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Destination path (including filename). |
| `base64data` | string | **yes** | — | File contents, base64-encoded. |
| `overwrite` | boolean | no | `false` | Replace an existing file. Without it, an existing path is an error. |
| `maxBytes` | number | no | `33554432` (32 MiB) | Refuse payloads larger than this. |
**Returns:** `{ path: string, bytes: number }`
```json
{
"host": "sftp.example.com", "username": "deploy",
"path": "/var/www/robots.txt",
"base64data": "VXNlci1hZ2VudDogKgpEaXNhbGxvdzoK",
"overwrite": true
}
```
---
#### `upload_dir`
Push a whole **directory tree** from in-memory files under a base directory,
creating the base and any parent directories as needed (`mkdir -p`). Pairs with
recursive `download_files` for full tree round-trips.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Remote base directory (created if missing). |
| `files` | `{ path: string, base64: string }[]` | **yes** | — | Files to write; each `path` is **relative to the base**. |
| `maxTotalBytes` | number | no | `33554432` (32 MiB) | Cap on the **combined** size. |
**Returns:** `{ count: number, written: { path: string, bytes: number }[] }`
```json
{
"host": "sftp.example.com", "username": "deploy",
"path": "/var/www/site",
"files": [
{ "path": "index.html", "base64": "PGgxPmhpPC9oMT4=" },
{ "path": "assets/app.css", "base64": "Ym9keXt9" }
]
}
```
---
#### `delete_file`
Delete a single file.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | **yes** | Remote file to delete. |
**Returns:** `{ ok: true, deleted: string }`
---
#### `delete_dir`
Remove a directory.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Remote directory to remove. |
| `recursive` | boolean | no | `false` | Remove contents too (`rm -rf`). Without it, the directory must be empty. |
**Returns:** `{ ok: true, removed: string, recursive: boolean }`
---
#### `make_directory`
Create a directory.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Remote directory to create. |
| `recursive` | boolean | no | `false` | Create missing parents (`mkdir -p`); idempotent. |
**Returns:** `{ ok: true, created: string }`
---
#### `move`
Rename or move a file or directory. Uses SFTP `posix-rename` — an **atomic**
overwrite (no delete-then-rename race) when `overwrite` is set.
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `path` | string | **yes** | — | Current path. |
| `newPath` | string | **yes** | — | Destination path. |
| `overwrite` | boolean | no | `false` | Replace an existing destination. Without it, an existing `newPath` is an error. |
**Returns:** `{ ok: true, from: string, to: string }`
```json
{
"host": "sftp.example.com", "username": "deploy",
"path": "/staging/build.zip", "newPath": "/releases/build.zip"
}
```
---
#### `chmod`
Change a path's permission bits.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `path` | string | **yes** | Remote path. |
| `mode` | string | **yes** | Octal permission string, e.g. `"644"` or `"755"`. |
**Returns:** `{ ok: true, path: string, mode: string }`
---
#### `symlink`
Create a symbolic link at `path` pointing to `target`.
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `target` | string | **yes** | The path the link points to. |
| `path` | string | **yes** | Where to create the link. |
**Returns:** `{ ok: true, path: string, target: string }`
---
### End-to-end example (agent flow)
A typical "pull the latest release, verify, promote" flow chains a few tools:
1. `list_files` — `path:/staging`, `wildcard:*.zip`, `sortField:modifyTime`, `sortDirection:desc` → newest build's `path`.
2. `stat` — that path → confirm `size` / `mode`.
3. `download_file` — that path → inspect the base64 contents.
4. `move` — `/staging/build.zip` → `/releases/build.zip`, `overwrite:true` → promote.
---
## Credentials — 100% per-call, zero config
Every tool call names its own target and secret, so an agent can talk to **any
number of SFTP servers, switched call-to-call, with no registration and no
restart.** There is no server-side config.
The secret in a call is **optional**:
- **Inline** — `password`, or `privateKey` (+ optional `passphrase`).
- **Omitted → local SSH fallback** — the server uses your **ssh-agent / default
`~/.ssh` key** (tries `id_ed25519`, `id_ecdsa`, `id_rsa`). Keeps private-key
material out of the model. (Meaningful for **stdio** only — a remote container
has no user agent.)
### Security — documented, and it maps to the transport
- **stdio = the safe mode for inline secrets.** The server runs locally as a child
of your client; the secret never leaves your machine. Use stdio (or the
ssh-agent fallback) for anything sensitive.
- **⚠️ streamable HTTP behind a public tunnel** — inline secrets transit the
network and any proxy in front. **Do not put inline passwords through a public
endpoint.** That is the one combination to avoid.
Built-in safe defaults (not config): a 32 MiB max transfer size (per-call
overridable), **no credential logging**, and **sanitized error messages** (no
stacks / internal paths leaked back to the model).
---
## Hardening — read-only mode & path jail
Two **launch flags** (operator-set, not per-call) let you expose the server —
e.g. over a shared streamable-HTTP endpoint — without handing agents full write
access to every path a credential can reach.
| Flag | Env | Effect |
|------|-----|--------|
| `--read-only` | `SFTP_READONLY=1` | Refuse every mutating tool (`upload_file`, `upload_dir`, `delete_file`, `delete_dir`, `make_directory`, `move`, `chmod`, `symlink`) with error code `EROFS`. Read tools still work. |
| `--allow <root>` | `SFTP_ALLOW=<root>[,<root>]` | Confine **all** paths to the given root(s). A path outside every root (after `.`/`..` normalization) is refused with `EACCES` before any connection is made. Repeat `--allow` for multiple roots. |
```bash
# A safe public read-only endpoint:
npx @cordfuse/sftp-mcp --http --read-only
# Writable, but jailed to /exports:
npx @cordfuse/sftp-mcp --http --allow /exports
```
The flags compose — `--read-only --allow /exports` gives read-only access
confined to `/exports`. They constrain what the *server* will do regardless of
what credentials a call carries.
---
## Responses, limits & errors
- **Success** — the tool returns a JSON object/array (the "Returns" shape above),
serialized as text content.
- **File contents are base64** — `download_file` / `download_files` return
`base64`; `upload_file` takes `base64data`. Binary flows through the model, so
transfers are **size-capped** (32 MiB default, per-call overridable via
`maxBytes` / `maxTotalBytes`). For very large files this server is the wrong
tool — it moves bytes through the agent, not disk-to-disk.
- **Errors** — a failed op returns `isError: true` and a JSON body
`{ code, message }` — a **structured code** so an agent can branch (rather than
string-match), plus a **sanitized, single-line** message (credentials and stack
traces are never included). Codes: `ENOENT` (no such path), `EACCES`
(permission denied / outside the [path jail](#hardening--read-only-mode--path-jail)),
`EEXIST` (exists without `overwrite`), `EROFS` ([read-only mode](#hardening--read-only-mode--path-jail)),
`EISDIR`, `ENOTDIR`, `ENOSYS` (unsupported extension, e.g. `disk_usage`),
`E2BIG` (a batch/tree selection over the combined size cap — a single-file
cap surfaces its message under `EFAILURE`), `EINVAL` (bad `mode`), `EFAILURE`
(fallback).
- **Connections** are opened per call and **always closed** (even on error), with
the `timeoutMs` ready-timeout applied.
---
## Transports
- **stdio** (default) — for Claude Code, Cursor, and local agent wiring. The safe
mode for inline secrets, and the only mode where the ssh-agent fallback applies.
- **streamable HTTP** — `--http [--port N]` (or `PORT` env; default `3901`).
Stateful sessions; liveness probe at `GET /health` → `{ "status": "ok" }`.
For remote / metamcp-style wiring.
---
## Docker / GHCR
```bash
docker run -p 3901:3901 ghcr.io/cordfuse/sftp-mcp:latest
# or, from a checkout:
docker compose -f docker/compose.yaml up
```
Serves streamable HTTP at `http://<host>:3901/mcp`, liveness at `/health`. The
image omits ssh2's optional native `cpu-features` accelerator and runs ssh2's
pure-JS crypto — no native toolchain in the image.
---
## Repository layout (monorepo)
```
sftp-mcp/
packages/
mcp/ @cordfuse/sftp-mcp (the server)
docker/ Dockerfile + compose.yaml (the --http server)
.github/ CI + release workflows
```
## Development
```bash
npm ci
npm run build -w @cordfuse/sftp-mcp
# tests need a live SFTP server:
docker run -d --name sftp-test -p 2222:22 atmoz/sftp foo:testpass:1001::upload
npm test -w @cordfuse/sftp-mcp # override with SFTP_HOST/PORT/USER/PASS
```
## CI / Release
- **CI** — typecheck + build + test on Node 20 & 22, against an `atmoz/sftp`
service container.
- **Release** — pushing a `v*` tag publishes the npm package `@cordfuse/sftp-mcp`
(guards tag == version) and the image `ghcr.io/cordfuse/sftp-mcp:<version>` +
`:latest`.
Heritage: ports and modernizes
[`steve-krisjanovs/sftp-rest`](https://github.com/steve-krisjanovs/sftp-rest)
(2023) — the SFTP domain logic — onto
[`ssh2-sftp-client`](https://github.com/theophilusx/ssh2-sftp-client) and MCP.
## License
MIT
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues