shelfmark
A local, privacy-first document catalogue with an MCP server. Point it at your document tree(s) and it gives Claude Code — or any MCP client — structured, governed search over what you have: by filename, author, slide title, document type, folder purpose, client, and year.
Metadata only, by design. No document body text is ever extracted or indexed, so nothing in the catalogue can leak file content into a prompt. What is indexed: paths, filenames, sizes, OOXML properties (author, company, title, slide titles), content hashes, and classification facets derived from your own rules.
Your roots are the trust boundary. Symlinks are not followed — a link
inside a root reads as an ordinary file and would otherwise walk straight
out of the tree you configured, and hash opens files. Skipped links are
reported, never silently dropped. To index another tree, add it as an extra
root: the boundary widens by saying so in config, not by planting a link.
The catalogue itself is refused inside any root, because a database that
indexes itself grows on every refresh.
Slide titles come from the deck's own properties, and most decks never set
them: PowerPoint files in Slide 1 … Slide 12, generator libraries
(pptxgenjs, python-pptx, HTML→deck exporters) do the same, and PowerPoint's
own filler is PowerPoint Presentation. Those are dropped rather than
indexed — a deck with no real headings reports none, instead of twelve
titles it does not have. Genuine titles on the same deck are kept, at their
original slide numbers.
Why not just grep / Spotlight / embeddings?
- Structure, not just matching.
browse_folderanswers "what do I have here" — which full-text search structurally cannot. Facets separate what a file is (doc_type) from what its folder is for (context_type), and who may reuse it (rights) from whether it may leave (confidential). - Governance enforced in the server, not the prompt. Files matching
your private/secret patterns are RESTRICTED: no tool returns their path,
name, metadata or content, no argument overrides it, they are never
opened for hashing, and the DB is opened read-only.
corpus_stats()reports a single corpus-wide count of sealed files and nothing else about them — not which root, not which folder. That count is the one thing disclosed, deliberately: silence about it would misrepresent the size of the corpus. - Current without being told, honest when it is not. The MCP server
refreshes its own index while it runs, so nobody schedules anything and
no agent has to remember. When it cannot — never refreshed, refresh
failing, clock unusable — every tool says so above its answer, and
corpus_stats()compares index against disk in full. An index that silently stops updating still answers confidently from a frozen snapshot, which is worse than no index at all. - Identifier-safe search. Real corpora are full of
ACME-2026-014style identifiers whose hyphens break naive FTS. Queries are quoted and retried so a stray quote returns results, not a parser error.
Install
uv tool install shelfmark # or: pipx install shelfmark
# from a checkout:
uv tool install /path/to/shelfmark
Python ≥ 3.11. macOS and Linux. Email ingestion is optional and pulls extra
dependencies: uv tool install "shelfmark[email]".
Quickstart
shelfmark init # writes ~/.config/shelfmark/config.toml
$EDITOR ~/.config/shelfmark/config.toml # set your [[roots]]
shelfmark refresh # first build (add --no-hash for a fast pass)
shelfmark review # answer a few questions -> rights get set
shelfmark stats # census of what it found
Register with Claude Code:
claude mcp add shelfmark -s user -- shelfmark-mcp
Then in a session: corpus_stats() to orient, browse_folder() to
navigate, search_docs() / get_file() to find and inspect.
Configuration
Everything corpus-specific lives in config.toml — the code ships with
neutral defaults only. Resolution order: --config flag →
$SHELFMARK_CONFIG → ~/.config/shelfmark/config.toml. See
config.example.toml for the full annotated reference. Highlights:
| Section | What it controls |
|---|---|
[[roots]] |
The trees to index. One unlabelled primary root; extra roots get a label prefix. |
[index] |
Where the SQLite catalogue lives. Must be outside every indexed root and outside cloud-synced folders — it is a mutating binary DB. |
[privacy] |
Regexes for secrets and private subtrees → RESTRICTED. Built-ins already cover .env, key/cert files, id_rsa, backup codes, identity documents. |
[authors] |
Regexes for your own name/company, for client authors, and for generator tools — drives OWN/REFERENCE classification from OOXML authorship. |
[rights] |
Path-prefix rules for the two-axis model: rights (may I reuse it) × confidential (may it leave). |
[facets] |
Which top-level folders count as work/personal; where client/project names sit in the path. |
[doc_types] / [context_types] |
Extra filename/folder rules, checked before the built-in bilingual (EN/ES) defaults; built-ins can be disabled by name. |
The two-axis rights model
rights and confidential are separate axes, deliberately:
rights— may I reuse it?OWN/REFERENCE/RESTRICTEDconfidential— may it leave?0/1
A deck you authored for a client is OWN (the method is yours) and
confidential (that artefact does not leave). Conflating the two is how a
corpus ends up mostly-RESTRICTED and unsearchable. shareable_only=True
means positively classified: confidential=0 AND rights IN (OWN, REFERENCE) — never-reviewed files are held back.
Getting rights set: shelfmark review
Most files carry no OOXML author — a corpus is mostly PDFs, markdown and
images — so authorship classifies almost nothing and path rules are the
only mechanism that can. They ship empty, which is why a fresh catalogue
is mostly UNKNOWN and shareable_only comes back nearly empty.
That knowledge is yours, not the corpus's, so review asks for it — biggest
win first, with what the catalogue knows on screen:
[1/8] Projects
493 unclassified of 662 files · folder reads as: pitch
is: note 202, document 89, code 88, pdf 51
authored by: R. Okonjo (31), A. Lindqvist (12), openpyxl (9)
e.g. programme-overview.pptx · phase-two-proposal.pptx
answer [skip]:
Answer own, own-private, reference, client, personal, scratch
or skip. On a ~1,900-file corpus, five answers settled 80% of the
unclassified files and eight settled 90%.
shelfmark review # dry run — shows what it would write
shelfmark review --apply # writes the prefixes, re-derives rights
It stops whenever you do, only ever asks about subtrees that are still
unclassified (so re-running continues where you left off), and writes
config, never rights values in the data — so any answer is undone by
editing the config and re-running shelfmark rights. Where authorship is
lopsided enough to be evidence it offers a default; where it is thin it
stays quiet rather than guessing.
Editing classification rules
Two things people trip over, learned the hard way:
-
Anchor short regex alternatives on both sides. An unanchored
rfimatches inside "Dockerfile" and the Spanish word "perfil". Before adding or deleting an alternative, list the filenames it actually matches. -
A rule edit does not relabel existing files. The builder is incremental, so after any rule change run:
shelfmark reclassify all # dry run — shows what would change shelfmark reclassify all --apply
Do not use
build --rebuildfor this: it re-walks everything (and on cloud-synced trees drags every evicted file back down) when the filenames are already in the DB.
Keeping it current
shelfmark refresh # build + rights + prune + assertions
shelfmark refresh --if-needed # only if a write landed or the index is old
refresh is incremental by (size, mtime, cloud-residency) — a couple of
seconds over a ~30k-file tree — and it asserts its own correctness on every
run: walk coverage (an OS-denied walk must not read as success), guarded
pruning of deleted files (refused when it looks like a mass deletion or an
unmounted root), and governance invariants (no RESTRICTED row in the search
index, no unsealed secret, no unsearchable non-RESTRICTED row). Results
land in REFRESH_STATUS.json next to the DB, and the MCP server reports
them on every corpus_stats() call.
Any guard that declines to touch the index says so on stderr and exits non-zero — you never get a clean-looking summary hiding a refusal.
When a guard stops you
The two size guards cannot tell "the root was unreadable" from "those files really were deleted" — both look like a short walk. So they refuse, name both possibilities, and leave the index untouched:
FAILED — walk saw 63/123 catalogued files, below the 80% floor — either the
root was unreadable to this process or that many files really went away.
Index NOT updated; re-run with --force if the deletion was real.
Check which it was. If the files are genuinely gone:
shelfmark refresh --force # accept the short walk, prune past the ceiling
--force backs the catalogue up to catalog.db.bak-preprune before
deleting anything. If instead a root was merely unmounted or unreadable,
fix that and refresh normally — the rows are still there.
You do not schedule this
The MCP server keeps its own index current. It is spawned by your client,
lives for the whole session, and refreshes once on startup and then
whenever refresh.max_age_seconds has passed. Nothing to install, no
timer to configure, no command to remember.
That works because a resident process the client already starts is a
better trigger than a scheduler: it inherits the same file access the
client has, and it is running exactly when you are asking questions. If
you maintain the catalogue some other way, shelfmark-mcp --no-auto-refresh leaves it alone.
Editor hooks are now an optimisation, not the mechanism — they cut the
delay between an agent writing a file and that file being searchable, from
one refresh interval to the end of the turn. With Claude Code, in
~/.claude/settings.json:
{
"hooks": {
"PostToolUse": [{"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [{"type": "command", "command": "shelfmark mark-dirty"}]}],
"Stop": [{"hooks": [{"type": "command",
"command": "shelfmark refresh --if-needed >/dev/null 2>&1"}]}]
}
}
mark-dirty drops a marker only when a write landed under an indexed root
(near-free on every other write); refresh --if-needed picks it up at the
end of the turn.
If you also want it current with no client running
Only worth it for non-MCP use, or an instant first query on a very large
tree. On Linux, a systemd --user timer running shelfmark refresh --if-needed is clean.
On macOS, the obstacle is TCC, not launchd. A LaunchAgent is denied
~/Documents, ~/Desktop and ~/Downloads by default; os.walk swallows
the error, so the build walks a handful of files and exits 0 — every
layer reports success while the index never updates. Grant Full Disk Access
to the interpreter that runs the job and a LaunchAgent is fine. The
coverage assertion catches the ungranted case either way: the refresh
fails loudly rather than quietly indexing nothing.
Content hashing and duplicates
The refresh never reads file contents (it must stay fast). To populate hashes for duplicate detection:
shelfmark hash # reads every unhashed, non-sensitive file
shelfmark hash --limit 2000 # chip away at it
Sensitive rows are never opened. On cloud-synced trees, dataless placeholder files are skipped — reading one silently yields the hash of the empty string, which would make distinct files look identical. Re-run after large materialisations.
MCP tools
| Tool | What it answers |
|---|---|
corpus_stats() |
What is here overall + an honest freshness line. Call first. |
browse_folder(prefix) |
What is inside a folder: counts, sizes, facet mix. |
search_docs(query, …) |
Metadata full-text search with facet filters. |
get_file(path) |
Full record for one file: rights, authorship, slide titles, identical copies, on-disk status. |
search_emails(query, …) |
Full-text over an ingested .pst/.msg email archive (optional). |
Result lists always say when they are cut (showing 100 of 195 …), unknown
filter values are reported as bad filters with suggestions (never as an
empty corpus), and excerpts mark their truncation point.
What is deliberately not built
- No embeddings. Metadata FTS + facets first; use it, note what you couldn't find, and let real misses decide. If misses cluster on "I know what it said, not what it was called", the fix is content extraction, not embeddings.
- No content extraction. Body text stays out of the index by design.
License
MIT — see LICENSE. Free to use, modify and redistribute.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file shelfmark-0.1.1.tar.gz.
File metadata
- Download URL: shelfmark-0.1.1.tar.gz
- Upload date:
- Size: 98.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8121a27f403ba4001cda654134559525fc4c36c176a42d390089ef27c6261230
|
|
| MD5 |
d688fd1773f97af0942a849ea5804c09
|
|
| BLAKE2b-256 |
af232023c71aca6aca9fbf009c804b651647605a5a4dea7b1d3e0db98d224540
|
Provenance
The following attestation bundles were made for shelfmark-0.1.1.tar.gz:
Publisher:
release.yml on Dankaro-projects/shelfmark
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shelfmark-0.1.1.tar.gz -
Subject digest:
8121a27f403ba4001cda654134559525fc4c36c176a42d390089ef27c6261230 - Sigstore transparency entry: 2370797977
- Sigstore integration time:
-
Permalink:
Dankaro-projects/shelfmark@1b67a78b27a732b7d57d65966f11bd5480067ec4 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/Dankaro-projects
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1b67a78b27a732b7d57d65966f11bd5480067ec4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file shelfmark-0.1.1-py3-none-any.whl.
File metadata
- Download URL: shelfmark-0.1.1-py3-none-any.whl
- Upload date:
- Size: 82.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
de96a8ddeb6087736fb40d54122bc111a0c34b60a4f16659b7c98a1b8a66ee13
|
|
| MD5 |
068c94b5f5251b0fcadcc030eeb39b49
|
|
| BLAKE2b-256 |
56ded2c6c343d20da764a9d602deb57b2113d299c372de54cc0ab293ba24ace2
|
Provenance
The following attestation bundles were made for shelfmark-0.1.1-py3-none-any.whl:
Publisher:
release.yml on Dankaro-projects/shelfmark
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
shelfmark-0.1.1-py3-none-any.whl -
Subject digest:
de96a8ddeb6087736fb40d54122bc111a0c34b60a4f16659b7c98a1b8a66ee13 - Sigstore transparency entry: 2370798003
- Sigstore integration time:
-
Permalink:
Dankaro-projects/shelfmark@1b67a78b27a732b7d57d65966f11bd5480067ec4 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/Dankaro-projects
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@1b67a78b27a732b7d57d65966f11bd5480067ec4 -
Trigger Event:
push
-
Statement type: