Skip to main content
Glama
Obsidian MCP (pgvector + Ollama, self-hosted)

Obsidian MCP (pgvector + Ollama, self-hosted)

by maxkuminov

Server Configuration

Describes the environment variables required to run the server.

NameRequiredDescriptionDefault
CHUNK_SIZENoApproximate tokens per chunk (4-char heuristic)512
OLLAMA_URLNoOllama server URL (required when provider is ollama)
SECRET_KEYYesSecret key for itsdangerous signer
VAULT_PATHYesIn-container path to the Obsidian vault (default: /obsidian)
DATABASE_URLYesPostgreSQL connection string (e.g., postgresql+asyncpg://user:pass@host/db)
CHUNK_OVERLAPNoToken overlap between chunks0
OPENAI_API_KEYNoOpenAI API key (required when provider is openai)
EMBEDDING_MODELNoOllama model name (used when provider is ollama)bge-m3
MULTI_USER_MODENoEnable multi-user mode (requires SECRET_KEY)false
OPENAI_BASE_URLNoBase URL for OpenAI-compatible APIhttps://api.openai.com/v1
MCP_SANDBOX_MODENoRegistry-eval only. Skips DB, indexer, embedding provider, and /mcp auth so introspection works without external deps. Do not enable in production.false
EMBEDDING_PROVIDERNoEmbedding provider: 'ollama' or 'openai'ollama
EMBEDDING_DIMENSIONSNopgvector column width (must match model output)1024
INDEX_INTERVAL_SECONDSNoPeriodic reindex cadence in seconds300
OPENAI_EMBEDDING_MODELNoOpenAI embedding model nametext-embedding-3-small

Instructions

Guidance the server publishes about itself, which clients place ahead of the tool catalog so the model reads it before choosing anything.

This server publishes no instructions, or was last inspected before Glama recorded them.

Capabilities

Features and capabilities supported by this server

Protocol revision2025-11-25

CapabilityDetails
tools
{
  "listChanged": false
}
prompts
{
  "listChanged": false
}
resources
{
  "subscribe": false,
  "listChanged": false
}
experimental
{}

Tools

Functions exposed to the LLM to take actions

NameDescription
keyword_searchA

Full-text keyword search via PostgreSQL tsvector. Use this for exact identifiers, code symbols, proper nouns, titles, or known phrases — anywhere semantic noise hurts.

Exact identifiers, code symbols, titles or known phrases: keyword_search. Conceptual or paraphrased questions: semantic_search. Unsure which: hybrid_search.

Bare terms are ANDed: budget forecast matches only notes containing both stems. Notes matching every term come first, tagged match: all; when they do not fill the page, notes matching some of the terms follow, tagged match: any, in their own rank order. The operators: "exact phrase" (adjacent words), budget OR forecast (either), -draft (exclude; honoured in the any block too). A note's title and frontmatter aliases are indexed above its body, so a query that is a note's title ranks that note first. A note's tags (frontmatter and inline #tags; a nested #a/b also as a and b) are indexed below its title and aliases and above its body.

Each result carries rank (a relative score: a body-only match scores in the thousandths, a title match near 1, and an any row is scored against the fallback query, so ranks compare only within one block of one page), match, matched_in (the parts that alone match: title — title or aliases — tags, body, comma-joined; or, when the terms are split between parts, the first of title+tags, title+body, tags+body, title+tags+body that matches), terms (k/n: how many of the query's terms the note contains), and a second line: snippet: — the matching body text with the matches in «» — or head: — the note's opening words when the body alone does not match or the match is beyond the stored excerpt (a note with no body has no second line). Snippets are computed for the first rows of the page (the header says snippets: first N when the page is longer). The header always carries the all/any counts and how many rows have no stored excerpt yet (notes indexed before this version; the footer names the one-time rebuild). Call read_note on a result's path for the full note.

Args: query: Keywords or phrase to match (websearch tsquery syntax: "foo bar", "foo OR bar", "-bar"). folder: Optional folder prefix (e.g. "Cards/", "Projects/"). limit: Maximum number of results (default 20). tags: Optional list of tag names; only notes carrying ALL listed tags match (e.g. ["project", "active"]). frontmatter: Optional dict of frontmatter key/value pairs; only notes whose JSONB frontmatter contains every pair match. Strict type matching — string "0" does not match integer 0 (e.g. {"status": "draft"}).

read_noteA

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.

list_notesA

List notes in a vault folder, sorted by most recently modified.

Results come from the index, so a note that exists on disk but has not yet been picked up by the indexer will not appear (lag is bounded by the index interval, typically up to 5 minutes).

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.

get_tagsB

List all tags used across the vault with note counts.

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

get_recentA

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.

semantic_searchA

Vector similarity search over the vault's chunk embeddings. Use this for conceptual or paraphrased queries — anywhere exact word matching would miss the point.

Exact identifiers, code symbols, titles or known phrases: keyword_search. Conceptual or paraphrased questions: semantic_search. Unsure which: hybrid_search.

Each result is one note (deduped) with its closest-matching chunk as a ~200-character preview. Call read_note on a result's path to get the full note content.

The header line carries a stale count and a truncated count, always — including when both are zero, so "nothing here is degraded" is distinguishable from a build that does not report it.

stale: true on a row means the note changed after it was embedded: it was matched and ranked against its previous content, and its preview is withheld rather than shown, because that excerpt is text the note no longer has. Its path, title and tags are current — the indexer refreshed them, which is how the staleness is known at all — so read_note on that path returns the true content and is the remedy. Stale notes are never filtered out: during an embedding outage that would empty the result set rather than degrade it.

embedding_truncated: true means the note is longer than the indexer's per-note chunk cap and only its head was embedded. A match against such a note is a match against its head; its tail is not reachable by semantic search at all, though keyword_search still covers the whole note.

Section locator. A fresh row names the section it matched on its result line: § "<heading path>" (#N) for a heading's section, or § (top of note) for text before the first heading. read_note(path, section="#N") reads exactly that section. The quoted path is note text; the (#N) after it is the server's. The locator is omitted on stale rows and on rows not yet re-embedded since heading-aware chunking. While any of your notes is not yet re-chunked, the line after the header reads index_transition: N notes not yet re-chunked; …: ranking across the two generations is approximate, so re-run once it is gone. Previews are one line.

The bound on the staleness signal, stated so it is not over-read: it reports what the index has committed. An edit that the indexer has not yet scanned is not marked, so a note edited in the last few minutes may come back unmarked with a superseded preview. The guarantee is "no result presents text the index knows to be superseded", not "no result is ever out of date".

rerank=true asks the deployment's reranker to reorder the candidates this call already fetched; it can add up to the configured timeout. Its rows are one-line quoted objects followed by server fields (with rerank_score when reranked). The header says reranked: true or reranked: false (<reason>), and an unreranked page keeps the unreranked order; reranked: false (not_configured) means this deployment has no reranker.

The header also carries confidence: ok or confidence: low (the closest match is under the deployment's threshold; ok when none is set).

Args: query: Natural language description of what you're looking for. limit: Maximum number of distinct notes to return (default 15). folder: Optional folder prefix (e.g. "Projects/"). tags: Optional list of tag names; only notes carrying ALL listed tags match (e.g. ["product"]). frontmatter: Optional dict of frontmatter key/value pairs; strict type matching — string "0" does not match integer 0 (e.g. {"status": "active"}). rerank: Reorder the fetched candidates with the deployment's reranker (default false).

create_noteA

Create a new markdown note in the Obsidian vault. Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope.

See get_vault_guide for Obsidian syntax and any vault-specific conventions (naming, folder placement, frontmatter, tags).

Refuses a path whose final component is a symlink, naming its target, so a write never lands on a note other than the one named; symlinked folders inside the vault work normally.

The note is published no-clobber: the content is staged out of sight and linked into place in one kernel-atomic step, so an existing file at path can never be replaced by this tool. A vault filesystem that cannot stage an unnamed file refuses the write with an error naming VAULT_ALLOW_NAMED_STAGING_FALLBACK rather than staging under a visible name.

expected_hash — binding a write to the bytes you read. Optional; omit it and nothing changes. Pass the content_hash a read returned, verbatim and canonical (sha256:<64 lowercase hex>), and the call is refused with nothing written if the file changed in between. It is always the whole file's hash, never a hash of the text you received. Two windows, both live: expected_hash covers your read → this call's read, the server's own pre-publication compare covers this call's read → its publication, and a match on the first does not exempt the second. Every refusal 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.

edit_noteA

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.

get_vault_guideA

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.

get_backlinksA

Notes that link TO path. Use this to discover what references a given note — projects citing a card, daily notes mentioning a person, etc.

Resolved links only (dangling references are not counted as backlinks).

Args: path: Vault-relative path to the target note (e.g. "Cards/Foo.md"). limit: Maximum results (default 50, hard cap 500).

get_linksA

Outgoing links from path — both resolved and dangling.

Useful for "what does this note depend on?" or finding broken references that need follow-up notes.

The result carries a truncated field. truncated: true means this note holds more links than the indexer's per-note cap and the list is the first N in document order only — treat it as incomplete rather than as the note's full outgoing-link set.

Links come back in document order and are capped by limit; when more rows exist the result says how many of them were persisted, so a partial page is never read as the whole set. limit raises the page up to a hard cap of 500 — this tool has no paging beyond that, so a note with more than 500 link rows can only be read in full from the note itself.

Each row's link text is clipped to 120 characters.

Args: path: Vault-relative path to the source note. limit: Maximum links returned (default 100, hard cap 500).

get_neighborhoodA

The connected subgraph reachable from path via links or backlinks, up to depth hops (treated as undirected).

Use this when an agent needs the local cluster around a topic — e.g. "summarize everything connected to this project". Prefer this over find_related when explicit links are the signal you want; prefer find_related when the connection is conceptual rather than linked.

Args: path: Vault-relative path to the seed note. depth: Maximum BFS depth (default 1, capped at 5). limit: Maximum distinct neighbor notes (default 50, hard cap 200).

find_relatedA

Semantically similar notes based on the source note's chunk embeddings, averaged then queried via pgvector.

Independent of the link graph — useful when the source is sparsely linked or when looking for thematic neighbors. For link-based exploration use get_neighborhood. For arbitrary topic queries use semantic_search.

Carries semantic_search's per-row markers and its header counts: stale: true means that neighbour changed after it was embedded, so it was ranked against its previous content and its preview is withheld — read_note returns the current text. embedding_truncated: true means only that note's head was embedded, so a match against it says nothing about its tail.

One marker is about the source rather than a neighbour. If the note you asked about has itself changed since it was embedded, the result says so once, at the top — and it says so on the empty result too. The query vector is the average of the source's stored chunk vectors, so a stale source means every neighbour answers a question about content that note no longer has, and "no related notes" from a stale source means "nothing is near what this note used to say", not "this note has no neighbours". The next embed pass repairs it. A source with no vectors at all is a different message ("has not been embedded yet") with a different fix.

The bound on the staleness signal: it reports what the index has committed, so an edit the indexer has not yet scanned is not marked.

Section locator. A fresh row names the section it matched on its result line: § "<heading path>" (#N) for a heading's section, or § (top of note) for text before the first heading. read_note(path, section="#N") reads exactly that section. The quoted path is note text; the (#N) after it is the server's. The locator is omitted on stale rows and on rows not yet re-embedded since heading-aware chunking. While any of your notes is not yet re-chunked, the line after the header reads index_transition: N notes not yet re-chunked; …: ranking across the two generations is approximate, so re-run once it is gone. Previews are one line.

Args: path: Vault-relative path to the source note. limit: Maximum results (default 10, hard cap 50).

find_orphansA

Notes with zero incoming AND zero outgoing resolved links — useful for vault hygiene ("what's disconnected?") and cleanup decisions.

Args: folder: Optional vault-relative folder prefix to scope the search (e.g. "Cards/"). limit: Maximum results (default 50, hard cap 500).

move_noteA

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

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.

delete_noteA

Delete a note from the vault. Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope.

By default this is a soft-delete: the file is moved to .trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex> inside the vault root, by a single non-replacing rename, so an existing trash entry is never overwritten and two deletes in the same second land on distinct names. The indexer skips dot-prefixed directories, so search and embeddings drop the note automatically on the next reindex pass (≤ 5 minutes). Soft-deleted files accumulate in .trash/ — emptying that directory is the user's responsibility.

A vault filesystem that cannot perform that non-replacing rename into .trash/ makes the soft delete refuse with an error naming the limitation rather than fall back to a rename that could overwrite; pass permanent=True to unlink instead.

With permanent=True, the file is unlinked directly with no recovery path inside this server. Existing backups are the rollback story.

A path whose final component is a symlink is refused, naming its target, so a delete never removes a note other than the one named; symlinked folders inside the vault work normally.

Dangling backlinks left behind by a delete are surfaced via get_backlinks and find_orphans. See get_vault_guide for context.

expected_hash — binding a write to the bytes you read. Optional 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.

set_frontmatterA

Mutate a note's YAML frontmatter without touching its body. Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope.

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.

read_fileA

Read any file in the vault — including non-markdown (PDFs, images, skill HTML/JS, data files). Peer to read_note, which stays markdown-only.

This is pure byte transport: the server does NOT extract or parse PDFs and cannot interpret binary bytes. Non-text/non-image files come back as an opaque base64 string intended for a client-side skill to decode — not as something the model can read directly.

Encoding:

  • "auto" (default): text-like files (HTML, JSON, CSV, source, …) return as readable text; images (PNG/JPEG/GIF/WebP) return as an inline image block that renders in-client; everything else returns as a labeled base64 string.

  • "text": force a UTF-8 text decode; errors if the file is not valid UTF-8.

  • "base64": force a raw-bytes base64 string regardless of type.

Files larger than MAX_FILE_READ_BYTES (default 10 MB) are refused with a size report. Base64 reads pass through the model context and inflate ~33%, so they are token-heavy — check a file's size with list_files before reading large binaries. Any path with a component starting with . is rejected — dot-directories (.obsidian, .git, .trash, …) and dot-files alike — as is path traversal.

Text results are additionally capped to a context-safe size: the cap bounds the returned window, and a truncated read appends a short notice carrying the offset to continue from. Base64 and image results are not windowed.

The base64 header and hash_only=True return the whole raw file's content_hash (sha256:<64 lowercase hex>), which write/delete tools accept as expected_hash. Their path is a quoted JSON string. Text stays deliberately unenveloped. Use base64 for byte-exact frontmatter bytes; read_note.frontmatter_yaml has normalized line endings.

Encoding is validated first, then hash_only/window compatibility, then ranges. hash_only refuses offset != 0 or any non-None limit; explicitly passing offset=0 is fine. A valid encoding has no effect in this mode.

Args: path: Vault-relative path to the file (e.g. "Reference Docs/spec.pdf"). hash_only: Return only path, byte count, MIME and hash, with no content. encoding: One of "auto" (default), "text", or "base64". offset: Character offset to start a text read from (default 0). Use the value the truncation notice reports to continue. limit: Maximum characters to return for a text read. Only lowers the server cap; it cannot raise it.

write_fileA

Write a file into the vault — including non-markdown (e.g. save a generated PDF or image). Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope. Peer to create_note/edit_note, which stay markdown-only.

content carries the bytes: with encoding="base64" (default) it is base64-decoded to raw bytes; with encoding="text" it is written verbatim as UTF-8. The write is atomic — the bytes are staged and flushed before anything is published — missing parent folders are created, and content over MAX_FILE_WRITE_BYTES (default 25 MB, decoded length) is refused.

No-clobber by default: writing over an existing file requires overwrite=True. The default publishes by linking a staged, never-named inode into place in one kernel-atomic step, so an existing file cannot be replaced; overwrite=True publishes with a single same-directory rename instead. A vault filesystem that cannot stage an unnamed file refuses the no-clobber write with an error naming VAULT_ALLOW_NAMED_STAGING_FALLBACK, rather than staging under a visible name. Any path with a component starting with . (dot-directories and dot-files alike) and path traversal are rejected; invalid base64 errors without writing anything. A path whose final component is a symlink is refused, naming its target, so overwrite=True cannot clobber a file through an alias; symlinked folders inside the vault work normally.

The MCP transport also bounds the whole request body (sized so a base64 write at the cap always gets through). Base64 is therefore the always-safe encoding: encoding="text" content whose JSON escaping inflates past that bound is rejected by the transport with a bare HTTP 413 before this tool runs — send such content as base64 instead.

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.

list_filesA

Browse the vault filesystem (ls-style), including non-markdown files. Peer to list_notes, which lists indexed markdown only; list_files reads the filesystem directly and reports sizes so you can gauge a binary before read_file.

By default lists the immediate children of folder — subdirectories and files, each file with size and modification time. pattern is a glob that filters file entries (e.g. "*.pdf"); recursive=True descends into subfolders and returns matching files. Anything with a path component starting with . is hidden — dot-directories (.obsidian, .git, .trash, …) and dot-files — and a folder with such a component is rejected.

At most limit entries are returned (default 200, hard cap 1000); the response indicates when the listing was truncated.

Args: folder: Vault-relative folder (default "." = vault root). pattern: Glob applied to file names (default "*"). recursive: If True, descend into subfolders. Off by default. limit: Maximum entries to return (default 200, hard cap 1000).

request_uploadA

Get a short-lived link a person can use to put a file into the vault. Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope. Peer to write_file, which takes the bytes directly — use this one when you do not have them.

No MCP client can hand a tool the bytes of a file the user is looking at, and your shell cannot reach their machine. This mints a link bound to exactly one destination path: hand it to the person you are helping, they open it and pick a file, and it lands at path. Nothing else can be written with it.

The token lives in the URL's # fragment, which browsers never send to a server, so it stays out of access logs. Treat the whole URL as a secret — whoever holds it can write that one path, once, until it expires. Never put it in a query string: that would log it.

Single use, and no-clobber unless you ask otherwise. With overwrite=True the link also remembers what the file looked like now and refuses to publish if it changed in the meantime, so a stale link cannot silently undo an edit someone made while it was waiting.

From a shell you can upload without the page: curl -H "Authorization: Bearer <token>" -T <file> <base>/transfer/upload.

Then call check_upload(upload_id) to confirm the bytes landed and get their sha256. See get_vault_guide for how files fit into the vault.

Args: path: Vault-relative destination (e.g. "Attachments/photo.png"). overwrite: If True, allow replacing an existing file at path. expires_in: Seconds until the link dies. Clamped to 60–3600; defaults to TRANSFER_TOKEN_TTL_SECONDS (600). A link can never outlive the credential you are calling with, so the deadline in the result may be earlier than you asked for — it says so when that happens.

check_uploadA

Ask what happened to an upload link you minted with request_upload.

Returns one of pending (nothing sent yet), uploading (bytes are in flight), completed (with the path, size, sha256 and MIME type of what landed), unknown (a stream started and the server never recorded how it ended), revoked (the link is dead because the credential or vault root changed under it), or expired. Use it to confirm a transfer really finished before you tell the user it did, and to get the sha256 if they want to verify it.

Visibility is scoped to the principal that minted the link, not to one credential row. For an API key that is the key itself. For OAuth it is the whole grant family behind the access token you are calling with, so a handle stays readable across the hourly token refresh that mints a new row. A different API key, a different client, or a separate approval of the same client reads as not found.

uploading names the deadline the stream has. Check again after it: past that point the answer becomes either completed or unknown. unknown does not mean nothing arrived — a publish can succeed and still fail to record its completion — so read or list the path before minting another link or telling anyone the file did not arrive.

Pass the upload_id itself — the short handle from request_upload, not the upload URL and not the token after the #. Anything else is refused without a lookup.

Args: upload_id: The upload_id that request_upload returned.

request_downloadA

Get a short-lived link a person can use to save a vault file. Peer to read_file, which returns the bytes to you — use this one when the file is for the human, not for you.

Handy for anything read_file would waste context on or cannot render: a PDF, a large image, an archive. Reading works with a read-only key.

The token lives in the URL's # fragment, so it never reaches an access log. Treat the whole URL as a secret — whoever holds it can read that one file until it expires. Never put it in a query string.

The link is bound to the file as it is now: if it is edited or replaced, the link stops working rather than serving different content than you described. Unlike an upload link it can be used more than once, so the person can preview and then save.

From a shell: `curl -H "Authorization: Bearer " -o

Args: path: Vault-relative path of the file to share. expires_in: Seconds until the link dies. Clamped to 60–3600; defaults to TRANSFER_TOKEN_TTL_SECONDS (600). A link can never outlive the credential you are calling with, so the deadline in the result may be earlier than you asked for — it says so when that happens.

import_from_urlA

Fetch a file from a public https URL straight into the vault. Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope. Peer to write_file and request_upload — use this one when the bytes are already somewhere public.

The server does the fetching, so nothing passes through your context: a 20 MB PDF costs one tool call. Returns the path, size, sha256, MIME type and the final URL after any redirects.

Only genuinely public addresses. This server sits on a private network next to a database and other services, so the fetch is restricted: https only — plain http is refused unless the operator has set IMPORT_ALLOW_HTTP=true — no credentials in the URL, no private/loopback/link-local/metadata addresses in any spelling, and the same rules re-checked at every redirect. A refusal names the rule that was violated — that is information about the URL, not a hint to work around it. Rewriting the URL to evade the check is never the right next step; ask the user for a public link instead.

Size-capped at MAX_FILE_WRITE_BYTES, with one 30-second deadline for the whole fetch. No-clobber unless overwrite=True. Nothing is written unless the whole body arrives intact.

This tool shares the transfer tools' preflight, so it also refuses when the server has no public origin configured (MCP_HOSTNAME or BASE_URL), even though it mints no link — that is an operator setting, not something to work around.

Args: url: Public https URL of the file, 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.

delete_fileA

Delete a non-markdown file from the vault. Requires write permission — a readwrite API key, or an OAuth token carrying the readwrite scope. Peer to delete_note, which stays markdown-only.

By default this is a soft delete: the file moves to .trash/<YYYYMMDD-HHMMSS>-<basename>-<8 hex> inside the vault, keeping a copy the user can recover. Two files with the same name deleted in the same second both survive — the trash never clobbers.

With permanent=True the file is unlinked outright and this server has no recovery path; the user's backups are the only rollback.

Refuses markdown files (use delete_note, which understands the index and backlinks), directories, and symlinks. Non-markdown files are not indexed, so search and embeddings are unaffected either way.

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.

list_tasksA

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.

get_changesA

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

Keyword and semantic search fused into one ranking, for a query that is not clearly an identifier or a paraphrase.

Exact identifiers, code symbols, titles or known phrases: keyword_search. Conceptual or paraphrased questions: semantic_search. Unsure which: hybrid_search.

Both arms run for the same query and filters in one database snapshot: the keyword_search ranking and the semantic_search ranking, each taken to its top 50. They are fused by reciprocal rank fusion over note paths with equal votes: a note's score is the sum, over the arms that found it, of 1/(k + rank) (k is the server's HYBRID_RRF_K, shown as rrf_k). A note outside both top-50 lists is not considered; the header's arm_depth: keyword n/50, semantic c/50 chunks says when an arm was full. Fusion helps when the query's kind is unclear, and a single arm can rank better than it on its own kind of query (an exact identifier, or a pure paraphrase); use that arm's tool then.

One line per result: a JSON object of the note-controlled fields {"title","path","tags","preview_kind","preview"} (every value a JSON string, so note text cannot forge a field), then the server fields: score, semantic_rank (with similarity) or -, keyword_rank (with match and matched_in) or -, and explain (the 1/(k+rank) terms that make the score). preview_kind is snippet (a keyword highlight in «»), head, chunk (semantic), stale_notice or none, from the arm that ranked the note better. stale: true and embedding_truncated: true mean what they mean in semantic_search, and so do the semantic arm's § "<heading path>" (#N) / § (top of note) field (read the section with read_note(path, section="#N")) and the index_transition: line under the header. A long response drops whole rows from the end and says how many; a first row that does not fit is cut and labelled. Call read_note on a path for the full note.

The header always says arms:. arms: both is a fused answer. arms: keyword-only (semantic unavailable: provider_error | provider_input_rejected) means the embedding provider failed or refused the query: the page is the keyword arm alone, in keyword order, and semantic_search will fail the same way until the provider recovers.

rerank=true asks the deployment's reranker to reorder the candidates this call already fetched; it can add up to the configured timeout. Its rows are one-line quoted objects followed by server fields (with rerank_score after them when reranked). The header says reranked: true or reranked: false (<reason>), and an unreranked page keeps the unreranked order; reranked: false (not_configured) means this deployment has no reranker.

Every page's header also says confidence: ok or confidence: low: low means no note matched all the keyword terms and the closest semantic candidate is under this deployment's similarity threshold (a line then suggests keyword_search or get_neighborhood), and ok is also what a deployment that set no threshold says, so it is not a quality claim.

Args: query: What you are looking for — words, a phrase or a question. limit: Maximum number of results (default 10, at most 50). folder: Optional folder prefix (e.g. "Projects/"). tags: Optional list of tag names; only notes carrying ALL listed tags match. frontmatter: Optional dict of frontmatter key/value pairs; strict type matching — string "0" does not match integer 0 (e.g. {"status": "active"}). rerank: Reorder the first 50 fused candidates with the deployment's reranker (default false).

Prompts

Interactive templates invoked by user choice

NameDescription

No prompts

Resources

Contextual data attached and managed by the client

NameDescription

No resources

TDQS

A4/5.0

Scored across 28 tools

Disambiguation4/5

The set has many neighboring tools (keyword/semantic/hybrid search, find_related vs get_neighborhood, note vs file operations), but descriptions explicitly distinguish their purposes and route uncertain cases. A few pairs still require careful reading, especially list_notes vs get_recent and the several search modes, so it is not perfectly unambiguous.

Naming Consistency5/5

All tool names are consistently snake_case and almost all follow a clear verb_noun pattern: get_tags, read_note, create_note, edit_note, delete_note, move_note, write_file, list_files, import_from_url, and so on. The few non-standard forms like get_recent and find_orphans still fit the same readable convention.

Tool Count2/5

At 28 tools this is a heavy surface for a single MCP server, exceeding the range where each tool easily stays top-of-mind for an agent. The domain is broad enough that most tools have distinct jobs, but the count still pushes into over-large territory.

Completeness4/5

The surface covers the core vault lifecycle well: note and file CRUD, search, links, backlinks, tags, tasks, change feeds, and transfer/import workflows. Minor gaps remain around folder management and moving non-markdown files, but agents can work around them with existing write and list tools.

Maintenance

ActivityActive
ResponsivenessResponsive