Skip to main content
Glama
maxkuminov

Obsidian MCP (pgvector + Ollama, self-hosted)

by maxkuminov

Obsidian MCP Server

Python License MCP PostgreSQL

Your agents' memory, in the vault you already have, reachable from claude.ai, ChatGPT, Claude Code and your phone.

A self-hosted Model Context Protocol server over an Obsidian vault. Three things it does that the alternatives mostly don't:

  • Your existing vault, served in place. No imposed note format, no sidecar syntax, no "memory" folder you have to adopt. Agents read what you read, follow your wikilinks, and pick up your folder layout, frontmatter schema and tag conventions from the vault itself.

  • Remote and multi-user. OAuth 2.0 PKCE and API keys, so claude.ai, Claude Desktop, Claude mobile, ChatGPT, Claude Code and n8n all read and write the same files, each user confined to their own vault root.

  • Writes cannot clobber. expected_hash preconditions refuse an edit made from a stale read, every publish is atomic and no-clobber by default, deletes go to .trash/, and dry_run returns the diff before anything lands.

Connect from Claude Code:

claude mcp add obsidian --transport http \
  --url "https://obsidian-mcp.<your-domain>/mcp" \
  --header "Authorization: Bearer omcp_..."

In claude.ai, Claude Desktop or ChatGPT, add a custom connector at https://obsidian-mcp.<your-domain>/mcp and sign in through the OAuth consent page. Connect a client has the rest.

Stack: Python 3.12, FastAPI, PostgreSQL with pgvector. Pluggable embeddings (Ollama bge-m3, or OpenAI text-embedding-3-{small,large}).

Dashboard

Contents

Related MCP server: obsidian-mcp-search

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.

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 extraction or decay pipeline running behind them, and nothing rewrites a note because a model judged it stale: an agent remembers something because it wrote a note, and forgets it because someone deleted one. By late 2026 that is less of a contrarian position than it sounds. Every vendor's consolidation step (the "dreaming" or "sleep-time" job) has turned into a separate process you can inspect, rather than silent extraction inside the write path. This server keeps the agent as the author. The consolidation help it will grow is a hygiene report that proposes merges and flags duplicates, and never writes (#359), not a pipeline that edits your notes while you are not looking.

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 28 MCP tools across five families, plus the auth and ops layer around them.

Every tool is published with a title and MCP ToolAnnotations, all four hints set explicitly: readOnlyHint on the eighteen tools that only read (search, read, list, graph, list_tasks, get_changes, check_upload), destructiveHint on the eight that can replace or remove something (edit_note, move_note, delete_note, set_frontmatter, write_file, delete_file, import_from_url, and request_upload, whose link can overwrite on redemption), openWorldHint on import_from_url only. create_note and request_download are additive. Clients that honour annotations run read-only tools without a per-call confirmation (Claude Code also runs them in parallel) and keep prompting on destructive ones. They are hints: the server enforces read-only credentials itself, regardless of what a client does with them. The initialize result also carries a short instructions paragraph — what the vault is, which tool to reach for, and the write-safety rules — for clients that load only tool names at session start. No description exceeds 4,000 characters on the wire, so none is cut off by a client's description limit.

