Skip to main content
Glama
alcor6502

archivist-mcp

by alcor6502

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

read_at, always

"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 .git

Every 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 dataset

Paths 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 dataset

Installation

  • 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. archivist-mcp

Homepage URL

https://<host>.<tailnet>.ts.net

Authorization callback URL

https://<host>.<tailnet>.ts.net/auth/callback

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/clone

Then 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 pass

Half 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

/mnt/<pool>/Vault//vault

App Data

/mnt/user/appdata/archivist-mcp/data/data

Tailscale State

/mnt/user/appdata/archivist-mcp/ts-state/var/lib/tailscale

Variables

Variable

Value

VAULT_ROOT

/vault

KEYS_FILE

/vault/keys.txt

GIT_RETENTION_MONTHS

0 (disabled)

BASE_URL

https://<host>.<tailnet>.ts.netno trailing slash

GITHUB_CLIENT_ID

from step 2

GITHUB_CLIENT_SECRET

from step 2

ALLOWED_GITHUB_LOGIN

your GitHub username

JWT_SIGNING_KEY

openssl rand -hex 32

PORT

3000

ALLOWED_CIDRS

160.79.104.0/21 # documented egress of the model provider

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

INFO, or WARNING for a quiet log. Nothing else: see below

HTTP_MODE

stateless on a new install; the code's fallback stays stateful: see below

VAULT_UID / VAULT_GID

99 / 100nobody:users, the right owner for share files

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 REFUSED

Finally 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

GITHUB_CLIENT_ID + SECRET

the GitHub OAuth App

make a new one in 5 minutes, then update the template

JWT_SIGNING_KEY

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 keys.txt

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-cache after touching sources.

  • Restart ≠ Apply. Restart reuses the old configuration.

  • mkstemp creates 0600, ignoring the umask. The code does an explicit chmod 666 after every atomic write, or new files would not be writable from SMB.

  • git and dubious ownership. The entrypoint declares safe.directory before 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 to autogroup:member is 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

datasets

the vault mount is wrong, or points at an empty folder

git

the repositories do not exist yet: relaunch, boot creates them

keys

keys.txt is not readable by 99:100, or a line has no TAB

oauth

a variable is still a placeholder, or BASE_URL is not https

token_store

FASTMCP_HOME is not under /data: tokens would not survive

funnel

the Funnel is off, or publishes a port other than PORT

node_key

the node key still has an expiry date

public_dns

the BASE_URL hostname does not resolve

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

append

no

change a phrase or a number

edit_file

yes

rewrite the file, or create it

write_file

yes ("new" if new)

write a PDF or a binary

write_binary

yes

move, rename, trash

move_path

no

know whether something exists, and where

search

know which files exist

list_files

know whether a tree changed at all

manifest

know how big a dataset is, how dirty, how many commits

dataset_status

know when something changed, and get its hash

history

read text

read_file

read a PDF or a binary

read_binary (needs a sandbox)

read a whole tree at once

archive (needs a sandbox, mind pattern, mind the client's ceiling)

read how it was before

read_at

see what changed between two moments

diff

make a new, empty dataset

dataset_create

roll a whole dataset back to a revision

dataset_restore

the manifest

empty Trash/ before a date

trash_purge

destroy a dataset

dataset_drop

the manifest

which datasets exist, and which are locked

vault_status

the manual, whole or one card

reference_guide

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

append block

64 KB

listable files

3,000

search lines

200

diff

60 KB (truncates, does not fail)

archive input

30 MB uncompressed

archive output

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

dataset ... is protected: a key is required

the key is in the project's instructions

no such dataset

vault_status lists them; the dataset goes in dataset, not in path

path must be relative to the dataset

the dataset is repeated at the head of path: drop it

path is relative to the dataset, not absolute

drop the leading /

CONFLICT: expected sha ...

re-read, reconcile, retry

the file already exists

you used "new" on an existing file

the file does not exist

the opposite: pass "new"

old_text NOT found

re-read and copy the exact fragment

old_text found N times

widen the context until it is unique

path not allowed

a segment is .., .git, or a lockfile: those names never appear in a path

destination already exists

move_path never overwrites

more than 3000 files

go one level deeper

block too large

append is not for rewrites

too many datasets

the vault is full at 200: nothing to widen

has a key and cannot be dropped

take its line out of the key registry on the server first

CONFLICT: expected manifest ...

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

vault.py

the engine: VaultRoot and Dataset

server.py

the MCP tools; parameters in the schema, prose in the guide

preflight.py

the blocking startup checks, and the IP-filter parser

reference-guide.md

the manual: a short model page, then one card per command, served by reference_guide()

entrypoint.sh

init, permissions, privilege drop, preflight, start

Dockerfile · requirements.txt

the image

archivist-mcp.xml

Unraid template, every field documented

archivist-icon.png

the icon, used in two places — see below

test_vault.py

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

FastMCP

Jeremiah Lowin

Apache-2.0

Tailscale

Tailscale Inc.

BSD-3-Clause

Model Context Protocol

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.

A
license - permissive license
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

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

  • F
    license
    Not graded
    quality
    Not graded
    maintenance
    An 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
  • A
    license
    Not graded
    quality
    C
    maintenance
    An 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.
    16
    60
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Remote 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
  • A
    license
    Not graded
    quality
    A
    maintenance
    Authenticated remote MCP server that exposes a private GitHub-hosted Obsidian vault to Claude, enabling list, read, write, and search operations on notes.
    12
    MIT

View all related MCP servers

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

View all MCP Connectors

Latest Blog Posts

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