The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the ShieldFive listing page.
A Model Context Protocol server that lets Claude, ChatGPT, Cursor or a local model work with two things:
ShieldFive's servers never see a file name or a byte of content in the clear, and that holds with this server running too. Decryption happens inside this process, on your computer. What the assistant then does with what it reads is a separate question, answered under Security model.
Requires Node 20 or newer.
Add the server to your assistant. For Claude Desktop, add this to
claude_desktop_config.json (Cursor uses the same block in ~/.cursor/mcp.json):
For Claude Code: claude mcp add shieldfive -- npx -y @shieldfive/mcp
Restart the assistant and ask it to tidy up my ShieldFive vault. It calls
vault_connect, which opens ShieldFive in your browser.
In that tab, choose the folders, Read only or Read and organize, and an expiry (1 hour to 90 days), then click Authorize.
That is the whole setup: the connection is delivered straight to the server
running on your computer — over 127.0.0.1, never through ShieldFive — and
stored in your system keychain. Nothing is copied by hand.
To connect before you start a conversation, run npx -y @shieldfive/mcp login:
same browser page, same result. login --paste takes a connection string you
copied from Settings → AI assistants instead, for a machine with no browser.
npx @shieldfive/mcp status shows which connection is configured and whether
ShieldFive still accepts it. npx @shieldfive/mcp logout removes it from the
keychain. Revoking it in ShieldFive is what cuts off access everywhere.
For CI or a machine without a keychain, set SHIELDFIVE_GRANT to the connection
string instead. Anything that can read the server's environment can then read
the connection, so prefer the keychain wherever there is one. Setting
SHIELDFIVE_GRANT=none keeps one client local-only on a machine whose keychain
holds a connection for another.
http://127.0.0.1:<port>/callback itself. A crafted link cannot send your
connection anywhere but your own machine./callback, Host
exactly the loopback address (so a rebound DNS name is refused), no Origin
but ShieldFive's, and a 256-bit state compared in constant time. Then it
closes.In plain terms:
vault_trash moves items into a folder in your Bin
that belongs to the connection. There is no permanent-delete tool, and the
API a connection can reach has no delete route. Every rename, move and trash
appears in Settings → AI assistants → Activity with an Undo button.What this does not protect:
vault_trash at 50 items per call, requires a preview for
every change, and keeps every change undoable. A model can still be talked
into a reversible mistake inside the folders you granted.The full design, including the threat model and the reasoning behind each
decision, is in
docs/mcp-grants-design.md.
vault_connect is always available. The rest are registered once a connection
exists — connecting mid-conversation announces them with
notifications/tools/list_changed. Everything below names things by id; paths
are for people.
| Tool | Needs | What it does |
|---|---|---|
vault_connect | — | opens ShieldFive in the browser to authorize a connection, and stores it in the keychain |
vault_list_files | read | files and folders in scope, with decrypted names, paths, sizes, dates |
vault_search_files | read | by name, path, extension, size or date, run locally over decrypted names |
vault_storage_stats | read | totals, the biggest folders and files, a breakdown by type |
vault_find_duplicates | read | same-size files decrypted in memory and compared by SHA-256; budgeted, and says when a result is a lower bound |
vault_read_file | read | text files as fenced, untrusted content (up to 1 M characters); other types return details only |
vault_rename | organize | rename a file or folder |
vault_move | organize | move into another folder in scope |
vault_create_folder | organize | create a folder in scope |
vault_upload | write | encrypt a local file here and put it in the vault, then read it back and compare before you are told it is safe to remove the original |
vault_trash | organize | up to 50 items into the connection's folder in the Bin |
vault_move_in | write | free up space: up to 50 local files uploaded and verified one by one, each original moved to the local trash only after its copy reads back identical — one approval, nothing deleted |
Limits a user may meet:
readable: false until you next open ShieldFive on the web, which adds the key
the connection needs. Files uploaded in the web app are ready straight away.vault_move_in puts each
verified original in .shieldfive-mcp-trash, with a manifest naming its vault
copy; the space comes back when you empty that directory.Every path after the package name is a root. The local tools can read and write inside those directories and nowhere else, and make no network request.
In claude_desktop_config.json:
With a connection configured and no roots, only the vault tools are registered. With roots and no connection, only the local tools are, and the server behaves exactly as 0.2.0 did. With both, you get both.
SHIELDFIVE_MCP_ROOTS adds roots as well — the two are combined, not
alternatives — as a list separated by your platform's path separator (: on
macOS and Linux, ; on Windows):
Whitespace around a root is ignored. In a path given to a tool it is not: there, every character is part of the path.
demo/run-demo.mjs builds five files in a temporary directory — two with
identical contents under different names, a same-size decoy, a 12 MB archive and
a two-year-old PDF — runs the read tools over them, previews a trash call, then
confirms it and shows the manifest. It touches nothing outside that directory
and removes it at the end (--keep leaves it in place).
They cannot tell you whether a local file is already in your vault. Matching a local name and size against a vault listing is how a tool deletes the only copy of something, and this server will not guess.
They cannot stop the results from reaching your AI provider. The local tools make no network request, and that is worth exactly what it says and no more: everything they return — paths, file names, sizes, dates, the digests they report — goes back to the AI client that called it, and if that client is a cloud assistant, those names travel to the assistant's provider like the rest of your conversation. Choose roots on that basis.
It will never infer that two files are the same from their names and sizes. Duplicate detection reads both files and compares a full SHA-256 of their contents. Name matching is how a deduplication tool deletes the only copy of something, and the cost of getting it right is a few seconds of disk I/O.
Hashing is budgeted, though, and the budget can make the answer incomplete.
Candidates are bucketed by size, screened on a hash of the first 64 KiB where
the files are bigger than that, then confirmed with a full digest. Every read of
either kind counts against max_files_hashed, 20,000 by default. When the
budget runs out, the rest goes unhashed — the group it runs out in is hashed in
part, oldest copies first — and the result says how many files and how much space
were never checked. Groups are processed largest-first, so what survives a tight
budget is what was worth the most.
What counts as reclaimable is counted per file on disk. Names that are hardlinks to one file are one copy, because removing one of them frees nothing. APFS clones — what Finder's Duplicate makes on an APFS volume — share their storage too, but nothing this server can read tells a clone from a real copy, so clones are reported as reclaimable when trashing one frees little or nothing. The copy nominated to keep is the one modified earliest; a tie goes to the shorter path, then to the path in code-unit order, so the same tree always nominates the same copy.
It deletes nothing of yours, with one exception. trash_local moves files
into a .shieldfive-mcp-trash directory on the same volume they are on, and
writes a manifest.json recording where each one was. No disk space is freed
until you delete that directory yourself, in your own file manager, with your
own undo. The tool says so in its own output so the assistant cannot report the
space as reclaimed. The exception is a move_local between volumes, which has
to copy: its source is removed, but only after the copy has been verified — see
Moving across volumes.
That holds for overwriting too. move_local with overwrite: true moves the
item already at the destination into the trash and then takes its place; it does
not remove it. The preview tells you how many files and how many bytes would be
displaced, not just how many are being moved. If the move then fails, the
displaced item is put back.
Every tool that changes anything does nothing by default. Call it without
confirm: true and it resolves the paths, checks containment, reports exactly
what it would do, and stops. The preview runs the same checks as the action, so
a plan that reports a refusal is a refusal.
And a confirmed call has to be the plan you saw. The preview returns a
plan_token; confirm: true without it is refused. The confirmed call plans
again from the filesystem as it is now, compares that plan with the one the
token approved — the paths, what each entry is, its size and modification time,
and the file and byte counts underneath it — and refuses if anything differs,
naming what changed. A token performs one change and expires after ten minutes.
So a directory that grew, a destination that appeared, or a path that now points
at a different file stops the call instead of silently widening it.
| Tool | Reads | Writes |
|---|---|---|
list_local | files, sizes, dates | — |
find_duplicates | file contents (SHA-256) | — |
find_large_files | sizes | — |
find_old_files | modification times | — |
storage_summary | sizes, by extension and directory | — |
move_local | sizes of both the source and anything it would displace | moves a file, folder or symlink; moves a displaced destination to the trash; between volumes, copies, verifies, then removes the source |
rename_local | — | renames in place, never over an existing name |
create_local_folder | — | creates a directory |
trash_local | sizes of the subtree being trashed | moves into the trash directory on the item's own volume, writes a manifest |
Defaults, all overridable per call: list_local returns 200 rows, the other
listings 100. find_large_files starts at min_bytes 100,000,000 (100 MB).
find_old_files at older_than_days 365. find_duplicates skips empty files
(min_bytes 1), hashes at most max_files_hashed 20,000 of them and returns
100 groups, each listing at most 50 of its copies. storage_summary reports the
top 15 extensions and top 15 directories. Every scan stops at max_files
200,000 files across all roots, and walks the root and 64 levels of
subdirectories below it.
Every override has a ceiling, enforced by the MCP schema and again by the tool
itself: limit 10,000 rows, max_files 1,000,000, max_files_hashed
1,000,000, paths 1,000 per trash_local call, 4,096 characters for a path and
255 bytes for new_name. A refusal quotes only the start of a value that was
too long.
find_old_files reports modification time, which is a weak signal: some copy
operations reset it to the copy date, and an untouched file is not an unwanted
one. The tool says this in its own result rather than leaving the assistant to
present a shortlist as a verdict.
Every read tool walks the same way, and it skips things by default:
include_hidden: true.node_modules, .git, .svn, .hg, .cache, .venv, venv,
__pycache__, .next, .turbo, dist, build, target, Pods,
.gradle, .tox, .mypy_cache, .pytest_cache, and this server's own
trash. build, dist and target are ordinary folder names outside a code
tree, so this can exclude real data — there is no way to override the list
yet.All three are counted and reported in the result's warnings, so a total that
looks too small says why. It still means storage_summary is not a disk-usage
tool: point it at a developer's home directory and it will tell you so, but it
will not tell you where the space went.
When the max_files budget runs out before every root has been walked, the
result lists the roots it walked under scanned and the others under
not_scanned, and its warning names them.
This server does not, and cannot. trash_local moves each item into
.shieldfive-mcp-trash/<batch>/ in the highest directory, between the item and
its root, that is on the item's own volume: the root itself, unless the item is
on a drive mounted inside the root, and then that drive's top directory.
<batch> is a timestamp, a process id and a counter, so no two calls share one.
The manifest.json beside the items is written before any of them moves and
lists where each came from; an entry whose trashed_to does not exist was
planned but not moved. Removing them for real is a rm -rf you run yourself,
once you have looked at what is in there. Nothing here frees disk space on its
own.
A mount point cannot be trashed, because no directory on its own volume inside
the root can hold it. If .shieldfive-mcp-trash is a symlink or a file,
trash_local and an overwriting move_local refuse rather than follow it.
rename(2) cannot cross volumes, so a move between them is a copy followed by
removing the source — the one place this server removes something you made. It
is done so that a failure at any point loses nothing:
.shieldfive-mcp-incoming-<pid>-<n>), created exclusively, and put in place
without replacing anything. Nothing that was already there is touched.source_left_in_place.A crash in the middle can leave a partial copy under that hidden name. The source is intact until its copy is in place.
A cancelled request starts no change. trash_local stops between items, never
inside one, so each item is either moved and recorded or untouched, and the
error says which. A move_local cancelled before its source starts being
removed is undone, including putting back anything it displaced; after that
point it finishes, because stopping would leave half a tree on each side. The
MCP SDK sends no response to a cancelled request, so what a cancelled call did
is written to the server's stderr log and, for the trash, to the manifest.
Every path an assistant supplies is used exactly as given, so "report " is
never "report", and resolved with realpath — following every symlink — before
anything reads it or writes through it. The result must sit inside a configured
root. A separator-aware boundary check means /data/roots-evil does not match
the root /data/root.
That ordering is the point. A string check on the supplied path is defeated by
..; a check after path.resolve is still defeated by a symlink, because
/allowed/link -> /etc resolves to a string under /allowed while reading
/etc. Resolving links first closes both, and it is why the directory walk uses
lstat and never follows a link — a link the walk traversed would be a path
containment never got to see.
Destinations that do not exist yet — a move target, a new folder — are checked
by resolving the nearest existing ancestor and re-appending the rest, so writing
through a symlinked parent is caught before the write rather than after it. A
symlink whose target does not exist is refused wherever a write would pass
through it: realpath reports it exactly like a missing path, and taking that
at its word would let a copy land wherever the link points.
The one thing not followed is the item a mutating tool acts on. A symlink given
to move_local, rename_local or trash_local is moved, renamed or trashed
itself, the way mv treats it, and what it points to is not touched; only the
link's own position has to be inside a root. The same goes for the destination
of a move: a symlink there, dangling or not, is an existing entry that
overwrite: true would move to the trash, not a folder to move into. Give the
folder's real path for that.
rename_local and move_local do not replace something that appears at the
destination after they have checked it. A file is hard-linked to its new name
and only then unlinked from the old one, a symlink is recreated, and a folder is
renamed over an empty placeholder made a moment before, so something appearing
in between makes the operation fail rather than be overwritten. Where there is
no such operation — FIFOs, sockets and device files, filesystems without hard
links such as FAT and exFAT, and folders on Windows — the tool checks and then
renames, and a file created in that instant would be replaced.
npm test runs 225 tests. The ones worth knowing about:
.shieldfive-mcp-trash that is a symlink out of the root is refused, and
nothing is written through it.trash_local leaves the bytes readable at their new location, on the same
volume, and reports space_freed_bytes: 0.src/vault/api.mjs calls fetch, and only to an https ShieldFive
origin. No local-tool module imports anything from the vault half, and the
only environment variables read are SHIELDFIVE_MCP_ROOTS,
SHIELDFIVE_GRANT and SHIELDFIVE_API_URL.src/vault/connect.mjs binds a random port on 127.0.0.1, opens no
connection of its own, and launches the browser with a fixed command and no
shell. A delivery from another origin, with another state, to another Host,
by another method, or after the first one, is refused.@shieldfive/crypto.The network assertion has a limit worth stating: it proves what src/ does,
not what the dependency tree could do. @modelcontextprotocol/sdk ships HTTP
transports for other people's servers. What closes that gap is that
server.mjs imports the stdio transport and no HTTP one, which is also
asserted.
storage_summary counts every name of a
hardlinked file.find_duplicates reports them as
reclaimable, and trashing one frees little or nothing.node:path, but nobody has run it there.SECURITY.md carries the rest: time-of-check/time-of-use, the windows in which a rename can still replace something, hardlinks, what inheriting the environment does and does not mean, and what the no-network assertion covers.
This server sends nothing anywhere unless a ShieldFive connection is
configured, and then only to ShieldFive's API (https://shieldfive.com, or the
origin in SHIELDFIVE_API_URL):
SHIELDFIVE_GRANT), never logged,
returned or written to a file.Contact: support@shieldfive.com, or security@shieldfive.com for vulnerabilities.
Report vulnerabilities to security@shieldfive.com. See
SECURITY.md.
MIT. Versions up to and including 0.6.3 were published under Apache-2.0.