Search and discovery

  • keyword_search(query, folder?, tags?, frontmatter?, limit=20), full-text via PostgreSQL tsvector. A note's title and frontmatter aliases rank above its body, so a query that is a note's title finds that note first; bare terms are ANDed, and when the full matches do not fill the page, partial matches follow tagged match: any; every row carries a snippet: with the matches highlighted (or head:, the note's opening words, for a title match). The text-search config(s) are configurable via FTS_CONFIGS (see Full-text search language(s))

  • semantic_search(query, folder?, tags?, frontmatter?, limit=15, rerank=false), vector similarity via pgvector, one one-line preview chunk per note. Notes are chunked at their headings, and each chunk is embedded under a Title > Heading > Subheading context prefix. A fresh row names the section it matched: § "Plan/Risks" (#7) or § (top of note), and read_note(path, section="#7") reads exactly that section. Stale rows, and rows not yet re-embedded since the upgrade to heading-aware chunks, carry no locator. While any of your notes is still on the old chunking, each response says so on an index_transition: line under its header

  • hybrid_search(query, limit=10, folder?, tags?, frontmatter?, rerank=false), both of the above fused by reciprocal rank fusion over each arm's top 50, one line per note with its fused score, each arm's rank and the 1/(k+rank) terms behind the score. The three are peers, and every description carries the same routing sentence: exact identifiers, code symbols, titles or known phrases go to keyword_search; conceptual or paraphrased questions to semantic_search; unsure which, hybrid_search. A single arm can beat the fusion on its own kind of query. If the embedding provider is down, hybrid_search returns the keyword ranking marked arms: keyword-only (semantic unavailable: …), never silently degraded: the marker is on every return path, so a keyword-only page cannot pass for a fused one.

  • Every page of semantic_search and hybrid_search carries a confidence: ok | low header token. low means no keyword note matched all the terms and the closest semantic candidate is under SEARCH_LOW_CONFIDENCE_SIMILARITY, with a one-line hint to try keyword_search or get_neighborhood when it costs no row. Nothing is filtered. The threshold ships at 0 (off: always ok) until you calibrate it.

  • rerank=true on either of the last two asks an optional, operator-configured reranker (RERANK_URL) to reorder the candidates the call already fetched (at most 50 notes). The response is either fully reranked (reranked: true (candidates: N), a rerank_score per row) or the unreranked page marked reranked: false (<reason>); without a reranker it answers reranked: false (not_configured). Its rows are one-line quoted objects, like hybrid_search's

  • list_notes(folder?, limit=50, since?, until?, order_by?, cursor?), sorted by modified time

  • get_recent(folder?, limit=20, since?, until?, order_by?, cursor?), recently changed, with each modification time in UTC (YYYY-MM-DD HH:MM UTC). On both, order_by="content_changed" with since lists notes whose content changed (a touch or a re-sync that keeps the bytes does not count); since is inclusive and until exclusive, each a UTC date (YYYY-MM-DD, the whole day) or an ISO 8601 datetime with an explicit offset. Any of the four switches to keyset paging with a more: cursor=… footer; a call with none of them is unchanged

  • get_changes(since, folder?, kinds?, by="any", limit=50), the change feed: one line per note created, modified, moved or deleted after a cursor, oldest first, each attributed to the API key or OAuth client that made it ((you) for your own credential) or human/sync for an edit the index discovered (Obsidian, a sync client, git). It returns next_since, an exact ev:<n> cursor an agent stores in its own notes for the next session; by is any | me | agents | human. Deletions say why (removed, not_indexable, quarantined); gap and reset lines say when the view is incomplete

  • get_tags(limit=50), tag and count

  • list_tasks(folder?, status="open", due_by?, scheduled_by?, tags?, limit=100, cursor?), checkbox tasks (- [ ], - [x], - [-], - [/], …) across the vault from the index, never reading a note: one line per task with its path and line, Tasks-plugin dates (📅 ⏳ 🛫 ✅), priority, the task's own #tags, its heading path and its text. Ordered by due date (undated last), paged with a next cursor; due_by / scheduled_by mean "on or before". Tasks-plugin query blocks are not evaluated. The header always counts notes not yet scanned for tasks and notes whose tasks could not be indexed, so an empty list is never a silent "nothing open"

  • get_vault_guide(), the Obsidian primer plus this vault's CLAUDE.md, served live

  • Retrieval changes are measured with make search-eval against judgments mined from the operator's own usage log (a search followed by a read of one of its results); see the search evaluation harness

Read and write

  • read_note(path, section?, offset=0, limit?) returns a structured result — path, title, tags, frontmatter_yaml and a JSON frontmatter view, heading (section reads), content, and truncation as data (truncated, offset, next_offset, total_chars, outline, notice). Bounded by MAX_READ_RESPONSE_CHARS (default 40,000) — see Response size limits. section=<heading> returns one section's body instead of the whole note; offset continues a truncated read.

  • create_note(path, content), atomic write, refuses overwrite

  • edit_note(path, …) with four mutually exclusive modes: full replace (default), append=True, find=… (with optional replace_all), or section=<heading> (ATX headings, supports Parent/Child path-style and #N ordinal disambiguation). dry_run=True returns a unified diff without writing. Legacy clients may use operation="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 notes

  • delete_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=True unlinks.

  • 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/base64 force the form. Refuses files over MAX_FILE_READ_BYTES (default 10 MB); text results are additionally bounded by MAX_READ_RESPONSE_CHARS and continue via offset. hash_only=True returns the whole file's content_hash without 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, text for UTF-8. No-clobber by default, auto-creates parent dirs, atomic write. Capped at MAX_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 is delete_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 at path — nothing else can be written with it.

  • check_upload(upload_id), reports pending / 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.

  • get_backlinks(path, limit=50), notes linking TO path

  • get_links(path), outgoing links, both resolved and dangling

  • get_neighborhood(path, depth=1, limit=50), undirected BFS over the resolved-link graph, capped at depth ≤ 5 and limit ≤ 200

  • find_related(path, limit=10), semantic neighbors via averaged chunk embeddings and pgvector cosine distance, deduped per note, with the same section locator and index_transition: line as semantic_search

  • find_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, with read and readwrite permission 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_logs with 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.

  • /health is unauthenticated and returns status plus two capability fields: transfer_mount_check_available (the kernel supports the mount check transfer writes need) and vault_named_staging_fallback_active (a write has actually staged under a name on this process).

  • /health also carries an indexer object (status, task_running, failing_scopes, embedding_failing_scopes, max_consecutive_failures, quarantined_notes, last_success_at), and the top-level status becomes "degraded" when the indexer task has died, any index, embedding or enumeration failure counter — or a scope's run of incomplete re-derives — reaches INDEXER_DEGRADED_AFTER_FAILURES, or a note is quarantined. The HTTP code stays 200 — a restart cannot repair vault content or a provider outage, and Kubernetes uses /health for liveness — so monitor the status field (a keyword or JSON check for "status":"ok"), not the status code. Counts only: no path, error text or user id.

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

The field has sorted itself into two shapes since this README was first written, and the axis that matters is not "lightweight versus heavy" but where the agent runs relative to the Obsidian app.

Desktop-local. The tool talks to a running Obsidian. The Local REST API plugin has shipped its own Streamable HTTP MCP server since 4.0.0 (2026-05-15), and 5.5.0 (2026-10-17) added If-Match conditional writes and result caps, so it is no longer "glue" but a real local MCP endpoint. The official Obsidian CLI (1.12) reads, writes, searches and walks backlinks, and requires the app to be running. In-app agent plugins sit in the same group. All of these get the app's own metadata cache for free; none of them work on a headless box, and none give claude.ai or a phone a way in without you exposing your desktop.

Headless and remote. The server works on the vault files directly, wherever they are mounted, with no Obsidian process. This server is one. Vault Cortex (aliasunder, September 2026) is a single container with OAuth 2.1, SQLite FTS5 plus sqlite-vec fused by RRF and a local reranker, tasks and daily-note helpers, and a bundled Obsidian Headless Sync client; one vault per container, no multi-user. StevenStavrakis/obsidian-mcp v2 (2026-08) is a stdio server with journaled atomic writes, etag/if_match preconditions and 30-day recovery snapshots, and no semantic search. jacksteamdev/obsidian-mcp-tools was archived on 2026-05-13 and is dropped from the table.

If Obsidian is open on the machine the agent runs on and you need no remote access, the official CLI or the REST plugin is the lighter choice. This server is for the headless, remote, multi-client and semantic case: nothing on the box but a container and Postgres, and every client you own hitting the same vault.

As of 2026-10-28:

This server

REST plugin MCP

Obsidian CLI

Vault Cortex

StevenStavrakis v2

Runs without Obsidian

✅

—

—

✅

✅

Semantic search

✅ pgvector

—

—

✅ sqlite-vec + RRF + reranker

—

Wikilink graph

✅ backlinks, neighborhood, orphans, related

backlinks

backlinks, orphans, dead ends

backlinks, outgoing, orphans

—

Atomic / journaled writes

✅ staged, fsync, no-clobber publish

?

?

✅ temp + rename

✅ journaled, rollback, snapshots

Write preconditions (hash / etag)

✅ expected_hash

✅ If-Match (5.5.0)

—

—

✅ if_match

Dry-run diff

✅

—

—

—

—

Soft-delete to trash

✅ .trash/

?

✅

✅

✅ 30-day snapshots

Multi-user vault roots

✅

—

—

—

— (multi-vault, one user)

OAuth for claude.ai / ChatGPT

✅ 2.0 PKCE + API keys

— (API key)

n/a

✅ 2.1 PKCE + DCR

n/a (stdio)

Audit log

✅ per call, in the panel

?

—

server logs

transaction journal

Setup tax

Postgres + Docker

Obsidian + plugin

Obsidian 1.12

one container (Sync subscription for the remote image)

Node 22

The table reflects each project's documented features on that date, read from their READMEs and release notes; a ? means I could not confirm it either way. 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.

One thing has changed since that paragraph was first true: files plus search is now the mainstream substrate, not the odd one out. Letta's MemFS is git-backed markdown; Claude Code's auto-memory is a MEMORY.md index plus topic files; Anthropic's memory tool is a file directory; OpenClaw and Hermes keep markdown workspaces. "Readable markdown memory" on its own no longer sets this server apart. The closest comparables are Basic Memory (markdown plus a SQLite or Postgres index, hybrid search, an optional reranker, structured frontmatter filters; its own folder and note format, AGPL, with a paid cloud) and doobidoo/mcp-memory-service (self-hosted, remote MCP over OAuth, opaque memory records rather than files). Against those, the differences are five, and plain:

  1. The existing vault, served in place, with no imposed folder or note format.

  2. Remote and multi-user over OAuth, one vault root per user.

  3. Destructive-write guards: expected_hash, atomic no-clobber publish, .trash/, dry-run diffs.

  4. MIT.

  5. No hosted tier, and none planned.

The difference from memory-as-a-service 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, in grep. 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.md at 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. The consolidation help planned here is a report that proposes and never writes (#359). 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_note can address a find-string or a single section rather than replacing the file, and dry_run=True returns the unified diff before anything lands. set_frontmatter mutates YAML structurally and leaves the body byte-identical.

  • No-clobber defaults. create_note and write_file refuse 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_note and delete_file soft-delete into .trash/ with a non-replacing rename; permanent=True is 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 desktop-local tools above; you don't need this.

  • Obsidian is open on the machine the agent runs on and you need no remote access. The official CLI or the Local REST API plugin's MCP server is the lighter choice.

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

Usage

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.

API keys OAuth clients

Vault browser

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

Vault

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.

Settings

Quick start

Deploying on a VPS from scratch? See DEPLOYMENT.md for the full walkthrough: Postgres setup, Caddy and TLS, vault sync via Nextcloud, and the gotchas that bite first-time deploys. Running Kubernetes? See docs/deployment-kubernetes.md and the kustomize manifests in deploy/kubernetes/.

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 pgvector 0.8.0 or newer installed

  • Either 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 .env

Using a bundled stack (docker-compose.simple.yml or docker-compose.proxy.yml)? It runs PostgreSQL with two identities: set OBSIDIAN_DB_PASSWORD in .env (the app's non-superuser role, from which the compose file builds DATABASE_URL) and copy postgres.env.example to postgres.env with its own POSTGRES_PASSWORD (the database superuser, which only the postgres container sees). make init generates both. See DEPLOYMENT.md.

In docker-compose.yml, point the /obsidian volume at your vault:

volumes:
  - /path/to/your/vault:/obsidian

2. 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-small

The 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=1024

This 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 container

The 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.

Prebuilt images are public at ghcr.io/maxkuminov/obsidian-mcp (:v<release> per release tag, :main for the tip), built and scanned by CI; a release tag is the image CI proved for that commit, retagged, never rebuilt. docker pull ghcr.io/maxkuminov/obsidian-mcp:v<release> needs no token. Or build locally as above.

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. Across the search-confidence release (#389):

  • No migration. Every semantic_search and hybrid_search page gains one header token: — confidence: ok (at the default threshold 0 that is the only byte that changes). A parser that matched the old header exactly needs to allow it. Usage rows gain confidence, top_similarity and (hybrid) keyword_all. One optional setting, SEARCH_LOW_CONFIDENCE_SIMILARITY (default 0 = off); leave it at 0 until the calibration recipe in DEPLOYMENT.md passes for both tools.

  • The search-eval harness reports recall@20 and recall@50, runs at --depth 50 by default and writes report.json schema_version 2; a runfile: baseline staged at depth 20 scores a null recall@50, so re-run it for a delta. It gains a confidence subcommand.

  • Rollback is a redeploy of the previous image; the new usage keys stay and are ignored.

Across the reranker release (#367):

  • No migration, nothing re-derived or re-embedded. semantic_search and hybrid_search gain an optional rerank argument (default false, output unchanged), and four optional settings (RERANK_URL, RERANK_MODEL, RERANK_TIMEOUT_MS, RERANK_ALLOW_PLAINTEXT). Nothing changes until you set RERANK_URL. The concurrency class mapping is unchanged, so readiness evidence carries over.

  • Rollback is a redeploy of the previous image. A client that keeps sending rerank is then refused by the unknown-argument rule, which is the normal cost of rolling back a new argument.

Across the hybrid-search release (#354):

  • No migration, nothing re-derived or re-embedded. A new tool, hybrid_search, and one optional setting, HYBRID_RRF_K (default 60).

  • A new concurrency-readiness epoch. hybrid_search shares the embedding class with semantic_search, so CLASS_MAPPING_VERSION moves to 5 and make concurrency-report evidence gathered before the upgrade does not carry over.

  • Rollback is a redeploy of the previous image; the tool's usage rows stay and are ignored.

Across the change-feed release (migration 033, #351):

  • One migration, no re-derive. 033 adds notes_metadata.content_changed_at (backfilled from each note's modification time) and three change-feed tables. Nothing is re-parsed or re-embedded. The feed starts with the first pass after the upgrade; nothing before 033 has events.

  • The feed tables are durable. make db-backup and the Kubernetes backup Job dump their rows (history, cursors and retention watermarks cannot be rebuilt from the vault). A restore rolls the feed back to the dump, so a cursor taken after it is refused and the agent restarts from a date.

  • A new concurrency-readiness epoch. get_changes joins the scan class, so CLASS_MAPPING_VERSION moves to 4 and make concurrency-report evidence gathered before the upgrade does not carry over.

  • Four new settings, all with working defaults: CHANGE_FEED_RETENTION_DAYS, CHANGE_FEED_MAX_EVENTS_PER_SCOPE, CHANGE_FEED_MAX_EVENTS_PER_PASS, CHANGE_FEED_LOCK_TIMEOUT_MS (see Configuration).

  • Rollback: the previous image neither reads nor writes any of these objects; alembic downgrade 032 drops them and loses only the feed's history.

Across the task-listing release (migration 032, #350):

  • One full re-derive pass after the upgrade, and no re-embedding. The extraction version moves to 4, so the first pass re-parses every note once and stores its checkbox tasks for the new list_tasks tool — the same cost as the earlier extraction-version bumps. Version 4 shares version 3's embedding cleaner, so nothing is re-embedded and no provider call is made. Until that pass finishes, list_tasks reports the notes it has not scanned yet in its header, and move_note(rewrite_links=True) refuses with index_not_ready as it does after every bump.

  • A new concurrency-readiness epoch. list_tasks joins the scan class, so CLASS_MAPPING_VERSION moves to 3 and make concurrency-report evidence gathered before the upgrade does not carry over.

  • Rollback is roll-forward. A bare redeploy of the previous image re-extracts every note once, and because it has no version-4 cleaner it also clears every note's embedding certification; the embed pass then re-chunks every note and reuses every stored vector (no provider calls), with semantic_search marking those notes stale until it catches up. Rolling forward again is one more re-derive pass.

  • No new setting.

Read this first when upgrading across the keyword-ranking release (migration 031, #344):

  • Keyword ranking changes at once; snippets need one rebuild. Migration 031 rewrites every keyword vector in place so titles and aliases rank above bodies — no operator step, and make deploy now stops the app before a pending migration and recreates it after (on a hand-driven compose stack: stop → alembic upgrade head → start; Kubernetes already Recreates). Snippets come from an excerpt that only a write stores, so notes indexed before 031 show no snippet (the header counts them and startup logs the count) until they are edited or you run make rebuild-tsvectors once (on Kubernetes, deploy/kubernetes/base/rebuild-tsvectors-job.example.yaml with the app scaled to zero). No embeddings are touched, no API calls are made.

  • New optional settings: RANK_WEIGHTS, KEYWORD_EXCERPT_CHARS, KEYWORD_SNIPPET_ROWS. See Configuration.

And across the internal-transport and panel-CSP release:

  • Breaking: the server refuses to run as a PostgreSQL superuser (#324). A Compose install created from docker-compose.simple.yml or docker-compose.proxy.yml before this release connects as the cluster superuser and must run the one-time conversion in DEPLOYMENT.md, "Upgrading: split database roles" (a backup, a new postgres.env, OBSIDIAN_DB_PASSWORD in .env, one script); the new compose files will not serve on an unconverted volume. Any other deployment whose DATABASE_URL names a superuser is refused at startup with a message; switch it to a non-superuser owner of the database, or set DATABASE_ALLOW_SUPERUSER=true as a stop-gap. The Kubernetes bundle is unaffected. A DATABASE_URL password of changeme / CHANGE_ME is now refused, and the built-in default URL carries no password.

  • Breaking: plaintext embedding endpoints must be acknowledged. If the active embedding URL (OLLAMA_URL, or OPENAI_BASE_URL with the OpenAI provider) is http:// to a non-loopback host — the http://ollama:11434 default included — add EMBEDDING_ALLOW_PLAINTEXT=true to .env before 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 any PGSSL* environment variable is refused at startup; move it to DATABASE_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_DIR and .netrc no longer apply to the embedding hop. Use EMBEDDING_CA_FILE for 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, set PANEL_CSP=report-only (or off) 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, and DEPLOYMENT.md for 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

text-embedding-3-small

$0.02

~$0.05

~$0.50

~$5.00

text-embedding-3-large

$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 write-tool descriptions and the server's instructions both point at it — as a description of where the vault's conventions live, not as an order — 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

  1. Set MULTI_USER_MODE=true and a strong SECRET_KEY in .env (openssl rand -hex 32 is fine). The app refuses to start with a placeholder SECRET_KEY unconditionally — single-user mode included — so this is not something the flag turns on.

  2. make deploy (or docker compose up -d --force-recreate).

  3. Visit the panel. Because the users table is empty, you're routed to /admin/register — the one-time bootstrap form. It's still behind Traefik's chain-oauth@file middleware, so only people Traefik already trusts can claim admin.

  4. Register with a chosen username and password. The bootstrap form pre-fills vault_path with whatever VAULT_PATH was 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

  1. Edit docker-compose.yml to 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 deploy to apply.

  2. In the panel, /admin/users/create — pick a username and set an initial password.

  3. /admin/users/{id}/edit — set the user's vault_path to the container path you just mounted (e.g. /vaults/bob). The form shows a dropdown of unassigned /vaults/* directories that exist on disk.

  4. 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/login and /admin/account/password are 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/login to 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_path validator 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/team and /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/inner leaves 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_URL

—

postgresql+asyncpg://user:pass@host/db. No TLS parameters here — they are refused; use DATABASE_SSL_MODE. A changeme / CHANGE_ME password is refused. The bundled compose files set it themselves from OBSIDIAN_DB_PASSWORD.

DATABASE_ALLOW_SUPERUSER

false

The server refuses to start when its database role is a PostgreSQL superuser. true downgrades that to a warning; a stop-gap while converting an install, not a setting to keep.

DATABASE_SSL_MODE

prefer

Database TLS: disable, prefer (try TLS, fall back to plaintext), require (encrypt, no verification), verify-ca, verify-full. Strict modes exit if the session is not encrypted. Any PGSSL* variable is refused.

DATABASE_SSL_CA_FILE

—

CA bundle (PEM) for verify-ca / verify-full; required by both, refused with any other mode. No system-store fallback.

DATABASE_SSL_CERT_FILE

—

Client certificate (PEM). Strict modes (require, verify-ca, verify-full) only; set together with DATABASE_SSL_KEY_FILE or not at all.

DATABASE_SSL_KEY_FILE

—

Client private key for DATABASE_SSL_CERT_FILE. Both or neither.

VAULT_PATH

/obsidian

In-container vault mount

SECRET_KEY

—

itsdangerous signer key

INDEX_INTERVAL_SECONDS

300

Periodic reindex cadence

CHANGE_FEED_RETENTION_DAYS

90

How long get_changes history is kept, in days (1–3650). null (or empty) disables the age limit; 0 is refused at startup. Pruned by the periodic tick, never during a pass; a cursor older than what survives reads history_truncated: true.

CHANGE_FEED_MAX_EVENTS_PER_SCOPE

100000

The most change-feed events kept per user (minimum 1,000). Always applies, even with the age limit off, so the table stays bounded; a scope's newest event is never pruned.

CHANGE_FEED_MAX_EVENTS_PER_PASS

2000

Events one index pass itemises per user (100–50,000); the rest become one gap line pointing at get_recent(order_by="content_changed", since=…).

CHANGE_FEED_LOCK_TIMEOUT_MS

2000

How long an event write waits for the user's feed lock (100–30,000). A timeout never fails a pass or a tool: a pass records a durable gap, a tool's change is recorded unattributed by the next pass.

INDEXER_DEGRADED_AFTER_FAILURES

3

Consecutive failures of any indexer counter (a scope's index passes, its embed passes, its incomplete re-derives, or the tick's own user enumeration) at which /health reports degraded and one CRITICAL "manual intervention required" line is logged.

INDEXER_QUARANTINE_RETRIES_PER_TICK

5

How many times one index pass may roll back and re-run a scope after quarantining a note the database refuses (a data-exception or program-limit error on its row, keyword vector, move or links). Beyond it the pass fails as an ordinary failure; the notes already found stay quarantined for the next tick.

MULTI_USER_MODE

false

In-app login, per-user vaults. See Multi-user mode.

VAULT_ROOT_OBSERVE_TIMEOUT_SECONDS

10

How long the vault-root overlap check waits on one root before giving up on it. Expiry quarantines that one account (root unexaminable) and the check carries on, so a hung mount cannot hold up startup. Multi-user mode only.

MCP_HOSTNAME

—

Public hostname. Derives BASE_URL, ALLOWED_ORIGINS and ALLOWED_HOSTS as https://<host>. Required (or BASE_URL) for the transfer tools.

BASE_URL

derived

Explicit public origin. HTTPS except on loopback.

ALLOWED_ORIGINS

derived

CORS origins, JSON list

ALLOWED_HOSTS

derived

Accepted Host headers, JSON list. localhost is always added.

SESSION_MAX_AGE

604800

Panel session lifetime, seconds (multi-user mode). Absolute — the server-side row is never extended, so a session used daily still expires

SESSION_COOKIE_NAME

omcp_session

Panel session cookie name

PANEL_CSP

enforce

Content-Security-Policy on the panel, login and consent pages: enforce, report-only (same policy, reports only), or off. A rollback lever — change it and recreate the container, no rebuild. Anything but enforce logs a WARNING at each start.

SESSION_TOUCH_INTERVAL_SECONDS

60

How stale a session's last_seen_at may get before a validated GET/HEAD rewrites it. Telemetry only — nothing authorizes on it. Must be ≥ 1.

SESSION_PURGE_RETAIN_DAYS

7

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.

OAUTH_KNOWN_REDIRECT_HOSTS

claude.ai,chatgpt.com

Redirect hosts the consent screen badges as known connector destinations. JSON or CSV. Matched by exact host equality — no wildcards, no suffixes; entries containing *, /, @ or internal whitespace are refused at startup. An empty list means every client is shown as unverified.

OAUTH_GRANT_ABSOLUTE_LIFETIME_DAYS

90

Absolute lifetime of an OAuth grant, in whole days (1–365), counted from when the application first receives its tokens. Refresh never extends it; after it the user must approve again. Stated on the consent page. No off value — empty, null, none and 0 refuse to start. Lowering it applies to every grant at its next request; raising it extends existing grants.

MAX_FILE_READ_BYTES

10485760

read_file cap (10 MB); bounds what the server reads from disk

MAX_FILE_WRITE_BYTES

26214400

write_file cap (25 MB), decoded byte length

MAX_READ_RESPONSE_CHARS

40000

read_note / read_file cap on what is returned to the caller (≈10K tokens). See Response size limits.

FTS_CONFIGS

english

Keyword-search text-search config(s). JSON or CSV. See Full-text search language(s).

RANK_WEIGHTS

{0.02, 0.2, 0.4, 1.0}

keyword_search's ts_rank_cd weight array {D, C, B, A}: the title and aliases are indexed at A, the body at D. At the default one two-word title match outweighs fifty body repetitions; PostgreSQL's own D=0.1 lets ten tie it. Four finite numbers, each in [1e-6, 1]; only title-versus-body order moves.

KEYWORD_EXCERPT_CHARS

20000

Leading characters of each note's indexed text stored for keyword_search snippets (1000–100000). Applies to notes indexed afterwards; make rebuild-tsvectors rewrites the rest. The snippet cost grows roughly linearly with this; the 300 ms budget was measured at the defaults.

KEYWORD_SNIPPET_ROWS

20

Rows per keyword_search page that get a snippet, whatever limit is (0–100; 0 turns snippets off). Bounds the per-call ts_headline cost, which grows roughly linearly with this; the 300 ms budget was measured at the defaults.

SEARCH_LOW_CONFIDENCE_SIMILARITY

0

Where semantic_search and hybrid_search start reading confidence: low (#389): no keyword note matched all terms and the best similarity of the call's candidates is strictly below this value. 0 is off (the shipped default: the token is still on every page and always reads ok, which means "not judged"). Range 0–1; out of range or non-finite refuses to start. Model-dependent: bge-m3's candidate value is 0.50 (live probes: gibberish 0.36–0.49, paraphrases 0.61–0.67), OpenAI text-embedding-3-* cosines run lower, so re-choose it when the embedding model changes. Ranks nothing and is not part of the retrieval fingerprint. Set it only after both tools pass the calibration recipe in DEPLOYMENT.md, "Calibrating search confidence".

HYBRID_RRF_K

60

hybrid_search's reciprocal-rank-fusion constant (1–1000; out of range refuses to start): a note scores the sum over the two arms of 1/(k + rank). Smaller lets each arm's top ranks dominate; larger flattens toward counting votes. Measure a change with the harness arms scripts.search_eval_arms.hybrid:make_arm / make_arm_alt (ARM_OPTS="hybrid_k=60 hybrid_alt_k=20", see DEPLOYMENT.md, "Measuring search quality"); there is no per-call argument.

TRANSFER_TOKEN_TTL_SECONDS

600

Default life of a transfer link. Per-call expires_in is clamped to 60–3600.

TRANSFER_MAX_UPLOAD_SECONDS

600

How long one claimed upload may stream before the token is spent

TRANSFER_MAX_CONCURRENT_UPLOADS

4

Simultaneous upload streams

IMPORT_ALLOW_HTTP

false

Let import_from_url fetch plain http. Off by default.

VAULT_ALLOW_NAMED_STAGING_FALLBACK

false

Accept named staging on filesystems without O_TMPFILE. One flag, both write paths. See System requirements.

WRITE_PRECONDITION_REQUIRED

false

Require expected_hash on supported destructive calls. Creation is exempt; enable after clients adopt read hashes.

EMBEDDING_PROVIDER

ollama

ollama or openai

EMBEDDING_DIMENSIONS

1024

pgvector column width

OLLAMA_URL

http://ollama:11434

Used when provider is Ollama. Must be https, loopback http, or covered by EMBEDDING_ALLOW_PLAINTEXT.

EMBEDDING_MODEL

bge-m3

Ollama model name. Changing it post-deploy requires make reset-embeddings; the server refuses to start until the stored vectors match. See Switching providers or models.

EMBEDDING_QUERY_INSTRUCTION

empty

Prepended verbatim, with no separator, to every search query sent to the embedding provider, and never to a note. For instruction-tuned embedders such as Qwen3-Embedding, e.g. "Instruct: Given a question, retrieve the notes that answer it\nQuery: " (double quotes, so .env turns \n into a newline). Up to 1,000 characters; NUL and control characters other than newline and tab are refused. It is not in the stored embedding fingerprint, so changing it needs no reset and re-embeds nothing; changing the model still does. The default model is unchanged (bge-m3, which wants no instruction).

OLLAMA_KEEP_ALIVE

-1

How long Ollama keeps the model resident. -1 pins it; a Go duration (30m) frees VRAM when idle. Ollama only.

OPENAI_API_KEY

—

Required when provider is OpenAI

OPENAI_BASE_URL

https://api.openai.com/v1

Override for Azure or proxies. Same transport rule as OLLAMA_URL when this provider is active.

EMBEDDING_ALLOW_PLAINTEXT

false

Permit http to a non-loopback embedding host. Without it such a URL refuses to start. .env.example sets it true to match its http://ollama:11434 default.

EMBEDDING_CA_FILE

—

Trust anchor (PEM) for an https embedding endpoint behind an internal CA; replaces the default certifi bundle. Refused with an http URL. Embedding clients ignore HTTP(S)_PROXY, SSL_CERT_* and .netrc.

RERANK_URL

—

Full URL of an optional reranker speaking the Jina-compatible /v1/rerank format (llama-server --reranking, vLLM, Infinity), e.g. http://127.0.0.1:8081/v1/rerank. Empty means no reranker: rerank=true answers reranked: false (not_configured). The endpoint receives the query and candidate note text. Same transport rule as the embedding URL (https, loopback http, or RERANK_ALLOW_PLAINTEXT); a bad URL refuses to start, an unreachable reranker never does (it is probed at startup and re-probed in the background; /health reports rerank). Opt-in: no quality gain is claimed until you measure it with the harness on a GPU reranker, and a CPU reranker will time out at the default deadline. See DEPLOYMENT.md, "Reranker (optional)".

RERANK_MODEL

—

Sent as model in the rerank request (≤ 200 characters). llama-server serves one model and ignores it.

RERANK_TIMEOUT_MS

1500

One deadline per rerank call (100–10000), covering connect, write and read; past it the page falls back unreranked with reranked: false (timeout). Also the extra time a rerank=true call can hold its concurrency slot.

RERANK_ALLOW_PLAINTEXT

false

Permit http to a non-loopback reranker host. Separate from EMBEDDING_ALLOW_PLAINTEXT, which does not cover it. An https reranker is verified against EMBEDDING_CA_FILE when set, else certifi's bundle.

OPENAI_EMBEDDING_MODEL

text-embedding-3-small

OpenAI model. Changing it post-deploy requires make reset-embeddings; the server refuses to start until the stored vectors match. See Switching providers or models.

CHUNK_SIZE

512

Approx tokens per chunk (4-char heuristic)

CHUNK_OVERLAP

0

Token overlap between chunks

EMBEDDING_EXCLUDE_PATTERNS

["*.excalidraw.md","Excalidraw/*"]

Globs skipped by the embedder. Excluded files stay keyword-searchable.

MCP_AUTH_FAILURE_LIMIT

60

Failed /mcp authentications one client address may make per window before a 429. Checked before the credential lookup, so a refused probe costs no query. Null disables. See Rate limits.

MCP_AUTH_FAILURE_WINDOW_SECONDS

300

The window that budget is counted over.

MCP_AUTH_FAILURE_TABLE_SIZE

4096

Counter slots in the fixed-size, per-process-salted address table. Memory is O(size); collisions only make the control stricter.

MCP_RATE_LIMIT_PER_MINUTE

600

Sustained tool calls per minute per principal (an API key, or an OAuth grant). Null — with the burst — disables the general bucket.

MCP_RATE_LIMIT_BURST

120

Capacity of the general bucket. Must be set together with its rate or nulled together with it.

MCP_WRITE_RATE_LIMIT_PER_MINUTE

120

Sustained vault-mutating calls per minute per principal — the eight write tools, plus PUT /transfer/upload charged to the principal that minted the capability.

MCP_WRITE_RATE_LIMIT_BURST

30

Capacity of the write bucket.

MCP_LIMITER_MAX_TRACKED_PRINCIPALS

10000

Principals holding their own limiter entry before further ones share one overflow entry.

MCP_REFUSAL_LOG_INTERVAL_SECONDS

10

How long one rate/slot-refusal coalescing window stays open. Inside it a refusal writes nothing; the row that lands stands for 1 + suppressed refusals.

MCP_CONCURRENCY_MODE

shadow

off, shadow, or enforce. Shadow observes pressure without rejecting or waiting. See Concurrency admission.

MCP_CONCURRENCY_WAIT_SECONDS

0

Tool admission wait in enforce mode, 0–5 seconds. Shadow requires zero.

MCP_CONCURRENCY_TOOLS

4

Global tool ceiling in enforce mode, also subject to class, tenant (3), and principal (2) ceilings.

MCP_CONCURRENCY_REQUESTS

32

Full MCP request ceiling, including open streams; per bearer fingerprint ceiling defaults to 4.

MCP_CONCURRENCY_AUTH

2

Authentication database-session ceiling. Released before response delivery or downstream work.

MCP_CONCURRENCY_WRITERS

1

Usage-log writer ceiling; includes fallback inserts. Defaults: 64 pending writers and a 0.25-second enforce-mode wait.

MCP_BODY_MEMORY_BUDGET_BYTES

unset

The /mcp body-memory budget (≥ 64 MiB). Unset derives it from the container's memory limit — it never means off. Refused at startup above half the limit (less fixed headroom).

MCP_BODY_MEMORY_FRACTION / MCP_BODY_MEMORY_MULTIPLIER

0.5 / 8

The derivation: limit × fraction ÷ multiplier = raw body bytes in flight. Fraction 0.1–0.5, multiplier 8–32.

MCP_BODY_BUDGET_WAIT_SECONDS / MCP_BODY_BUDGET_WAITERS

15 / 8

How long a body that does not fit waits before its 429, and how many may wait at once.

DEFAULT_DAILY_REQUEST_LIMIT

5000

Daily quota a newly created API key receives when the caller names none (an omitted JSON field or a blank panel field). Existing keys are untouched. Only an administrator can create or restore an unlimited key, with the panel's Unlimited box or an explicit JSON null. Null makes a limit required on create.

KEY_CREATION_ACCOUNT_LIMIT

10

API keys one account may create per window, across POST /api/keys and the panel form together. Null disables.

KEY_CREATION_ADDRESS_LIMIT

20

API keys created from one client address per window, across both routes and all accounts. Null disables.

KEY_CREATION_WINDOW_SECONDS

3600

The fixed window both key-creation limits are counted over.

KEY_MAX_ACTIVE_PER_ACCOUNT

25

Active API keys a non-admin account may hold; beyond it, creation is refused until a key is revoked. Admins exempt. Null disables.

MCP_REJECT_UNKNOWN_ARGUMENTS

true

Refuse a tool call carrying an argument the tool does not declare (a tool error naming it), and publish additionalProperties: false on every input schema. false restores the SDK's silent ignore — a rollback for a client that sends extras; change it and recreate the container. false logs a WARNING at each start.

MCP_SANDBOX_MODE

false

Registry-eval only. Skips DB, indexer, embedding provider, and /mcp auth so introspection works without external deps. Do not enable in production.

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.

That limit is per request. The sum of request bodies in flight is bounded too, always (#322): each /mcp POST reserves its declared size against a budget derived from the container's memory limit (half of it, ÷ 8 for the parsing overhead — 128 MiB of raw body on a 2 GiB container), waits up to 15 s if it does not fit, and otherwise gets HTTP 429 with code: body_memory and Retry-After: 2. The server refuses to start in a container too small to admit one maximum-size body (about 1.1 GiB with the defaults). The MCP_BODY_* settings in .env.example only allow the safe direction.

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_PROVIDER

  • EMBEDDING_MODEL (Ollama) or OPENAI_EMBEDDING_MODEL (OpenAI) — including a swap between two models of the same dimension, which the dimension guard cannot see

  • EMBEDDING_DIMENSIONS

  • CHUNK_SIZE and CHUNK_OVERLAP

Two things are deliberately not on that list. EMBEDDING_QUERY_INSTRUCTION touches only queries, so no stored vector depends on it. And the chunking format itself (heading-aware sections and the context prefix) is versioned per note: when it changes, the indexer re-embeds notes in the background over several passes, without a refused startup or a reset.

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:

  1. Update .env.

  2. make deploy (or docker 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.

  3. make reset-embeddings while it is down. The target is docker compose run --rm, so it starts a one-off container that reads your edited .env: it recreates the column at the new dimension, clears every embedded_content_hash, and records the new fingerprint in the same transaction.

  4. 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-m3 is a mutable Ollama tag, so ollama pull can replace the weights behind it, and OLLAMA_URL / OPENAI_BASE_URL are 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 requires make 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.

The vector holds the note's title and frontmatter aliases at weight A and its body at weight D, per configured config, and ranking uses RANK_WEIGHTS — so a query that is a note's title ranks that note first, where it used to lose to a long note repeating the words. Bare terms are ANDed; when the full matches do not fill the page, the same terms OR-ed fill the rest, tagged match: any. Each row carries a snippet: built from a stored excerpt of the indexed text (KEYWORD_EXCERPT_CHARS), computed for the first KEYWORD_SNIPPET_ROWS rows. The excerpt is written with the vector; make rebuild-tsvectors also stores it for notes indexed before it existed.

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:

FTS_CONFIGS

Behavior

english

English Snowball stemmer (default; running ↔ run).

simple

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 semantic_search (bge-m3 is multilingual) handles morphological recall.

english,norwegian

Both stemmers applied — keyword-side morphology for two languages at once.

simple,norwegian

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:

  1. Edit FTS_CONFIGS in .env.

  2. make deploy. The new container refuses at the keyword fingerprint guard and stays down.

  3. 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.

  4. 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_CONFIGS back 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-m3 tokenizes to bge + m3. simple preserves 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_note on 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 pass section= or raise the cap.

And read_note used to return one rendered string — a # <title> / **Path:** header, a \n---\n separator, 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 read content (and, for section reads, heading) instead; clients that ignore structuredContent still 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 (600) sustained, MCP_RATE_LIMIT_BURST (120) capacity — on every tool call.

  • A write bucket — 120/min, burst 30 — that the eight vault-mutating tools must pass in addition, and that PUT /transfer/upload consumes 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 /mcp authentication — 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 600 calls per minute, so the call was refused before it ran. Nothing was read, written, or counted against the daily quota. Retry in 1 seconds, or slow the calling loop down.
MCP-REFUSAL {"code":"rate_limited","scope":"principal","limit":600,"limit_unit":"calls_per_minute","retry_after_seconds":1}

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/performance as refusal counts, not in the latency percentiles. Repeated rate and enforced slot refusals are coalesced — one row per credential/tool/scope per MCP_REFUSAL_LOG_INTERVAL_SECONDS, each standing for 1 + suppressed refusals — so that a refusal loop cannot make writing the log the load.

  • The velocity defaults are estimates against a small sample. Read /admin/performance for a week before treating any as settled, and disable one by setting it empty, null or none (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) unless they name a limit — a blank panel field gets the default too — and only an administrator can make a key unlimited, explicitly. Keys that already existed keep whatever they had, and OAuth grants have no daily ceiling at all — velocity bounds only.

  • Every new key is a new principal with full bursts, so key creation has its own budget: 10 keys per account and 20 per client address per hour, shared by the JSON API and the panel form, and a non-admin account may hold at most 25 active keys.

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  │                       │  │  (28 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_hash

The 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

notes_metadata

Path, title, tags, frontmatter, content hash, embedded hash, tsvector, modified time

note_embeddings

One row per chunk. embedding is vector(EMBEDDING_DIMENSIONS).

note_links

Wikilink graph: source/target IDs, target_path, kind (link, embed, markdown)

api_keys

Hashed bearer tokens, prefix for display, permission, expiry

usage_logs

Per-tool-call audit

oauth_clients, oauth_codes, oauth_tokens

OAuth 2.0 PKCE state, including the grant id that ties a consent's tokens together

transfer_tokens

Capability rows behind the /transfer/* links: direction, destination path, state, fingerprint, expiry

users

Multi-user mode: login, role, per-user vault_path, and the vault the index was last built under

user_sessions

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
pytest

The 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.

Dependencies are exact pins (==) in requirements*.txt, bumped by Renovate; renovate.json holds mcp below 2.x (the 2.x migration is issue #365, not a routine bump) and digest-pins the Docker base image.

To run the server outside Docker:

DATABASE_URL=... SECRET_KEY=... VAULT_PATH=... uvicorn src.main:app --reload --no-proxy-headers

Make 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 (derived index tables schema-only; BACKUP_FULL=1 dumps their rows too)
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.yml uses Traefik with an OAuth chain. Don't expose /admin directly 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 outside OAUTH_KNOWN_REDIRECT_HOSTS is 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, .trash and 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: Bearer header. 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 authenticated check_upload tool.

  • import_from_url fetches only genuinely public addresses, under an explicit deny list re-applied at every redirect.

  • Failed /mcp authentication 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: DENY and Referrer-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 be https or loopback unless EMBEDDING_ALLOW_PLAINTEXT says otherwise. Each start logs one transport line per hop and an internal_transport_plaintext security 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.

Listed in the official MCP Registry as io.github.maxkuminov/obsidian-mcp, published from server.json on every release tag, and on Glama. Both listings carry the tagline at the top of this README: a memory system for your AI agents, stored as plain markdown you can open in Obsidian.

License

MIT. See LICENSE.

Available Tools

28 tools
check_uploadCheck uploadA
Read-onlyIdempotent

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.

NameRequiredDescriptionDefault
upload_idYes

Output Schema

NameRequiredDescription
resultYes

TDQS

A5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already establish that this is a read-only, idempotent, non-destructive operation. The description adds substantial behavioral context beyond annotations: status meanings, principal-scoped visibility, `not found` behavior across credentials, the `uploading` deadline, and the important caveat that `unknown` does not mean nothing arrived.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is long but front-loaded and information-dense. Each paragraph serves a distinct purpose: status enumeration, usage, visibility scoping, deadline behavior, the `unknown` caveat, and argument identification. No sentence appears redundant.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Although only one parameter is involved, the tool has subtle status semantics and credential-scoped visibility. The description covers those nuances, and the output schema means return formatting need not be restated. An agent has everything needed to call and interpret this tool correctly.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must carry the parameter semantics, and it does: `upload_id` is the short handle from `request_upload`, not the upload URL, and not the token after `#`. It also states that anything else is refused without a lookup.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: asking what happened to an upload link minted by `request_upload`. It enumerates the exact statuses returned and makes clear this is a status-checking tool, not a transfer or listing tool.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It explicitly tells the agent when to use it: to confirm a transfer finished before telling the user, and to obtain the sha256 for verification. It also warns what to do before minting another link and after the `uploading` deadline, which is strong operational guidance.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

create_noteCreate 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 the server raises ends with one machine-readable MCP-REFUSAL {"code":…} line (an undeclared or mistyped argument is rejected by protocol validation before the tool runs and carries no such 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.

NameRequiredDescriptionDefault
pathYes
contentYes
expected_hashNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.2/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The description adds substantial behavioral information beyond the annotations: write permission requirements, refusal of symlink paths, kernel-atomic no-clobber publication, staging fallback errors, and the exact refusal codes an agent may encounter. These details clarify auth, failure modes, and mutation semantics rather than merely repeating the annotation hints.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is front-loaded and structured with Args, which helps. However, the expected_hash section is lengthy and repetitive, restating the always-refused no_incumbent outcome several times, so it is not as concise as it could be.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

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 presence of an output schema, the description supplies everything needed for correct invocation: permissions, symlink behavior, no-clobber guarantees, expected_hash semantics, and refusal handling. Return values are covered by the output schema and briefly referenced.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

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: it explains the path format and automatic .md extension, what content includes, and the unusual expected_hash behavior including malformed and no_incumbent refusal handling. Each parameter is given operational meaning beyond the bare schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

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: 'Create a new markdown note in the Obsidian vault.' That is clear enough to identify the operation. It does not explicitly name sibling alternatives such as edit_note or write_file, so it falls short of full sibling differentiation.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description states important context — write permission is required and get_vault_guide should be consulted for conventions — but it does not explicitly say when to use create_note instead of write_file, edit_note, or import_from_url. Usage is implied by 'new note' and the no-clobber behavior, but the agent must infer the boundaries.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

delete_fileDelete fileA
Destructive

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.

expected_hash binds the whole raw file in either mode; optional by default — omitted, the delete proceeds — unless the deployment requires it, when an omitted hash is refused precondition_required. 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. 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.

NameRequiredDescriptionDefault
pathYes
permanentNo
expected_hashNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.7/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already flag destructiveHint=true, readOnlyHint=false and idempotentHint=false, but the description adds far more than that baseline: default soft-delete into `.trash/<timestamp>-<basename>-<hex>` with no clobbering, permanent=True as an unrecoverable unlink, what happens to the index/embeddings, and the `precondition_required` refusal path for `expected_hash`. It even discloses the TOCTOU caveat ('not an atomic filesystem compare-and-delete') and that a successful delete reports no 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.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Exceptionally front-loaded — the one-line purpose and the alternative come first, then behavior, then the hash contract. The main deduction is the trailing 'Args:' block, which restates `path`, `permanent`, and `expected_hash` in terser terms already covered in the prose, adding length without new information.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a destructive, non-idempotent file tool, everything needed is present: permissions, trash-vs-unlink semantics, refusal cases, precondition ordering, and the fact that no content hash is returned. An output schema exists, so the description correctly does not spend words on the return shape beyond that one relevant negative.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0% — the properties only carry titles — so the description must carry the semantics, and it does: `path` is vault-relative, `permanent` switches from trash-move to unlink, and `expected_hash` gets format (`sha256:<64 lowercase hex>`), accepted sources, when it is required, and the ordering of refusal codes. This is exactly the compensation the low coverage demands.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with a specific verb+resource+scope: 'Delete a non-markdown file from the vault.' It immediately names the sibling it is distinguished from ('Peer to `delete_note`, which stays markdown-only'), so an agent can route between them without opening either schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicit routing rules: use this for non-markdown, use `delete_note` for markdown (with the reason — the index and backlinks), and it refuses directories and symlinks. It also states the permission prerequisite (readwrite API key or OAuth scope), which is the first thing an agent must verify before calling.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

delete_noteDelete noteA
Destructive

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 by default — omitted, the delete proceeds against whatever is on disk — unless the deployment requires it, when an omitted hash is refused precondition_required. 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. Every refusal the server raises ends with one machine-readable MCP-REFUSAL {"code":…} line (an undeclared or mistyped argument is rejected by protocol validation before the tool runs and carries no such 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.

NameRequiredDescriptionDefault
pathYes
permanentNo
expected_hashNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.6/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already flag destructiveHint=true, but the description adds far more: the exact .trash naming scheme, atomic rename semantics, the indexer's ≤5-minute drop, the irreversibility of permanent=True, symlink refusal, and the full list of MCP-REFUSAL codes with what resolves each. This is context structured fields cannot carry.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with purpose and permissions, but the expected_hash section sprawls across three paragraphs with repeated restatements ('It applies in both modes' revisiting the preflight comparison) and a long refusal-code list. Content is useful but the prose is bloated for what is a three-argument tool.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Covers modes, permissions, failure semantics, and precondition behavior, and correctly omits return-value details since an output schema exists (noting only that a successful delete reports no content_hash). 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.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries the full burden — and it does: path is vault-relative, permanent defaults to soft-delete vs unlink, and expected_hash is documented with its exact canonical format (sha256:<64 lowercase hex>), whole-file scope, default-omitted behavior, and deployment override to precondition_required.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ('Delete a note from the vault') and immediately scopes it with permission requirements and the soft-vs-permanent distinction. Although it never names the sibling delete_file explicitly, the symlink-scoping paragraph makes clear this operates on named notes only, which differentiates it sufficiently.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives explicit conditions for optional arguments: pass permanent=True when the filesystem cannot do the non-replacing rename, and expected_hash when the deployment requires preconditions. It also points at get_backlinks, find_orphans, and get_vault_guide for follow-up. It does not state when to prefer this over the delete_file sibling, so not a 5.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

edit_noteEdit noteA
Destructive

Edit an existing note in the Obsidian vault. Requires write permission (a readwrite API key or OAuth scope).

Arguments: path, content, at most one mode flag — append, find or section (operation is a legacy alias) — plus replace_all, dry_run, replace_frontmatter, and the expected_hash precondition. Every refusal the server raises ends with one machine-readable MCP-REFUSAL {"code":…} line; an undeclared or mistyped argument is rejected by protocol validation before the tool runs and carries no such line. See get_vault_guide for vault conventions.

Modes (mutually exclusive):

  1. Full replace (default): content becomes the note's body; an existing valid line-1 YAML frontmatter block is preserved byte-identically ahead of it. replace_frontmatter=True overwrites the entire file, frontmatter included. A note with no valid block is replaced wholesale. Change frontmatter with set_frontmatter or find=; never write back read_note's frontmatter JSON view. The round-trip guarantee covers a complete, unwindowed whole-note read only — no section, offset=0, truncated false; page a truncated read to the end first, or full replacement replaces the whole body with the fragment. A section response belongs to section mode.

  2. Append: append=True; content is added at the end, after one newline.

  3. Find & replace: find=<exact text> is replaced with content; it must match exactly once unless replace_all=True.

  4. Section: section=<heading> replaces the whole body under the named ATX heading. Parent/Child disambiguates repeated headings; a #N ordinal ("#7", 1-based document order) is the only form that addresses duplicate siblings. A selector resolves to the same section here as in read_note on any write this tool admits — parity is about resolution, not admission.

dry_run=True returns a unified diff and writes nothing.

expected_hash: pass the content_hash a read returned, verbatim, and the call is refused with nothing written if the file changed since — in every mode, dry_run included. It is the whole file's hash, never a hash of the text you received. Optional by default — omitted, a stale edit overwrites — unless the deployment requires it, when an omitted hash is refused precondition_required. Codes: stale_precondition, concurrent_write, no_incumbent, malformed_precondition, precondition_unavailable, precondition_required. Success reports the content_hash of the bytes this call wrote, not what is on disk when you read it; dry_run reports none.

Section mode. content is the section's body: the text beginning on the line immediately after the matched heading line; the heading line is never rewritten. The body is replaced whole — anything content does not resend, a fenced code block under the heading included, is deleted — so a blank line you want after the heading belongs in content. read_note(path, section=...) is the matching read: its heading field carries the heading line and its content field the body, and this tool takes exactly that content. Byte-identity holds for notes whose body newlines are LF: non-LF terminators inside the selected body come back as LF. #N ordinals agree with read_note; a heading inside a fenced code block is not a heading.

Two shapes refuse a section write outright, naming the problem and writing nothing: a malformed frontmatter block, whose refusal names the replace_frontmatter=True repair; and a fence opener indented by one to three spaces that nothing below it closes — close the fence or unindent it to column zero, then reissue. Both are asymmetric with reads: read_note keeps working on such notes. A symlinked final component is refused.

NameRequiredDescriptionDefault
findNo
pathYes
appendNo
contentYes
dry_runNo
sectionNo
operationNo
replace_allNo
expected_hashNo
replace_frontmatterNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Goes far beyond the annotations: it discloses exactly what is destroyed (the section body is replaced whole, including fenced code blocks; a fragment read replaces the whole body), what is preserved (line-1 YAML frontmatter byte-identically), the two shapes that refuse a section write outright, the symlink refusal, and the full refusal-code vocabulary with the distinction that protocol-validation errors carry no `MCP-REFUSAL` line. This is precisely the destructive-mutation context an agent needs and annotations cannot carry.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded and organized: purpose, then arguments, then numbered modes, then per-flag paragraphs in bold. Length is largely earned given ten parameters, four modes, and destructive semantics, but the dedicated "Section mode" paragraph substantially re-covers ground already laid out in mode 4, and the round-trip guarantee passage is dense enough to need a second read.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

An output schema exists, so return values need only the one clause provided (success reports the hash of the bytes written; `dry_run` reports none). Given the tool's complexity — destructive whole-body writes, mode dispatch, preconditions, asymmetric read/write failure modes — the description leaves nothing material unaddressed apart from the `path` format.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

With 0% schema description coverage, the description carries the full burden and does so for nine of ten parameters: mode flags and their mutual exclusivity, `operation` as a legacy alias, `replace_all`'s exactly-once requirement, `dry_run`'s diff return, `replace_frontmatter`'s whole-file overwrite, and a detailed `expected_hash` contract (whole-file hash, not the returned text's hash). The gap is `path`, whose expected form (vault-relative? extension included?) is never stated.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ("Edit an existing note in the Obsidian vault") and immediately frames the four mutually exclusive modes, which is what actually determines the tool's shape. It also names the sibling to use for a nearby task ("Change frontmatter with `set_frontmatter`"), so an agent can discriminate it from `set_frontmatter`, `write_file`, and `create_note` without opening a schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicit when-to-use for each mode, explicit when-not-to (never write back `read_note`'s frontmatter JSON view; use `set_frontmatter` or `find=`), an explicit prerequisite ("Requires write permission (a `readwrite` API key or OAuth scope)"), and explicit pointers to `read_note` and `get_vault_guide`. The `dry_run` and `expected_hash` guidance further tells the agent when to prefer a safer call shape.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

find_orphansOrphan notesA
Read-onlyIdempotent

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).

NameRequiredDescriptionDefault
limitNo
folderNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.4/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The annotations already declare readOnlyHint, idempotentHint, destructiveHint=false and closed-world scope, so the safety profile is covered. The description adds the non-obvious behavioral fact of a hard 500-result cap on top of the default limit, which an agent needs when sizing queries.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The qualifying condition is front-loaded in the first sentence, followed by a compact Args block. The Args block is docstring-style but earns its place because the schema documents nothing; no filler sentences.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given a two-optional-parameter, read-only tool with rich annotations and an output schema, the description covers definition, purpose, and both parameters adequately. Only minor gaps remain, such as folder prefix matching semantics (deep vs. immediate).

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries the full burden and does so: folder is defined as an optional vault-relative prefix with a concrete example ('Cards/'), and limit is given a default of 50 plus a hard cap of 500. Nothing about either parameter is left ambiguous.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a precise verb-and-resource plus the exact qualifying condition: notes with zero incoming AND zero outgoing resolved links. That definition is what separates it from link-centric siblings like get_backlinks, get_links, and get_neighborhood, which each look at one direction only.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Frames the use case explicitly (vault hygiene, 'what's disconnected?', cleanup decisions), which tells an agent when this is the right tool. It stops short of naming alternatives or stating when not to use it versus find_related or get_links.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

get_changesChange feedA
Read-onlyIdempotent

List what changed in your vault after a cursor: one line per note created, modified, moved or deleted, oldest first.

`since` is either the `next_since` cursor a previous call returned
(`ev:<number>`, exclusive and exact) or a UTC date/datetime
(`YYYY-MM-DD`, or ISO 8601 with `Z`/`±HH:MM`). A date or datetime *starts*
a feed and is approximate; `next_since` *continues* one without losing or
repeating an event. Store `next_since` (it is in the header and the last
line) in your own notes and pass it next session. A cursor means
something only for your own vault; one above this vault's last event is
refused (`invalid_argument`).

Each line: `- ev:<n> <UTC time> <kind> "<path>"` (a move shows
`"<from>" -> "<to>"`; paths are JSON-quoted, so a newline in a name is
`

), then reason=…where it applies, then who:by api_key "" via orby oauth "" via , marked (you)when it was your own credential, orby human/syncfor anything the index discovered — edits outside MCP (Obsidian, a sync client, git, a shell) are human/sync. createdmeans first seen at this path: a new note, one half of a rename that could not be paired, or a note that became indexable. deletedalways carriesreason=: removed(the file is gone), not_indexable(still on disk but cannot be indexed) orquarantined`.

Two meta lines are always shown, whatever the filters: `gap` (events not
itemised, with a count) and `reset` (the index was rebuilt from empty).
After either, resync with `get_recent(order_by="content_changed",
since=…)`. `history_truncated: true` in the header means events older than
the retained history (default 90 days) were removed.

Args:
    since: A `next_since` cursor (`ev:<n>`) or a UTC date/datetime.
    folder: Only events whose path or source path is under this prefix (a
        move out of the folder counts).
    kinds: Subset of `created`, `modified`, `moved`, `deleted`.
    by: `any` (default); `me` (your exact credential — the same API key,
        or the same OAuth grant across token refreshes); `agents` (any MCP
        caller of this vault); `human` (`human/sync`, discovered by the index).
    limit: Events per page (1–500, default 50). A full page says
        `more: true`; pass its `next_since` to continue.
NameRequiredDescriptionDefault
byNoany
kindsNo
limitNo
sinceYes
folderNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Beyond the readOnly/idempotent/non-destructive annotations, the description discloses cursor exclusivity, date approximation, invalid_argument behavior for out-of-range cursors, gap and reset meta lines, and history truncation. It also explains credential attribution and the meaning of created/deleted reasons, which is substantial behavioral context.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is front-loaded with purpose and then structured into cursor semantics, line format, meta lines, and Args. It is dense and mostly earns its length, but some output-format detail could potentially live in the output schema, making it slightly longer than strictly necessary.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a complex change-feed tool, the description covers the critical operational details: cursor handling, filtering, pagination, error conditions, meta lines, truncation, and resync behavior. With an output schema present, it does not need to explain every return field, and the remaining behavioral gaps are minimal.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must carry parameter meaning, and it does: since accepts either ev:<n> or UTC date/datetime, folder filters by path prefix including move-out cases, kinds restricts event types, by defines credential scope, and limit documents range and pagination behavior. This fully compensates for the sparse input schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: list what changed in the vault after a cursor, with one line per created/modified/moved/deleted note, oldest first. It also distinguishes the cursor-based continuation model from date-starting feeds and names get_recent for the resync case, so the agent can identify the tool's role among siblings.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It explicitly explains when to pass a next_since cursor versus a date/datetime, why a cursor continues without loss or repetition, and how to handle gap/reset by resyncing with get_recent. It also states that a cursor is vault-specific and that a cursor above the vault's last event is refused, giving clear usage boundaries.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

get_neighborhoodLink neighborhoodA
Read-onlyIdempotent

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).

NameRequiredDescriptionDefault
pathYes
depthNo
limitNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.8/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare readOnly/idempotent/non-destructive/closed-world, so the safety profile is covered. The description adds real behavioral facts beyond that: traversal is undirected, depth is capped at 5, and the neighbor limit is a hard cap of 200. It stops short of saying whether the seed note is included in results or what happens for a missing path.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with the core behavior, then the selection rule, then compact argument notes. No redundant restatement of the tool name or title; every clause carries information.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

An output schema exists, so return shape need not be explained. With traversal model, caps, defaults, and the sibling routing rule all present, an agent has everything needed to call this correctly against a 3-parameter read tool.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries the full burden and does: `path` is vault-relative, `depth` is BFS depth with default 1 and cap 5, `limit` counts distinct neighbor notes with default 50 and hard cap 200. The caps and the 'distinct neighbor notes' definition are meaning the schema does not provide.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb+resource: the connected subgraph reachable from a seed path via links/backlinks up to N hops. It also fixes the traversal model ('treated as undirected'), which an agent cannot infer from the name, and explicitly separates it from the `find_related` sibling.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives a concrete use case ('summarize everything connected to this project') plus an explicit routing rule: prefer this when explicit links are the signal, prefer `find_related` when the connection is conceptual. Both the when and the when-not are present with a named alternative.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

get_recentRecent notesA
Read-onlyIdempotent

Get recently modified notes, newest first.

Each line shows the note's modification time in UTC, as "YYYY-MM-DD HH:MM UTC".

Notes whose content changed since a point in time: order_by="content_changed" with since. File mtime (the default order) also moves when a file is touched or re-synced without a content change; content_changed moves only when the indexed content hash changes, and each line is then labelled changed. For an exact list of creations, edits, moves and deletions since a stored cursor, see get_changes.

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"}). since: Optional UTC lower bound, inclusive: YYYY-MM-DD (that whole UTC day) or an ISO 8601 datetime with Z or ±HH:MM (no offset is refused). until: Optional UTC upper bound, exclusive; a date includes that whole day. order_by: "mtime" (default) or "content_changed"; the bounds apply to it. cursor: The more: cursor=… value from the previous page of the same call. When any of since/until/order_by/cursor is given, a full page ends with that footer.

NameRequiredDescriptionDefault
tagsNo
limitNo
sinceNo
untilNo
cursorNo
folderNo
order_byNo
frontmatterNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Beyond the annotations, the description discloses return-line formatting with UTC timestamps, the mtime versus content_changed behavioral difference, pagination footer behavior, and strict date parsing rules. These details materially help an agent predict what the tool returns and how filtering behaves.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is dense but front-loaded, opening with the core behavior before elaborating on ordering and arguments. Given eight parameters and zero schema description coverage, every section carries useful information without obvious repetition.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

The tool is moderately complex with eight optional parameters, and the description covers all of them plus ordering, pagination, and timestamp semantics. With an output schema present, return values do not need fuller explanation, and 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.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Although schema description coverage is 0%, the description documents all eight parameters with meaning, defaults, examples, and edge cases. It explains inclusive since, exclusive until, strict frontmatter type matching, ALL-tags matching, and cursor reuse semantics.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: 'Get recently modified notes, newest first.' It also explicitly distinguishes the default mtime ordering from content_changed ordering and routes exact creation/edit/move/deletion tracking to get_changes.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It gives explicit when-to-use guidance for content_changed versus the default mtime order, including the caveat that file mtime moves on touch or re-sync without content changes. It also names the alternative tool get_changes for an exact event list since a stored cursor.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

get_tagsList tagsB
Read-onlyIdempotent

List all tags used across the vault with note counts.

Args: limit: Maximum number of tags to return (default 50)

NameRequiredDescriptionDefault
limitNo

Output Schema

NameRequiredDescription
resultYes

TDQS

B3.2/5.0
Behavior3/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare the operation as read-only, idempotent, non-destructive, and closed-world. The description adds useful behavior context that tags come with note counts, but it does not disclose ordering, whether counts include all notes or only some, or pagination behavior beyond the limit.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is short and front-loaded with the main behavior, then gives the one parameter. It is appropriately sized for a simple list operation, though repeating the schema default is slightly redundant.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness3/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

An output schema exists, so return structure need not be explained. However, for a tag-listing tool the description omits ordering and scope details that would help an agent use the result correctly, such as whether tags are sorted, case-normalized, or drawn from all file types.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

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 the single parameter. It does define limit as the maximum number of tags to return and restates the default, but the schema already encodes the default and the description adds little beyond the obvious meaning of 'limit'.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: listing all tags used across the vault, and it adds that note counts are included. It is clear enough to distinguish from file/note read tools, but it does not explicitly contrast with any sibling because there is no other tag-related sibling.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines2/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

There is no guidance on when to use this tool versus alternatives such as list_notes, keyword_search, or semantic_search. The description simply says what it does, leaving tool selection to inference.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

get_vault_guideVault guideA
Read-onlyIdempotent

Returns a two-part guide for working with this Obsidian vault:

  1. Obsidian primer — generic syntax (wikilinks, embeds, block refs, heading refs, tags, frontmatter, callouts, comments, highlights, math, mermaid, footnotes, tasks, plugin literals).

  2. Vault-specific conventions — folder structure, naming rules, frontmatter requirements, and tag taxonomy as configured by the vault owner in CLAUDE.md. If CLAUDE.md is absent, the response includes instructions for creating one.

NameRequiredDescriptionDefault

No parameters

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.2/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare readOnly, idempotent, non-destructive, closed-world, so the safety profile is covered. The description adds genuine behavioral context beyond them: the response is two-part, and it discloses the fallback path when `CLAUDE.md` is absent (instructions for creating one), which an agent could not predict from annotations or schema.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with the core claim, then a clean two-item enumeration; every line maps to a real part of the response. Slightly longer than strictly necessary, but the structure makes it scannable and nothing is redundant.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

An output schema exists, so return-value shape needn't be restated, and the description still gives enough of the content outline plus the missing-`CLAUDE.md` fallback for an agent to know what it will receive. For a zero-parameter read of static documentation, nothing material is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The tool takes zero parameters, so the baseline is 4 by rule; there is no parameter semantics to explain and the description correctly spends no words on them.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ('Returns a two-part guide') and enumerates exactly what the two parts contain, including concrete topics (wikilinks, frontmatter, callouts, tag taxonomy). No sibling tool returns documentation content, so it is unambiguously distinguishable from read_note, list_notes, or get_tags.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines3/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The phrase 'for working with this Obsidian vault' implies it is a reference to consult before authoring content, but the description never states when to call it versus alternatives, nor does it say it should be called before create_note/edit_note/set_frontmatter. Usage is inferable rather than explicit.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

import_from_urlImport from URLA
Destructive

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, at most 8,192 characters; a longer one is refused with argument_too_long before anything is fetched. path: Vault-relative destination (e.g. "Attachments/paper.pdf"). overwrite: If True, allow replacing an existing file at path.

NameRequiredDescriptionDefault
urlYes
pathYes
overwriteNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A5/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Beyond the annotations, it discloses write-permission requirements, server-side fetching behavior, return data, SSRF restrictions, redirect re-checks, size and timeout limits, no-clobber defaults, and atomicity guarantees. These details add substantial operational context not captured by `destructiveHint`, `openWorldHint`, or `readOnlyHint`.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is long but appropriately dense for a security-sensitive import tool. It is front-loaded with purpose, permission, and sibling differentiation, then proceeds logically through behavior, safety constraints, failure modes, and arguments without redundant filler.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given the tool's complexity, open-world network access, destructive potential, and 0% schema description coverage, the description supplies the missing safety, permission, and failure-mode context. It also explains return values even though an output schema exists, leaving no important operational gap for an agent.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Although schema description coverage is 0%, the description fully documents all three parameters. It explains `url` as a public https URL capped at 8,192 characters, `path` as a vault-relative destination with an example, and `overwrite` as permitting replacement of an existing file.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description states a specific verb and resource: fetch a public https URL into the vault. It explicitly distinguishes itself from siblings by naming `write_file` and `request_upload` and explaining that this tool is for bytes already public elsewhere.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It gives clear when-to-use guidance relative to alternatives: use this when the source bytes are already public, rather than using `write_file` or `request_upload`. It also gives strong when-not-to-use guidance by restricting usage to genuinely public addresses and warning not to rewrite URLs to evade SSRF checks.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

list_filesList filesA
Read-onlyIdempotent

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).

NameRequiredDescriptionDefault
limitNo
folderNo.
patternNo*
recursiveNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already declare readOnly/idempotent/non-destructive, and the description adds real behavior beyond them: dot-directories and dot-files are hidden, a `folder` containing a dot component is rejected, default limit 200 with a hard cap of 1000, and truncation is signaled in the response. Those are exactly the surprises an agent would otherwise hit.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with purpose and sibling disambiguation, then behavior, then a compact Args block. The one inefficiency is that defaults and meanings are stated both in the prose and again in the Args list, which is mild redundancy rather than bloat.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a four-param read-only listing tool with an output schema (so return shape needn't be spelled out), the description covers scope, filtering, recursion, hiding rules, limits, and truncation signaling. Nothing an agent needs in order 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.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must carry all parameter meaning — and it does, documenting all four params with defaults, glob semantics with an example ('*.pdf'), the effect of `recursive=True`, and the cap on `limit`. This fully compensates for the empty schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ('Browse the vault filesystem (`ls`-style)') and immediately contrasts it with the sibling `list_notes`, which lists indexed markdown only. It also names `read_file` as the natural follow-on, so an agent can distinguish all three without opening a schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicitly routes the agent: use this when you need raw filesystem contents including non-markdown, use `list_notes` for indexed markdown, and use this to gauge a binary's size before `read_file`. Both the selection condition and the downstream use case are stated.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

list_notesList notesA
Read-onlyIdempotent

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).

Notes whose content changed in a window: order_by="content_changed" with since (and optionally until). File mtime (the default order) also moves when a file is merely touched or re-synced; content_changed moves only when the indexed content hash changes.

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"}). since: Optional UTC lower bound, inclusive: YYYY-MM-DD (that whole UTC day) or an ISO 8601 datetime with Z or ±HH:MM (no offset is refused). until: Optional UTC upper bound, exclusive; a date includes that whole day. order_by: "mtime" (default) or "content_changed"; the bounds apply to it. cursor: The more: cursor=… value from the previous page of the same call. When any of since/until/order_by/cursor is given, a full page ends with that footer.

NameRequiredDescriptionDefault
tagsNo
limitNo
sinceNo
untilNo
cursorNo
folderNo
order_byNo
frontmatterNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

The annotations already declare read-only, idempotent, non-destructive, and closed-world behavior. The description adds important non-obvious traits: index freshness can lag up to 5 minutes, on-disk notes not yet indexed will be missing, and pagination footers appear when since/until/order_by/cursor are used.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The purpose is front-loaded, and the longer passages are justified by non-obvious index freshness and pagination behavior. The Args block is compact and each parameter note adds needed meaning rather than repeating the schema.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Because an output schema exists, the description appropriately omits return-value details. It still covers the operational context needed to invoke the tool correctly: index lag, filtering semantics, ordering modes, time bounds, and pagination.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries the full burden and does so thoroughly. It documents all eight parameters, including folder path conventions, limit default, ALL-tags matching, strict frontmatter type matching, UTC date/ISO bounds with inclusive/exclusive behavior, order_by values, and cursor source.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The opening sentence gives a specific verb, resource, and scope: 'List notes in a vault folder, sorted by most recently modified.' This clearly distinguishes note listing from note creation, file-level listing, and search siblings, even without naming those alternatives.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explains when to choose content_changed ordering versus the default mtime ordering, and it explains how pagination cursors are used. It does not explicitly route the agent between list_notes and keyword_search or semantic_search, so it falls short of full when/when-not/alternative guidance.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

list_tasksList tasksA
Read-onlyIdempotent

List checkbox tasks (- [ ] …) across the vault in one call, from the index — no note is read.

Reads the index, refreshed each pass (default every 300 s): a just-edited note may show its previous tasks until the next pass. Tasks-plugin and Dataview query blocks are fenced text and are not evaluated; only the checkbox lines themselves are tasks. A checkbox inside a fenced code block or inline code is not a task.

Status: [ ], [/] (in progress) and any other marker are open; [x] and [X] are done; [-] is cancelled — excluded from open, included in all. Each line shows the raw marker.

Ordering: due date ascending, undated tasks last, then path, then line. When more rows match than fit, the header says more: yes and the response ends with next: <cursor>; pass it back as cursor with the same filters to continue. A cursor from other filters or another owner is refused (invalid_argument); so is a compact cursor whose note has since gone — start again without one.

Each task is one line: the marker, the path in backticks with :line, then any of due, scheduled, start and done dates, priority, the task's own #tags, the heading path (under:), and text: last, as written (Tasks-plugin emoji included). Text past 1,000 characters is cut and labelled; a task whose tags were capped (64 kept) says tags cut. A first row too long for the response is shown abbreviated rather than skipped.

The header always states not yet scanned for tasks (notes the index has not re-derived yet, e.g. right after an upgrade), tasks could not be indexed and over the task cap (5,000 per note), each even when zero, and with tags the count of tasks whose tags were cut. When either of the first two is non-zero the list is incomplete and the response says so — "no tasks" then does not mean none exist.

Args: folder: Only notes under this path prefix (e.g. "Projects/"). status: open (default), done, cancelled or all. due_by: YYYY-MM-DD; only tasks due on or before this date. Tasks with no due date are excluded when it is given. scheduled_by: YYYY-MM-DD; only tasks scheduled on or before this date. Tasks with no scheduled date are excluded when it is given. tags: Only tasks carrying every one of these inline tags on the task line itself (a leading # is optional; note-level tags are not consulted). At most 32, each 1–128 characters. limit: Maximum tasks per page (1–500, default 100). The response size cap can end a page sooner; next then continues it. cursor: The next value from the previous page of the same query.

NameRequiredDescriptionDefault
tagsNo
limitNo
cursorNo
due_byNo
folderNo
statusNoopen
scheduled_byNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Well beyond the annotations, which only declare read-only/idempotent/non-destructive: the description discloses index staleness (refresh default 300 s, so just-edited notes may be stale), truncation rules (text cut past 1,000 chars, 64 tags kept, 5,000-task-per-note cap), cursor failure modes (invalid_argument on mismatched filters, stale compact cursors), and header health flags where a non-zero value means the list is incomplete. Nothing conflicts with the annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Purpose and read/no-read scope are front-loaded, and the dense paragraphs on ordering, paging, row format and header flags each carry non-obvious information. It is long, but length is justified by a 7-param tool with 0% schema documentation; only minor redundancy (restating that a non-zero flag means the list is incomplete, then "'no tasks' then does not mean none exist") could be trimmed.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With annotations already covering the safety profile and an output schema present, the description still supplies everything an agent needs: ordering, page/continuation mechanics, row layout, truncation markers, and the caveat that header flags can make an empty result unreliable. No material gap remains for correct invocation.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, yet the Args block documents all seven parameters with meaning beyond the schema: folder is a path prefix, status enumerates and defines open/done/cancelled/all plus the marker-to-status mapping, due_by/scheduled_by exclude undated tasks and require YYYY-MM-DD, tags are task-line-only (note-level tags not consulted) with 32-tag/1–128-char bounds, limit bounds 1–500 with a size cap that can shorten pages, and cursor must come from the same query.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ("List checkbox tasks across the vault") plus the defining mechanism ("from the index — no note is read"), which immediately distinguishes it from note-content siblings like read_note or keyword_search. It also scopes what counts as a task (checkbox lines only, code fences excluded).

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives clear context for when this is the right call: vault-wide task enumeration without reading notes, with explicit boundaries (Tasks-plugin/Dataview blocks not evaluated, inline-code checkboxes ignored). It does not name an alternative sibling or state when NOT to use it (e.g. use keyword_search for text, read_note to see a task's surrounding note), so it stops short of full routing guidance.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

move_noteMove noteA
Destructive

Move or rename a note inside the vault. Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope.

Arguments: from_path, to_path (must not exist; parent directories are created), rewrite_links (default False) and the optional expected_hash precondition. The index follows the move, so backlinks keep working without rewriting source notes. Every refusal the server raises ends with one machine-readable MCP-REFUSAL {"code":…} line; an undeclared or mistyped argument is rejected by protocol validation before the tool runs and carries no such line.

expected_hash binds from_path's own bytes and nothing else: pass the content_hash a read returned, verbatim, and the move is refused with nothing written if that note changed since. It is compared at preflight, before the rename and before any rewrite; the rename then pins inode identity but does not compare the bytes again, so an in-place edit landing after that comparison is still moved. The backlink sources a rewrite_links=True move rewrites are not bound, because you never read them. Optional by default — omitted, the move proceeds — unless the deployment requires it, when an omitted hash is refused precondition_required. Codes: stale_precondition, concurrent_write, no_incumbent, malformed_precondition, precondition_unavailable, precondition_required.

With rewrite_links=True, every source note that links to this note is rewritten in place — wikilinks, embeds, path-style links, and bare-href markdown links [text](Old.md), whose new href is written relative to the linking note's own folder; angle-bracket hrefs [text](<Old.md>) are not rewritten. Aliases and anchors survive. 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 past the note size limit, or the planning would exceed the aggregate memory bound, the whole move is refused before any file is touched, naming the source or the count.

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. 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 or a lost vault assignment stops the remaining rewrites and the result reads partial success: …, naming the sources that still link to the old path. The move is not rolled back; treat the link graph as agreeing with the vault bytes only on plain success, and fix the named sources with edit_note.

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 failed rewrite stays a partial success and never claims nothing was written; if another writer changed to_path in between, no hash is reported and the result says to re-read to_path first.

Writes are atomic; a symlinked final component on either path is refused. See get_vault_guide for vault folder conventions.

Args: from_path: Vault-relative path of the existing note. to_path: Vault-relative path of the destination. rewrite_links: Also rewrite incoming links in source notes (off by default). expected_hash: from_path's content_hash as you last read it.

NameRequiredDescriptionDefault
to_pathYes
from_pathYes
expected_hashNo
rewrite_linksNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations flag destructiveHint=true and non-idempotency, but the description adds substantially more: preflight-before-rename semantics, that a failed rewrite leaves a *partial success* with no rollback, that no hash is reported on concurrent modification, atomicity, and symlink refusal. The expected_hash precondition window (compared once at preflight, not re-compared after) is exactly the kind of subtlety annotations cannot convey.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded purpose and permission note, then per-concern paragraphs — well organized for a behavior-heavy tool. It is long (~450 words) and repeats the 'nothing was written' vs. 'partial success' contrast more than once, plus the fence-indentation edge case gets a full paragraph, so not every sentence is maximally efficient.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Output schema exists, yet the description still usefully summarizes what success reports (the published content_hash, and when it is omitted). Combined with the refusal-code contract and the non-rollback behavior, an agent has everything needed to invoke this correctly and recover from failures.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must carry all four parameters — and it does: from_path is the existing note, to_path must not already exist with parent directories auto-created, rewrite_links defaults off and what it triggers, and expected_hash is bound to from_path's bytes with a full list of refusal codes. Each parameter's constraints and failure modes are spelled out.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with a specific verb+resource+scope: "Move or rename a note inside the vault." That cleanly distinguishes it from edit_note, create_note, and delete_note without requiring the agent to open any schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives real routing guidance: state the write-permission prerequisite, tell the agent to use rewrite_links=False plus edit_note when the fence refusal fires, and point to get_vault_guide for folder conventions. It never explicitly contrasts with a sibling mover (there is none) and doesn't say when *not* to move vs. e.g. edit_note, so it falls 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_fileRead fileA
Read-onlyIdempotent

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.

NameRequiredDescriptionDefault
pathYes
limitNo
offsetNo
encodingNoauto
hash_onlyNo

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already cover the safety profile, but the description adds substantial context beyond them: the server does not parse PDFs, binary results are opaque base64 meant for client-side decoding, files over MAX_FILE_READ_BYTES (10 MB) are refused, base64 inflates ~33% in context, dot-components and traversal are rejected, and text reads are windowed with a truncation notice. That is exactly the kind of behavior an agent cannot infer from readOnlyHint/idempotentHint.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with purpose and the key caveat (no parsing), then organized into labeled sections for encoding, limits, hash behavior, and args. Dense and mostly waste-free, though the encoding/validation paragraph is verbose enough that a tighter phrasing would improve scanability.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

No output schema exists, and the description compensates by describing return shapes: readable text for text-like files, inline image blocks for PNG/JPEG/GIF/WebP, labeled base64 strings otherwise, JSON-quoted paths in the base64 header, and the sha256 content_hash format that write/delete tools accept as expected_hash. Nothing needed 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.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must carry parameter meaning entirely — and it does, documenting all five params with defaults, valid values, and interactions (auto/text/base64 semantics, offset/limit windowing, hash_only refusing offset != 0 or a non-None limit, and that limit can only lower the server cap). The validation ordering note further clarifies which errors take precedence.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Names a specific verb and resource ('Read any file in the vault — including non-markdown') and immediately delimits scope against the sibling: 'Peer to `read_note`, which stays markdown-only.' An agent can select between the two without opening either schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Gives explicit routing rules: use read_note for markdown, use base64 for byte-exact frontmatter bytes because read_note.frontmatter_yaml normalizes line endings, and check size with `list_files` before reading large binaries. It even states when encoding has no effect (hash_only mode), which is unusual and useful guidance.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

read_noteRead noteA
Read-onlyIdempotent

Read a note from the Obsidian vault by its relative path.

Arguments: path, an optional section (an ATX heading), and offset / limit for paging. Returns a structured result, not a rendered document — read the fields:

  • content — the selected note text. Whole-note reads: the body with a valid YAML frontmatter block stripped, which is byte-exact input for edit_note(path, content) full replacement only when the read is complete and unwindowed (offset=0 and truncated false). Section reads: the section's body only, byte-exact input for edit_note(path, content, section=...) under the same condition. Pass it straight back; do not add, strip or split anything. Page a truncated read to the end before writing it back, or full replacement replaces the whole body with the fragment.

  • heading — section reads only: the matched heading line, with no line terminator. It is not part of content; a section write never rewrites the heading line.

  • error — set when the read failed (missing note, bad offset/limit, unknown section). A normal result, not a transport error; the content-bearing fields are absent beside it.

  • content_hash — the whole file's digest, sha256:<64 lowercase hex>, in every mode (a section or truncated read returns the value a whole-note read would), and the token the write tools take as expected_hash. It is not a hash of content: never compute it yourself, hand it back verbatim.

  • path, title, tags — metadata as data.

  • frontmatter_yaml — the frontmatter block's YAML source, fence lines excluded, LF-normalized (a CRLF block comes back with LF terminators — the same declared residual content carries, because this tool normalises and the write tools work on raw bytes; edit_note still reattaches the original block byte-identically). This is the authoritative copy. frontmatter is a best-effort JSON view of the same block and may be absent; metadata_omissions then says which fields were dropped and why. To change frontmatter use set_frontmatter, or edit the raw block with edit_note(find=...); never write back a round trip of the JSON view.

  • truncated, offset, next_offset, total_chars — truncation as data. outline (truncated whole-note reads) lists every section with its #N ordinal so you can fetch the one you want directly, and notice carries the guidance in prose. Read the one section you need with section= rather than paging a large note; content, the outline and the metadata fields each have their own budget.

  • metadata_omissions — any metadata field this response had to drop, and why. Nothing is ever signalled by a marker inside a field.

Byte-identity holds for notes whose body newlines are LF: terminators inside the selected content come back as LF. read_file on the same path is the byte-exact route to the file itself.

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 only form that can address duplicate headings sharing the same parent; the outline of a truncated note carries the ordinal for every section. A bare "#N" always selects by position; use "Parent/#N" to reach a heading titled "#N". offset: Character offset to start reading from (default 0); continue from the reported next_offset. limit: Maximum characters of content to return. Only lowers the server cap; it cannot raise it.

NameRequiredDescriptionDefault
pathYes
limitNo
offsetNo
sectionNo

Output Schema

NameRequiredDescription
pathNo
tagsNo
errorNo
titleNo
noticeNo
offsetNo
contentNo
headingNo
outlineNo
truncatedNo
frontmatterNo
next_offsetNo
total_charsNo
content_hashNo
frontmatter_yamlNo
metadata_coercionsNo
metadata_omissionsNo

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already cover safety (readOnly/idempotent/non-destructive), so this is bonus context and it delivers heavily: frontmatter stripping on whole-note reads, LF normalization as a declared residual, `error` returned as a normal result rather than a transport failure, per-field truncation budgets, and content_hash being the whole-file digest in every mode. None of this is derivable from annotations or schema.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded and bullet-organized, but roughly 500 words for a four-parameter read tool, with some repetition ("Pass it straight back; do not add, strip or split anything" restates the byte-exactness point made twice more) and a trailing Args block that re-explains what the body already covered. The density is defensible for the round-trip semantics, but a third of it could be cut without loss.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

It never leaves the agent guessing: what is returned in each mode, what is absent when `error` is set, how truncation is signalled, and what token the write tools need. Although an output schema exists and the field-by-field enumeration partly duplicates it, nothing required to call or consume the result is missing.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries the full load and does: `section` documents three addressing forms (plain heading, Parent/Child chain, #N ordinal) including the duplicate-heading case, and `offset`/`limit` explain the default, the paging handoff via next_offset, and that limit only lowers the server cap. That is meaning well beyond the bare types in the schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with a specific verb+resource+scope: "Read a note from the Obsidian vault by its relative path," and immediately separates itself from the nearest sibling ("read_file on the same path is the byte-exact route to the file itself"). An agent can pick between read_note and read_file without opening either schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Explicit routing to alternatives with conditions: section reads instead of paging a large note, set_frontmatter or edit_note(find=...) to change frontmatter, read_file for byte-exact access. It also states negative guidance (never write back a truncated read, never compute content_hash yourself), which is the when-not half of the rule.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

request_downloadRequest download linkA

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.

NameRequiredDescriptionDefault
pathYes
expires_inNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Rich disclosure beyond annotations: token lives in the URL fragment so it avoids access logs, the whole URL is a secret, the link is bound to the file's current state (breaks on edit rather than serving different content), it is reusable unlike an upload link, and expiry is clamped and may be earlier than the credential's deadline. Annotations (readOnlyHint=false, idempotentHint=false, destructiveHint=false) are consistent with a link-creating, non-destructive, non-idempotent operation.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Front-loaded with purpose and the read_file contrast, then security and expiry notes. Every section is useful, though the curl example and multiple prose paragraphs make it longer than strictly minimal.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a link-generation tool with an output schema, the description covers purpose, security handling, expiry behavior, and file-binding semantics thoroughly. An agent has everything needed to call it and to warn the user appropriately.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0%, so the description must carry parameter meaning, and it does: path is a vault-relative path of the file to share, and expires_in is seconds clamped to 60–3600, defaulting to TRANSFER_TOKEN_TTL_SECONDS (600), with the caveat that the result deadline may be earlier.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

States a specific verb and resource ('Get a short-lived link ... to save a vault file') and explicitly differentiates from the sibling read_file, contrasting 'returns the bytes to you' vs 'for the human'.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Names the alternative (read_file) and the exact condition for choosing this one: when the file is for the human, or when read_file would waste context or cannot render (PDF, large image, archive). Clear when-to-use routing.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

request_uploadRequest upload linkA
Destructive

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.

NameRequiredDescriptionDefault
pathYes
overwriteNo
expires_inNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.9/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already flag a mutation with destructiveHint=true; the description goes well beyond by disclosing single-use semantics, no-clobber-by-default, the stale-link guard under `overwrite=True`, expiry clamping (60–3600) and credential-bound lifetime, plus the secret-handling rule for the URL fragment and the explicit warning never to put it in a query string. That is genuinely new operational context, not a restatement of the hints.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Well front-loaded: purpose, then auth, then the alternative, then mechanics. Some material is arguably surplus for a tool call decision — the paragraph explaining why MCP clients can't pass bytes, and the raw curl recipe — though both are defensible for a model reasoning about capabilities. Slightly long, but each section is doing work.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With an output schema present, return values needn't be enumerated, yet the description still flags that the result carries a deadline that may be earlier than requested and names the verifying follow-up call. For a single-use transfer-link mint with three params, an agent has everything needed to call it and act on the result.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 0%, so the description must carry all parameter meaning — and it does: `path` gets a concrete format example ("Attachments/photo.png"), `overwrite` gets its replacement semantics and stale-check behavior, and `expires_in` gets units, clamp range, default source, and the caveat that the effective deadline may be earlier. Nothing material is left undocumented.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

First sentence gives a specific verb+resource+scope: a short-lived link that lets a person put a file into the vault. It then explicitly contrasts with the sibling `write_file` ("takes the bytes directly — use this one when you do not have them"), so the agent can route between the two without opening either schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

States the exact condition that selects this tool over its alternative (you don't have the bytes), states the follow-up step (`check_upload(upload_id)`), and points to `get_vault_guide` for vault conventions. The precondition (readwrite API key or OAuth scope) is spelled out, which is exactly the when-can-I-use-this information an agent needs.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

set_frontmatterSet frontmatterA
Destructive

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.

Arguments: path, updates (keys to set), remove (keys to drop) and the optional expected_hash precondition. Parses the existing frontmatter, merges in updates (overwriting matching keys, adding new ones), then drops the keys in remove; the body is preserved byte-for-byte. A note with no frontmatter (no --- fence on line 1) gets a fresh block prepended ahead of the unchanged body.

expected_hash: pass the content_hash a read returned, verbatim (sha256:<64 lowercase hex>), and the call is refused with nothing written if the file changed since. It is the whole file's hash, never a hash of the text you received. Optional by default — omitted, a stale edit overwrites — unless the deployment requires it, when an omitted hash is refused precondition_required. 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. Every refusal the server raises ends with one machine-readable MCP-REFUSAL {"code":…} line (an undeclared or mistyped argument is rejected by protocol validation before the tool runs and carries no such line) — stale_precondition (with the current hash), concurrent_write, no_incumbent, malformed_precondition, precondition_unavailable, precondition_required — each stating what resolves it. The server also re-compares the file immediately before publishing. A write reports the content_hash of the bytes this call published; a no-op publishes nothing and reports none.

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, 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 goes through PyYAML, which does NOT preserve YAML comments — any # comment in the original frontmatter is lost on the first set_frontmatter call. A path whose final component is a symlink is refused, naming its target; symlinked folders inside the vault work normally. See get_vault_guide for vault frontmatter conventions.

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.

NameRequiredDescriptionDefault
pathYes
removeNo
updatesNo
expected_hashNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Despite annotations already declaring destructive/non-readonly, the description goes well beyond them: required scope (`readwrite` key or scope), the full refusal taxonomy with machine-readable codes, ordering of precondition checks relative to no-op and malformed-block diagnosis, comment loss through PyYAML, byte-for-byte body preservation, symlink refusal, and the exact no-op rule. This is precisely the kind of concrete behavioral context the annotations cannot convey.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The critical information — what it mutates, permission requirement, and no-body-change guarantee — is front-loaded, and the bolded sections make skimming feasible for a genuinely complex tool. It is long, though, and the trailing Args block partly restates the `expected_hash` and `remove` prose already given above, which is mild duplication rather than dead weight.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a mutation tool with four parameters, an output schema, and hash-precondition semantics, everything an agent needs is present: auth requirements, error/refusal behavior and resolution, edge cases (empty fence valid, unclosed fence refused, last key removed drops the block), and operational caveats (comment loss, symlinks). Nothing material is deferred to guesswork.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description carries the full burden and does: `updates` is a key→value mapping that overwrites and adds, `remove` ignores absent keys (and alone makes a no-op), and `expected_hash` must be the whole-file `sha256:<64 lowercase hex>` from a prior read, not a hash of received text. Value comparison is even specified as type-sensitive (`true` is not `1`).

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The opening sentence gives a specific verb and resource ('Mutate a note's YAML frontmatter') plus the key scope constraint ('without touching its body'), which immediately separates it from write_file, edit_note, and create_note. An agent can select this tool without opening the schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

It routes the agent elsewhere for the malformed-block case by naming `edit_note(path, content, replace_frontmatter=True)` as the repair path, and points to `get_vault_guide` for conventions. It also states when `expected_hash` should and shouldn't be supplied. What's missing is an explicit statement of when to prefer this over edit_note/write_file for ordinary edits, so it falls short of a full when/when-not/alternatives treatment.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

write_fileWrite fileA
Destructive

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.

expected_hash binds an existing whole file on overwrite=True; optional by default — omitted, the overwrite replaces whatever is on disk — unless the deployment requires it, when an omitted hash on an overwrite is refused precondition_required. 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.

NameRequiredDescriptionDefault
pathYes
contentYes
encodingNobase64
overwriteNo
expected_hashNo

Output Schema

NameRequiredDescription
resultYes

TDQS

A4.8/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations already mark this destructive and non-idempotent, but the description goes far beyond them: atomic staging/publish, parent folder creation, 25 MB decoded cap, dot-path and traversal rejection, symlink-final-component refusal, transport-level HTTP 413 for inflated text payloads, and the `precondition_required`/`no_incumbent`/`concurrent_write` error semantics. It also discloses that the returned hash reflects what this call published, not necessarily current disk state — a subtlety no annotation conveys.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness3/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The purpose and sibling routing are correctly front-loaded, but the body is a dense ~350-word block mixing behavior, permissions, error codes, and transport limits without structure. The no-clobber default is asserted at least three times ("No-clobber by default," the "unless the deployment requires preconditions" restatement, and again in the Args line), and the hash discussion loops back on itself, costing readability.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness5/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a destructive write with auth requirements, size caps, and precondition logic, virtually every decision the agent needs is present: permissions, atomicity, limits, path rules, overwrite semantics, and how to source and interpret `expected_hash`. An output schema exists and the description still usefully flags that the returned hash is publication-time, not current state.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters5/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must carry the full load, and it does: encoding choices with decode semantics, overwrite's default and rename-vs-link behavior, and a detailed account of `expected_hash` including the canonical `sha256:<64 lowercase hex>` format and the three ways to obtain it. The Args block also documents `path` as vault-relative with an example, which the bare `string` schema does not.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Opens with a specific verb+resource ("Write a file into the vault") and immediately scopes it to non-markdown artifacts such as PDFs or images. The second sentence explicitly positions it against siblings: "Peer to `create_note`/`edit_note`, which stay markdown-only," so an agent can route correctly without opening any schema.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Names the alternatives (`create_note`/`edit_note` for markdown) and states the condition that selects this tool instead. It also gives in-tool routing rules: default no-clobber, `overwrite=True` for replacement, `encoding="text"` only when JSON escaping stays under the transport bound, otherwise send 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.

  1. 6 tool updatesv0.10.0
    • Addedget_changes
    • Changedget_recent4 fields changed
      • addedInput schema / properties / cursor
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Cursor"
        +}
      • addedInput schema / properties / order_by
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Order By"
        +}
      • addedInput schema / properties / since
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Since"
        +}
      • addedInput schema / properties / until
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Until"
        +}
    • Addedhybrid_search
    • Changedlist_notes4 fields changed
      • addedInput schema / properties / cursor
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Cursor"
        +}
      • addedInput schema / properties / order_by
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Order By"
        +}
      • addedInput schema / properties / since
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Since"
        +}
      • addedInput schema / properties / until
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Until"
        +}
    • Addedlist_tasks
    • Changedsemantic_search1 field changed
      • addedInput schema / properties / rerank
        Added value: +{
        +  "default": false,
        +  "title": "Rerank",
        +  "type": "boolean"
        +}
  2. 25 tool updatesv0.8.2
    • Changedcheck_upload1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedcreate_note1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changeddelete_file1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changeddelete_note1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changededit_note1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedfind_orphans1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedfind_related1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedget_backlinks1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedget_links1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedget_neighborhood1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedget_recent1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedget_tags1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedget_vault_guide1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedimport_from_url1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedkeyword_search1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedlist_files1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedlist_notes1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedmove_note1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedread_file1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedread_note1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedrequest_download1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedrequest_upload1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedsemantic_search1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedset_frontmatter1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
    • Changedwrite_file1 field changed
      • addedInput schema / additionalProperties
        Added value: +false
  3. 10 tool updatesv0.8.1
    • Changedcreate_note1 field changed
      • addedInput schema / properties / expected_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Expected Hash"
        +}
    • Changeddelete_file1 field changed
      • addedInput schema / properties / expected_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Expected Hash"
        +}
    • Changeddelete_note1 field changed
      • addedInput schema / properties / expected_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Expected Hash"
        +}
    • Changededit_note1 field changed
      • addedInput schema / properties / expected_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Expected Hash"
        +}
    • Changedget_links1 field changed
      • addedInput schema / properties / limit
        Added value: +{
        +  "default": 100,
        +  "title": "Limit",
        +  "type": "integer"
        +}
    • Changedmove_note1 field changed
      • addedInput schema / properties / expected_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Expected Hash"
        +}
    • Changedread_file1 field changed
      • addedInput schema / properties / hash_only
        Added value: +{
        +  "default": false,
        +  "title": "Hash Only",
        +  "type": "boolean"
        +}
    • Changedread_note3 fields changed
      • addedOutput schema / $defs / MetadataCoercion
        Added 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"
        +}
      • addedOutput schema / properties / content_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Content Hash"
        +}
      • addedOutput schema / properties / metadata_coercions
        Added value: +{
        +  "anyOf": [
        +    {
        +      "items": {
        +        "$ref": "#/$defs/MetadataCoercion"
        +      },
        +      "type": "array"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Metadata Coercions"
        +}
    • Changedset_frontmatter1 field changed
      • addedInput schema / properties / expected_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Expected Hash"
        +}
    • Changedwrite_file1 field changed
      • addedInput schema / properties / expected_hash
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Expected Hash"
        +}
  4. 1 tool updatev0.8.0
    • Changedread_note20 fields changed
      • addedOutput schema / $defs
        Added 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"
        +  }
        +}
      • addedOutput schema / description
        Added 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."
      • addedOutput schema / properties / content
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Content"
        +}
      • addedOutput schema / properties / error
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Error"
        +}
      • addedOutput schema / properties / frontmatter
        Added value: +{
        +  "anyOf": [
        +    {
        +      "additionalProperties": true,
        +      "type": "object"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Frontmatter"
        +}
      • addedOutput schema / properties / frontmatter_yaml
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Frontmatter Yaml"
        +}
      • addedOutput schema / properties / heading
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Heading"
        +}
      • addedOutput schema / properties / metadata_omissions
        Added value: +{
        +  "anyOf": [
        +    {
        +      "items": {
        +        "$ref": "#/$defs/MetadataOmission"
        +      },
        +      "type": "array"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Metadata Omissions"
        +}
      • addedOutput schema / properties / next_offset
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "integer"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Next Offset"
        +}
      • addedOutput schema / properties / notice
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Notice"
        +}
      • addedOutput schema / properties / offset
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "integer"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Offset"
        +}
      • addedOutput schema / properties / outline
        Added value: +{
        +  "anyOf": [
        +    {
        +      "$ref": "#/$defs/NoteOutline"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null
        +}
      • addedOutput schema / properties / path
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Path"
        +}
      • removedOutput schema / properties / result
        Removed value: -{
        -  "title": "Result",
        -  "type": "string"
        -}
      • addedOutput schema / properties / tags
        Added value: +{
        +  "anyOf": [
        +    {
        +      "items": {
        +        "type": "string"
        +      },
        +      "type": "array"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Tags"
        +}
      • addedOutput schema / properties / title
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Title"
        +}
      • addedOutput schema / properties / total_chars
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "integer"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Total Chars"
        +}
      • addedOutput schema / properties / truncated
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "boolean"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Truncated"
        +}
      • removedOutput schema / required
        Removed value: -[
        -  "result"
        -]
      • changedOutput schema / title
        Previous value: -"read_noteOutput"New value: +"ReadNoteResult"
  5. 1 tool updatev0.7.2
    • Changededit_note1 field changed
      • addedInput schema / properties / replace_frontmatter
        Added value: +{
        +  "default": false,
        +  "title": "Replace Frontmatter",
        +  "type": "boolean"
        +}
  6. 14 tool updatesv0.7.0
    • Addedcheck_upload
    • Addedcreate_note
    • Addeddelete_file
    • Addededit_note
    • Addedfind_related
    • Addedimport_from_url
    • Addedkeyword_search
    • Addedlist_notes
    • Addedmove_note
    • Changedread_file2 fields changed
      • addedInput schema / properties / limit
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "integer"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Limit"
        +}
      • addedInput schema / properties / offset
        Added value: +{
        +  "default": 0,
        +  "title": "Offset",
        +  "type": "integer"
        +}
    • Addedread_note
    • Addedrequest_download
    • Addedrequest_upload
    • Addedset_frontmatter
  7. 8 tool updatesv0.5.4
    • Removedcreate_note
    • Removededit_note
    • Removedfind_related
    • Removedkeyword_search
    • Removedlist_notes
    • Removedmove_note
    • Removedread_note
    • Removedset_frontmatter
  8. 3 tool updatesv0.4.0
    • Addedlist_files
    • Addedread_file
    • Addedwrite_file
  9. 17 tool updates
    • First observedcreate_note
    • First observeddelete_note
    • First observededit_note
    • First observedfind_orphans
    • First observedfind_related
    • First observedget_backlinks
    • First observedget_links
    • First observedget_neighborhood
    • First observedget_recent
    • First observedget_tags
    • First observedget_vault_guide
    • First observedkeyword_search
    • First observedlist_notes
    • First observedmove_note
    • First observedread_note
    • First observedsemantic_search
    • First observedset_frontmatter

TDQS

A4.2/5.0

Scored across 28 tools

Disambiguation4/5

Most tools target clearly distinct resources, and the descriptions actively route between the tricky trios (keyword/semantic/hybrid_search, read_note/read_file, delete_note/delete_file). However, list_notes, get_recent, and get_changes all describe overlapping 'modified/changed' listing behavior with subtle differences, and set_frontmatter partly overlaps edit_note's frontmatter handling, leaving a little room for misselection.

Naming Consistency5/5

Every tool is snake_case with a predictable verb-first or modifier+noun shape (read_note, write_file, list_files, find_related, keyword_search, semantic_search). The keyword/semantic/hybrid search family and the upload/download/check transfer family follow visible naming patterns, so names alone telegraph intent.

Tool Count3/5

At 28 tools the surface is heavy, well past the comfortable 3-15 band, though each tool covers a genuinely distinct capability (search, note CRUD, file CRUD, links, tasks, changes, transfers, guide). The scope is broad enough to justify many of them, but the count is still on the excessive side.

Completeness5/5

Coverage is thorough: three search modes, full note and file CRUD (including soft/permanent delete, move with link rewriting, frontmatter mutation), link graph navigation (backlinks, outgoing links, neighborhood, orphans), task listing, a cursor-based change feed, and complete upload/download/import transfer tooling plus a vault guide. No obvious gaps or dead ends for the stated vault-management purpose.

Maintenance

ActivityActive
ResponsivenessResponsive

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    Standalone 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.
    10
    33
    115 npm
    32
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Self-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
  • A
    license
    Not graded
    quality
    A
    maintenance
    Local-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