Obsidian MCP (pgvector + Ollama, self-hosted)
It is a self-hosted MCP server that gives AI agents durable, shared memory stored as plain markdown in an Obsidian vault, with full-text and semantic search, a wikilink graph, file transfer, and an admin/ops layer.
Search and discovery: keyword search (PostgreSQL tsvector), semantic/vector search via pgvector embeddings, list/recent notes, tags, and a live vault-guide tool.
Read/write markdown notes: structured note reads with sections/frontmatter/outlines/hashes, atomic create/edit/move/delete, structured frontmatter mutation, dry-run diffs, soft-delete to
.trash, and stale-read guards viaexpected_hash.Wikilink graph operations: backlinks, outgoing links, neighborhood traversal, semantically related notes, orphan detection.
Raw file access: read/write/list/delete arbitrary vault files (PDFs, images, data files), byte-transport encoding options, size caps, and soft delete.
File transfer capabilities: human-mediated upload and download links, upload status polling, and importing from public HTTPS URLs.
Auth and operations: API keys (
omcp_) with read/readwrite scopes, OAuth 2.0 PKCE flow, admin control panel, per-tool usage logs, rate limits, concurrency controls, and multi-user mode with per-user vaults.
Provides tools to search, read, write, and manage an Obsidian vault, enabling AI agents to interact with notes, wikilinks, tags, and frontmatter.
Obsidian MCP Server
A memory system for your AI agents — stored as plain markdown you can open in Obsidian.
A self-hosted Model Context Protocol server that gives every agent you connect a durable, shared place to remember things. The storage isn't a vector database you can't see into: it's a folder of markdown files in your Obsidian vault, backed by full-text and semantic search and by your own wikilink graph. Obsidian is the human window onto it — open a note, read exactly what an agent wrote about you, correct it, delete it, or take the whole folder somewhere else. Self-describing, too — agents read what you read, link what you link, and pick up your folder layout, frontmatter schema, and tag conventions on the first call instead of being briefed from scratch every session.
To be precise about the scope: what the server supplies is MCP-accessible storage, keyword and semantic search, and graph operations over markdown notes, for whatever MCP clients you connect. The agents direct their own reads and writes. There is no automatic extraction, consolidation, or decay pipeline running behind them — an agent remembers something because it wrote a note, and forgets it because someone deleted one.
Stack: Python 3.12, FastAPI, PostgreSQL with pgvector. Pluggable
embeddings (Ollama bge-m3, or OpenAI text-embedding-3-{small,large}).

