archivist-mcp
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@archivist-mcpwhat files changed in the 'Research' dataset since yesterday?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Archivist MCP
A document vault an LLM can read and write, git-versioned on every write, self-hosted on your own server.
No data leaves your machine except towards the conversation that asked for it. Every change is a commit. Nothing is deleted by accident, and what is deleted can be recovered.
An Italian translation was maintained until v2.1.0 and is still readable at
that tag. It was dropped rather than left to rot: the sweep that ended it found
seven divergences, and all seven were on the Italian side — including a command
that skipped nothing because the names it passed had been translated too. Two
files of prose cannot be kept honest by a test, and one that is wrong is worse
than one that is missing, because it gets believed.
Why it exists
Anyone working with an LLM on something serious hits the same wall: the conversations do not remember. Every chat starts from nothing, and the material that should accumulate — decisions, data, working notes — ends up scattered between attachments re-uploaded every time and old chats you can no longer find.
The obvious answer is "put the files in a shared folder". But a shared folder solves half the problem and creates the other half:
Synced folder | Archivist | |
The model reads the files | you re-upload them by hand | it reads them when it needs them |
The model writes the files | no | yes, with a commit |
Two conversations writing at once | last one wins, silently | the second is refused and told why |
"How did this file look on Tuesday?" | depends on the service's bin |
|
"What changed" | nothing | full git history |
One project must not see another | nothing | datasets with keys |
The real leap is not the access: it is git underneath. Once every write is a commit, you stop being afraid to let a model write. If it gets something wrong, you go back. If two chats collide, it tells you instead of letting the last one win. If a file disappears, it was only moved.
Datasets
The vault root holds datasets: top-level directories, each with its own independent git repository. The name is borrowed from ZFS, for the same reason: a dataset is a unit that moves, replicates and restores on its own, without touching the others.
vault/
├── keys.txt ← key registry
├── Example Project/ ← a dataset, with its own .git
│ ├── 01 Notes/
│ └── Trash/
└── Scratch/ ← another dataset, with its own .gitEvery call names its dataset explicitly, in its own dataset argument;
path is relative to that dataset, and an empty path means the whole dataset.
There is no root-level operation at all — and the entire protection model
follows from that single rule, with no exception lists to maintain. keys.txt
is not merely refused: it is not expressible, because it is not a dataset.
dataset="Example Project", path="01 Notes/a.md" → that one file
dataset="Example Project", path="" → the whole datasetPaths come back relative too, which is the point of the arrangement: the string
a result hands you is the same string the documents inside the vault use, so a
path copied from one to the other still means what it says. Repeating the
dataset at the head of path is refused, never silently corrected — on
reads exactly as on writes, because a read that normalised would teach the wrong
form and never complain.
Keys
A dataset with a line in keys.txt is locked: every call must carry its
key. Without a line it is open.
The key is not there to keep strangers out — OAuth does that, and only one account gets in at all. It is there to separate projects from each other. The concrete case: a chat opened on the fly, outside any project, that starts reading a serious project's data because it knows the data exists. The key lives in the project's instructions, so only conversations started inside that project have it in context.
From this follows a rule that replaces three mechanisms that would otherwise be needed: the presence of a key is the declaration that this data matters. A dataset with a key cannot be dropped by the tools, full stop. One without a key can, because it was born to be thrown away.
Related MCP server: obsidian-vault-mcp
How it is built
Every piece was chosen for a specific reason, and the reasons are worth stating: they are the same ones you need if you want to adapt it.
MCP — Model Context Protocol
The protocol the model uses to talk to external tools. An MCP server exposes tools: functions with a name, typed parameters and a description. The model reads the descriptions and decides on its own when to call them.
That has a consequence which governs the whole design: every tool's description rides at the head of every request, always, even when none of them is used — and it arrives isolated, read without the rest of the surface in view. Hence the refusal to multiply tools for fun, and hence the division of labour, which since 2.7.0 has three levels instead of two:
the description carries the signature and one line of what the tool does, and nothing else — measured, all twenty-one together come to about 310 tokens;
reference_guide()is the model: datasets and paths, the sha, the absence of a delete tool, the ceiling that belongs to the client. Only what a signature cannot say, and it is fetched when it is wanted;reference_guide("archive")is one command's card — its defaults, its limits, the way it bites. Asking for a card costs between 30 and 300 tokens instead of the whole manual.
The reason for the third level is that the second was all-or-nothing: to learn
one thing about one tool, a caller paid for the entire manual. vault_status(),
which is the first call anyone makes anyway, says in its guide field that
cards exist — so the pointer is paid for once, not repeated in twenty-one
descriptions where it would cost more than it saves.
Extended documentation for humans is in this README, which costs nobody anything.
FastMCP
The Python implementation of the protocol. It handles HTTP transport, schema serialisation and — the part that really earns its keep — the whole OAuth 2.1 dance with Dynamic Client Registration and PKCE that a remote connector requires. Writing that by hand would have been the bulk of the work.
OAuth 2.1 with GitHub login
The service has no users of its own: it delegates login to GitHub and then refuses anyone who is not the single configured username. Anyone on GitHub can attempt to log in; the refusal comes from the server, not from GitHub.
Since v2.1 the refusal covers every request, the handshake included: a stranger who authenticates with their own GitHub account does not open a session at all, and never sees that the tools exist. Until v2.0 the filter ran on tool calls only, so a valid but unauthorised token could still list the tools with their descriptions — no data, but the shape of the surface. Refusals are logged with the method and the reason, because from the client a refused stranger and a broken deployment produce the same symptom: the connector will not connect.
Why GitHub rather than a password: a password on an exposed service is a secret living in plaintext somewhere with no revocation. An OAuth identity has expiry, revocation and no client-side secret.
Tailscale Funnel
The service listens on 127.0.0.1 and does not know how traffic reaches it.
The Funnel runs in the same container and publishes that port on a public HTTPS
URL with a valid certificate, without opening a single port on the router and
without exposing a home IP address.
The decoupling is deliberate: put a reverse proxy there instead tomorrow and not a line of code changes.
Git, server-side
Every write is a commit. This is not a backup: it is a memory of intent.
history says what happened, diff what changed, read_at how it was,
dataset_restore puts it back.
And one detail that makes the difference in daily use: if something writes into the vault outside the tools — over SMB, by hand, with an editor — the server notices and commits those changes separately, with an honest message, before running its own. Tool commits stay pure and the history never lies by accident.
Docker
The container starts as root only to fix permissions, then drops privileges
and runs as nobody:users with umask 000, so the files stay usable from SMB
shares too.
Four settings live in the image as ENV rather than in the code, because
FastMCP reads them when it is imported — too early for anything in
server.py to have a say. Three of them just quiet the startup down; the
fourth, FASTMCP_CHECK_FOR_UPDATES=off, removes an outbound call made at
every boot to ask what the latest version is. On a service that pins its
version on purpose, that call buys nothing and contradicts the sentence at the
top of this page. A static check makes sure all four are still there: losing
one would not fail, it would just quietly change the behaviour.
Blocking preflight
The preflight checks run at startup. If a single one fails, the service does not start — and a check that crashes counts as failed, not as passed.
It looks excessive until it happens to you: a wrong mount that makes the vault appear empty, a Funnel publishing the wrong port, a node key with expiry still enabled that will switch everything off in six months. A service that refuses to start and tells you why beats one that starts and misbehaves.
Architecture
The model (hosted)
│ HTTPS + OAuth 2.1 (DCR + PKCE)
▼
Tailscale Funnel ──► https://<host>.<tailnet>.ts.net
│ (in the same container)
▼
127.0.0.1:3000 server.py ── the MCP tools
│ ├─ GitHub identity filter
│ └─ source IP filter
▼
vault.py ── VaultRoot (datasets, keys) ──► Dataset (files + git)
│
▼
/vault ── one git repository per datasetInstallation
A Tailscale tailnet with MagicDNS and HTTPS Certificates enabled.
Unraid 7 with the Tailscale plugin installed: it provides the Docker hook that gives the container its own Tailscale identity. Do not uninstall it, even if Tailscale on the host is disabled.
On the host: Allow Tailscale Funnel = No. The Funnel belongs to the container, not to the host.
An SSD pool for the vault. Spinning disks pay the spin-up on every touch, and this service touches often.
A GitHub account.
github.com → Settings → Developer settings → OAuth Apps → New OAuth App
Field | Value |
Application name | anything, e.g. |
Homepage URL |
|
Authorization callback URL |
|
Generate a new client secret, then store Client ID and Client Secret in your password manager: the secret is shown once, but never expires.
⚠ A new application per service. Do not reuse another container's: there is only one callback and the two will fight over it.
zfs create <ssd-pool>/Vault
mkdir "/mnt/<ssd-pool>/Vault/Example Project"
chown -R 99:100 "/mnt/<ssd-pool>/Vault"
chmod -R 777 "/mnt/<ssd-pool>/Vault"Copy your files in with rsync or scp. The git repository is created by the
server at first boot: no git init by hand, and the files need not belong to
anyone in particular — the entrypoint declares safe.directory to avoid git's
dubious ownership.
⚠ ZFS snapshots on the Vault dataset, not on individual projects. Snapshots
are the net for a catastrophe and they protect the .git directories too;
day-to-day rollback is git's job, not theirs.
⚠ Mount the direct pool path (/mnt/<pool>/Vault) in the container, never
/mnt/user/...: no FUSE in the middle.
printf 'Example Project\tk7m2xq4p\n' > "/mnt/<ssd-pool>/Vault/keys.txt"
chown 99:100 "/mnt/<ssd-pool>/Vault/keys.txt"
chmod 640 "/mnt/<ssd-pool>/Vault/keys.txt"Dataset name, TAB, key. One line per dataset; blank lines and lines starting
with # are comments.
Eight alphanumeric characters are plenty: OAuth is already in front, and the
threat is a conversation guessing, not a brute-force attack. Avoid 0/O and
1/l, since you will retype them by hand.
640 owned by 99:100: the service reads it, the world does not. Not
root-only — the service does not run as root and could not open it.
The file is hot-reloaded: add or remove a line from a file manager and it takes effect immediately, with no restart.
It lives inside the vault yet is unreachable from the tools, because its name is
not a dataset name — the same check that stops .. and .git stops it.
Two routes, and the template takes the first.
Use the published image. Every v* tag runs the suite and then publishes to
ghcr.io/alcor6502/archivist-mcp; a tag that does not pass never becomes an
image. archivist-mcp.xml already points there, so there is nothing to build.
Or build it yourself, from a clone on the server:
docker build --no-cache -t archivist-mcp /path/to/the/cloneThen change Repository in the template to archivist-mcp, or Unraid pulls the
published image over the one you just built and nothing tells you. Do not build
on an Apple Silicon Mac: the image comes out arm64.
⚠ --no-cache is not pedantry. Docker's build cache has been known to
report CACHED for a layer whose file had changed. You lose an hour testing the
old image, convinced you fixed something.
Before installing, test the engine with no network and no Docker:
python3 test_vault.py # the engine checks, all must passHalf of those checks verify things that must not happen — traversal, wrong keys, dropping protected datasets — and they are the ones that matter most.
Import archivist-mcp.xml into Unraid, or create the container by
hand. Every field carries its own description in the UI; here is the summary.
Paths
Name | Host → Container |
Vault |
|
App Data |
|
Tailscale State |
|
Variables
Variable | Value |
|
|
|
|
|
|
|
|
| from step 2 |
| from step 2 |
| your GitHub username |
|
|
|
|
|
|
Updating from an earlier version: there is nothing to do about ALLOWED_CIDRS.
The previous name ANTHROPIC_CIDR is deprecated but still honoured, so a
container that already runs keeps working untouched.
Under Show more settings
Variable | Value |
|
|
|
|
|
|
LOG_LEVEL governs this service's logger and nothing else: the root logger, the
access log and FastMCP are set elsewhere. INFO prints, once, what was found at
boot — the datasets and their state, the key registry, and the line with
version, public URL, allowed user and IP filter — which is what you read to
confirm that an update actually took. It also prints one line per refused
call — a CONFLICT, a wrong key, a path that is not allowed — in the form
refused edit_file: …. WARNING silences all of that; what it does not
silence is the gate's refusals, and any fault, which keeps its full
traceback at ERROR because a broken machine is not a refusal. The list is
closed on purpose: below INFO there is nothing to switch on, and above
WARNING the gate's refusals go silent too, and that is the one line that
tells a stranger turned away apart from a broken deployment. WARN is
honoured as WARNING — it is Python's own alias, not a typo, and whoever
writes it wants less noise, so correcting it to INFO would hand back more.
HTTP_MODE is the shape of the HTTP transport. stateless serves each request
on its own transport: no initialize handshake, no session id, and no GET
stream for server-initiated notifications — that route answers 405, because
in this mode it has no GET at all. A new installation is shipped
stateless, because this server never sends anything of its own accord: the
session buys it nothing and can only break. The code's fallback stays
stateful, which is what every version before 2.8.0 did, so a container
installed earlier and never given this variable keeps behaving exactly as it
did.
It exists because calls sent by the client in the same batch fall over, while the retry always passes. Eight at once lost two, four at once lost none, and the calls that fell were the ones that touch nothing — they read a file out of the image — so it is not the work being done but the number of requests in flight, which puts the fault under the tools, in the transport. The message the client shows ("This connector's server hostname doesn't resolve or isn't reachable from this network") is neither true nor useful: it says only which side is reporting. Switching the mode takes effect at startup and needs no new image, so it can be switched back the same way. The startup line always prints the mode actually running, which is the only place worth believing.
The service listens on loopback inside the container, and that is not a setting. Legitimate traffic arrives from the Funnel, which runs in the same container.
Tailscale: Enabled true, Hostname <host>, Serve funnel, Serve Port
equal to PORT, State Dir /var/lib/tailscale.
Then Apply, never Restart. Restart reboots the existing container with the old configuration; only Apply recreates it from the updated template.
In the container log you should see, in order: git init per dataset, the permission pass, the privilege drop, the preflight all green, and then the server starting.
If preflight blocks, the message names the check and the reason. It is not a warning: the service did not start.
Then, in the client: Settings → Connectors → Add custom connector, URL
https://<host>.<tailnet>.ts.net/mcp. The GitHub login opens, you authorise,
and the tools appear.
Try these first, in order:
vault_status() → must list the datasets
dataset_status("Example Project", "") → must be REFUSED
dataset_status("Example Project", "k7m2xq4p") → must answer
dataset_create("Scratch") → created open
list_files("Scratch") → works with no key
dataset_drop("Example Project", "<manifest>") → must be REFUSEDFinally paste the key into the instructions of the project the dataset belongs to. From then on only conversations started inside that project have it in context.
There are three cache layers: the server, the connector and the chat session.
After any change to the tool surface — names, parameters, docstrings — you must disconnect and reconnect the connector, and test in a new conversation. Skip that and you will see the old tools and conclude the deployment failed.
Changes to internal behaviour (limits, formats, logic) do not alter the surface: recreating the container is enough.
A release is a v* tag. The workflow runs the suite first and only then builds
and publishes, so a tag that does not pass never becomes an image.
The registry does not knock: Unraid finds out when asked. Check for Updates on the Docker page, then apply the update it offers. Read the startup line in the log afterwards — it carries the version, and that is how you know the new image is the one running rather than the old one restarting.
If the update changed the tool surface, do step 8 as well.
The way back costs one field: the previous tag is still on the registry, so
put it in Repository in place of :latest and Apply. It is a change of
surface like any other, so step 8 applies to it too. Nothing else moves: the
vault, the tokens and the Tailscale identity all live outside the image.
Maintenance and failures
Item | Where it lives | If you lose it |
| the GitHub OAuth App | make a new one in 5 minutes, then update the template |
| only in the template | stored tokens become unreadable: reconnect the connector. But never change it without reason — the effect is the same |
The keys in | the vault | they must be rewritten, and re-pasted into the project instructions |
The vault | the ZFS dataset + snapshots + git | the only real loss |
⚠ The template Unraid saves under /boot/config/plugins/dockerMan/templates-user/
contains the secrets in plaintext, masked fields included. That backup is
sensitive material: the shareable copy is the sanitised template in this repo.
Docker's build cache lies. Always
--no-cacheafter touching sources.Restart ≠ Apply. Restart reuses the old configuration.
mkstempcreates 0600, ignoring the umask. The code does an explicitchmod 666after every atomic write, or new files would not be writable from SMB.git and dubious ownership. The entrypoint declares
safe.directorybefore touching any repository.Funnel permission is tied to the node identity. Recreate the container and lose
ts-state, and the node comes back as new with the Funnel needing re-authorisation. In the tailnet policy, granting Funnel toautogroup:memberis more robust than naming specific nodes.Node key expiry is a scheduled outage. Disable it in the admin console, under Machines. Preflight checks it precisely because it is silent: everything works for six months, then stops.
Tailscale auto-updates can break the Funnel. It happened with 1.102.1, where a regression made incoming Funnel connections fail; fixed in 1.102.2 on 4 August 2026. If it happens to you, compare the version against the changelog before hunting for the fault at home.
Preflight names the failing check. The frequent ones:
Check | What to look at |
| the vault mount is wrong, or points at an empty folder |
| the repositories do not exist yet: relaunch, boot creates them |
|
|
| a variable is still a placeholder, or |
|
|
| the Funnel is off, or publishes a port other than |
| the node key still has an expiry date |
| the |
To test while skipping the network checks:
PREFLIGHT_SKIP="funnel,node_key,public_dns". Never in production.
Almost always BASE_URL does not match the callback registered on GitHub
exactly — scheme included, trailing slash included. It is the number one
first-run mistake.
If the service answers but no tools appear, it is caching: disconnect and reconnect the connector, then open a fresh conversation.
In order of severity:
history("Example Project", "file.md", 20) what happened
read_at("Example Project", "file.md", "<hash>") how it was
write_file(...) put it back
diff("Example Project", "HEAD~5") what changed across the dataset
dataset_restore("Example Project", "<hash>", "<manifest>", key)dataset_restore rewrites every file in the dataset, but as a forward
commit: history is not lost and this too can be undone.
Underneath everything sits the ZFS snapshot, which is the net for when git itself is gone.
Usage guide
1. Every tool returns a verdict, not a dump. The result is a small object
holding facts: hashes, counts, bytes, the commit id. Content travels only when
asked for. To know, use search, manifest, list_files; to read,
read_file.
2. The sha256 is the unit of truth. Every read gives it, every write demands it:
read_file(ds, "X") → sha256: a3f9…
↓
write_file(ds, "X", new, expected_sha256="a3f9…")If the file changed in the meantime, the write is refused without touching
anything. That is compare-and-swap. For a new file: expected_sha256="new".
Every write hands the new sha back, so a chain needs no re-read in between:
the sha256 returned by write_file goes straight into the edit_file that
follows it. append returns it too. move_path does not, and does not need
to — the content did not change, so the sha you already held still holds.
3. Nothing is deleted. There is no delete tool. Disposal is move_path
into Trash/, and move_path never overwrites.
4. Every write is atomic, verified and committed. Lock, optional commit of
external changes, write to a temporary file, os.replace (atomic), read back and
compare hashes, commit. If a tool fails, the vault is exactly as it was.
5. external_commit_first is not an error. It means the repo was dirty and
changes from outside were committed separately before yours.
You want to | Use | Sha? |
add lines to a register or log |
| no |
change a phrase or a number |
| yes |
rewrite the file, or create it |
| yes ( |
write a PDF or a binary |
| yes |
move, rename, trash |
| no |
know whether something exists, and where |
| — |
know which files exist |
| — |
know whether a tree changed at all |
| — |
know how big a dataset is, how dirty, how many commits |
| — |
know when something changed, and get its hash |
| — |
read text |
| — |
read a PDF or a binary |
| — |
read a whole tree at once |
| — |
read how it was before |
| — |
see what changed between two moments |
| — |
make a new, empty dataset |
| — |
roll a whole dataset back to a revision |
| the manifest |
empty |
| — |
destroy a dataset |
| the manifest |
which datasets exist, and which are locked |
| — |
the manual, whole or one card |
| — |
append needs no sha because it never touches existing bytes: no conflict is
possible, so there is nothing to protect. It is the right operation for logs and
registers.
edit_file sends only the two fragments instead of the whole file: on an 80 KB
file that is the difference between a light call and a heavy one.
Every dataset-level tool takes dataset first, and path relative to it; an
empty path means the whole dataset. key is accepted by all of them and is
only needed for locked datasets; it is omitted in the examples. Returns are
given as their keys, and every return that carries a path also carries
dataset, so the pair can be put back together. Any write may additionally
carry external_commit_first when changes made outside the tools were committed
before yours — that is information, not an error.
Vault level — no key
vault_status() — is the vault alive, and which datasets exist.
Returns vault · version · guide · datasets[{name, state}]
vault_status()
→ {"vault": "ok", "version": "…",
"guide": "reference_guide() for the model; reference_guide(name) for one command's card",
"datasets": [{"name": "Example Project", "state": "open"},
{"name": "Ledger", "state": "locked"}]}reference_guide(name="") — the manual, served from the image, in two
grains. Empty gives the model and the list of card names; a command name gives
that command's card; an unknown name is refused with the list, so a wrong
guess is one round trip from the right one.
Returns version · guide · cards · or version · command · guide
reference_guide()
→ {"version": "…", "guide": "# Archivist MCP — manual\n\n## THE MODEL\n…",
"cards": ["append", "archive", …], "how": "reference_guide(name) for …"}
reference_guide("archive")
→ {"version": "…", "command": "archive",
"guide": "archive(dataset, path='', pattern='*.md', max_chars=0, key='')\n…"}dataset_create(name) — a new dataset: open, empty, its own git.
Returns dataset · state · git · note
dataset_create("Scratch")dataset_drop(dataset, expected_manifest) — delete an open dataset and
everything in it. expected_manifest is the current manifest_sha256: you
cannot throw away what you have not looked at. A locked dataset refuses.
Returns dropped · files_removed · note
manifest("Scratch") # → manifest_sha256: 7c1e…
dataset_drop("Scratch", "7c1e…")A dataset name is the ONLY thing dataset accepts. keys.txt, the lockfiles
and anything else sitting in the vault root are not datasets, so they cannot be
named — the refusal costs no denylist.
Dataset level — every one also accepts key=""
dataset_status(dataset) — one dataset in detail.
Returns dataset · total_files · md_files · files_in_trash · git · total_commits · git_size_bytes · last_commit
dataset_status("Example Project")
→ {"dataset": "Example Project", "total_files": 18, "md_files": 14,
"files_in_trash": 2, "git": "clean", "total_commits": 62,
"git_size_bytes": 423676,
"last_commit": "2781f59 2026-08-08T18:30:51+02:00 edit: Notes.md"}list_files(dataset, path="") — recursive listing with size and sha per
file. An empty path lists the whole dataset. An empty folder does not
appear: git does not keep empty directories, so nothing here can — keep a file
in a folder that has to stay visible.
Returns dataset · base · count · files[{path, size, sha256}]
(on a single file: dataset · file · size · sha256)
list_files("Example Project", "01 Notes")
→ {"dataset": "Example Project", "base": "01 Notes", "count": 2,
"files": [{"path": "01 Notes/a.md", "size": 412, "sha256": "a3f9…"}, …]}read_file(dataset, path) — a UTF-8 text file. The sha256 it returns is
the one write_file and edit_file want back.
Returns dataset · path · size · sha256 · content
read_file("Example Project", "01 Notes/a.md")
→ {"dataset": "Example Project", "path": "01 Notes/a.md", "size": 412,
"sha256": "a3f9…", "content": "# Notes\n…"}read_binary(dataset, path) — any file as base64. Max 2 MB. Useless
without a sandbox to decode it in.
Returns dataset · path · size · sha256 · content_base64
read_binary("Example Project", "Scans/invoice.pdf")read_at(dataset, path, rev) — the file as it was at a past revision.
Read-only. rev is a short hash from history, or "HEAD~3". Revisions are
those of the dataset, not of the vault.
Returns dataset · path · rev · size · sha256 · content
read_at("Example Project", "01 Notes/a.md", "HEAD~3")search(dataset, pattern, path="", regex=False) — server-side grep:
nothing is downloaded. Text files only; find binaries by name with list_files.
Returns dataset · base · pattern · files_scanned · matches · truncated · lines[]
search("Example Project", "deadline")
→ {"dataset": "Example Project", "pattern": "deadline", "files_scanned": 14,
"matches": 3, "truncated": false,
"lines": ["01 Notes/a.md:31: the deadline is …", …]}manifest(dataset, path="") — the fingerprint of a tree in one number. Two
equal manifests mean identical trees. Required by dataset_drop and
dataset_restore.
Returns dataset · base · file_count · total_bytes · manifest_sha256
manifest("Example Project")
→ {"dataset": "Example Project", "base": "", "file_count": 8,
"total_bytes": 147600, "manifest_sha256": "24280b93…"}archive(dataset, path="", pattern="*.md", max_chars=0) — every matching
file in ONE call, as a base64 tar.gz. Replaces hundreds of read_file calls in
an audit; needs a sandbox to extract it in. Member names inside the tgz are
dataset-relative, so extracting reproduces the dataset's own tree. A big archive
may not reach the caller at all — that ceiling is the client's, not this
server's, and max_chars is how a caller declares it: 0 means "no ceiling of
mine", any positive number refuses instead of producing what will not travel,
and says how large it would have been. See archive and the one ceiling that
is not this server's, below.
Returns dataset · base · file_count · original_bytes · tgz_bytes · tgz_base64
archive("Example Project", "", "*.md")
archive("Example Project", "", "*", max_chars=20000)
→ the archive would be 317768 characters of base64, over the 20000 you asked
for (101 files, 246467 bytes in): narrow `path` or `pattern`append(dataset, path, text) — a block at the end of an existing file. It
never touches existing bytes, so it needs no sha: this is the operation for
logs and registers. Max 64 KB.
Returns dataset · path · size · sha256 · commit
append("Example Project", "Log.md", "\n- 2026-08-08 · reconciled\n")
→ {"dataset": "Example Project", "path": "Log.md", "size": 2140,
"sha256": "b71c…", "commit": "9da9597"}write_file(dataset, path, content, expected_sha256) — the WHOLE file.
Compare-and-swap: expected_sha256 must match the file's current sha, or
"new" for a file that does not exist yet. A mismatch is refused without
touching anything. UTF-8, max 2 MB.
Returns dataset · path · size · sha256 · commit
read_file("Example Project", "a.md") # → sha256: a3f9…
write_file("Example Project", "a.md", "# Notes\nrewritten\n", "a3f9…")
write_file("Example Project", "new.md", "# Fresh\n", "new")write_binary(dataset, path, content_base64, expected_sha256) — same
compare-and-swap, from base64. Max 2 MB decoded. Always compare the returned
sha with the one computed at the source: base64 travels as generated text.
Returns dataset · path · size · sha256 · commit
write_binary("Example Project", "Scans/invoice.pdf", "JVBERi0…", "new")edit_file(dataset, path, old_text, new_text, expected_sha256) — replaces
old_text, which must occur exactly once, with new_text. Only the two
fragments travel, not the file. Same compare-and-swap as write_file.
Returns dataset · path · size · sha256 · commit
read_file("Example Project", "a.md") # → sha256: a3f9…
edit_file("Example Project", "a.md", "retention: 0", "retention: 6", "a3f9…")move_path(dataset, src, dst) — move, rename or trash. Both paths are
relative to the same dataset, so a move across datasets is not expressible at
all. Never overwrites. There is no delete tool: moving into Trash/ is the
disposal route, and it resets the file's mtime so trash_purge can date it.
Returns dataset · from · to · trashed · commit
move_path("Example Project", "a.md", "Trash/a.md")
→ {"dataset": "Example Project", "from": "a.md", "to": "Trash/a.md",
"trashed": true, "commit": "0583255"}history(dataset, path="", n=10) — the last n commits. An empty path gives
the dataset's history; a file gives its own, following renames. The short hash
goes verbatim into read_at and diff.
Returns dataset · path · entries[]
history("Example Project", "a.md", 2)
→ {"dataset": "Example Project", "path": "a.md",
"entries": ["2781f59 · 2026-08-08T18:30:51+02:00 · edit: a.md",
"0583255 · 2026-08-08T18:22:33+02:00 · edit: a.md"]}diff(dataset, rev_a, path="", rev_b="HEAD") — differences between two
revisions. An empty path gives the per-file summary; a file gives its full diff.
Truncates at 60 KB rather than failing.
Returns dataset · path · from · to · diff
diff("Example Project", "HEAD~1")
diff("Example Project", "HEAD~5", "a.md")dataset_restore(dataset, rev, expected_manifest) — ⚠ rewrites EVERY file
in the dataset back to rev. Not destructive: it is a forward commit, so
history is not lost and the restore can itself be undone. Check the revision
with history first.
Returns dataset · restored_from · commit · file_count · manifest_sha256
history("Example Project", "", 5) # pick the revision
manifest("Example Project") # → manifest_sha256: 2428…
dataset_restore("Example Project", "0583255", "2428…")trash_purge(dataset, before) — empty Trash/ of everything trashed
before an ISO date. The date is when the file was trashed, not when it was
last modified. Contents remain in git history: this removes clutter, it does
not destroy information.
Returns dataset · before · removed · bytes_freed · files · note
(plus commit, when something was actually removed)
trash_purge("Example Project", "2026-06-01")Limit | Value |
text read and write | 2 MB |
binaries | 2 MB |
| 64 KB |
listable files | 3,000 |
| 200 |
| 60 KB (truncates, does not fail) |
| 30 MB uncompressed |
| 5 MB of tgz |
datasets in the vault | 200 |
The binary limits are calibrated on actual consumption: a file larger than 2 MB
is not usable inside a conversation anyway. A talking refusal beats a silent
failure further down. Above that threshold files travel over SMB or scp, and
the vault acts as the archivist.
Every limit in the table above is enforced by this server, and every one of them fails loudly. There is one more that is not ours, does not appear in the table, and is the one a large archive meets first: the cap the client puts on the size of a tool result.
Above that cap a client does not truncate. It writes the whole result to a file and hands the model a path instead of the data. Whether that is excellent or useless depends on something this server cannot see:
the result lands | what happens | |
A — under the cap | in the message | works, and costs context |
B — over it, file lands where the caller's code runs | in the sandbox | works, and costs no context |
C — over it, file lands elsewhere | outside reach | the data exists and cannot be got |
Case B is the good one, and it is why the 5 MB ceiling here is generous rather than mean: the archive never crosses the context at all. Case C is the trap, and it is not "cloud versus local" — it is only ever about where the spill lands.
The test, once, and then it is known. The first time a result comes back as
a path instead of data — it happens on its own, a read_file on a long
document will do it — run ls <that path> where the caller's code runs.
There → case B: archive freely, up to the server's own 5 MB. Not there →
case C: keep archives small.
Until that test has been run, the safe setting is max_chars=20000, which works
everywhere.
max_chars is how a caller declares its ceiling. 0 by default, meaning "no
ceiling of mine". Given a number, the archive is refused rather than
produced, and the refusal says how many characters it would have been — so one
round trip reports the size instead of losing the payload.
Why this is documented here and not only in a docstring: the cap belongs to
the transport, not to archive. Any large result meets it — read_file on a
long document, diff over a distant revision, list_files on a big tree.
archive is only where it can cancel the purpose of the tool rather than one
call.
Written with X for the dataset.
change a number: read_file → sha → edit_file(X, path, old, new, sha)
add to a log: append(X, path, line)
create a document: write_file(X, path, content, "new")
archive something: move_path(X, "doc.md", "Trash/doc.md")
find something: search(X, "term") → read_file on the right file only
recover content: history → read_at(X, path, hash) → write_file
did anything move: manifest before, manifest after — equal means no
compare two moments: diff(X, "HEAD~5", path="a.md", rev_b="HEAD")
full audit: manifest → list_files → archive(X, pattern="*") → manifest
a WHOLE dataset only in case B; otherwise folder by
folder, or pass max_chars and let it report the size
find by expression: search(X, "^## ", regex=True)
destroy a dataset: manifest → dataset_drop(X, manifest_sha256)
copy across datasets: read_file("A", "x.md") → write_file("B", "x.md", …, "new")Message | Cure |
| the key is in the project's instructions |
|
|
| the dataset is repeated at the head of |
| drop the leading |
| re-read, reconcile, retry |
| you used |
| the opposite: pass |
| re-read and copy the exact fragment |
| widen the context until it is unique |
| a segment is |
|
|
| go one level deeper |
|
|
| the vault is full at 200: nothing to widen |
| take its line out of the key registry on the server first |
| someone wrote after you looked: re-read the manifest, then retry |
A failing tool never leaves a partial write.
What it deliberately does not do
No "run command" tool. No file deletion. No git gc --prune on demand. No
dumps: every tool returns a verdict, because every byte coming back lands in the
conversation's context, and context is the scarce resource.
And one thing worth stating plainly: dataset keys are not authentication. The service recognises a single account, and all of its conversations share one identity — the server cannot tell them apart. The keys work because a conversation without the key in its context cannot invent it: they are a boundary between projects, not a defence against an attacker. That is what OAuth is for.
Package contents
File | |
| the engine: |
| the MCP tools; parameters in the schema, prose in the guide |
| the blocking startup checks, and the IP-filter parser |
| the manual: a short model page, then one card per command, served by |
| init, permissions, privilege drop, preflight, start |
| the image |
| Unraid template, every field documented |
| the icon, used in two places — see below |
| the engine checks, no network needed |
The icon, and where it is actually seen
archivist-icon.png is pointed at by its raw GitHub URL from two files: the
Unraid template, which puts it on the container, and server.py, which passes
it to FastMCP as icons=[…]. A check compares the two URLs, because two hand
copies of one string have an expiry date.
Passing icons buys the OAuth consent page — the page seen when the
connector is added or reconnected — where FastMCP renders it in place of its
own logo.
It does not buy the icon in Claude's connector list. That surface ignores
serverInfo.icons, which the MCP spec has carried since revision 2025-11-25
(SEP-973); serving /favicon.ico and a root page with <link rel="icon"> are
ignored as well. The tracking issue is
anthropics/claude-ai-mcp#152.
Under a Tailscale Funnel the list shows Tailscale's icon, which is consistent
with that surface deriving the icon from the DOMAIN — nothing in this
repository can reach it. The field is sent anyway: the day the client reads it,
the list follows with no change here.
Licence and credits
Released under the MIT licence — see LICENSE.
It builds on third-party components, which remain their authors' under their own licences:
Component | Author | Licence |
Jeremiah Lowin | Apache-2.0 | |
Tailscale Inc. | BSD-3-Clause | |
Anthropic | MIT | |
Python, git, Debian slim | respective projects | PSF / GPL-2.0 / various |
The distributed Docker image contains these components installed: their licences travel with them inside the image, as required.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- FlicenseNot gradedqualityNot gradedmaintenanceAn MCP server that wraps gitingest to enable Claude Code to analyze GitHub repositories, providing access to code structures, statistics, and full content. It facilitates the creation of structured study notes and supports both public and private repositories through the Model Context Protocol.1
- AlicenseNot gradedqualityCmaintenanceAn MCP server that enables Claude Desktop to read and write an Obsidian vault hosted on a VPS, using SSH or HTTP transport with OAuth authentication.1660MIT
- AlicenseNot gradedqualityBmaintenanceRemote MCP server that exposes a personal git repository of markdown notes to Claude, enabling reading, writing, searching, and running scripts with automatic git commits and GitHub OAuth authentication.MIT
- AlicenseNot gradedqualityAmaintenanceAuthenticated remote MCP server that exposes a private GitHub-hosted Obsidian vault to Claude, enabling list, read, write, and search operations on notes.12MIT
Related MCP Connectors
A MCP server built for developers enabling Git based project management with project and personal…
An MCP server that gives your AI access to the source code and docs of all public github repos
Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/alcor6502/archivist-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server