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-mcptreats the remote as a filesystem — the operations that make it browsable and mutable.
- Quick start
- Tool reference — connection params · the 17 tools
- Credentials & security
- Hardening — read-only mode & path jail
- Responses, limits & errors
- Transports · Docker · Development
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):
{
"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.
Seventeen tools. Every tool is self-contained: each call carries its own connection details (there is no server-side config — see Credentials), so a single running server can address any number of SFTP hosts, switched call-to-call.
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.
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):
The per-tool examples below omit the secret for brevity — they assume form 3 (local key). For explicit auth, add
privateKey(form 1, preferred) orpassword(form 2) to any of them.
FileType (used below) is one of: "file", "directory", "symlink",
"other".
Connect and immediately disconnect — validates credentials and reachability.
No parameters beyond the connection fields.
Returns: { ok: true, message: string }
{ "host": "sftp.example.com", "username": "deploy" }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).
{
"host": "sftp.example.com", "username": "deploy",
"path": "/var/www/releases",
"wildcard": "*.tar.gz", "types": ["file"],
"sortField": "modifyTime", "sortDirection": "desc"
}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.
{ "host": "sftp.example.com", "username": "deploy", "path": "/var/www/app.tar.gz" }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.
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 }
{ "host": "sftp.example.com", "username": "deploy", "path": "/var/www/../www/current" }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 }
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 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 }
{ "host": "sftp.example.com", "username": "deploy", "path": "/var/www/config.json" }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.
{
"host": "sftp.example.com", "username": "deploy",
"path": "/exports", "wildcard": "*.csv", "recursive": true
}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 }
{
"host": "sftp.example.com", "username": "deploy",
"path": "/var/www/robots.txt",
"base64data": "VXNlci1hZ2VudDogKgpEaXNhbGxvdzoK",
"overwrite": true
}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 }[] }
{
"host": "sftp.example.com", "username": "deploy",
"path": "/var/www/site",
"files": [
{ "path": "index.html", "base64": "PGgxPmhpPC9oMT4=" },
{ "path": "assets/app.css", "base64": "Ym9keXt9" }
]
}Delete a single file.
| Param | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Remote file to delete. |
Returns: { ok: true, deleted: string }
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 }
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 }
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 }
{
"host": "sftp.example.com", "username": "deploy",
"path": "/staging/build.zip", "newPath": "/releases/build.zip"
}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 }
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 }
A typical "pull the latest release, verify, promote" flow chains a few tools:
list_files—path:/staging,wildcard:*.zip,sortField:modifyTime,sortDirection:desc→ newest build'spath.stat— that path → confirmsize/mode.download_file— that path → inspect the base64 contents.move—/staging/build.zip→/releases/build.zip,overwrite:true→ promote.
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, orprivateKey(+ optionalpassphrase). - Omitted → local SSH fallback — the server uses your ssh-agent / default
~/.sshkey (triesid_ed25519,id_ecdsa,id_rsa). Keeps private-key material out of the model. (Meaningful for stdio only — a remote container has no user agent.)
- 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).
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. |
# A safe public read-only endpoint:
npx @cordfuse/sftp-mcp --http --read-only
# Writable, but jailed to /exports:
npx @cordfuse/sftp-mcp --http --allow /exportsThe 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.
- Success — the tool returns a JSON object/array (the "Returns" shape above), serialized as text content.
- File contents are base64 —
download_file/download_filesreturnbase64;upload_filetakesbase64data. Binary flows through the model, so transfers are size-capped (32 MiB default, per-call overridable viamaxBytes/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: trueand 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),EEXIST(exists withoutoverwrite),EROFS(read-only mode),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 underEFAILURE),EINVAL(badmode),EFAILURE(fallback). - Connections are opened per call and always closed (even on error), with
the
timeoutMsready-timeout applied.
- 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](orPORTenv; default3901). Stateful sessions; liveness probe atGET /health→{ "status": "ok" }. For remote / metamcp-style wiring.
docker run -p 3901:3901 ghcr.io/cordfuse/sftp-mcp:latest
# or, from a checkout:
docker compose -f docker/compose.yaml upServes 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.
sftp-mcp/
packages/
mcp/ @cordfuse/sftp-mcp (the server)
docker/ Dockerfile + compose.yaml (the --http server)
.github/ CI + release workflows
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 — typecheck + build + test on Node 20 & 22, against an
atmoz/sftpservice container. - Release — pushing a
v*tag publishes the npm package@cordfuse/sftp-mcp(guards tag == version) and the imageghcr.io/cordfuse/sftp-mcp:<version>+:latest.
Heritage: ports and modernizes
steve-krisjanovs/sftp-rest
(2023) — the SFTP domain logic — onto
ssh2-sftp-client and MCP.
MIT