Contents
Related MCP server: Vault Cortex Obsidian MCP Server
Why this exists
There are three things going on here, and they're more interesting together than apart.
1. Agent memory that you can actually read
If you let an agent run for a while, it needs memory. Most setups solve this with an opaque vector store, a SQLite blob, or a managed "memory" service that you can't see into. That works until you want to know what the agent thinks it knows about you, or you need to correct something, or you want to understand why it just made a weird suggestion.
This server gives you a different deal. Agent memory lives as markdown files in your vault. Folder structure, file names, frontmatter, all visible. You can open the file in Obsidian and read it. You can edit it. You can delete it. You can grep it. The agent's "memory" is a human-auditable artifact that sits in the same place as your own notes, with the same tools available.
The home lab is the use case that sold me on this. My vault has notes on the rack, the network, and every Home Assistant integration. I can say "set up a night-light mode in the master bathroom, 1% after 11pm" and a sysadmin agent finds the right config, makes the change, and updates the doc in the same pass. Six months later when I've forgotten how it works, the answer is in the vault, not buried in some chat history I can't search.
The semantic search and wikilink graph still work over that material, so retrieval is fast and conceptual. But the substrate is files you own, not a black box.
2. A shared memory layer between you and your agents
The other half runs the other way: the vault isn't only the agents' memory, it's mine. I think of my Obsidian vault as my exocortex. The "big me" that includes notes, calendars, scripts, search, and AI assistants is substantially more capable than the "small me" of the biological brain alone. It's also where I do most of my thinking, because writing something down is itself a form of thought.
The problem is that until recently, the vault was passive. I had to go find things. Agents that wanted to help me had to be briefed from scratch every session, and they had no way to see what I'd already written about a topic.
This server fixes that. Now the same vault feeds my own daily writing and any agent I plug into it. The agent reads what I read, links what I link, follows the same wikilinks, sees the same frontmatter. When I write a project note on Sunday, my Monday-morning briefing agent already knows about it. When the agent leaves notes from a research session, they show up in my normal Obsidian search.
A concrete version of this: I'll spend a session in Claude Code on a project, wrap up, push the commits, and then just say "update Obsidian." The agent reads the vault guide, figures out where project notes live in my structure, picks the right format and frontmatter, and leaves a session log I can later roll into a status report. No path-passing, no telling it what to write — the conventions are already in the vault, and it follows them.
That's the exocortex idea made concrete: one place that holds context, and both the human and the agents reading and writing into it on the same terms.
3. The vault follows you
The thing that still surprises me is that this is internet-facing. Same vault, same notes, reachable from anywhere I happen to be talking to an agent — Claude in a browser tab, Claude on my phone on the train, an n8n workflow firing on a schedule, a Claude Code session on whatever laptop is in front of me. All behind OAuth or a bearer key, all hitting the same source of truth.
So when I hear an interesting podcast on a commute and want to think out loud about it with Claude on my phone, the agent isn't starting cold. It can pull up what I've already written on adjacent topics, surface a note I half-finished six months ago, and at the end of the conversation suggest updates and write them in. The vault doesn't have to be near me to be the thing I'm working in. (A session away from the keyboard walks through exactly one of these.)
A session at the keyboard
To make the abstract concrete, a short transcript from a real session. Tool calls inline; the user types in plain English and the agent picks the right tool.
you > find anything I've written about why retrieval-augmented
chat is hard
claude
→ semantic_search(query="why retrieval-augmented chat is hard")
• 2025-12-03 - RAG failure modes.md
• 2025-10-18 - Chunking tradeoffs.md
• 2024-06-22 - Vector recall vs precision.md
the closest hit is your December note on RAG failure modes. let
me see what it's connected to.
→ get_neighborhood(path="2025-12-03 - RAG failure modes.md",
depth=1)
8 connected notes. notable ones:
• "Hybrid search" — a re-ranking design you sketched but
didn't ship
• "Out-of-distribution queries" — a half-finished draft from
October
• "Embedding model choice" — links to bge-m3, your current
model
you have three connected pieces here: a failure-modes catalog,
an unshipped hybrid-search sketch, and an abandoned O-O-D draft.
want me to summarize all three together, or pull up the draft
so you can decide whether to finish it?
you > pull up the draft, and tag it #revisit-2026 in frontmatter
while you're there
claude
→ read_note(path="2025-10-18 - OOD queries (draft).md")
→ set_frontmatter(
path="2025-10-18 - OOD queries (draft).md",
updates={"tags": ["draft", "rag", "revisit-2026"]}
)
here's the draft, and the tag is on. you stopped right before
the section on confidence thresholds; the open question you
left yourself was…Two things to notice. First, the agent didn't need to be told what
folder to look in or what tools to use — it picked them. Second, the
write at the end is structured (set_frontmatter mutating YAML, not
a regex over the file body), so the note round-trips cleanly. The
self-describing vault and the wikilink graph are doing the work that
makes this feel natural.
A session away from the keyboard
The transcript above is the easy case: I'm at a desk, I can see what the agent is doing, and Obsidian is one alt-tab away. The session that actually changed how I think about this server had none of that.
I was out walking with a health podcast in my ears — a long one, two people who clearly disagreed with each other, an hour of it. I had my phone and no intention of going home to a laptop. So I pulled the episode's transcript, handed it to Claude on my phone, and we talked it through while I kept walking: what the actual claim was, which parts I already had notes on, where it cut against something I'd decided months ago and written down at the time.
The agent had the vault the whole way. It surfaced what I'd already written on the topic, flagged that two dates in an older note were wrong, and asked whether a decision I'd recorded last year still stood given what the episode argued. By the time I got back it had written all of it in: the health-related decisions I'd actually landed on during the walk, the date corrections in the old note, a couple of new notes on the episode itself — and, because the conversation kept circling back to it, a durable note on how I decide which experts to trust on medical questions in the first place. That last one is the artifact I keep returning to. It wasn't about the episode at all; it was the reasoning underneath a whole class of decisions, and it now sits in the vault where the next agent will find it.
I never opened Obsidian. Not on the walk, not when I got home. The whole session — retrieval, argument, correction, and the writing that came out of it — went through an agent, and the vault is simply where it landed. Obsidian is how I check the work afterwards, not how the work gets done. That inversion is most of the reason this project looks the way it does.
What's in the box
The server exposes 25 MCP tools across five families, plus the auth and ops layer around them.
Search and discovery
keyword_search(query, folder?, tags?, frontmatter?, limit=20), full-text via PostgreSQLtsvector; the text-search config(s) are configurable viaFTS_CONFIGS(see Full-text search language(s))semantic_search(query, folder?, tags?, frontmatter?, limit=15), vector similarity via pgvector, one preview chunk per notelist_notes(folder?, limit=50), sorted by modified timeget_recent(folder?, limit=20), recently changedget_tags(limit=50), tag and countget_vault_guide(), the Obsidian primer plus this vault'sCLAUDE.md, served live
Read and write
read_note(path, section?, offset=0, limit?)returns a structured result —path,title,tags,frontmatter_yamland a JSONfrontmatterview,heading(section reads),content, and truncation as data (truncated,offset,next_offset,total_chars,outline,notice). Bounded byMAX_READ_RESPONSE_CHARS(default 40,000) — see Response size limits.section=<heading>returns one section's body instead of the whole note;offsetcontinues a truncated read.create_note(path, content), atomic write, refuses overwriteedit_note(path, …)with four mutually exclusive modes: full replace (default),append=True,find=…(with optionalreplace_all), orsection=<heading>(ATX headings, supportsParent/Childpath-style and#Nordinal disambiguation).dry_run=Truereturns a unified diff without writing. Legacy clients may useoperation="append";operation="replace"explicitly selects full replace.move_note(from_path, to_path, rewrite_links=False), relocates and optionally rewrites incoming[[Old]],[[Old|alias]],[[Old#anchor]],![[Old]], and[[folder/Old]]references in source notesdelete_note(path, permanent=False), soft-delete to.trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex>by default, via a single non-replacing rename, so it never overwrites an existing trash entry (a filesystem that cannot do that rename makes the soft delete refuse with a named error rather than fall back).permanent=Trueunlinks.set_frontmatter(path, updates, remove?), structured YAML mutation. Body is byte-identical when only frontmatter changes.
File access (non-markdown)
Raw read/write/browse of arbitrary vault files (PDFs, images, skill assets, data files) — distinct peers to the note tools, which stay markdown-only. Pure byte transport: no server-side PDF/text extraction, no embedding or indexing of non-markdown files.
read_file(path, encoding="auto", offset=0, limit?), returns text-like files as text, images as an inline image block that renders in-client, and other binaries as a base64 string.text/base64force the form. Refuses files overMAX_FILE_READ_BYTES(default 10 MB); text results are additionally bounded byMAX_READ_RESPONSE_CHARSand continue viaoffset.hash_only=Truereturns the whole file'scontent_hashwithout content; base64 results include that hash in their header. Text results remain plain text.write_file(path, content, encoding="base64", overwrite=False), lands a file in the vault; base64 for binary,textfor UTF-8. No-clobber by default, auto-creates parent dirs, atomic write. Capped atMAX_FILE_WRITE_BYTES(default 25 MB).list_files(folder=".", pattern="*", recursive=False, limit=200),ls-style browse of files and subdirectories with size and mtime, glob-filterable and result-capped.delete_file(path, permanent=False), soft-deletes a non-markdown file to.trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex>with a single atomic rename. Refuses markdown (that isdelete_note), directories, and symlinks.
All four reuse the path-traversal guard and exclude any path with a
component starting with . (dot-directories and dot-files)
(.obsidian, .git, .trash, …), matching the indexer's visibility
rule.
Guarding edits against stale reads
Pass a read's content_hash as expected_hash when editing, updating
frontmatter, moving or deleting a note, overwriting a raw file, or deleting
a raw file. The canonical token is sha256:<64 lowercase hex>, computed
over the complete raw file bytes; a section or truncated read_note still
returns the whole-file hash. For raw files, use read_file(hash_only=True)
or the base64 header. Do not hash the returned text yourself.
A stale token refuses the operation before mutation, with a final
MCP-REFUSAL JSON line naming stale_precondition and the current hash.
Re-read and reconsider the edit before retrying. Moves bind the source
note only; moves and deletes still allow an in-place edit after their
preflight comparison. Overwrites retain their separate in-call byte check.
Successful publishing writes report a new hash when available.
The argument is optional by default. WRITE_PRECONDITION_REQUIRED=true
requires it on the supported destructive calls; enable this only after
clients supply tokens. Creation is exempt and refuses a supplied token
as no_incumbent. Files above their read cap cannot be guarded.
File transfer
No MCP client can hand a tool the bytes of a file the user is looking
at, so write_file is only usable when the agent already has the
content. These tools close that gap with short-lived capability links,
redeemed over the public /transfer/* routes.
request_upload(path, overwrite=False, expires_in?), mints a single-use link bound to exactly one destination path. The human opens it, picks a file, and it lands atpath— nothing else can be written with it.check_upload(upload_id), reportspending/uploading/completed(with path, size, sha256 and MIME) /unknown(a stream started and the server never recorded how it ended — read the path before re-minting) /revoked(the credential or vault root changed under the link) /expired, scoped to the identity that minted it.request_download(path, expires_in?), mints a link the human can save one vault file from. Usable more than once until it expires, and bound to the file's exact bytes at mint time.import_from_url(url, path, overwrite=False), fetches a public https asset straight into the vault under an explicit outbound deny policy (no private, loopback, link-local, metadata or tunnelled addresses, in any spelling, re-checked at every redirect).
The token travels in the URL fragment, which browsers never send, so
no server-generated request target or access log contains it. Uploads
are claimed before a body byte is read, published atomically with
no-clobber semantics, and bound at mint time to the file state they
were minted against — a link cannot silently undo an edit made while it
was waiting. MCP_HOSTNAME or BASE_URL must be set; without a public
origin the mint tools refuse rather than emit a localhost link.
Wikilink graph
get_backlinks(path, limit=50), notes linking TOpathget_links(path), outgoing links, both resolved and danglingget_neighborhood(path, depth=1, limit=50), undirected BFS over the resolved-link graph, capped at depth ≤ 5 and limit ≤ 200find_related(path, limit=10), semantic neighbors via averaged chunk embeddings and pgvector cosine distance, deduped per notefind_orphans(folder?, limit=50), notes with zero in or out resolved links
Auth and ops
API keys with the
omcp_prefix, stored as SHA-256 hashes, withreadandreadwritepermission scopes. Write tools refuse on read-only keys.OAuth 2.0 PKCE (S256) flow for public and confidential clients, including ChatGPT, Claude Desktop, and claude.ai. Dynamic registration defaults to both vault permission levels; the user chooses the actual grant on the consent screen.
Control panel (Jinja2, hand-written CSS, vendored Chart.js, nonce-based CSP) for keys, usage logs, indexer status, embedding-provider info, and a danger-zone reset.
Every tool call is logged to
usage_logswith name, params (truncated to 200 chars), duration, response size, and the calling credential's name — recorded at call time, so the audit trail survives deleting the key or OAuth client it describes./healthis unauthenticated and returnsstatusplus two capability fields:transfer_mount_check_available(the kernel supports the mount check transfer writes need) andvault_named_staging_fallback_active(a write has actually staged under a name on this process).
Every write — note tools, write_file, uploads and imports — stages
the new bytes in a temporary inode, fsyncs them, and only then
publishes. Creation publishes with a kernel-atomic hard link that
refuses to clobber; move_note and the soft delete publish with a
single non-replacing rename; an overwrite is a same-directory rename
onto the destination. The destination directory (and any directory the
call created) is fsynced afterwards, so a crash mid-write can neither
truncate a note nor lose one the server reported as written.
Staging happens in an unnamed inode wherever the filesystem supports
one, so no temporary name is ever visible in the vault. On a mount that
refuses that (some NFS exports do), those writes refuse with an error
naming VAULT_ALLOW_NAMED_STAGING_FALLBACK; setting that flag takes
named staging back on both write paths as a declared, weaker guarantee.
See System requirements.
vs. other Obsidian MCP servers
There are several existing MCP servers for Obsidian, and most of them solve a different problem than this one. The lightweight ones are glue over Obsidian's Local REST API plugin or the filesystem: they let an agent reach the files, but don't build any infrastructure of their own. They're great if "I just want Claude to read my notes" is the goal and you keep Obsidian running locally.
This server is on the other end of the spectrum: a real backend with a persistent index, semantic retrieval, a wikilink graph, OAuth, and an admin UI. The cost is Postgres and Docker. The benefit is everything you can build on top of that.
This server | ||||
Persistent index (Postgres) | ✅ | — | — | — |
Semantic search (vectors) | ✅ | — | — | — |
Wikilink graph queries | ✅ | — | — | partial |
Runs without Obsidian open | ✅ | — | ✅ | — |
OAuth 2.0 client flow | ✅ | — | — | — |
Multi-user / per-user vaults | ✅ | — | — | — |
Admin UI + usage logs | ✅ | — | — | — |
Atomic writes + dry-run diffs | ✅ | — | — | — |
Setup tax | Postgres + Docker | Obsidian + REST plugin | Python only | Obsidian plugin |
Comparison reflects each project's documented features at time of writing; verify the specifics before betting on them.
vs. hosted memory systems
The comparison that matters more, now that most of my vault traffic is agents rather than me, is against memory as a service: your agent calls an API, the service stores what it's told, and it hands back what it judges relevant later. mem0, Zep and Letta are the names people usually reach for. What follows is about that architecture — memory behind a service boundary — not about any one product's current feature list, which moves faster than a README can track.
The difference is where the memory lives and who can open it.
Readability. When memory sits behind a service API, reading it means whatever endpoint or console the service exposes, in whatever shape it stores. Here the memory is the artifact:
Health/2026-08 - Trusting expertise.md, in a folder, in your editor, ingrep. There's no gap between what the agent stored and what you can look at.Shared with you, and between agents. A memory service is generally scoped to an application and its users; the human's own writing is a different system. Here it's one corpus. I write into it by hand, and every connected client — Claude Desktop, Claude Code, Claude on the phone, an n8n workflow — reads and writes the same files on the same terms. A note I type on Sunday is context for an agent on Monday with no import step.
Portability. The exit path from a folder of markdown is
cp -r. No export format, no migration script, no question about what you'd be left holding if a project stopped being maintained. That's a property of files, not something this server does for you.Self-description. The rules live in the corpus rather than in client config.
CLAUDE.mdat the vault root tells every agent, on its first call, where things go and what frontmatter they carry, so conventions are versioned next to the notes they govern.
What the hosted shape buys you in exchange is real, and worth saying plainly. There's no Postgres to run, no pgvector version to keep current, no container to babysit — you get a memory layer by adding a dependency, which is a genuinely better trade for most people. And systems in that class typically do work this server deliberately doesn't attempt: pulling facts out of a conversation automatically, reconciling ones that contradict each other, and scoring relevance or decaying old memories so they stop crowding out new ones. Here an agent remembers something because it decided to write a note, and the judgment about what's worth keeping is the agent's, not the server's. If you want memory that curates itself, that's a fair reason to pick the other shape.
vs. an agent with raw file access
The other baseline isn't an MCP server at all: point Claude Code, a
generic filesystem MCP, or any agent with file tools straight at the
vault folder. That works — until a write goes wrong. An agent
rewriting a whole file from its memory of an earlier read will
eventually clobber a note, follow a symlink somewhere it shouldn't,
or "tidy up" your .obsidian config. Nothing in a raw file API
pushes back. This server's write path is shaped by exactly that kind
of incident, and it assumes the caller will eventually do something
wrong:
Targeted edits instead of rewrites.
edit_notecan address a find-string or a single section rather than replacing the file, anddry_run=Truereturns the unified diff before anything lands.set_frontmattermutates YAML structurally and leaves the body byte-identical.No-clobber defaults.
create_noteandwrite_filerefuse to overwrite an existing file; replacing one is an explicit opt-in.Atomic writes. Content is staged and renamed into place against a descriptor opened at validation time — a note is never left half-written, and the file that gets replaced is the file that was checked.
Reversible deletes.
delete_noteanddelete_filesoft-delete into.trash/with a non-replacing rename;permanent=Trueis the explicit escape hatch, not the default.Kernel-proved containment. Paths resolve under the vault root via
openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS), writes refuse a symlink as the final component, and dot-directories (.obsidian,.git,.trash) are out of reach of every tool.Bounded responses. Reads are capped and truncation is data (
truncated,next_offset, an outline) rather than silent loss, so one huge note can't flood an agent's context into a bad edit.An audit trail. Every call is attributed to a key and logged; the control panel shows who touched what, and when.
When an agent misbehaves through this server you get a refused call,
a diff, a trash entry, and a usage-log line. When it misbehaves with
raw file access you get whatever git diff can recover — if the
vault was in git at all.
Who this is for
Homelab folks who already run Postgres and Docker, or are happy to spin them up. The setup tax is the price of admission for the semantic and graph layers.
People who keep an opinionated vault — task placement logic, frontmatter schemas, tag taxonomy — and want agents to follow those conventions on the first call instead of being briefed every session.
Anyone running more than one MCP client (Claude Desktop, Claude Code, Claude in a browser, n8n) against the same notes and tired of re-explaining the vault to each.
Folks who want agent memory to live as plain markdown files they can read, edit, grep, and version-control, not in an opaque vector store or a managed memory service.
Who this isn't for
"I just want Claude to read my notes" with the lightest possible setup. Use one of the filesystem-glue projects above; you don't need this.
Anyone unwilling to run a database. There is no SQLite fallback; pgvector is doing real work, and a managed Postgres with pgvector support is part of the stack.
People who want a turnkey hosted product. This is a self-hosted server you run yourself.
Control panel
The server ships with a built-in admin UI for the parts of operations that are easier to look at than to query: minting keys, watching the indexer, eyeballing tool-call traffic, and resetting embeddings when you switch providers.
Usage
Per-tool-call audit log with a 14-day request histogram. Every MCP call is recorded with the calling key, tool name, duration, and response size — useful for noticing a misbehaving agent burning tokens on something it shouldn't.

API keys and OAuth clients
Bearer keys with read / readwrite scopes for API clients, and a
separate OAuth 2.0 PKCE flow for clients like ChatGPT, Claude Desktop,
and claude.ai that expect a proper authorization-code dance. The OAuth
server supports public (none) and confidential (client_secret_post)
token-endpoint authentication plus refresh tokens.
Each client's page lists its grants — one row per /authorize approval,
not per token — with a Revoke control and a permission select per grant,
so revoking really ends the session instead of leaving a refresh token
to mint a replacement. Revoked and expired rows stay listed, dimmed, for
a week.

Vault browser
A read-only file tree of the mounted vault, mostly for sanity-checking that the container sees what you think it sees.

Settings
Indexer status, current embedding provider and model, vault path, and the danger zone: Reset embeddings (drops and recreates the embeddings column at the configured dimension — use it when switching providers) and Force re-embed (keeps the column, clears every note's embedded-content hash so the next pass re-embeds the vault). Both pause the indexer while they run.
The dashboard separates two things that used to be conflated: Last
run is the indexer's own heartbeat — the last pass that completed,
whether or not anything had changed — and Last change detected is
the newest indexed_at on any note. A quiet vault makes the second one
old while the indexer is perfectly healthy.

Quick start
Deploying on a VPS from scratch? See
DEPLOYMENT.mdfor the full walkthrough: Postgres setup, Caddy and TLS, vault sync via Nextcloud, and the gotchas that bite first-time deploys.
The bundled Caddy configuration fails closed on /admin, /api, and
/authorize; replace its placeholder basic-auth hash before starting it.
Prerequisites
Docker and Docker Compose
A PostgreSQL 16 instance reachable from the container, with
pgvector0.8.0 or newer installedEither an Ollama instance running
bge-m3, or an OpenAI API key. Anything that speaks the OpenAI embeddings protocol works (Azure OpenAI, OpenRouter, Together, etc.).Linux, kernel 5.6 or newer (see below)
System requirements
The server checks these at startup and tells you which one failed rather than misbehaving later.
Linux kernel ≥ 5.6. Every directory below the vault root is opened
with a single openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS), which is what makes the kernel — not the
application — prove that a write stayed inside the vault. There is no
fallback: on an older kernel, or under a container seccomp profile that
blocks openat2, the server logs the reason and exits non-zero.
Kernel ≥ 5.8 for file transfer. statx()'s STATX_MNT_ID is how a
publication refuses a destination that sits on a different mount than
the staging directory (a nested bind mount under the vault root would
otherwise fail only after a whole upload body had streamed). Below 5.8
the server logs one warning and starts: request_upload,
import_from_url and PUT /transfer/upload refuse, and everything else
— reads, note writes, search, downloads, the panel, OAuth — is
unaffected. /health reports it as transfer_mount_check_available.
pgvector ≥ 0.8.0. Filtered semantic search needs
hnsw.iterative_scan, which landed in 0.8.0. An older extension accepts
the setting as an unknown placeholder and silently runs a plan that
drops post-filter candidates — silently worse search results — so the
server exits instead. Fix with ALTER EXTENSION vector UPDATE or a
newer database image.
Filesystem. Case-sensitive and non-normalising (ext4, xfs, and the
usual bind mounts). It must support hard links within the vault root and
renameat2(RENAME_NOREPLACE); without those, note creation, move_note
and the soft delete refuse with a named error rather than degrading to a
publish that can clobber. O_TMPFILE is wanted but optional: where it
is unavailable, set VAULT_ALLOW_NAMED_STAGING_FALLBACK=true to accept
named staging instead (see Configuration). macOS and
Windows hosts are out of scope; run the container on a Linux VM.
1. Clone, configure, point at your vault
git clone https://github.com/maxkuminov/obsidian-mcp.git
cd obsidian-mcp
cp .env.example .env
$EDITOR .envIn docker-compose.yml, point the /obsidian volume at your vault:
volumes:
- /path/to/your/vault:/obsidian2. Pick an embedding backend
Option A, OpenAI (zero local infra):
EMBEDDING_PROVIDER=openai
OPENAI_API_KEY=sk-...
EMBEDDING_DIMENSIONS=1024
OPENAI_EMBEDDING_MODEL=text-embedding-3-smallThe server validates OPENAI_API_KEY at startup and refuses to boot
if it's missing.
Option B, Ollama (self-hosted, GPU recommended):
EMBEDDING_PROVIDER=ollama
OLLAMA_URL=http://your-ollama-host:11434
EMBEDDING_ALLOW_PLAINTEXT=true
EMBEDDING_MODEL=bge-m3
EMBEDDING_DIMENSIONS=1024This is the default. Omitting EMBEDDING_PROVIDER falls back to
Ollama.
The embedding URL must be https, or http to a loopback host
(localhost, 127.x, ::1). Plaintext http to any other host —
another container such as http://ollama:11434 included — refuses to
start unless EMBEDDING_ALLOW_PLAINTEXT=true acknowledges that chunks
and queries cross that hop unencrypted. .env.example ships with it
set for that reason; drop it once the endpoint is https (use
EMBEDDING_CA_FILE for an internal CA). Inside a container, localhost
is the container itself, so an Ollama on the Docker host still needs
the override.
3. Deploy
make init # data dirs and .env from template (skip if you've already edited)
make db-init # create database, user, and pgvector extension
make deploy # build, push to local registry, run migrations, recreate containerThe first deploy backfills the index, the wikilink graph, and the
embeddings. For a 2 to 3k-note vault on Ollama with a GPU this takes
a few minutes. On text-embedding-3-small it's seconds.
4. Connect a client
Mint an API key in the control panel, then point your MCP client at:
URL: https://obsidian-mcp.<your-domain>/mcp
Auth: Bearer omcp_...For Claude Desktop, add to claude_desktop_config.json:
{
"mcpServers": {
"obsidian": {
"url": "https://obsidian-mcp.<your-domain>/mcp",
"headers": { "Authorization": "Bearer omcp_..." }
}
}
}For Claude Code:
claude mcp add obsidian --transport http \
--url "https://obsidian-mcp.<your-domain>/mcp" \
--header "Authorization: Bearer omcp_..."The first thing any agent should do in a new session is call
get_vault_guide(). That's how it learns your folder structure,
naming conventions, and YAML schema before it writes anything.
Upgrading
Pull, then make deploy (or rebuild your compose stack); migrations
run on start. Read this first when upgrading across the internal-transport
and panel-CSP release:
Breaking: plaintext embedding endpoints must be acknowledged. If the active embedding URL (
OLLAMA_URL, orOPENAI_BASE_URLwith the OpenAI provider) ishttp://to a non-loopback host — thehttp://ollama:11434default included — addEMBEDDING_ALLOW_PLAINTEXT=trueto.envbefore deploying, or the server refuses to start with a message naming the setting.Database TLS has one source. A TLS parameter in
DATABASE_URL(?ssl=…,?sslmode=…) or anyPGSSL*environment variable is refused at startup; move it toDATABASE_SSL_MODE. The default,prefer, is the behaviour you had before.Embedding clients ignore the environment's network settings.
HTTP(S)_PROXY,SSL_CERT_FILE/SSL_CERT_DIRand.netrcno longer apply to the embedding hop. UseEMBEDDING_CA_FILEfor an internal CA.The panel now sends a nonce-based Content-Security-Policy (
PANEL_CSP=enforce), and htmx is gone from it. If a panel control misbehaves, setPANEL_CSP=report-only(oroff) and recreate the container; no rebuild.New optional settings:
DATABASE_SSL_MODE,DATABASE_SSL_CA_FILE,DATABASE_SSL_CERT_FILE,DATABASE_SSL_KEY_FILE,EMBEDDING_ALLOW_PLAINTEXT,EMBEDDING_CA_FILE,PANEL_CSP. See Configuration, andDEPLOYMENT.mdfor moving both hops to verified TLS.
Cost expectations
If you go the OpenAI route (the realistic path on a CPU-only VPS), the first-index spend is small and the steady state is nearly free. Rough numbers assuming an average note around 1,500 tokens (three 512-token chunks), at OpenAI's published rate at time of writing:
Model | $/1M tokens | 1k notes | 10k notes | 100k notes |
| $0.02 | ~$0.05 | ~$0.50 | ~$5.00 |
| $0.13 | ~$0.30 | ~$3.00 | ~$30.00 |
After the first index, only changed notes are re-embedded. Ongoing cost is proportional to edits — pennies a month for a typical vault.
If you self-host Ollama with a GPU, embedding cost is whatever your power bill is. Ollama on CPU works but is too slow to be usable on a vault of more than a few hundred notes.
The self-describing vault
This is the part most "MCP for Obsidian" projects miss. They stop at read, write, and list. The interesting question isn't "can the agent reach the files," it's "does the agent know the rules?"
If you have an opinionated vault — task placement logic, folder conventions, required frontmatter, tag taxonomy — an agent with write access can do real damage without that context. Tasks land in the wrong folder. Bare-date filenames collide with templates. Wrong tags break Dataview queries. The data layer works fine; the context layer is where the failures show up.
The fix is small. Keep a machine-readable instruction file
(CLAUDE.md at the vault root) that describes the system's own rules.
Expose it as a dedicated tool. Every connecting agent calls it once at
the start of a session and immediately knows how the vault works.
Update the file, every agent sees the change on the next call. No
client-side config. No system-prompt injection. The vault is
authoritative about its own rules.
get_vault_guide() does exactly this. It returns a generic Obsidian
primer (wikilink syntax, embed syntax, tag conventions, common plugin
literals) plus the vault's CLAUDE.md live. The hint to call it first
is baked into the write-tool descriptions so the agent gets pulled
into the right behavior even without prompting.
Multi-user mode
Single-user mode is the default and works exactly as described above — one vault, one set of API keys, no in-app user concept. Multi-user mode is an opt-in flag that turns the same container into a small multi-tenant deployment: in-app username/password login, per-user vault scoping, an admin role for troubleshooting, and a regular-user role that sees only its own keys/OAuth clients/usage. One container, one Postgres, strict isolation between users.
Enable it on an existing deployment with no data loss — your current vault and keys carry over to the bootstrap admin.
Enabling
Set
MULTI_USER_MODE=trueand a strongSECRET_KEYin.env(openssl rand -hex 32is fine). The app refuses to start with a placeholderSECRET_KEYunconditionally — single-user mode included — so this is not something the flag turns on.make deploy(ordocker compose up -d --force-recreate).Visit the panel. Because the
userstable is empty, you're routed to/admin/register— the one-time bootstrap form. It's still behind Traefik'schain-oauth@filemiddleware, so only people Traefik already trusts can claim admin.Register with a chosen username and password. The bootstrap form pre-fills
vault_pathwith whateverVAULT_PATHwas set to, so your existing notes immediately belong to this new admin. No re-index, no re-embed, no data loss — every previously indexed note, API key, OAuth client, and usage log row gets backfilled to the bootstrap user in a single transaction.
Inviting users
Edit
docker-compose.ymlto add a volume mount for the new user's vault under/vaults/<username>. Host paths with spaces must be quoted as a single YAML string:volumes: - "/storage/vaults/alice:/vaults/alice" - "/storage/shared/bob/Obsidian:/vaults/bob"make deployto apply.In the panel,
/admin/users/create— pick a username and set an initial password./admin/users/{id}/edit— set the user'svault_pathto the container path you just mounted (e.g./vaults/bob). The form shows a dropdown of unassigned/vaults/*directories that exist on disk.Share the credentials out-of-band. The user logs in at
/admin/auth/login, gets their own keys/OAuth/usage views, and cannot see other users' notes.
What admins see
Admins see API keys, OAuth clients, and usage logs for all users; they
own the Settings page (embedding provider, indexer trigger, danger
zone) and the Users page. Admins do not browse other users' vault
contents through the panel — that's intentional. Troubleshooting
another user's vault means either inspecting it via docker exec or
temporarily reassigning their vault_path, not UI snooping.
Rolling back
Set MULTI_USER_MODE=false, restart. Existing API keys keep working
(per-user filters skip when no user context is set), the login UI and
session cookies disappear, and the panel falls back to its
Traefik-OAuth-only mode. The schema stays in place, so flipping back
to multi-user later resumes where you left off without re-bootstrapping
(the users table is non-empty, so /admin/register is closed).
Constraints and known limits
The indexer iterates active users sequentially each cycle. Fine for tens of users; hundreds would need parallelization.
Password recovery is admin-driven — there's no email-based reset. A signed-in user can rotate their own password at
/admin/account(current password, new password, confirmation; minimum 12 characters), which signs their other browsers out and keeps the one they changed it from signed in. The admin reset stays the recovery path for somebody who cannot sign in at all, and it also ends every live session of the account it resets./admin/auth/loginand/admin/account/passwordare rate-limited at 5 requests per minute; the login limit is keyed on the client address, and the password change carries two independent limits — one per account, one per address. The limiter's storage is in-memory and per-process, so counters reset on restart. The Traefik OAuth gate in front of the panel is still the main brute-force defense; if you expose/admin/auth/loginto the open internet, put a rate-limit middleware in front of it as well.Panel sessions are server-side rows (
user_sessions), so logging out, changing a password, a deactivation or a delete really ends them. The trade-off: the first deploy of the build that introduced the registry signs every live panel session out once, because a cookie issued before it carries no session id and is refused rather than grandfathered. Everyone signs in again; nothing else changes.The
vault_pathvalidator does not resolve symlinks, so an admin can technically point a user at host files via a symlinked/vaults/<name>. Treat/vaults/as an admin-trust boundary. What is checked, since the vault-root overlap guard: two active users' roots may not name overlapping directories. Each root is opened once and compared by inode identity —(st_dev, st_ino), which catches a symlink alias or a bind mount naming one directory twice — and by a component-wise containment test over the two canonical real paths in both directions, which catches an ancestor/descendant pair like/vaults/teamand/vaults/team/private. A conflicting assignment is refused in the panel naming the other user, and the same checks re-run before every index pass, so an alias created after the assignment quarantines both accounts: their MCP tools, index passes and transfer redemptions are refused until an administrator corrects it, and no index rows are deleted. A root that cannot be opened at all quarantines only its own account. What is still not detected, and the consequence: a bind mount that grafts one user's vault — or any mount nested inside it — to a path inside another user's root.mount --bind /vaults/b /vaults/a/innerleaves both root inodes distinct and both canonical paths outside each other, so neither check sees it, and user A can then read, overwrite and delete every note in user B's vault through the ordinary write tools, while A's index pass files B's notes under A's account so A's searches return B's content. The same gap covers an accessible alias of a root that could not be examined: that peer keeps serving. Neither condition is reported anywhere. Both require an administrator to write a bind mount into the deploy configuration — which is why/vaults/and the compose file's mounts are the admin-trust boundary, not just the path strings. This is a permanent, stated limit rather than a pending fix: mount detection was specified, failed on a new topology in each of three review rounds, and was dropped. The operator rule: never mount one user's directory, or anything nested in it, inside another user's root.
Configuration
Variable | Default | Purpose |
| — |
|
|
| Database TLS: |
| — | CA bundle (PEM) for |
| — | Client certificate (PEM). Strict modes ( |
| — | Client private key for |
|
| In-container vault mount |
| — | itsdangerous signer key |
|
| Periodic reindex cadence |
|
| In-app login, per-user vaults. See Multi-user mode. |
|
| How long the vault-root overlap check waits on one root before giving up on it. Expiry quarantines that one account ( |
| — | Public hostname. Derives |
| derived | Explicit public origin. HTTPS except on loopback. |
| derived | CORS origins, JSON list |
| derived | Accepted |
|
| Panel session lifetime, seconds (multi-user mode). Absolute — the server-side row is never extended, so a session used daily still expires |
|
| Panel session cookie name |
|
| Content-Security-Policy on the panel, login and consent pages: |
|
| How stale a session's |
|
| How long a dead panel session row is kept, measured from the later of its expiry and its revocation, so a revocation stays visible for the full window. Must be ≥ 1. |
|
| Redirect hosts the consent screen badges as known connector destinations. JSON or CSV. Matched by exact host equality — no wildcards, no suffixes; entries containing |
|
|
|
|
|
|
|
|
|
|
| Keyword-search text-search config(s). JSON or CSV. See Full-text search language(s). |
|
| Default life of a transfer link. Per-call |
|
| How long one claimed upload may stream before the token is spent |
|
| Simultaneous upload streams |
|
| Let |
|
| Accept named staging on filesystems without |
|
| Require |
|
|
|
|
| pgvector column width |
|
| Used when provider is Ollama. Must be |
|
| Ollama model name. Changing it post-deploy requires |
|
| How long Ollama keeps the model resident. |
| — | Required when provider is OpenAI |
|
| Override for Azure or proxies. Same transport rule as |
|
| Permit |
| — | Trust anchor (PEM) for an |
|
| OpenAI model. Changing it post-deploy requires |
|
| Approx tokens per chunk (4-char heuristic) |
|
| Token overlap between chunks |
|
| Globs skipped by the embedder. Excluded files stay keyword-searchable. |
|
| Failed |
|
| The window that budget is counted over. |
|
| Counter slots in the fixed-size, per-process-salted address table. Memory is O(size); collisions only make the control stricter. |
|
| Sustained tool calls per minute per principal (an API key, or an OAuth grant). Null — with the burst — disables the general bucket. |
|
| Capacity of the general bucket. Must be set together with its rate or nulled together with it. |
|
| Sustained vault-mutating calls per minute per principal — the eight write tools, plus |
|
| Capacity of the write bucket. |
|
| Principals holding their own limiter entry before further ones share one overflow entry. |
|
| How long one rate/slot-refusal coalescing window stays open. Inside it a refusal writes nothing; the row that lands stands for |
|
|
|
|
| Tool admission wait in enforce mode, 0–5 seconds. Shadow requires zero. |
|
| Global tool ceiling in enforce mode, also subject to class, tenant (3), and principal (2) ceilings. |
|
| Full MCP request ceiling, including open streams; per bearer fingerprint ceiling defaults to 4. |
|
| Authentication database-session ceiling. Released before response delivery or downstream work. |
|
| Usage-log writer ceiling; includes fallback inserts. Defaults: 64 pending writers and a 0.25-second enforce-mode wait. |
|
| Daily quota a newly created API key receives when the caller does not say otherwise. Existing keys are untouched; an explicit null (or a blank panel field) still means unlimited. |
|
| Refuse a tool call carrying an argument the tool does not declare (a tool error naming it), and publish |
|
| Registry-eval only. Skips DB, indexer, embedding provider, and |
See .env.example for the full set with comments. For first-index
spend on OpenAI, see Cost expectations above.
The MCP transport's request-body limit is derived, not configured:
max(2 × MAX_FILE_WRITE_BYTES, 6 × 10 MB) + 1 MiB, which is 61 MiB with
the defaults. It has to track the write caps so that every supported
write is refused by the tool — with an actionable message — rather than
by the transport with a bare HTTP 413. Raise MAX_FILE_WRITE_BYTES and
the transport limit follows.
Switching providers or models
Different models produce vectors in different spaces, and cosine distance between two spaces is meaningless. So any change to what produced the stored vectors requires a full re-embed — not only a provider switch. That is every one of:
EMBEDDING_PROVIDEREMBEDDING_MODEL(Ollama) orOPENAI_EMBEDDING_MODEL(OpenAI) — including a swap between two models of the same dimension, which the dimension guard cannot seeEMBEDDING_DIMENSIONSCHUNK_SIZEandCHUNK_OVERLAP
The server stores a fingerprint of that configuration and compares it at startup. On a mismatch it logs both fingerprints and the fields that differ, names the repair, and exits non-zero — so a model swap that used to mix two vector spaces in one column silently, for ever, now stops the process instead.
The steps, in this order:
Update
.env.make deploy(ordocker compose up -d --force-recreate). The new container will refuse to start — at the fingerprint guard, or at the dimension guard if the width changed — and that refusal is the point: a container that will not start embeds nothing while the reset runs.make reset-embeddingswhile it is down. The target isdocker compose run --rm, so it starts a one-off container that reads your edited.env: it recreates the column at the new dimension, clears everyembedded_content_hash, and records the new fingerprint in the same transaction.Restart the service. It starts silently, because the stored rows really were produced under the configuration it is now running, and the next indexer pass re-embeds the vault.
This inverts the older reset-before-recreate advice. That ordering
was safe only while nothing depended on a stored claim about the
configuration; now the reset is what writes that claim, so it has to
run with the new .env in place and with no old-configuration container
able to embed against it. Skipping a step costs time rather than
correctness — a database-level generation lock makes an
old-configuration container's certifications refuse rather than land —
but the ordering above is the one that never has to rely on it.
Maintenance waits for an in-flight index pass. That same generation
lock is taken at the head of the index pass's transaction and held until
it commits, so make reset-embeddings and make rebuild-tsvectors block
until the pass finishes — up to a few minutes on a large vault — rather
than interleaving with it. That wait is the required behaviour, not a
stall to work around: a reset that landed mid-pass is precisely the
interleaving that stores vectors from one configuration under a
fingerprint naming another. Neither command sets a short lock timeout,
and neither should be given one — and because the server sets a 60-second
statement_timeout on every connection, both commands (and the panel's
Danger-zone resets) lift that timeout for the acquisition itself and
restore it once the lock is theirs. Without that, a command started
against a live service was cancelled after a minute rather than waiting,
which reads as a broken command instead of a busy index.
You can also use Settings → Danger zone → Reset embeddings in the control panel, which performs the same SQL — including the fingerprint record — while the server is running (pauses the indexer, runs the SQL, resumes).
The fingerprint records the configuration, not the model artifact.
bge-m3is a mutable Ollama tag, soollama pullcan replace the weights behind it, andOLLAMA_URL/OPENAI_BASE_URLare deliberately excluded from the fingerprint — repointing at another host or proxy is usually an infrastructure move that serves the identical artifact, and including it would demand a full re-embed for one. The consequence is an accepted limitation: replacing the artifact behind an unchanged model name — re-pulling a tag, or pointing at a host serving different weights under the same name — mixes vector spaces undetected. It requiresmake reset-embeddings, and no startup check will catch it if you skip that. No value available to the server distinguishes the two cases, and a probe would have to trust the endpoint it is checking.
Full-text search language(s)
keyword_search runs over a PostgreSQL tsvector. The text-search
configuration it uses — the stemmer and stop-word dictionary — is
controlled by FTS_CONFIGS. It defaults to english, which reproduces
the historical behavior exactly, so existing deployments need no action.
FTS_CONFIGS is a list, settable as JSON
(FTS_CONFIGS=["simple","norwegian"]) or comma-separated
(FTS_CONFIGS=simple,norwegian). Each note is indexed under every
listed config, and a query matches if any listed config's parse hits.
This is what makes a mixed-language vault work:
| Behavior |
| English Snowball stemmer (default; |
| Language-agnostic. No stemming or stop-words — matches exact word forms. A principled default for mixed-language vaults: keyword search is the exact-match arm, while |
| Both stemmers applied — keyword-side morphology for two languages at once. |
| Verbatim lexemes plus Norwegian stems. |
The setting is global — applied to every vault (consistent with
EMBEDDING_MODEL, CHUNK_SIZE, etc., which are global too). For a
mixed-language multi-user instance, set a superset (e.g.
["english","norwegian"], or ["simple"]). Per-user FTS config is a
clean future extension but is not implemented.
A typo'd or uninstalled config name fails fast at startup with a message listing the configs available in your Postgres instance, rather than producing silent zero-result searches.
Changing FTS_CONFIGS requires a rebuild, and the server refuses to
start until it has run. Stored tsvectors are computed at index time,
so they go stale when the config list changes — and a stale stemmer is
not merely incomplete. Under english the token running is stored as
the lexeme run, so a query under simple for run matches a note
that does not contain the word — a false positive, indistinguishable
from a real hit. Keyword vectors therefore fail closed exactly as
embeddings do: the server stores a fingerprint of FTS_CONFIGS, compares
it at startup, and on a membership change logs both lists and the
differing entries, names the rebuild, and exits non-zero. (Reordering the
same names is not a change: a note is indexed under every config and a
query matches if any hits, so order changes nothing and is not compared.)
The runbook:
Edit
FTS_CONFIGSin.env.make deploy. The new container refuses at the keyword fingerprint guard and stays down.make rebuild-tsvectors. It rebuilds every scope that holds rows — every owner, including rows with no owner in single-user mode — in one transaction, and records the new fingerprint only if every one of them reported a completed rebuild. It is all-or-nothing: one scope it cannot rebuild rolls the whole thing back, names the scope and the reason, and writes no fingerprint, because the fingerprint is a single claim about every retained row.Restart. It starts silently.
If step 3 names a scope it could not rebuild — a user whose vault is not assigned, a tenant still re-deriving its provenance, or ownerless rows under multi-user mode — there are three recourses, in order of preference:
Settle the scope: assign or delete the user, or let the re-derive finish, then re-run the rebuild.
Delete or reassign the ownerless rows, then re-run the rebuild.
Put
FTS_CONFIGSback to its previous value. That clears the refusal immediately, with no rebuild at all — a configuration edit is always reversible, which is what keeps this refusal from being an outage.
The rebuild re-reads each note and recomputes its content_tsvector
under the new config(s). It rebuilds the keyword index only — it does
not touch embeddings/vectors and makes no API calls, so it
finishes in seconds for a few thousand notes. (Do not confuse it with the
expensive make reset-embeddings flow.)
Tokenization caveat: the tsvector parser still splits on punctuation and hyphens regardless of config, so
bge-m3tokenizes tobge+m3.simplepreserves word forms, not punctuation-bearing strings; exact-string-with-punctuation matching would need a trigram index and is out of scope.
Response size limits
A tool result is model input. Whatever read_note returns is fed
straight back into the caller's next request, so an unbounded read is
an unbounded prompt — and the caller usually finds out only when its
inference provider rejects the request.
MAX_READ_RESPONSE_CHARS (default 40,000, roughly 10K tokens) bounds
what read_note and the text results of read_file return. It is a
different limit from MAX_FILE_READ_BYTES, which bounds what the
server reads off disk. A 3 MB note is comfortably within the 10 MB read
cap and will still destroy a context window; both caps are needed and
they have different correct values.
It applies per component, not once to the whole response: the
content window gets the cap, the heading outline gets it
independently, and the metadata fields (title, tags,
frontmatter_yaml and its JSON view, heading) share a third. A
truncated read can carry all three, so budget for a worst case of
roughly 3 × MAX_READ_RESPONSE_CHARS plus fixed prose — doubled again
because the MCP result carries both structured content and a JSON text
block, and multiplied by JSON escaping for content that is mostly
control characters.
When a note exceeds the cap you get the first window plus truncation as
data — truncated, the next_offset to continue from, total_chars —
and, for a whole-note read, an outline of the note's sections:
{"entries": [
{"ordinal": 1, "depth": 1, "text": "Client Records",
"size": 2855343, "exceeds_cap": true, "duplicate": false},
{"ordinal": 2, "depth": 2, "text": "Balance Sheet.xlsx",
"size": 391199, "exceeds_cap": true, "duplicate": false},
{"ordinal": 3, "depth": 2, "text": "Lease Agreement.pdf",
"size": 464, "exceeds_cap": false, "duplicate": false},
{"ordinal": 4, "depth": 2, "text": "Invoice 2025-044.pdf",
"size": 1075, "exceeds_cap": false, "duplicate": true}
], "truncated": false}Paging a multi-megabyte note 40K at a time is technically possible and
practically useless, so prefer the outline: read the one section you
want with read_note(path, section="Lease Agreement.pdf"). Sections are
addressable three ways — the #N ordinal shown in the outline, the
Parent/Child path-style form, and exact heading text. The ordinal is
the only form that separates duplicate sibling headings, which share
every ancestor and so cannot be disambiguated by path; notes generated
by bulk extraction tend to be full of them.
A bare #N always selects by position, so an ordinal we hand you in
an outline can never be shadowed by a heading that happens to be titled
#2. Such a heading stays reachable via the path form (Parent/#2) or
via its own ordinal.
The outline is itself bounded by the cap: a note with thousands of
headings gets a truncated listing that reports how many sections were
omitted (omitted) and the full ordinal range (first_ordinal,
last_ordinal), rather than an outline larger than the content window
it accompanies. Metadata that does not fit its budget is dropped whole
and reported in metadata_omissions — never cut short and never marked
inside the field itself, so nothing in a note-controlled field is ever
a prefix or server prose. frontmatter_yaml is the frontmatter block's
YAML source with the fence lines removed, LF-normalized (the same
declared terminator residual content carries); it is the authoritative
copy, and the frontmatter JSON view beside it is a convenience that is
omitted, with a reason, when YAML holds something JSON cannot say.
limit can lower the cap for a single call but never raise it. If your
clients genuinely want larger reads, raise MAX_READ_RESPONSE_CHARS —
that is an operator decision, made once, by someone who knows the
deployment.
Upgrading: three visible contract changes.
read_noteon a large note used to return the whole thing; it now truncates. The response is self-describing, so an agent needs no prior knowledge to continue, but a script that assumed whole-note reads should either passsection=or raise the cap.And
read_noteused to return one rendered string — a# <title>/**Path:**header, a\n---\nseparator, then the content. It now returns fields, because every component of that header was note-controlled: a note could forge the separator, so an agent recovering the section body by splitting the response could recover a crafted string and write it back over the section. A client that parsed the old envelope must readcontent(and, for section reads,heading) instead; clients that ignorestructuredContentstill get an unambiguous JSON text block.Panel sessions are now server-side rows, so everyone is signed out once at that upgrade. A cookie issued before it carries no session identifier, and such a cookie is refused rather than grandfathered — accepting it would keep the old replay window open for another seven days after the fix shipped. Sign in again; there is nothing to migrate.
Rate limits
The consumer of this server is an agent, and a retry-storming or prompt-injected agent is an ordinary input. Three controls bound how fast one credential can create work.
A general bucket —
MCP_RATE_LIMIT_PER_MINUTE(120) sustained,MCP_RATE_LIMIT_BURST(30) capacity — on every tool call.A write bucket — 60/min, burst 15 — that the eight vault-mutating tools must pass in addition, and that
PUT /transfer/uploadconsumes too, charged to the principal that minted the capability so the write rate cannot be escaped by minting links and redeeming them.A per-address budget on failed
/mcpauthentication — 60 failures per 5 minutes — checked before the credential lookup, so a refused probe costs no database query.
The bucket is per principal: an API key, or an OAuth grant.
Refreshing an access token continues the same allowance rather than
minting a fresh one, and two separate /authorize approvals for the same
client hold independent allowances.
What an agent actually sees. A refusal is an ordinary tool result — never a protocol error, never a silent empty result set — and it ends with one machine-readable line:
Error: this credential exceeded its general rate limit of 120 calls per minute, so the call was refused before it ran. Nothing was read, written, or counted against the daily quota. Retry in 3 seconds, or slow the calling loop down.
MCP-REFUSAL {"code":"rate_limited","scope":"principal","limit":120,"limit_unit":"calls_per_minute","retry_after_seconds":3}The MCP-REFUSAL sentinel is line-initial and the JSON is one line, so
it survives being quoted into a transcript. A structured tool returns the
identical text in its declared error field. retry_after_seconds is
present only where waiting can actually help — a refusal for an
unassigned vault or an unencodable argument omits it rather than invite a
loop that cannot end. The same shape covers the daily quota
(over_quota), the query length cap (argument_too_long), and tool-body
refusals such as not_found, already_exists, and invalid_path. A partial
write also carries a typed outcome: read its explanation before retrying,
because some bytes may already have changed. Empty search results and
successful no-op calls remain successes.
The transport refusals are outside that contract, because there
is no tool call to answer: an over-budget unauthenticated request or an enforced MCP request/authentication
concurrency refusal gets an HTTP 429 with Retry-After, and so does an over-rate PUT /transfer/upload — which releases its claim rather than consuming
it, so the same link is still redeemable once the bucket refills.
Operational notes.
Limiter state is in-process and is not persisted, so a restart begins with every bucket full. That is sound only because the container runs
--workers 1; raising the worker count multiplies every rate above by the worker count.Refusals appear on
/admin/performanceas refusal counts, not in the latency percentiles. Repeated rate and enforced slot refusals are coalesced — one row per credential/tool/scope perMCP_REFUSAL_LOG_INTERVAL_SECONDS, each standing for1 + suppressedrefusals — so that a refusal loop cannot make writing the log the load.The velocity defaults are estimates against a small sample. Read
/admin/performancefor a week before treating any as settled, and disable one by setting it empty,nullornone(zero is refused at startup).The daily quota is the durable ceiling and it is separate: keys created from now on get
DEFAULT_DAILY_REQUEST_LIMIT(5,000), keys that already existed keep whatever they had, and OAuth grants have no daily ceiling at all — velocity bounds only.
The rationale lives in
docs/architecture/rate-limits.md.
Concurrency admission
Concurrency admission ships with MCP_CONCURRENCY_MODE=shadow. It records
pressure under concurrency_shadow on existing usage rows and emits bounded
security events for request/authentication pressure. Calls keep their actual
outcome, quota accounting and duration. Shadow mode observes current occupancy
with zero wait; it does not predict how traffic would behave under enforcement.
In enforce mode, the server limits full MCP requests (including open SSE
streams), authentication database sessions, tools, and usage-log writers.
Tools pass velocity, vault and argument checks before acquiring slots; daily
quota is checked afterward. A rejected tool receives slot_timeout without
spending daily quota. Zero wait means immediate admission or refusal; a positive
wait uses a bounded queue and one deadline. A retry hint is not a promise that
a running call will finish by that time.
The four tool classes each default to one concurrent call: semantic_search
uses embedding, find_related uses vector, the eight vault-mutating tools use
write, and the remaining tools use other. Global, tenant and principal ceilings
default to 4, 3 and 2. OAuth refresh keeps the same principal. Full request and
per-bearer ceilings default to 32 and 4, authentication to 2, and usage writers
to 1. All settings and queue limits are listed in .env.example.
Startup validates the pool budget as auth + 2 × tools + writers + 4 ≤ 15.
The four connections of headroom are shared with panel, OAuth, indexing and
transfer work; this arithmetic cannot guarantee availability when those other
consumers exhaust it. Shadow mode does not enforce that budget. The controller
is in-process and requires the existing single-worker deployment.
Review pressure observations and long-lived stream occupancy before enabling
enforce. Choose off to disable concurrency admission; the existing velocity
limits and daily quotas still apply. Shadow requires a zero tool wait and never
adds a writer wait or drops a usage row because of its observed pressure.
Architecture
┌──────────────┐ ┌──────────────────────┐
│ MCP clients │ HTTP + Bearer key │ FastAPI app │
│ Claude Desk │ ────────────────────▶ │ ┌────────────────┐ │
│ Claude Code │ │ │ MCP server │ │
│ n8n agents │ │ │ (25 tools) │ │
│ OpenWebUI │ │ └─────┬──────────┘ │
└──────────────┘ │ ▼ │
│ ┌────────────────┐ │
│ │ Services: │ │
│ │ - vault │ │
│ │ - search │ │
│ │ - embeddings │ │
│ │ - links │ │
│ │ - indexer │ │
│ └─────┬──────────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Postgres + │ │
│ │ pgvector │ │
│ └────────────────┘ │
└──────────┬───────────┘
▼
┌────────────────────┐
│ Embedding │
│ provider │
│ (Ollama / OpenAI) │
└────────────────────┘Indexing pipeline
.md files in vault
↓ skip dot-dirs
parse frontmatter, extract tags (YAML + inline #hashtags)
↓ SHA-256 hash
skip if unchanged
↓
UPSERT notes_metadata (path, title, tags[], frontmatter JSONB,
content_hash, tsvector, modified_at)
↓
extract wikilinks/embeds/markdown-links → resolve targets →
note_links (source_id, target_id or NULL for dangling)
↓
chunk content (512 tokens, no overlap) → embed via provider →
note_embeddings (note_id, chunk_index, chunk_text, embedding[N])
↓
set embedded_content_hash = content_hashThe indexer runs on startup and every INDEX_INTERVAL_SECONDS (5
minutes by default). Hashes are content-only, so the change detector
ignores mtime jitter. Stale embeddings are caught by the
embedded_content_hash != content_hash mismatch.
Database schema
Table | Purpose |
| Path, title, tags, frontmatter, content hash, embedded hash, tsvector, modified time |
| One row per chunk. |
| Wikilink graph: source/target IDs, target_path, kind ( |
| Hashed bearer tokens, prefix for display, permission, expiry |
| Per-tool-call audit |
| OAuth 2.0 PKCE state, including the grant id that ties a consent's tokens together |
| Capability rows behind the |
| Multi-user mode: login, role, per-user |
| One revocable row per live panel browser session, keyed on the SHA-256 of the cookie's session id. Cascades with the user. |
GIN indexes on content_tsvector and tags[]. B-tree indexes on the
hot foreign keys. pgvector HNSW expression index
(embedding::halfvec(N)) halfvec_cosine_ops (m=16, ef_construction=64),
built when the dimension is ≤ 2000; results are re-ranked by the
full-precision distance. Queries set
hnsw.ef_search=80 and dedupe per note in Python after a 5x overfetch.
Project layout
src/
main.py FastAPI app, lifespan, MCP mount
config.py pydantic-settings
database.py async SQLAlchemy engine/session
models/db.py ORM models
mcp_server/ MCP server, tools, auth middleware
services/ vault ops, anchored filesystem, search, FTS,
embeddings, links, indexer, transfer
transfer/ public /transfer/* capability-redemption routes
auth/ login, sessions, per-request identity context
api/ control-panel REST endpoints
control_panel/ Jinja2 templates and static assets
oauth/ OAuth 2.0 authorization-code flow
alembic/ database migrations
scripts/ one-off ops scripts (e.g. reset_embeddings.py)
tests/ pytest suite + smoke-test docs
openspec/ change proposals (spec-driven workflow)Development
pip install -r requirements-dev.txt
pytestThe unit-test suite covers the embedding-provider abstraction, OpenAI
batching and retry behavior, config validation, and the
dimension-mismatch startup check. Network-bound tests use respx to
mock httpx, so no real network access is required.
To run the server outside Docker:
DATABASE_URL=... SECRET_KEY=... VAULT_PATH=... uvicorn src.main:app --reload --no-proxy-headersMake targets
make init First-time setup (data dirs, .env)
make build Build Docker image (no cache)
make build-cached Build Docker image (with cache)
make push Push the image to the configured registry
make image Build and push
make deploy Build, scan, push, backup, migrate, recreate container
make up / down / restart / shell Container lifecycle
make logs Tail container logs
make db-init Create database, user, and pgvector extension
make db-migrate Run alembic migrations
make db-check alembic check — schema vs. ORM models (must be clean)
make test-schema Schema gate: migrations vs. models on a throwaway pgvector container
make db-backup Dump database to backups dir
make db-restore FILE=<path> Restore from a backup
make reindex Explain how to trigger a reindex (panel only; there is no headless trigger)
make reset-embeddings Drop and recreate embedding column at configured dim
make rebuild-tsvectors Recompute keyword index for FTS_CONFIGS (no embeddings, no API calls)
make status Show container and health status
make audit Audit Python dependencies (pip-audit)
make trivy Scan the local image for HIGH/CRITICAL CVEs (SCAN_IMAGE=obsidian-mcp:local for the bundled stacks)
make clean Remove containers and images (data preserved)make deploy runs the whole pipeline: build, image scan, push, database
backup, alembic upgrade head, then recreate the container. Run
make test-schema before any deploy that carries a migration, and
make db-check after one.
Security notes
API keys use the
omcp_prefix and are stored as SHA-256 hashes. The raw key is shown exactly once at creation.The control panel is intended to sit behind an external auth gateway. The included
docker-compose.ymluses Traefik with an OAuth chain. Don't expose/admindirectly to the internet.Panel sessions are server-side rows. The signed cookie carries a 256-bit random id; the database stores only its SHA-256, so a database dump contains no usable session. Logging out revokes that row, and a password change, an admin reset, a deactivation or a delete revokes every session of the account.
The OAuth consent screen identifies the client it is asking about: the redirect host the authorization code would be sent to (taken from the URI's hostname, never its
netloc, and shown in punycode rather than decoded), the server-generated client id, and the registration date. Every render says the application registered itself and is not verified by this server; a host outsideOAUTH_KNOWN_REDIRECT_HOSTSis called out as unrecognised.The OpenAI key is rendered on the settings page as
key[:8] + "..." + key[-4:]and never appears in full in HTML or JS sources.Path traversal is blocked at the service layer, and containment is proved by the kernel: every directory below the vault root is opened with one
openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS)from an open root descriptor, and the rest of the operation acts on that descriptor rather than re-walking a name.Mutating tools act on the path as named. A final component that is a symlink is refused (naming the link's target) instead of being followed, so an in-vault alias cannot redirect a write. Reads still follow links, which is what an alias is for.
Every path guard also refuses hidden components, so
.obsidian,.git,.trashand friends are out of reach of every tool.Transfer links carry their token in the URL fragment, which browsers never send, and are redeemed only from an
Authorization: Bearerheader. Keep header logging off at your reverse proxy and APM. Unknown, expired, consumed and revoked tokens all get one identical 404 from the public routes; precise status comes from the authenticatedcheck_uploadtool.import_from_urlfetches only genuinely public addresses, under an explicit deny list re-applied at every redirect.Failed
/mcpauthentication is budgeted per client address, counted before the credential lookup so a refused probe costs no database session and no query. The address comes from the proxy headers the app trusts, never from a header read directly, and a request with no resolvable address is charged to a shared slot rather than exempted. What it bounds is the database work an unauthenticated caller can force; it is not a defence against guessing a 256-bit key. See Rate limits.Parameterized queries everywhere. No string interpolation into SQL.
Response headers include HSTS,
X-Content-Type-Options: nosniff,X-Frame-Options: DENYandReferrer-Policy: no-referrer. The panel, login and consent pages add a per-response nonce Content-Security-Policy with no inline script (PANEL_CSP).The app's own hops are checked at startup: the database follows
DATABASE_SSL_MODE, and the embedding endpoint must behttpsor loopback unlessEMBEDDING_ALLOW_PLAINTEXTsays otherwise. Each start logs one transport line per hop and aninternal_transport_plaintextsecurity event for each hop still in cleartext.
Status
Single-author, in active use as the maintainer's personal exocortex (2,500+ notes, multiple connecting agents). Public for anyone who wants to fork it. Issues and PRs welcome but expect opinionated review. This is a working system, not a generic platform.
License
MIT. See LICENSE.
Available Tools
25 toolscheck_uploadA
Ask what happened to an upload link you minted with request_upload.
Returns one of pending (nothing sent yet), uploading (bytes are in
flight), completed (with the path, size, sha256 and MIME type of what
landed), unknown (a stream started and the server never recorded how it
ended), revoked (the link is dead because the credential or vault root
changed under it), or expired. Use it to confirm a transfer really
finished before you tell the user it did, and to get the sha256 if they
want to verify it.
Visibility is scoped to the principal that minted the link, not to one
credential row. For an API key that is the key itself. For OAuth it is the
whole grant family behind the access token you are calling with, so a handle
stays readable across the hourly token refresh that mints a new row. A
different API key, a different client, or a separate approval of the same
client reads as not found.
uploading names the deadline the stream has. Check again after it: past
that point the answer becomes either completed or unknown. unknown
does not mean nothing arrived — a publish can succeed and still fail to
record its completion — so read or list the path before minting another
link or telling anyone the file did not arrive.
Pass the upload_id itself — the short handle from request_upload, not
the upload URL and not the token after the #. Anything else is refused
without a lookup.
Args:
upload_id: The upload_id that request_upload returned.
| Name | Required | Description | Default |
|---|---|---|---|
| upload_id | Yes |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and does so thoroughly. It discloses the full set of return statuses, the visibility scoping by principal (API key vs OAuth grant family), the deadline semantics for `uploading`, the nuance that `unknown` does not imply nothing arrived, and that any input other than the raw `upload_id` is refused without a lookup. This is exemplary transparency.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but each section earns its place: statuses, visibility, deadline, and input guidance. It is front-loaded with the key information (statuses) and structured logically. It could be trimmed slightly, but given the complexity and the need to cover edge cases, the length is justified and not wasteful.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Despite having an output schema (which covers return value types), the description goes beyond it by explaining the meaning of each status, the visibility rules, and the deadline behavior. For a single-parameter tool, this is complete: an agent knows exactly how to call it, what to expect, and how to interpret results. Nothing critical is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description must compensate, and it does. It explains exactly what to pass: the `upload_id` from `request_upload`, and explicitly what not to pass (the upload URL or the token after the `#`). This adds critical meaning beyond the bare type definition, ensuring the agent won't misuse the parameter.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose: to check the status of an upload link created with `request_upload`. It enumerates the possible statuses and their meanings, and explicitly positions it as a way to confirm a transfer finished. This distinguishes it from siblings like `request_upload` (minting) and `request_download` (retrieval), leaving no ambiguity about what it does.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides clear usage context: use it to confirm a transfer really finished and to retrieve the sha256. It also gives timing advice (check after the deadline) and explicitly warns about the `unknown` status. However, it doesn't explicitly state when *not* to use it or name alternative tools for related tasks, though the context strongly implies its role relative to `request_upload`.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
create_noteA
Create a new markdown note in the Obsidian vault. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
See get_vault_guide for Obsidian syntax and any vault-specific conventions
(naming, folder placement, frontmatter, tags).
Refuses a path whose final component is a symlink, naming its target, so a write never lands on a note other than the one named; symlinked folders inside the vault work normally.
The note is published no-clobber: the content is staged out of sight and
linked into place in one kernel-atomic step, so an existing file at path
can never be replaced by this tool. A vault filesystem that cannot stage an
unnamed file refuses the write with an error naming
VAULT_ALLOW_NAMED_STAGING_FALLBACK rather than staging under a visible
name.
expected_hash — binding a write to the bytes you read. Optional; omit
it and nothing changes. Pass the content_hash a read returned, verbatim
and canonical (sha256:<64 lowercase hex>), and the call is refused with
nothing written if the file changed in between. It is always the whole
file's hash, never a hash of the text you received. Two windows, both
live: expected_hash covers your read → this call's read, the server's own
pre-publication compare covers this call's read → its publication, and a
match on the first does not exempt the second. Every refusal ends with one
machine-readable MCP-REFUSAL {"code":…} line — stale_precondition (with
the file's current hash, ready to resend), concurrent_write,
no_incumbent, malformed_precondition, precondition_unavailable,
precondition_required — and each states what resolves it.
Here there are no incumbent bytes to bind: a supplied hash is answered
no_incumbent before any filesystem work, nothing is created, and the
remedy is to call again without it. A malformed hash is still reported as
malformed first. Success reports the content_hash of the note this call
published.
Args:
path: Vault-relative path for the new note (e.g. "Cards/New Topic.md"). The .md extension is added if missing.
content: Full markdown content for the note, including any frontmatter.
expected_hash: Accepted and always refused as no_incumbent, so the
refusal is a normal result rather than a protocol error.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| content | Yes | ||
| expected_hash | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full burden. It thoroughly discloses mutation semantics (requires write permission), safety (no-clobber atomic publish, symlink refusal), and error handling (refusal codes and resolutions). This goes far beyond typical descriptions and gives the agent a complete behavioral model.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but every sentence earns its place. It front-loads the purpose and permission requirement, then methodically covers constraints, atomicity, and the expected_hash contract. Bold headers and structured paragraphs make it easy to scan. No fluff or redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity, the description is complete. It covers permissions, filesystem edge cases, atomicity, concurrency controls, and error codes. An output schema exists so return values are not needed. There is nothing an agent needs to call this correctly that is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description must explain every parameter. It does: path (vault-relative, .md auto-added), content (full markdown with frontmatter), and expected_hash (detailed semantics, canonical format, refusal codes). This fully compensates for the lack of schema descriptions and adds substantial meaning.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States the exact verb (Create), resource (new markdown note), and container (Obsidian vault). It is unambiguous and clearly distinct from the sibling tools like edit_note, move_note, and write_file, which all have different purposes.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Provides clear prerequisites (write permission, readwrite key/scope) and references get_vault_guide for conventions. It also explains when the tool refuses (symlink path, expected_hash mismatches). However, it does not explicitly contrast with alternatives like edit_note or write_file, so an agent might need to infer when to choose this over a sibling. That minor gap keeps it from a 5.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
delete_fileA
Delete a non-markdown file from the vault. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
Peer to delete_note, which stays markdown-only.
By default this is a soft delete: the file moves to
.trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex> inside the vault, keeping a
copy the user can recover. Two files with the same name deleted in the same
second both survive — the trash never clobbers.
With permanent=True the file is unlinked outright and this server has no
recovery path; the user's backups are the only rollback.
Refuses markdown files (use delete_note, which understands the index and
backlinks), directories, and symlinks. Non-markdown files are not indexed,
so search and embeddings are unaffected either way.
Optional expected_hash binds the whole raw file in either mode. Use the
canonical sha256:<64 lowercase hex> from read_file's base64 header,
read_file(hash_only=True), or a write success. Malformed hashes refuse
before path checks; unavailable, required and stale preconditions refuse
before any trash entry or unlink. The deployment may require a hash.
This checks the caller-read-to-call window; a later concurrent replacement
remains possible, so it is not an atomic filesystem compare-and-delete.
A successful delete reports no content hash.
Args:
path: Vault-relative path to the file.
permanent: If True, unlink instead of moving to .trash/.
expected_hash: Optional whole-file raw-byte digest of the incumbent.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| permanent | No | ||
| expected_hash | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and does so thoroughly: it discloses soft-delete vs. permanent unlink, trash naming and collision behavior, no-recovery path, refusal cases, hash precondition ordering, non-atomicity, and lack of a content hash in success responses. This is far beyond what a bare 'Delete a file' would offer.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Long but tightly structured: each paragraph or sentence adds a distinct fact about permission, trash behavior, permanent mode, refusals, or hashing. The key semantics are front-loaded, and the Args section cleanly summarizes the parameters.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity and the total absence of annotations, the description covers permissions, error precedence, edge cases, non-atomicity, recovery expectations, and parameter semantics. The presence of an output schema means return-value details need not be repeated, so nothing essential is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, but the description compensates completely. It explains path, permanent with its destructive semantics, and expected_hash including the canonical format, sources, refusal behavior, and timing guarantees. An agent can construct correct arguments using only this text.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('Delete a non-markdown file from the vault') and immediately distinguishes itself from delete_note by explicitly saying delete_note is markdown-only. The refusal of markdown files, directories, and symlinks further clarifies the exact scope.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly says when to use alternatives ('Refuses markdown files (use delete_note...)' and names delete_note as the peer tool). It also states the required write permission, default soft-delete behavior, and when permanent deletion is the only appropriate choice.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
delete_noteA
Delete a note from the vault. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
By default this is a soft-delete: the file is moved to
.trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex> inside the vault root, by a
single non-replacing rename, so an existing trash entry is never
overwritten and two deletes in the same second land on distinct names. The
indexer skips dot-prefixed directories, so search and embeddings drop the
note automatically on the next reindex pass (≤ 5 minutes). Soft-deleted
files accumulate in .trash/ — emptying that directory is the user's
responsibility.
A vault filesystem that cannot perform that non-replacing rename into
.trash/ makes the soft delete refuse with an error naming the limitation
rather than fall back to a rename that could overwrite; pass
permanent=True to unlink instead.
With permanent=True, the file is unlinked directly with no recovery path
inside this server. Existing backups are the rollback story.
A path whose final component is a symlink is refused, naming its target, so a delete never removes a note other than the one named; symlinked folders inside the vault work normally.
Dangling backlinks left behind by a delete are surfaced via
get_backlinks and find_orphans. See get_vault_guide for context.
expected_hash — binding a write to the bytes you read. Optional; omit
it and nothing changes. Pass the content_hash a read returned, verbatim
and canonical (sha256:<64 lowercase hex>), and the call is refused with
nothing written if the file changed in between. It is always the whole
file's hash, never a hash of the text you received. expected_hash
covers your read through this call's preflight read. The delete acts through the
pinned parent directory but does not compare the bytes again: an in-place edit after that
comparison can still be deleted. Precondition refusals end with one
machine-readable MCP-REFUSAL {"code":…} line — stale_precondition (with
the file's current hash, ready to resend), concurrent_write,
no_incumbent, malformed_precondition, precondition_unavailable,
precondition_required — and each states what resolves it.
It applies in both modes, and the comparison runs before the .trash
rename and before the unlink, so a refused delete leaves the note where it
was and creates no trash entry. A successful delete reports no
content_hash: nothing remains to hash.
Args:
path: Vault-relative path to the note.
permanent: If True, unlink instead of soft-deleting.
expected_hash: The note's content_hash as you last read it. Refuses
the delete, changing nothing, if the note has changed since.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| permanent | No | ||
| expected_hash | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full burden of behavioral disclosure. It thoroughly explains the soft-delete mechanism (renaming to .trash), the refusal on symlink paths, the expected_hash precondition semantics, error handling, and the absence of recovery for permanent deletes. It clearly details the order of operations and what happens on refusal, leaving no ambiguity.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long, but every section adds critical operational detail that cannot be inferred from the schema. It is well-structured with paragraph breaks and a clear flow: mode details, symlink handling, backlinks, hash semantics, and argument descriptions. It is front-loaded with the most essential behavioral details before the parameter list.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Despite the tool's complexity (multiple modes, precondition checks, edge cases), the description covers all relevant aspects: side effects, error conditions, interaction with other tools, and the meaning of the output schema (no content_hash on success). The presence of an output schema reduces the need to explain return values, but the description still covers behavioral outcomes thoroughly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0% and the schema only lists parameter names without descriptions. The tool description compensates fully by explaining each parameter: 'path' is the vault-relative path, 'permanent' flips between unlink and soft-delete, and 'expected_hash' is described in depth—its format, purpose, and the precondition refusals it can trigger. This goes far beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb ('delete'), a clear resource ('a note from the vault'), and distinguishes it from sibling tools like 'delete_file' and 'move_note' by emphasizing the two deletion modes (soft vs permanent) and the safety checks. It is not a tautology and clearly identifies what the tool accomplishes.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description explicitly explains when to use soft-delete (default) vs permanent deletion, warns about trash accumulation, and mentions related tools for handling backlinks ('get_backlinks', 'find_orphans') and context ('get_vault_guide'). It clarifies the behavior for symlinks and the expected_hash precondition, providing clear guidance for safe usage.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
edit_noteA
Edit an existing note in the Obsidian vault. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
See get_vault_guide for Obsidian syntax and any vault-specific conventions
(naming, folder placement, frontmatter, tags).
Four mutually exclusive modes (set at most one of append/find/section):
Full replace (default): provide only
content.contentbecomes the note's body; an existing valid line-1 YAML frontmatter block is preserved byte-identically ahead of it. Passreplace_frontmatter=Trueto overwrite the entire file, frontmatter included.Append:
append=True;contentis added at the end (preceded by a single newline).Find & replace:
find=<exact text>; replaced withcontent. Must match exactly once unlessreplace_all=True. This mode operates on the raw file, so it is the one mode that can edit frontmatter text in place.Section:
section=<heading>; replaces the whole body under the named ATX heading — see "Section mode: whatcontentreplaces" below. Use the path-style formParent/Childto disambiguate when the same heading appears more than once, or the#Nordinal form ("#7", 1-based document order) — the ordinal is the only form that can address duplicate headings sharing one parent, and it is the selector the outline of a truncatedread_noteadvertises. A bare "#N" always selects by position and is never shadowed by a heading whose text happens to be "#N"; reach such a heading by title with "Parent/#N". A selector resolves to the same section inread_noteas in this tool on any write this tool admits — that parity is about resolution, not about admission: see the two section-mode refusals below, where a section that reads fine is deliberately not writable. Setext (====/----) headings are not matched.
Frontmatter and the round trip. Read a note, edit the content field
of the response, pass it straight back to full replacement: the frontmatter
survives. No property of content's shape changes that — a body whose
first line is a thematic break ---, or which itself begins with a
complete mapping-shaped fenced block, is body. A note with no valid block
(no line-1 fence, or a malformed one) is replaced wholesale by default,
which is the repair path and needs no flag. To change the frontmatter
itself use set_frontmatter, or edit the raw block through find=; a
read_note response's frontmatter JSON view is a lossy convenience and
must never be written back.
The round-trip guarantee covers a complete, unwindowed whole-note read
only — read_note(path) with no section, offset=0 and truncated
false in the response. A truncated read must be paged to the end before it
is written back, or full replacement will replace the whole body with the
fragment.
Section mode: what content replaces.
In section mode
contentis the section's body: the text beginning on the line immediately after the matched heading line, running to the next heading of equal-or-shallower depth or to end of note. The heading line itself is never removed or rewritten.A section write replaces that body whole. Anything
contentdoes not resend is deleted — a blank line, a list, and a fenced code block sitting directly under the heading included. There is no third region between the heading line and the body that survives a write.So a blank line you want between the heading and its content belongs in
content(send"\ntext", not"text").read_note(path, section=...)is the matching read: its response carries the heading line in theheadingfield and the body in thecontentfield, and this tool takes exactly thatcontent. Pass the field through unchanged — there is nothing to split off and nothing to strip.Byte-identity holds for notes whose body newlines are LF. Every non-LF terminator inside the selected body (CRLF, or a lone CR) comes back as LF — the read path normalises and this tool writes raw bytes — whether the note uses one dialect throughout or mixes them. Terminators outside the selected body are untouched, so a round trip can leave a note with more mixed endings than it started with.
Section mode resolves headings over the frontmatter-stripped body, exactly
as read_note does, so #N ordinals agree between the two and a YAML #
comment inside the block is never selectable. A heading inside a fenced
code block is not a heading: fences count with up to three spaces of
indentation and a closer at least as long as the opener, and an unclosed
column-zero fence hides everything below it.
Two shapes refuse a section write outright, naming the problem and writing nothing:
a malformed frontmatter block (unclosed fence, YAML error, non-mapping) — the refusal names the defect and the
replace_frontmatter=Truerepair;a fence opener indented by one to three spaces that nothing below it closes — such an opener may sit inside a list item, whose code block ends where the item does, and this server does not parse container blocks, so it will not guess whether the text below is code or content. Close the fence or unindent it to column zero, then reissue.
Both refusals are asymmetric with reads on purpose: read_note(section=…)
and the truncation outline keep working on such notes, because a read
destroys nothing.
Flags:
operation="append": legacy alias forappend=True. This is accepted to prevent older clients from silently falling through to full replacement.operation="replace"explicitly selects full replacement.replace_all=True: withfind, replace every occurrence rather than failing on multiple matches. Ignored whenfindis unset.replace_frontmatter=True: full replacement overwrites the entire file including the frontmatter block. Combined with append/find/section it is an error and nothing is written.dry_run=True: compute the would-be result and return a unified diff without writing. Works for all four modes, and diffs the composed result.
Writes are atomic: the composed result is staged in the note's own directory,
flushed to disk, and published with a single same-directory rename, so a
crash mid-write cannot truncate the destination. The publish is guarded,
but only against a change landing inside this call: the file is read
here and re-compared immediately before the rename, so a writer racing this
tool's own read-modify-write fails with File changed while editing: <name> and nothing is written. That is not a guard on your read — it
is the second of the two windows below, and expected_hash is the first.
Structured frontmatter mutation is better done via set_frontmatter —
PyYAML serialization there discards YAML comments. A
path whose final component is a symlink is refused in every mode
(dry_run included), naming the link's target; symlinked folders inside
the vault work normally.
expected_hash — binding a write to the bytes you read. Optional; omit
it and nothing changes, including today's silent overwrite of whatever
landed since your read_note. Pass the content_hash a read returned,
verbatim and canonical (sha256:<64 lowercase hex>), and the call is
refused with nothing written if the file changed in between. It is always
the whole file's hash, never a hash of the text you received. Two
windows, both live: expected_hash covers your read → this call's read,
the server's own pre-publication compare covers this call's read → its
publication, and a match on the first does not exempt the second. Every
refusal ends with one machine-readable MCP-REFUSAL {"code":…} line —
stale_precondition (with the file's current hash, ready to resend),
concurrent_write, no_incumbent, malformed_precondition,
precondition_unavailable, precondition_required — and each states what
resolves it.
It applies in all four modes, dry_run included, and is checked before
mode dispatch, the size cap, the diff and every no-op branch, so a stale
base never yields a diff or a "no changes" answer. A section= write binds
the whole file as well: #N ordinals are positional, so a body-only
digest could certify an unchanged body while an insertion above it changed
which section the selector names. That makes the narrowest mode the most
conflict-prone — an unrelated edit elsewhere refuses it — which is exactly
why the argument is optional: bind when you reasoned about what you read,
omit when you are appending to a log. Every mode that publishes reports the
content_hash of the bytes this call wrote (not of whatever is on disk
when you read the message), so an edit→edit chain needs no intervening
read; dry_run publishes nothing and reports none.
Args:
path: Vault-relative path to the note.
content: New body (full replace), replacement text, text to append, or
section body.
append: If True, append content to the end of the note.
operation: Legacy mode selector; accepts "append" or "replace".
find: Exact text to find and replace.
section: ATX heading text identifying the section whose body to replace.
Use Parent/Child to disambiguate repeated headings, or a "#N"
ordinal ("#7", 1-based document order) for duplicate siblings.
replace_all: With find, replace every match instead of requiring uniqueness.
dry_run: Return a unified diff and do not write.
replace_frontmatter: Full-replace only. If True, content replaces the
entire file including any frontmatter block. Default False
preserves an existing valid block.
expected_hash: The note's content_hash as you last read it. Refuses
the write, changing nothing, if the file has changed since.
| Name | Required | Description | Default |
|---|---|---|---|
| find | No | ||
| path | Yes | ||
| append | No | ||
| content | Yes | ||
| dry_run | No | ||
| section | No | ||
| operation | No | ||
| replace_all | No | ||
| expected_hash | No | ||
| replace_frontmatter | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description fully bears the burden of behavioral disclosure, and it excels. It reveals atomic write semantics, the concurrency guard ('File changed while editing'), the expected_hash binding, refusal codes, section-mode deletion behavior, line-ending normalization, symlink refusal, and the round-trip guarantee. Nothing about side effects or failure modes is left undisclosed.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is lengthy, but for a tool with 10 parameters and four interacting modes, the detail is justified. It is well-structured with headers, numbered lists, and bolded key terms, and the most critical purpose and mode overview are front-loaded. Some redundancy (e.g., repeated references to section parity) could be trimmed, but overall the structure makes navigation effective.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description covers every conceivable operational aspect: all four modes, edge cases (fenced code blocks, malformed frontmatter), concurrency, refusals, round-trip guarantees, line endings, and even recommendations for sibling tools. Given the tool's complexity, nothing an agent needs to call it correctly is missing. An output schema exists, and the description focuses on behavior rather than return values.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, but the description compensates with a dedicated 'Args:' section that explains every parameter's meaning, defaults, and interactions (e.g., 'replace_frontmatter: Full-replace only... Default False preserves an existing valid block'). It also clarifies subtle semantics like the canonical expected_hash format and the meaning of '#'N' ordinals. This goes well beyond the bare schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a clear verb+resource: 'Edit an existing note in the Obsidian vault.' It then enumerates four distinct modes with precise conditions, and explicitly differentiates from siblings like set_frontmatter ('To change the frontmatter itself use set_frontmatter'). An agent can immediately tell what this tool does and how it relates to neighboring tools.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage guidance is explicit and exhaustive. The description specifies when to use each mode, when not to (e.g., 'Structured frontmatter mutation is better done via set_frontmatter'), and directs users to get_vault_guide for syntax conventions. It also covers prerequisites like write permission. No ambiguity about selection between alternatives.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
find_orphansA
Notes with zero incoming AND zero outgoing resolved links — useful for vault hygiene ("what's disconnected?") and cleanup decisions.
Args: folder: Optional vault-relative folder prefix to scope the search (e.g. "Cards/"). limit: Maximum results (default 50, hard cap 500).
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | ||
| folder | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden; it explains the core filtering behavior, including the 'resolved links' nuance and the AND condition on incoming/outgoing links. It does not explicitly state that the operation is read-only, but the query language and output schema make side effects unlikely.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is compact and front-loaded: a one-sentence definition plus use case, followed by a concise Args list. Every sentence earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a two-optional-parameter read tool with an output schema, the description covers what the tool returns, when to use it, and the parameter semantics. Nothing essential is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The Args section fully documents both parameters despite 0% schema coverage: folder's vault-relative prefix semantics with an example, and limit's default and hard cap. This adds real meaning beyond the bare schema types.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description precisely defines the result set: notes with zero incoming AND zero outgoing resolved links. It clearly identifies the resource and the orphan criterion, though it relies on the tool name for the verb and does not explicitly contrast itself with sibling link tools.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The 'useful for vault hygiene and cleanup decisions' phrase gives clear context for when to call it. It does not name alternatives or state when not to use it, but the intended use case is evident.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_backlinksA
Notes that link TO path. Use this to discover what references a given
note — projects citing a card, daily notes mentioning a person, etc.
Resolved links only (dangling references are not counted as backlinks).
Args: path: Vault-relative path to the target note (e.g. "Cards/Foo.md"). limit: Maximum results (default 50, hard cap 500).
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| limit | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the behavioral disclosure burden. It discloses a key trait beyond the schema: 'Resolved links only (dangling references are not counted as backlinks).' It also surfaces the hard cap of 500 for limit, which is not visible in the schema.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is tight and front-loaded: core purpose, then use case, then behavioral caveat, then argument meanings. Every sentence earns its place with no filler.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given two simple parameters, an output schema, and no annotations, the description covers purpose, parameter semantics, and the key resolved-links behavior. It could strengthen sibling differentiation, but nothing needed for correct invocation is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, and the description fully compensates. It explains path as 'Vault-relative path' with an example, and adds the hard cap of 500 for limit beyond the schema's default 50.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
Description opens with a specific, directional verb and resource: 'Notes that link TO `path`.' It gives concrete use cases (projects citing a card, daily notes mentioning a person) and the directionality clearly distinguishes backlinks from siblings like get_links.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly frames when to use the tool ('Use this to discover what references a given note') with examples. It does not name alternatives or state when not to use it, but the TO-direction scoping makes the intended context unambiguous.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_linksA
Outgoing links from path — both resolved and dangling.
Useful for "what does this note depend on?" or finding broken references that need follow-up notes.
The result carries a truncated field. truncated: true means this note
holds more links than the indexer's per-note cap and the list is the first
N in document order only — treat it as incomplete rather than as the
note's full outgoing-link set.
Links come back in document order and are capped by limit; when more rows
exist the result says how many of them were persisted, so a partial page is
never read as the whole set. limit raises the page up to a hard cap of
500 — this tool has no paging beyond that, so a note with more than 500
link rows can only be read in full from the note itself.
Each row's link text is clipped to 120 characters.
Args: path: Vault-relative path to the source note. limit: Maximum links returned (default 100, hard cap 500).
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| limit | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Since annotations (readOnlyHint, destructiveHint) are not provided, the description carries the full burden of behavioral disclosure. It does an excellent job of detailing the `truncated` field, indicating that results may be incomplete when exceeding the indexer's per-note cap. It also explains the hard cap of 500 on `limit` and the lack of paging, plus the 120-character clipping of link text. This is exemplary behavioral transparency, though it could be even stronger if it noted the non-destructive nature, but it's a read-only operation by context.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured and front-loaded: it opens with the core function, then provides use cases, then explains important caveats (truncation, caps, clipping). Each sentence adds value without redundancy. It is appropriately sized for the complexity, covering multiple behavioral details while remaining readable. No wasted words.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's simplicity (2 parameters, mainly read-only) and the presence of an output schema, the description is complete. It covers the main behavioral caveats: truncation, hard cap, no paging, and text clipping. The output schema likely documents the return structure, so the description doesn't need to explain return values. An agent has enough information to call this tool correctly without surprises.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema provides only names (path, limit) with minimal descriptions (none in the schema properties, only titles). The description adds crucial semantics: 'path' is vault-relative, and 'limit' controls the maximum links returned with a default and hard cap. Since schema coverage is 0%, this compensation is strong, but it could also clarify the type of path (e.g., must be existing note) and the effect of limit on pagination. The description does add meaning beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose: retrieves outgoing links from a given path, including resolved and dangling links. It uses a specific verb ('get') and resource ('links'), and distinguishes itself from siblings like get_backlinks (incoming links) by focusing on outgoing dependency links. The context about 'what does this note depend on?' makes the purpose unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides useful use cases ('what does this note depend on?' or finding broken references), giving clear context on when to use it. It does not explicitly state when not to use it or name alternative tools, but the distinct purpose from siblings (e.g., get_backlinks) is implied. This is slightly above average but lacks explicit exclusion guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_neighborhoodA
The connected subgraph reachable from path via links or backlinks,
up to depth hops (treated as undirected).
Use this when an agent needs the local cluster around a topic — e.g.
"summarize everything connected to this project". Prefer this over
find_related when explicit links are the signal you want; prefer
find_related when the connection is conceptual rather than linked.
Args: path: Vault-relative path to the seed note. depth: Maximum BFS depth (default 1, capped at 5). limit: Maximum distinct neighbor notes (default 50, hard cap 200).
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| depth | No | ||
| limit | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden. It discloses the BFS traversal, undirected treatment, depth and limit caps, and that it returns distinct neighbor notes. However, it does not mention error handling (e.g., missing path) or whether the seed note itself is included, which are minor gaps for a read-only query tool.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with a clear definition first, usage guidance, and a separate Args list. Every sentence adds value, and it is not overly verbose for a tool with three parameters.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool has an output schema (which handles return format) and the description covers behavior and parameters, nothing essential is missing for an agent to call it correctly. It is complete for a graph-query tool.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The description includes an Args section that explains each parameter beyond the schema's type/default info: path is the seed note, depth is max BFS depth with default and cap, limit is max distinct neighbors with cap. This fully compensates for the 0% schema description coverage.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool returns a connected subgraph reachable from a seed note via links/backlinks, with a specific traversal algorithm. It also names the sibling tool it is not (find_related) and explains the distinction, so an agent can differentiate without inspecting schemas.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly states when to use this tool ('when an agent needs the local cluster around a topic') and provides a concrete example. It also contrasts with find_related, giving clear selection criteria based on whether links are explicit or conceptual.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_recentB
Get recently modified notes.
Args: limit: Number of recent notes to return (default 20). folder: Optional folder prefix to filter (e.g. "Projects/"). tags: Optional list of tag names; only notes carrying ALL listed tags match (e.g. ["meeting"]). frontmatter: Optional dict of frontmatter key/value pairs; strict type match (e.g. {"status": "active"}).
| Name | Required | Description | Default |
|---|---|---|---|
| tags | No | ||
| limit | No | ||
| folder | No | ||
| frontmatter | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries full responsibility for behavioral disclosure. It does not state that the tool is read-only, whether it sorts results, or any side effects or limitations. Only parameter constraints like 'strict type match' are mentioned.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is a two-part docstring: a one-line summary followed by an ordered parameter list. Every sentence adds value, and the parameter details are formatted for quick scanning. No wasted words.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a read tool with four optional parameters, the description covers all inputs and the output schema exists, so return values need no explanation. However, it lacks any use-case context or sibling differentiation, which matters given the large toolset. It's adequate but not complete.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must fully explain parameters. It does so with defaults, types, and examples for limit, folder, tags, and frontmatter, and clarifies important semantics like ALL-tag matching and strict type matching—information not present in the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description 'Get recently modified notes' states a clear verb and resource. It implicitly distinguishes from siblings like list_notes by the 'recently modified' time-based criterion, but does not explicitly compare or name any alternative.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
No guidance is given for when to use this tool versus the many siblings (keyword_search, semantic_search, list_notes). The description only explains parameters, not the use cases or exclusions.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_tagsA
List all tags used across the vault with note counts.
Args: limit: Maximum number of tags to return (default 50)
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full burden of behavioral disclosure. It does reveal a key output detail ('with note counts') and implies a read-only operation through the verb 'get', but it does not mention ordering, case sensitivity, whether all tags are returned despite the limit, or any performance implications. This is adequate for a simple listing tool but not thorough.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is extremely concise, with the primary purpose stated in a single sentence, followed by a minimal and necessary argument explanation. There is no fluff, and the critical information is front-loaded.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's simplicity (one optional parameter) and the presence of an output schema, the description covers the essentials. It mentions the output format (note counts) and the limit semantics. It does not specify ordering or edge cases, but these are likely defined in the output schema or are not critical for a basic listing operation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The description explicitly explains the only parameter: 'limit: Maximum number of tags to return (default 50)'. Since the schema itself provides no description for the parameter, this adds clear semantic meaning beyond the structured definition, fully compensating for the 0% schema coverage.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's action ('List all tags'), the resource ('tags'), the scope ('across the vault'), and the output ('with note counts'). This is a specific verb-resource pair that distinguishes it from the sibling tools, none of which focus on tags.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides no guidance on when to use this tool versus alternatives. It does not mention any conditions, exclusions, or related tools. Given the large set of siblings (keyword_search, list_notes, get_recent, etc.), an agent receives no help selecting this tool over others.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_vault_guideA
Returns a two-part guide for working with this Obsidian vault:
Obsidian primer — generic syntax (wikilinks, embeds, block refs, heading refs, tags, frontmatter, callouts, comments, highlights, math, mermaid, footnotes, tasks, plugin literals).
Vault-specific conventions — folder structure, naming rules, frontmatter requirements, and tag taxonomy as configured by the vault owner in
CLAUDE.md. IfCLAUDE.mdis absent, the response includes instructions for creating one.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Since no annotations are provided, the description carries the full burden. It explicitly mentions the behavior when CLAUDE.md is absent (includes instructions for creating one), adding valuable behavioral context beyond the basic 'returns a guide' statement.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with a clear numbered list, front-loading the main purpose and then detailing the two components. Every sentence adds value, and it is appropriately sized for the tool's simplicity.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the zero parameters and the presence of an output schema, the description is complete. It covers what the guide contains, the two parts, and the behavior when CLAUDE.md is absent, leaving no critical gaps for an agent to call it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool has zero parameters, so the schema provides no parameter documentation. The description compensates by fully explaining what the return value contains, making the tool's interface clear despite the lack of parameters.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool returns a two-part guide for the Obsidian vault, specifying the primer and vault-specific conventions. It distinguishes itself from sibling tools by focusing on documentation/reference rather than direct file operations.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It provides clear context on what the tool does and its components, but it does not explicitly state when to use this tool versus alternatives, nor does it mention any exclusions or alternatives. The intended use is implied as a general reference for vault conventions.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
import_from_urlA
Fetch a file from a public https URL straight into the vault. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
Peer to write_file and request_upload — use this one when the bytes are
already somewhere public.
The server does the fetching, so nothing passes through your context: a 20 MB PDF costs one tool call. Returns the path, size, sha256, MIME type and the final URL after any redirects.
Only genuinely public addresses. This server sits on a private network
next to a database and other services, so the fetch is restricted: https
only — plain http is refused unless the operator has set
IMPORT_ALLOW_HTTP=true — no credentials in the URL, no
private/loopback/link-local/metadata addresses in any spelling, and the same
rules re-checked at every redirect.
A refusal names the rule that was violated — that is information about the
URL, not a hint to work around it. Rewriting the URL to evade the check is
never the right next step; ask the user for a public link instead.
Size-capped at MAX_FILE_WRITE_BYTES, with one 30-second deadline for the
whole fetch. No-clobber unless overwrite=True. Nothing is written unless
the whole body arrives intact.
This tool shares the transfer tools' preflight, so it also refuses when the
server has no public origin configured (MCP_HOSTNAME or BASE_URL), even
though it mints no link — that is an operator setting, not something to work
around.
Args:
url: Public https URL of the file.
path: Vault-relative destination (e.g. "Attachments/paper.pdf").
overwrite: If True, allow replacing an existing file at path.
| Name | Required | Description | Default |
|---|---|---|---|
| url | Yes | ||
| path | Yes | ||
| overwrite | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and delivers thoroughly: requires readwrite permission, server-side fetching (no context load), returns specific fields, enforces https and address restrictions, rechecks at redirects, size cap, deadline, no-clobber default, atomic write, and preflight refusal. It even clarifies that refusal messages are informational, not workaround hints. This is exemplary transparency.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Although long, every sentence carries critical security or usage information. The structure front-loads purpose, then usage, then constraints, then args. There is no fluff; the density is justified by the tool's security sensitivity. It is well-organized and scannable.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description is complete for a complex tool: it covers return values (path, size, sha256, MIME, final URL), error handling (refusals, redirect rules), prerequisites (permissions, public origin), and constraints (size, deadline, no-clobber). The output schema exists, but the description still explains the returned fields. Nothing an agent needs to call it correctly is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description must compensate. It provides an 'Args' section explaining each parameter: url (public https URL), path (vault-relative destination with example), and overwrite (allow replacing). This adds meaning well beyond the bare schema, giving agents everything needed to fill them correctly.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description uses a specific verb (fetch) and resource (file from URL into vault), and explicitly distinguishes from siblings by naming write_file and request_upload and stating when to use this one ('when the bytes are already somewhere public'). This leaves no ambiguity about the tool's role.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It explicitly states when to use this tool versus alternatives ('Peer to write_file and request_upload — use this one when the bytes are already somewhere public'). It also provides concrete usage conditions: public https only, no credentials, size caps, deadline, and preflight refusal. These conditions guide correct invocation.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
keyword_searchA
Full-text keyword search via PostgreSQL tsvector. Use this for exact identifiers, code symbols, proper nouns, or known phrases — anywhere semantic noise hurts.
For conceptual or paraphrased queries, use semantic_search instead.
Args: query: Keywords or phrase to match (websearch tsquery syntax: "foo bar", "foo OR bar", "-bar"). folder: Optional folder prefix (e.g. "Cards/", "Projects/"). limit: Maximum number of results (default 20). tags: Optional list of tag names; only notes carrying ALL listed tags match (e.g. ["project", "active"]). frontmatter: Optional dict of frontmatter key/value pairs; only notes whose JSONB frontmatter contains every pair match. Strict type matching — string "0" does not match integer 0 (e.g. {"status": "draft"}).
| Name | Required | Description | Default |
|---|---|---|---|
| tags | No | ||
| limit | No | ||
| query | Yes | ||
| folder | No | ||
| frontmatter | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the behavioral transparency burden. It discloses meaningful matching behaviors beyond the schema, such as ALL-tag semantics, strict type matching in frontmatter, and websearch tsquery syntax. It stops short of explicitly stating read-only/non-destructive behavior, but the search characteristics and strict matching rules are well conveyed.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is efficiently organized: a one-sentence purpose, a one-sentence routing instruction, then a terse Args list. Every sentence adds value, and the most decision-relevant information is front-loaded.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a 5-parameter search tool with no annotations, the description covers all parameters, explains the matching model, provides syntax guidance, and directs the agent to the correct sibling. The presence of an output schema means return-value documentation is not the description's responsibility, so nothing essential is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must fully compensate, and it does. Each parameter is explained with concrete examples: query syntax, folder prefix, limit default, tag AND-semantics, and frontmatter strict type matching. This adds substantial meaning over the bare schema properties.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource ('Full-text keyword search via PostgreSQL tsvector') and immediately defines the intended use cases: exact identifiers, code symbols, proper nouns, or known phrases. It also distinguishes itself from semantic_search, eliminating ambiguity about what this tool is for.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly tells the agent when to use this tool versus an alternative: 'Use this for exact identifiers... For conceptual or paraphrased queries, use semantic_search instead.' This is direct, actionable routing guidance with no reliance on inference.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_filesA
Browse the vault filesystem (ls-style), including non-markdown files.
Peer to list_notes, which lists indexed markdown only; list_files reads
the filesystem directly and reports sizes so you can gauge a binary before
read_file.
By default lists the immediate children of folder — subdirectories and
files, each file with size and modification time. pattern is a glob that
filters file entries (e.g. "*.pdf"); recursive=True descends into
subfolders and returns matching files. Anything with a path component
starting with . is hidden — dot-directories (.obsidian, .git,
.trash, …) and dot-files — and a folder with such a component is
rejected.
At most limit entries are returned (default 200, hard cap 1000); the
response indicates when the listing was truncated.
Args: folder: Vault-relative folder (default "." = vault root). pattern: Glob applied to file names (default "*"). recursive: If True, descend into subfolders. Off by default. limit: Maximum entries to return (default 200, hard cap 1000).
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | ||
| folder | No | . | |
| pattern | No | * | |
| recursive | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries full responsibility. It discloses hidden dot-file/dot-directory filtering, rejection of dot-containing folder paths, default limit and hard cap, truncation signaling, and the fact that files are reported with size and modification time. This is thorough behavioral disclosure.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is information-dense and well-organized, with a high-level overview followed by a structured Args list. Every sentence adds meaningful guidance about behavior, defaults, or limitations; there is no filler or repetition of schema defaults beyond what is necessary.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's moderate complexity, the description is complete: it covers defaults, recursion, glob filtering, hidden-file behavior, truncation, and parameter semantics. The presence of an output schema means return structure need not be repeated, and the description provides everything an agent needs to call the tool correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must compensate. It explains all four parameters (folder, pattern, recursive, limit) with defaults, types, and behavioral effects. This fully covers what the schema leaves ambiguous.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb ('Browse the vault filesystem') and resource ('filesystem'), and explicitly distinguishes itself from list_notes by noting it includes non-markdown files and reads the filesystem directly. An agent can immediately tell which tool to select.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description explicitly names the sibling tool list_notes as the alternative for indexed markdown only, and explains when list_files should be preferred (e.g., gauging a binary before read_file). It also covers the default non-recursive behavior and when recursive=True is appropriate, giving clear context for tool selection.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_notesA
List notes in a vault folder, sorted by most recently modified.
Results come from the index, so a note that exists on disk but has not yet been picked up by the indexer will not appear (lag is bounded by the index interval, typically up to 5 minutes).
Args: folder: Vault-relative folder path (e.g. "Cards/", "Projects/"). Empty for vault root. limit: Maximum number of results (default 50). tags: Optional list of tag names; only notes carrying ALL listed tags match (e.g. ["idea"]). frontmatter: Optional dict of frontmatter key/value pairs; strict type match (e.g. {"status": "active"}).
| Name | Required | Description | Default |
|---|---|---|---|
| tags | No | ||
| limit | No | ||
| folder | No | ||
| frontmatter | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations present, the description carries the full burden, and it delivers: results come from an index with bounded lag, notes may be missing until indexed, sorting is by most recent modification, tag matching requires ALL tags, and frontmatter matching is strict on type. This goes well beyond a generic 'list' operation.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The main behavior is front-loaded in one sentence, the index caveat earns its place, and the Args section is tightly formatted without fluff. Every sentence adds needed information.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a read-only listing tool with four parameters and an output schema, the description is complete: it explains index staleness, filter semantics, defaults, and folder-root behavior, while return values are covered by the output schema.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must compensate. Each parameter gets a human-readable explanation, default, and example: folder with empty-for-root semantics, limit with maximum/default, tags with ALL-match semantics, and frontmatter with strict type matching.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource — 'List notes in a vault folder' — and adds sorting behavior, distinguishing it from file-listing siblings like list_files and search siblings like keyword_search. The folder scope and filter approach make the tool's job unmistakable.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It clearly describes when to use the tool: to list notes in a folder or vault root, with optional tag/frontmatter filters. It does not explicitly name sibling alternatives or state when not to use it, so it misses the top tier, but the context is unambiguous.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
move_noteA
Move or rename a note inside the vault. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
Updates notes_metadata.file_path for the moved note and note_links.target_path
rows whose stored target matched the old path. Backlinks via target_note_id
keep working without rewriting source notes (the moved note's id is unchanged).
With rewrite_links=True, also opens every source note that linked to this
note and rewrites the link in place: [[Old]] → [[New]],
[[Old|alias]] → [[New|alias]], [[Old#anchor]] → [[New#anchor]],
![[Old]] → ![[New]], path-style [[folder/Old]] → [[new/folder/New]],
and markdown links [text](Old.md) → [text](<new path>), whose href is
written relative to the linking note's own folder (anchors preserved).
Aliases and anchors survive; only the target portion is rewritten.
The moved note's own body is rewritten as well, so a self-reference
does not end up pointing at the old path.
The rewrites are planned before anything changes: if one would push a source note past the 10 MiB note limit the whole move is refused, naming that source, before any file is touched. That preflight is also bounded in aggregate: if the originals plus rewrites for all backlink sources would exceed 256 MiB in memory the move is refused before anything changes, naming the note count and the limit.
The same preflight refuses the whole move, before the rename, when any
source it would rewrite — the moved note's own body included — contains a
fence opener indented by one to three spaces that nothing below it
closes. The refusal names each such source and where its opener sits. A
link under such an opener may be inside a list item's code block, which
this server does not parse, and a rewrite would mutate text whose
code-or-content status had to be guessed. Move with rewrite_links=False
(unaffected by this refusal) and fix the links yourself, or close the
fences first.
A rewrite can still fail after the move has committed. The move is one
rename and the rewrites follow it, so an I/O failure, a vault reassignment,
or a database that cannot be reached to confirm the assignment stops the
remaining rewrites — and the result then reads partial success: …, naming
the sources that still link to the old path. The move is not rolled back
and the index rows describe where the note now is. Treat the link graph as
agreeing with the vault bytes only when the result reports plain success;
on a partial outcome, fix the named sources with edit_note.
Writes are atomic. Either path is refused, naming the link's target, when
its final component is a symlink; symlinked folders inside the vault work
normally and the recorded paths are the real ones behind them. See
get_vault_guide for vault folder conventions.
expected_hash — binding a write to the bytes you read. Optional; omit
it and nothing changes. Pass the content_hash a read returned, verbatim
and canonical (sha256:<64 lowercase hex>), and the call is refused with
nothing written if the file changed in between. It is always the whole
file's hash, never a hash of the text you received. expected_hash
covers your read through this call's preflight read. The rename pins inode
identity but does not compare the bytes again: an in-place edit after that
comparison can still be moved. Each optional link rewrite retains its own
pre-publication byte comparison. Precondition refusals end with one
machine-readable MCP-REFUSAL {"code":…} line — stale_precondition (with
the file's current hash, ready to resend), concurrent_write,
no_incumbent, malformed_precondition, precondition_unavailable,
precondition_required — and each states what resolves it.
Here it binds from_path's own bytes and nothing else, compared before
the rename and before any rewrite; the backlink sources a
rewrite_links=True move would rewrite are not bound, because you never
read them. Success reports the content_hash of the bytes actually
published at to_path — the moved bytes for a plain move, the post-rewrite
bytes when the moved note's own body was rewritten — and that value binds a
following edit_note(to_path, …, expected_hash=…). A rewrite that fails
after the rename stays a partial success and never claims nothing was
written; if it failed because another writer changed to_path in between,
no hash is reported at all and the result says to re-read to_path
before writing to it.
Args:
from_path: Vault-relative path of the existing note.
to_path: Vault-relative path of the destination. Must not exist. Parent
directories are created automatically.
rewrite_links: If True, also rewrite incoming wikilinks and embeds in
source notes. Off by default — opting in is destructive (it modifies
other notes' bodies).
expected_hash: from_path's content_hash as you last read it.
Refuses the move, changing nothing, if that note has changed since.
| Name | Required | Description | Default |
|---|---|---|---|
| to_path | Yes | ||
| from_path | Yes | ||
| expected_hash | No | ||
| rewrite_links | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full behavioral disclosure burden, and it does so thoroughly. It discloses metadata and link-table updates, preflight failure modes for note-size and fence conditions, partial-success behavior after the move commits, 'not rolled back' semantics, atomic writes, symlink handling, and the exact behavior of expected_hash including refusal codes and returned content_hash.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is front-loaded with a one-sentence summary and uses bold section headers, making a long and complex behavior navigable. It is not padded, though the expected_hash narrative somewhat restates the Args bullet and the overall length is substantial. The structure earns a high score rather than a maximum.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a mutating tool with no annotations, complex preconditions, and possible partial failures, the description includes everything needed for correct invocation: permissions, preflight conditions, refusal codes, partial-success handling, symlink behavior, and hash chaining to a subsequent edit_note. An output schema exists, so return-value documentation is not required here.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, but the description's Args section defines all four parameters with meaning far beyond the schema names. It explains that to_path must not exist and parent directories are created automatically, that rewrite_links is destructive and off by default, and the canonical form and precondition behavior of expected_hash.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The opening sentence states a specific action and resource: 'Move or rename a note inside the vault.' This clearly distinguishes it from content-editing, creating, or deleting siblings. The rest of the description reinforces that it is about changing a note's path, not its content.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives explicit alternatives and exclusions for edge cases: 'Move with rewrite_links=False ... fix the links yourself, or close the fences first,' 'fix the named sources with edit_note,' and 'See get_vault_guide for vault folder conventions.' It also states the write-permission requirement. It does not, however, provide a general when-to-use versus content-editing alternatives statement, so it stops short of a 5.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
read_fileA
Read any file in the vault — including non-markdown (PDFs, images,
skill HTML/JS, data files). Peer to read_note, which stays markdown-only.
This is pure byte transport: the server does NOT extract or parse PDFs and cannot interpret binary bytes. Non-text/non-image files come back as an opaque base64 string intended for a client-side skill to decode — not as something the model can read directly.
Encoding:
"auto"(default): text-like files (HTML, JSON, CSV, source, …) return as readable text; images (PNG/JPEG/GIF/WebP) return as an inline image block that renders in-client; everything else returns as a labeled base64 string."text": force a UTF-8 text decode; errors if the file is not valid UTF-8."base64": force a raw-bytes base64 string regardless of type.
Files larger than MAX_FILE_READ_BYTES (default 10 MB) are refused with a
size report. Base64 reads pass through the model context and inflate ~33%,
so they are token-heavy — check a file's size with list_files before
reading large binaries. Any path with a component starting with . is
rejected — dot-directories (.obsidian, .git, .trash, …) and dot-files
alike — as is path traversal.
Text results are additionally capped to a context-safe size: the cap bounds
the returned window, and a truncated read appends a short notice carrying
the offset to continue from. Base64 and image results are not windowed.
The base64 header and hash_only=True return the whole raw file's
content_hash (sha256:<64 lowercase hex>), which write/delete tools
accept as expected_hash. Their path is a quoted JSON string. Text stays
deliberately unenveloped. Use base64 for byte-exact frontmatter bytes;
read_note.frontmatter_yaml has normalized line endings.
Encoding is validated first, then hash_only/window compatibility, then
ranges. hash_only refuses offset != 0 or any non-None limit; explicitly
passing offset=0 is fine. A valid encoding has no effect in this mode.
Args: path: Vault-relative path to the file (e.g. "Reference Docs/spec.pdf"). hash_only: Return only path, byte count, MIME and hash, with no content. encoding: One of "auto" (default), "text", or "base64". offset: Character offset to start a text read from (default 0). Use the value the truncation notice reports to continue. limit: Maximum characters to return for a text read. Only lowers the server cap; it cannot raise it.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| limit | No | ||
| offset | No | ||
| encoding | No | auto | |
| hash_only | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and does so thoroughly. It discloses that the server does not parse PDFs or interpret binaries, explains encoding behaviors (auto/text/base64) and their return formats, size caps (10 MB refusal, windowing), security restrictions (dot-path rejection, traversal rejection), the truncation notice with offset continuation, validation order, and hash-only behavior. It even notes the ~33% base64 size inflation. This goes well beyond a basic mutation description.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but every sentence earns its place. It is front-loaded with the core purpose and sibling contrast, then organized into clearly labeled sections (encoding, size limits, path restrictions, windowing, hash, validation order). There is no filler; the structure makes the dense information easily scannable for an agent.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity (5 parameters, no schema coverage, no annotations, no output schema), the description covers every aspect an agent needs to call it correctly: return types for each encoding, size caps, security, windowing, hash behavior, and validation precedence. It even specifies the hash format and that text results are unenveloped. Nothing material is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0%, so the description must fully explain parameters, and it does. Each parameter is defined: `path` (vault-relative), `hash_only` (metadata only), `encoding` (with detailed auto/text/base64 semantics), `offset` (character offset, use truncation notice value), and `limit` (only lowers cap). It also explains interactions (e.g., `hash_only` refuses non-zero offset or non-None limit, valid encoding has no effect in hash_only) and gives examples of path format.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a clear verb-resource pair: 'Read any file in the vault' and explicitly contrasts itself with `read_note` ('which stays markdown-only'), instantly distinguishing its scope from a sibling. The inclusion of non-markdown types (PDFs, images, skill HTML/JS, data files) further sharpens the purpose.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides explicit when-to-use guidance: it is the tool for any non-markdown file, while `read_note` is the markdown-only alternative. It also advises checking file size with `list_files` before reading large binaries, warns about token inflation with base64, and explains when to use `hash_only` for byte-exact frontmatter bytes. These conditions and alternatives are unambiguous.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
read_noteA
Read a note from the Obsidian vault by its relative path.
Returns a structured result, not a rendered document. Metadata and note content sit in separate fields, so there is no envelope to parse and no textual procedure to get wrong — read the fields:
content— the selected note text. Whole-note reads: the body with a valid YAML frontmatter block stripped, which is exactly whatedit_note(path, content)full replacement accepts. Section reads: the section's body only, which is exactly whatedit_note(path, content, section=...)accepts. Pass it straight back; do not add, strip or split anything.heading— section reads only: the matched heading line, with no line terminator. It is not part ofcontent, and a section write must not be sent it — the heading line is never rewritten.path,title,tags— metadata as data.frontmatter_yaml— the frontmatter block's YAML source, fence lines excluded, LF-normalized (a CRLF or lone-CR block comes back with LF terminators — the same declared residualcontentcarries, because this tool normalises and the write tools work on raw bytes;edit_notestill reattaches the original block byte-identically). This is the authoritative copy.frontmatteris a best-effort JSON view of the same block for convenience and may be absent — dates, non-string keys, recursive aliases and unpaired-surrogate escapes have no faithful JSON form, andmetadata_omissionsthen says which and why. To change frontmatter useset_frontmatter, or edit the raw block withedit_note(find=...); never write back a round trip of the JSON view.truncated,offset,next_offset,total_chars— truncation as data.outline(whole-note reads that were truncated) lists every section with its#Nordinal so you can fetch the one you want directly, andnoticecarries the guidance in prose.metadata_omissions— any metadata field this response had to drop, and why. Nothing is ever signalled by a marker inside a field.content_hash— this note's file digest,sha256:<64 lowercase hex>, and the token the write tools accept asexpected_hashto bind a write to the bytes you actually read. Three things about it: it is the whole file's hash in every mode — a section read and a truncated read return the same value a whole-note read of the unchanged file returns, so a section write guarded with it is refused when anything in the file changed (that is the trade, and it is why the argument is optional: bind when you reasoned about what you read, omit when you are appending to a log); it comes from the same read that built this response, never a second one; and it is not a hash ofcontent— a note with frontmatter, or with CRLF terminators, has a digest the returned text cannot reproduce, so never compute it yourself, hand this value back verbatim.read_fileon the same path is the byte-exact route to the file itself,frontmatter_yamlhere being authoritative but LF-normalized.error— set when the read failed (missing note, badoffset/limit, unknown section). It is a normal result, not a transport error, and the content-bearing fields are absent beside it.
Budgets are per field, not per response: content is bounded by the
server's response cap, the outline by its own equal budget, and the
metadata fields by a third — so a truncated whole-note read can carry
several capped components. Read the one section you need with section=
rather than paging a large note.
Round trips. A whole-note content is byte-exact input for
edit_note(path, content) only when the read is complete and unwindowed
(offset=0 and truncated false); a truncated read must be paged to the
end first, or full replacement replaces the whole body with the fragment.
A section content is byte-exact input for edit_note(section=...) under
the same completeness condition. Byte-identity holds for notes whose body
newlines are LF: terminators inside the selected content come back as LF,
because this tool normalises and the write tools rewrite raw bytes.
Args:
path: Vault-relative path to the note (e.g. "Cards/My Note.md")
section: Optional ATX heading to read instead of the whole note. Plain
text ("Balance Sheet"), a path-style chain ("Parent/Child") when the
heading appears under different parents, or a "#N" ordinal ("#7",
1-based document order) — the ordinal is the only form that can
address duplicate headings sharing the same parent. The outline
returned with a truncated note carries the ordinal for every
section. A bare "#N" always selects by position and is never
shadowed by a heading whose text happens to be "#N"; use
"Parent/#N" to reach such a heading by title.
offset: Character offset to start reading from (default 0). Use the
next_offset the response reports to continue.
limit: Maximum characters of content to return. Only lowers the
server cap; it cannot raise it.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| limit | No | ||
| offset | No | ||
| section | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| path | No | |
| tags | No | |
| error | No | |
| title | No | |
| notice | No | |
| offset | No | |
| content | No | |
| heading | No | |
| outline | No | |
| truncated | No | |
| frontmatter | No | |
| next_offset | No | |
| total_chars | No | |
| content_hash | No | |
| frontmatter_yaml | No | |
| metadata_coercions | No | |
| metadata_omissions | No |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries full burden and exceeds it. It discloses truncation semantics, content_hash is the whole-file hash and not content's, LF normalization behavior, error as a normal result, and round-trip byte-exactness conditions. Nothing is hidden or ambiguous.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long, but its structure (bolded field labels, separate 'Round trips' section) makes it scannable. Every sentence adds needed detail for a complex tool. Slight redundancy like 'that is the trade, and it is why the argument is optional' could be tighter, but overall it is appropriately sized for the complexity.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the absence of parameter descriptions in the schema and the presence of an output schema, the description covers all parameters, all return fields with their invariants, error conditions, round-trip guarantees, and cross-tool relationships. Nothing an agent needs to call it correctly is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0% (only types/titles). The description fully compensates: path gets an example, section explains heading formats, ordinals, and duplicate handling, offset clarifies default and next_offset usage, limit explains it only lowers the server cap. Every parameter is semantically enriched.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The opening sentence states exactly what the tool does: 'Read a note from the Obsidian vault by its relative path.' It goes further to say it returns a structured result (not rendered), and distinguishes itself from read_file as the byte-exact route. This is a specific verb+resource with clear differentiation.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description explicitly directs when to use alternatives: read_file for byte-exact reads, edit_note for writes, set_frontmatter for frontmatter changes. It also advises reading a section directly rather than paging a large note, and explains the trade-off of using content_hash for write binding. All usage context is explicit.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
request_downloadA
Get a short-lived link a person can use to save a vault file. Peer to
read_file, which returns the bytes to you — use this one when the file is
for the human, not for you.
Handy for anything read_file would waste context on or cannot render: a
PDF, a large image, an archive. Reading works with a read-only key.
The token lives in the URL's # fragment, so it never reaches an access
log. Treat the whole URL as a secret — whoever holds it can read that
one file until it expires. Never put it in a query string.
The link is bound to the file as it is now: if it is edited or replaced, the link stops working rather than serving different content than you described. Unlike an upload link it can be used more than once, so the person can preview and then save.
From a shell: `curl -H "Authorization: Bearer " -o
Args:
path: Vault-relative path of the file to share.
expires_in: Seconds until the link dies. Clamped to 60–3600; defaults
to TRANSFER_TOKEN_TTL_SECONDS (600). A link can never outlive the
credential you are calling with, so the deadline in the result may
be earlier than you asked for — it says so when that happens.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| expires_in | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full behavioral burden. It discloses that the token lives in the URL fragment to avoid access logs, that the URL must be treated as a secret, that the link is bound to the file's current state and stops working if edited, and that expiry is clamped. This is rich, security-relevant behavioral context beyond what structured fields could provide.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long but every section earns its place: purpose first, then usage context, security warning, link lifecycle, and parameter details. Important warnings are front-loaded before the shell example and Args, making the structure effective for agent comprehension.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description is complete for a tool of this complexity. It covers purpose, selection criteria, security model, expiration behavior, parameter meanings, and even a curl example. An output schema exists, so return values do not need to be explained in the description.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must compensate, and it does. The Args section defines path as a Vault-relative path and expires_in with units, default, clamping range (60–3600), and the relationship to credential lifetime. This fully explains the parameter semantics.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource: 'Get a short-lived link a person can use to save a vault file.' It explicitly contrasts itself with read_file, making the tool's role clear and differentiating it from sibling tools without requiring the agent to inspect schemas.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It states exactly when to use this tool ('use this one when the file is for the human, not for you'), gives concrete examples where read_file would be unsuitable (PDF, large image, archive), and contrasts it with upload links by noting repeated use. This is explicit when/when-not guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
request_uploadA
Get a short-lived link a person can use to put a file into the vault.
Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
Peer to write_file, which takes the bytes directly — use this one when you
do not have them.
No MCP client can hand a tool the bytes of a file the user is looking at,
and your shell cannot reach their machine. This mints a link bound to
exactly one destination path: hand it to the person you are helping, they
open it and pick a file, and it lands at path. Nothing else can be
written with it.
The token lives in the URL's # fragment, which browsers never send to a
server, so it stays out of access logs. Treat the whole URL as a secret
— whoever holds it can write that one path, once, until it expires. Never
put it in a query string: that would log it.
Single use, and no-clobber unless you ask otherwise. With
overwrite=True the link also remembers what the file looked like now and
refuses to publish if it changed in the meantime, so a stale link cannot
silently undo an edit someone made while it was waiting.
From a shell you can upload without the page:
curl -H "Authorization: Bearer <token>" -T <file> <base>/transfer/upload.
Then call check_upload(upload_id) to confirm the bytes landed and get
their sha256. See get_vault_guide for how files fit into the vault.
Args:
path: Vault-relative destination (e.g. "Attachments/photo.png").
overwrite: If True, allow replacing an existing file at path.
expires_in: Seconds until the link dies. Clamped to 60–3600; defaults
to TRANSFER_TOKEN_TTL_SECONDS (600). A link can never outlive the
credential you are calling with, so the deadline in the result may
be earlier than you asked for — it says so when that happens.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| overwrite | No | ||
| expires_in | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries full weight and does so thoroughly. It discloses permission requirements (readwrite key/scope), single-use and no-clobber behavior, overwrite semantics with optimistic concurrency, expiry clamping, the secret-in-fragment security detail, and a direct curl example. It even warns against logging the URL, making all behavioral traits explicit.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Although lengthy, every sentence earns its place: purpose, permission, alternative, security, behavior, parameter details, curl example, and follow-up references. The structure is front-loaded with the core purpose and then flows logically into usage, security, and parameters, with an Args block at the end. No filler words.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity (3 params, security implications, interaction with other tools), the description is complete. It covers return flow (check_upload with sha256), expected usage from both MCP and shell, and relevant siblings. An agent can correctly select and invoke this tool with no further documentation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema has 0% description coverage, so the description must fully document each parameter. It explains 'path' with an example, 'overwrite' with behavior, and 'expires_in' with clamping, defaults, and the caveat about credential lifetime. Every schema parameter is meaningfully expanded.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a precise verb and object: 'Get a short-lived link a person can use to put a file into the vault.' It immediately distinguishes itself from the sibling 'write_file' by stating it is the peer for when you don't have the bytes, so an agent can tell them apart without opening schemas.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Explicitly tells when to use this tool vs alternatives: 'Peer to write_file, which takes the bytes directly — use this one when you do not have them.' It also refers to check_upload for confirming the upload and get_vault_guide for vault context, giving the agent a complete usage path.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
semantic_searchA
Vector similarity search over the vault's chunk embeddings. Use this for conceptual or paraphrased queries — anywhere exact word matching would miss the point.
For exact identifiers, code symbols, proper nouns, or known phrases, use keyword_search instead.
Each result is one note (deduped) with its best-matching chunk as a ~200-character preview.
Call read_note on a result's path to get the full note content.
The header line carries a stale count and a truncated count, always — including when both are zero, so "nothing here is degraded" is distinguishable from a build that does not report it.
stale: true on a row means the note changed after it was embedded: it was
matched and ranked against its previous content, and its preview is
withheld rather than shown, because that excerpt is text the note no
longer has. Its path, title and tags are current — the indexer refreshed
them, which is how the staleness is known at all — so read_note on that
path returns the true content and is the remedy. Stale notes are never
filtered out: during an embedding outage that would empty the result set
rather than degrade it.
embedding_truncated: true means the note is longer than the indexer's
per-note chunk cap and only its head was embedded. A match against such a
note is a match against its head; its tail is not reachable by semantic
search at all, though keyword_search still covers the whole note.
The bound on the staleness signal, stated so it is not over-read: it reports what the index has committed. An edit that the indexer has not yet scanned is not marked, so a note edited in the last few minutes may come back unmarked with a superseded preview. The guarantee is "no result presents text the index knows to be superseded", not "no result is ever out of date".
Args: query: Natural language description of what you're looking for. limit: Maximum number of distinct notes to return (default 15). folder: Optional folder prefix (e.g. "Projects/"). tags: Optional list of tag names; only notes carrying ALL listed tags match (e.g. ["product"]). frontmatter: Optional dict of frontmatter key/value pairs; strict type matching — string "0" does not match integer 0 (e.g. {"status": "active"}).
| Name | Required | Description | Default |
|---|---|---|---|
| tags | No | ||
| limit | No | ||
| query | Yes | ||
| folder | No | ||
| frontmatter | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full burden and excels. It details result deduplication, preview character length, stale and truncated indicators, and even the guarantee about staleness, explaining edge cases like edits not yet scanned. It also explains that stale notes are never filtered out, and how to retrieve full content via read_note. This depth is exceptional.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is lengthy but well-structured, with clear sections and bolded headings for emphasis. It front-loads the core purpose and usage guidance, then goes into detailed behavioral nuances. While every paragraph adds value, the length might be overwhelming, but the use of bold and numbered details aids readability, earning a slightly high score.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity (semantic search with deduplication, staleness, truncation), the description is comprehensive. It explains behavioral edge cases, return format, and result limits. The output schema exists, so return values need no further explanation. Sibling tools are addressed, and parameter semantics fully covered, making this complete for successful invocation.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The schema description coverage is 0%, and the description compensates fully. It explains the nature of the query parameter ('Natural language description'), provides example values for folder, tags, and frontmatter, and clarifies frontmatter's strict type matching (string '0' vs integer 0). This goes beyond the schema, which only provides titles and defaults.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states it performs vector similarity search over chunk embeddings, targeting conceptual or paraphrased queries. It distinguishes itself from keyword_search explicitly, making the tool's purpose unambiguous. The verb 'search' and resource 'vault's chunk embeddings' are specific, and the distinction from siblings is clear.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It provides explicit guidance on when to use this tool: for conceptual or paraphrased queries where exact matching fails, and when not to use it: for exact identifiers, code symbols, proper nouns, or known phrases, directing to keyword_search instead. This clear routing to alternatives is ideal.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
set_frontmatterA
Mutate a note's YAML frontmatter without touching its body. Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
Parses the existing frontmatter, merges in updates (overwriting matching
keys, adding any new ones), then drops keys listed in remove. The note
body is preserved byte-for-byte. If the note has no frontmatter (no ---
fence on line 1), a fresh block is prepended ahead of the unchanged body.
A malformed block is refused, never worked around. An unclosed line-1
fence, YAML that fails to parse, and YAML that is not a mapping (null,
~, comments only, a list, a scalar) each return an error naming the
defect and pointing at edit_note(path, content, replace_frontmatter=True)
as the repair. Nothing is written — in particular no second block is
prepended above the broken one — and remove= refuses identically rather
than silently doing nothing. This is reported even for a call with no
updates and no remove. An empty fenced block (--- immediately
followed by ---) is valid: it is a valid empty mapping and is updated
in place.
Only an effective change writes. updates that set every named key to
the value it already holds (compared type-sensitively, so true is not
1) together with remove naming only absent keys report no changes and
leave the file byte-identical. Removing the last key removes the block
entirely — no fences, no separator, exactly the prior body.
Re-serialization uses yaml.safe_dump(default_flow_style=False, sort_keys=False, allow_unicode=True). Caveat: PyYAML does NOT preserve
YAML comments — any # comment in the original frontmatter will be lost on
the first set_frontmatter call.
A path whose final component is a symlink is refused, naming its target, so the frontmatter of an unnamed note is never rewritten; symlinked folders inside the vault work normally.
See get_vault_guide for vault frontmatter conventions.
expected_hash — binding a write to the bytes you read. Optional; omit
it and nothing changes. Pass the content_hash a read returned, verbatim
and canonical (sha256:<64 lowercase hex>), and the call is refused with
nothing written if the file changed in between. It is always the whole
file's hash, never a hash of the text you received. Two windows, both
live: expected_hash covers your read → this call's read, the server's own
pre-publication compare covers this call's read → its publication, and a
match on the first does not exempt the second. Every refusal ends with one
machine-readable MCP-REFUSAL {"code":…} line — stale_precondition (with
the file's current hash, ready to resend), concurrent_write,
no_incumbent, malformed_precondition, precondition_unavailable,
precondition_required — and each states what resolves it.
The comparison runs ahead of the malformed-block diagnosis and ahead of the
no-op check, so a stale base never yields "no changes" or a defect report
about bytes you have not seen. A write reports the content_hash of the
bytes this call published; a no-op publishes nothing and reports none.
Args:
path: Vault-relative path to the note.
updates: Mapping of keys to set. Use the empty dict (or omit) to skip.
remove: List of keys to delete from the frontmatter. Missing keys are
ignored (and, on their own, make the call a no-op rather than a
write).
expected_hash: The note's content_hash as you last read it. Refuses
the write, changing nothing, if the note has changed since.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| remove | No | ||
| updates | No | ||
| expected_hash | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations present, the description carries the full burden, and it does so thoroughly: write-permission requirements, byte-for-byte body preservation, malformed-block refusal, no-op behavior, block removal, PyYAML comment loss, symlink refusal, expected_hash semantics, refusal codes, and check ordering are all disclosed. This far exceeds baseline and leaves little hidden behavior for an agent to discover at runtime.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is long, but every block earns its place: permission, mutation semantics, malformed-input behavior, no-op rules, serialization caveat, symlink edge case, and expected_hash contract. Bold section headers and a final Args list make the density navigable without burying the core action.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a mutating tool with preconditions and subtle edge cases, the description covers all required context: when a write happens, when it is refused, what error codes look like, what is serialized back, and what the call returns (content_hash on write, none on no-op). The output schema exists, so not restating the full response shape is acceptable.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must compensate, and it does. Each parameter gets an Args entry that goes beyond type: path is vault-relative, updates is a mapping with empty-dict/omit semantics, remove is a list with missing-key behavior, and expected_hash is explained with canonical format, staleness refusal, and coverage over the whole file. This is more than the schema alone provides.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb and resource: 'Mutate a note's YAML frontmatter without touching its body.' This precisely distinguishes it from body-editing tools like edit_note and write_file. The scope (frontmatter only) is unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description clearly establishes when set_frontmatter is appropriate (frontmatter-only mutation) and references edit_note(path, content, replace_frontmatter=True) as the repair path for malformed blocks. It does not enumerate every sibling alternative, but the context and the explicit edit_note alternative give solid usage direction.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
write_fileA
Write a file into the vault — including non-markdown (e.g. save a
generated PDF or image). Requires write permission — a readwrite API key, or an OAuth token
carrying the readwrite scope.
Peer to create_note/edit_note, which stay markdown-only.
content carries the bytes: with encoding="base64" (default) it is
base64-decoded to raw bytes; with encoding="text" it is written verbatim
as UTF-8. The write is atomic — the bytes are staged and flushed before
anything is published — missing parent folders are created, and content over
MAX_FILE_WRITE_BYTES (default 25 MB, decoded length) is refused.
No-clobber by default: writing over an existing file requires
overwrite=True. The default publishes by linking a staged, never-named
inode into place in one kernel-atomic step, so an existing file cannot be
replaced; overwrite=True publishes with a single same-directory rename
instead. A vault filesystem that cannot stage an unnamed file refuses the
no-clobber write with an error naming
VAULT_ALLOW_NAMED_STAGING_FALLBACK, rather than staging under a visible
name. Any path with a component starting with . (dot-directories and
dot-files alike) and path traversal are rejected; invalid
base64 errors without writing anything. A path whose final component is a
symlink is refused, naming its target, so overwrite=True cannot clobber a
file through an alias; symlinked folders inside the vault work normally.
The MCP transport also bounds the whole request body (sized so a base64
write at the cap always gets through). Base64 is therefore the always-safe
encoding: encoding="text" content whose JSON escaping inflates past that
bound is rejected by the transport with a bare HTTP 413 before this tool
runs — send such content as base64 instead.
Optional expected_hash binds an existing whole file on overwrite=True.
Obtain its canonical sha256:<64 lowercase hex> value from read_file's
base64 header, read_file(hash_only=True), or a prior write's success.
Syntax is checked before path work; a hash with no-clobber or a missing
destination is no_incumbent. An over-cap incumbent cannot be guarded.
A stale hash refuses before publication; matching also enables the in-call
comparison, which refuses an edit arriving during this call as
concurrent_write. Without a hash, overwrite remains unconditional unless
the deployment requires preconditions. Creation is exempt.
Success reports the hash of the bytes this call published, not necessarily what remains when the response arrives. Over-cap incumbents or results omit the hash without failing an otherwise permitted unguarded write.
Args: path: Vault-relative destination path (e.g. "Outputs/report.pdf"). content: File contents — base64 string (default) or UTF-8 text. encoding: "base64" (default) or "text". overwrite: If True, replace an existing file. Off by default. expected_hash: Optional whole-file raw-byte digest of the incumbent.
| Name | Required | Description | Default |
|---|---|---|---|
| path | Yes | ||
| content | Yes | ||
| encoding | No | base64 | |
| overwrite | No | ||
| expected_hash | No |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries full behavioral burden. It discloses atomicity, no-clobber semantics, symlink handling, hash-based concurrency protection, and error conditions (refused symlinks, invalid base64, over-cap content). It also explains the transport 413 limit and the fallback staging error.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
While long, every sentence adds value. The structure is logical: purpose first, then encoding, atomicity, overwrite, hash semantics, and transport limits. No filler or repetition; it's dense but organized for an agent to parse.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a complex write tool with no annotations, this is exceptionally complete. It covers all edge cases: encoding, overwrite, symlinks, dot-paths, hash concurrency, transport limits, and staging fallback. The output schema presumably covers the return value, so that gap is acceptable.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must explain each parameter. It does: path (vault-relative), content (base64/text), encoding (base64 default), overwrite (replaces existing), expected_hash (binds incumbent). It adds crucial meaning beyond the schema, including encoding format details and hash acquisition methods.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with 'Write a file into the vault — including non-markdown' which is a specific verb+resource+scope. It explicitly distinguishes itself from siblings: 'Peer to create_note/edit_note, which stay markdown-only.' An agent can immediately tell it apart from note tools.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It names the alternatives (create_note/edit_note) and the condition that selects them (markdown-only). It also gives explicit encoding guidance (base64 vs text) and explains when overwrite is required (no-clobber by default). The transport limit note clarifies when to use base64.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
25 tool updates
v0.8.2- Changed
check_upload1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
create_note1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
delete_file1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
delete_note1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
edit_note1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
find_orphans1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
find_related1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
get_backlinks1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
get_links1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
get_neighborhood1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
get_recent1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
get_tags1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
get_vault_guide1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
import_from_url1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
keyword_search1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
list_files1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
list_notes1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
move_note1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
read_file1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
read_note1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
request_download1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
request_upload1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
semantic_search1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
set_frontmatter1 field changed- added
Input schema / additionalPropertiesAdded value: +false
- Changed
write_file1 field changed- added
Input schema / additionalPropertiesAdded value: +false
10 tool updates
v0.8.1- Changed
create_note1 field changed- added
Input schema / properties / expected_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hash" +}
- Changed
delete_file1 field changed- added
Input schema / properties / expected_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hash" +}
- Changed
delete_note1 field changed- added
Input schema / properties / expected_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hash" +}
- Changed
edit_note1 field changed- added
Input schema / properties / expected_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hash" +}
- Changed
get_links1 field changed- added
Input schema / properties / limitAdded value: +{ + "default": 100, + "title": "Limit", + "type": "integer" +}
- Changed
move_note1 field changed- added
Input schema / properties / expected_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hash" +}
- Changed
read_file1 field changed- added
Input schema / properties / hash_onlyAdded value: +{ + "default": false, + "title": "Hash Only", + "type": "boolean" +}
- Changed
read_note3 fields changed- added
Output schema / $defs / MetadataCoercionAdded value: +{ + "description": "One metadata value this response rendered in a canonicalized form.\n\nServer-authored, every field of it, exactly as `MetadataOmission` is: the\nchannel is out of band precisely so that nothing has to be signalled inside\nthe note-controlled field itself.", + "properties": { + "detail": { + "title": "Detail", + "type": "string" + }, + "field": { + "title": "Field", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "field", + "reason", + "detail" + ], + "title": "MetadataCoercion", + "type": "object" +} - added
Output schema / properties / content_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content Hash" +} - added
Output schema / properties / metadata_coercionsAdded value: +{ + "anyOf": [ + { + "items": { + "$ref": "#/$defs/MetadataCoercion" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metadata Coercions" +}
- Changed
set_frontmatter1 field changed- added
Input schema / properties / expected_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hash" +}
- Changed
write_file1 field changed- added
Input schema / properties / expected_hashAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expected Hash" +}
1 tool update
v0.8.0- Changed
read_note20 fields changed- added
Output schema / $defsAdded value: +{ + "MetadataOmission": { + "description": "One metadata field this response could not carry, and why.\n\nServer-authored, every field of it. This is the *only* channel that reports\na dropped field: nothing is ever signalled by writing a marker into the\nnote-controlled field itself.", + "properties": { + "detail": { + "title": "Detail", + "type": "string" + }, + "field": { + "title": "Field", + "type": "string" + }, + "reason": { + "title": "Reason", + "type": "string" + } + }, + "required": [ + "field", + "reason", + "detail" + ], + "title": "MetadataOmission", + "type": "object" + }, + "NoteOutline": { + "description": "The outline, with its degraded states as data rather than as prose.\n\n`truncated` is the explicit marker the requirement asks for: when the\nbudget cannot hold even one entry, `entries` is empty and `truncated` is\ntrue, which is a statement, not a silence. `omitted`, `first_ordinal` and\n`last_ordinal` are present exactly when the listing is incomplete — a\ncomplete listing carries no omission summary, so nothing has to be reserved\nfor one.", + "properties": { + "entries": { + "default": [], + "items": { + "$ref": "#/$defs/OutlineEntry" + }, + "title": "Entries", + "type": "array" + }, + "first_ordinal": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "First Ordinal" + }, + "last_ordinal": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Last Ordinal" + }, + "omitted": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Omitted" + }, + "truncated": { + "default": false, + "title": "Truncated", + "type": "boolean" + } + }, + "title": "NoteOutline", + "type": "object" + }, + "OutlineEntry": { + "description": "One section of a truncated whole-note read's heading outline.", + "properties": { + "depth": { + "title": "Depth", + "type": "integer" + }, + "duplicate": { + "title": "Duplicate", + "type": "boolean" + }, + "exceeds_cap": { + "title": "Exceeds Cap", + "type": "boolean" + }, + "ordinal": { + "title": "Ordinal", + "type": "integer" + }, + "size": { + "title": "Size", + "type": "integer" + }, + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "ordinal", + "depth", + "text", + "size", + "exceeds_cap", + "duplicate" + ], + "title": "OutlineEntry", + "type": "object" + } +} - added
Output schema / descriptionAdded value: +"What `read_note` returns.\n\nEvery field is either server-controlled or a note-controlled value sitting\nalone in a field of its own. Nothing here is composed into a frame, so\nnothing note-controlled can change which field another value appears in —\nthat is the whole point (#149).\n\nOn an error result the content-bearing fields are absent: `error` is the\nanswer, and a caller must never find a half-response beside it." - added
Output schema / properties / contentAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Content" +} - added
Output schema / properties / errorAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Error" +} - added
Output schema / properties / frontmatterAdded value: +{ + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frontmatter" +} - added
Output schema / properties / frontmatter_yamlAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Frontmatter Yaml" +} - added
Output schema / properties / headingAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Heading" +} - added
Output schema / properties / metadata_omissionsAdded value: +{ + "anyOf": [ + { + "items": { + "$ref": "#/$defs/MetadataOmission" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Metadata Omissions" +} - added
Output schema / properties / next_offsetAdded value: +{ + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Next Offset" +} - added
Output schema / properties / noticeAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Notice" +} - added
Output schema / properties / offsetAdded value: +{ + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Offset" +} - added
Output schema / properties / outlineAdded value: +{ + "anyOf": [ + { + "$ref": "#/$defs/NoteOutline" + }, + { + "type": "null" + } + ], + "default": null +} - added
Output schema / properties / pathAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" +} - removed
Output schema / properties / resultRemoved value: -{ - "title": "Result", - "type": "string" -} - added
Output schema / properties / tagsAdded value: +{ + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Tags" +} - added
Output schema / properties / titleAdded value: +{ + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Title" +} - added
Output schema / properties / total_charsAdded value: +{ + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Total Chars" +} - added
Output schema / properties / truncatedAdded value: +{ + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Truncated" +} - removed
Output schema / requiredRemoved value: -[ - "result" -] - changed
Output schema / titlePrevious value: -"read_noteOutput"New value: +"ReadNoteResult"
1 tool update
v0.7.2- Changed
edit_note1 field changed- added
Input schema / properties / replace_frontmatterAdded value: +{ + "default": false, + "title": "Replace Frontmatter", + "type": "boolean" +}
14 tool updates
v0.7.0- Added
check_upload - Added
create_note - Added
delete_file - Added
edit_note - Added
find_related - Added
import_from_url - Added
keyword_search - Added
list_notes - Added
move_note - Changed
read_file2 fields changed- added
Input schema / properties / limitAdded value: +{ + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Limit" +} - added
Input schema / properties / offsetAdded value: +{ + "default": 0, + "title": "Offset", + "type": "integer" +}
- Added
read_note - Added
request_download - Added
request_upload - Added
set_frontmatter
8 tool updates
v0.5.4- Removed
create_note - Removed
edit_note - Removed
find_related - Removed
keyword_search - Removed
list_notes - Removed
move_note - Removed
read_note - Removed
set_frontmatter
3 tool updates
v0.4.0- Added
list_files - Added
read_file - Added
write_file
17 tool updates
- First observed
create_note - First observed
delete_note - First observed
edit_note - First observed
find_orphans - First observed
find_related - First observed
get_backlinks - First observed
get_links - First observed
get_neighborhood - First observed
get_recent - First observed
get_tags - First observed
get_vault_guide - First observed
keyword_search - First observed
list_notes - First observed
move_note - First observed
read_note - First observed
semantic_search - First observed
set_frontmatter
TDQS
Scored across 25 tools
Most tools are well-separated by their primary function (read vs. write, note vs. file, search vs. link analysis). However, there is some overlap between read_note and read_file, and between create_note/edit_note vs write_file, though the descriptions make the distinction clear. Also, find_related and semantic_search are similar but differentiated by source, and get_neighborhood vs find_related is clarified.
The naming is mostly consistent with a verb_noun pattern (list_notes, create_note, edit_note, delete_note, read_file, write_file, etc.). Minor deviations include request_upload, request_download (not verb_noun but consistent with each other), and check_upload. The mix is readable and intuitive.
With 25 tools, this is at the high end of the 'well-scoped' range but remains justified for a comprehensive vault management server. Each tool covers a distinct operation (notes, files, search, links, transfers), and the count is appropriate for the breadth of features offered. Slightly over the typical 3-15 range but not excessive.
The server provides a complete lifecycle for both notes and files: create, read, edit/update, delete, plus advanced features (frontmatter mutation, move, search, link analysis, upload/download, import). The coverage is comprehensive with no obvious dead ends. Even edge cases like stale indexes and non-markdown files are handled.
Maintenance
Related MCP Connectors
Markdown-based note-taking with a hosted MCP server. Your notes serve you and your AI.
Personal knowledge base MCP server with semantic search, auto-categorization, metadata extraction
Serve a folder of Markdown notes as an MCP server: hybrid search, reading, and sourced answers.
Token-efficient MCP memory for Markdown vaults. Tiered search, GraphRAG, AI memories.
Related MCP Servers
- AlicenseBqualityCmaintenanceHeadless semantic MCP server for Obsidian, Logseq, Dendron, Foam, and any markdown folder. Features built-in hybrid semantic search, surgical AST editing, template scaffolding, zero-config local embeddings, and workflow tracking.520 npm12MIT
- AlicenseAqualityAmaintenanceStandalone MCP server for Obsidian vaults - hybrid search (FTS5 + vector + cross-encoder reranking), images and PDFs in agent-readable form, Kanban-aware tasks (Tasks-plugin + Dataview formats), structured memory with topic recall, fine-grained read/write tools for optimal token efficiency, and link graph support. Run locally, self-host, or one-click deploy for remote access. OAuth 2.1.33291 npm20MIT
- AlicenseNot gradedqualityDmaintenanceSelf-hosted MCP server that provides embedding-powered semantic search with graph context over Obsidian vaults, supporting multiple vaults, local embeddings, and a web dashboard.MIT
- AlicenseNot gradedqualityAmaintenanceLocal-first MCP server for retrieval over markdown wikilink vaults, offering hybrid vector+lexical search, note reading, neighbor expansion, and recent activity tracking with fully local embeddings and no network egress.MIT