Skip to main content

notes-vault-mcp

An MCP server for a vault of markdown notes — the kind Obsidian keeps: a folder of .md files with YAML frontmatter. The vault lives either in a local directory or in an S3 bucket (MinIO included), and the server gives an agent a cheap, indexed way to read and write it.

The point is that an agent should be able to answer "what do we already know about this?" in one call, and should be told when the vault has drifted away from the code. So the server does more than read and write files:

  • A local SQLite index. Every tool call refreshes it, fetching only the notes whose version changed. Search never downloads the vault.
  • Full-text search with BM25 ranking, folder weights, recency decay and a status factor, so the living system note outranks a two-year-old archived plan on the same words.
  • A schema. The frontmatter contract lives in the vault as .vault/schema.yml: which folders exist and what each is for, which fields are required, which statuses and kinds are legal, which folders must link an area. Writes are validated against it and refused when they do not hold.
  • A lifecycle. close archives a finished note and stamps its status; log_append writes one dated line per repo per session; lint reports every kind of drift it can see.
  • Session hooks for Claude Code: session-start hands the agent the system notes for the repo it is about to touch — plus the commits made since each note was last updated — and stop refuses to end a session that left commits unlogged or notes stale.

Swedish or English notes both work: the index folds diacritics, stems every word in the vault's languages so äppelpressar finds äppelpressen and pressing finds presses, and the schema carries a synonym list so bokning finds booking.

Install

As a Claude Code plugin

claude plugin marketplace add https://github.com/gronare/claude-plugins
claude plugin install vault@gronare

The plugin asks for the vault settings and passes them as CLAUDE_PLUGIN_OPTION_* environment variables, which this server reads as if they were the bare names.

As an MCP server, straight from PyPI

claude mcp add vault -s user \
  -e VAULT_PATH=$HOME/vault \
  -- uvx notes-vault-mcp

Or against S3 / MinIO:

claude mcp add vault -s user \
  -e S3_ENDPOINT=https://minio.example.com \
  -e S3_ACCESS_KEY=... \
  -e S3_SECRET_KEY=... \
  -e S3_BUCKET=vault \
  -- uvx notes-vault-mcp

As a container

claude mcp add vault -s user -- \
  docker run --rm -i \
  -e S3_ENDPOINT -e S3_ACCESS_KEY -e S3_SECRET_KEY -e S3_BUCKET \
  ghcr.io/gronare/notes-vault-mcp:latest

Configuration

Every variable is also read from CLAUDE_PLUGIN_OPTION_<NAME>, which is how the Claude Code plugin passes its user config. The bare name wins when both are set.

Variable Required Meaning
VAULT_PATH for a local vault Directory holding the vault. Selects the local backend.
S3_ENDPOINT for an S3 vault Endpoint URL, e.g. https://minio.example.com.
S3_ACCESS_KEY for an S3 vault Access key.
S3_SECRET_KEY for an S3 vault Secret key.
S3_BUCKET for an S3 vault Bucket holding the vault.
S3_PREFIX no Key prefix inside the bucket.
S3_REGION no Region, default us-east-1.
VAULT_CACHE_DIR no Where the index lives, default ~/.cache/notes-vault-mcp.
VAULT_SCHEMA no Local path to a schema file, overriding the one in the vault.
VAULT_TOKEN for --auth bearer Static bearer token for the LAN mode of --transport http.
VAULT_ALLOWED_HOSTS no Extra Host header values the server answers on, space or comma separated.
VAULT_STOP_HOOK no off disables the stop hook.

Set VAULT_PATH or the four S3_* variables. With neither, the server exits with one line saying so.

First run

uvx notes-vault-mcp init

init writes into the vault, and refuses to overwrite anything without --force:

  • .vault/schema.yml — the frontmatter contract, copied from the built-in default so you can edit it.
  • Areas.base, Open tasks.base, Resources.base, Backlog.base, Log.base — Obsidian Bases views over the same structure.

It then prints a CLAUDE.md snippet to stdout: the workflow rules an agent needs on its side of the conversation.

The schema

.vault/schema.yml is deep-merged over the built-in default, so it only needs to carry what differs. The default lays out five folders:

Folder Kind Weight Role
Areas/ system 3.0 One living note per system. Current state only. The hubs of the graph.
Resources/ reference 2.0 Traps, how-tos and decisions with their reasons.
Projects/ task 1.0 Open work spanning sessions. Closed with close.
Log/ log 1.0 Append-only log per repo, one note per repo.
Archive/ archive 0.3 History. Searched only on request.

and the contract for a note:

frontmatter:
  required: [title, date, updated, tags, status]
  optional: [kind, area, summary, path, superseded_by]
  area_required_in: [Projects, Resources, Log]
  status_values: [draft, active, complete, superseded]
  kind_values: [system, task, trap, howto, decision, reference, log]

Each kind carries a one-line rule saying when a note is that kind, and the server puts the rules in its instructions so every client picks kinds the same way:

kinds:
  trap: "a gotcha that bit once and will bite again; nothing to do, nothing was chosen"
  decision: "a choice and its reasons; something was chosen, alternatives were dropped"
  howto: "steps that are known to work; nothing chosen, nothing open"

A vault adds a kind by adding an entry (research: "reading notes on a topic; no task, no decision"), rewrites a rule by restating it, and drops a kind by setting it to null. kind_values alone, without rules, still works.

path is what ties a note to code: a comma-separated list of directories (~ is kept as written and also indexed expanded). That is what context and the session hook match against.

The repo log is one note per repo, and both its filename and its line format are schema settings:

log:
  folder: Log
  file_format: "{repo}-log.md"
  entry_format: "- [{date}] {line} | commits: {commits} | {area}"

file_format takes a single {repo} placeholder, and the default suffix is what keeps the log clear of the hub note: with Areas/myapp.md and Log/myapp.md both in the vault, Obsidian cannot resolve [[myapp]]. Every place that builds the log path reads this setting — log_append, the log tail in context, the stop hook's unlogged-commit check, changelog and lint — so changing it moves all of them at once. Rename the existing files to match when you change it.

language is the vault's primary language and other_languages the rest, as ISO codes (language: sv, other_languages: [en]); search stems in every one Snowball supports, and changing them rebuilds the index. When nothing matches, the reply tells the client which languages the vault is written in so it can translate the terms and search again; a partial result says the same in one line. A stemmed-only match weighs stemmed_weight (0.5 by default, below the body's 1.0), so the exact form ranks first.

Also configurable: the tag vocabulary and whether it is enforced, the synonym groups search expands, stale_after_days, and the search weights.

Tools

Every call refreshes the index first, throttled to at most once every 20 seconds.

Tool Cost What it does
search cheap Full-text over the index. File stem, title, summary, tags and body, with synonyms, prefixes, stemming, quoted phrases and folded diacritics. A query that is exactly a note's stem (or [[stem]]) puts that note first, even from the archive. Every term is required; when no note has them all, it returns the notes matching the most terms and the header says the result is partial. A bare commit sha finds the notes that mention it. Hides archive and superseded notes and says how many.
context cheap The session-start call: the system notes covering a path, the open tasks, a triage list of open notes older than stale_after_days with the three ways to settle each, the backlog, the reference notes and the tail of the repo log, in one answer.
list_files cheap Paths only.
read_file moderate One note, prefixed with etag: <version>. A superseded note carries a warning callout.
obsidian_access write --auth forwarded only. Mints the personal token Obsidian's Remotely Save plugin uses over WebDAV and shows it once; a new token revokes the previous one.
lint moderate Reads every note and reports drift.
init write Sets a vault up: writes .vault/schema.yml and the Obsidian Bases views into the root and keeps what exists; force overwrites. The plugin's /vault:init calls it.
write_file write Validates against the schema and refuses the write if it does not hold. Stamps updated, fills date. Pass expected_etag to make the write conditional. Files that are not notes (.vault/schema.yml, .base views) are stored verbatim.
append_file write Appends and bumps updated. Creates the note when missing.
set_status write Changes a note's status without rewriting it: backlog with a priority parks it, active picks it up again.
close write Sets status complete (or superseded, with superseded_by, when merged_into is given) and moves the note into the archive.
log_append write One dated line in the repo log, with the commits it produced. Creates the log when missing.
move_file write Moves or renames.
delete_file write Deletes for good. Prefer close.

search filters: folder, status, tag, kind, area, path_prefix, since, include_archive, include_superseded, limit.

What lint reports

broken_frontmatter, missing_required (per field), missing_area, unknown_tags (only when the vocabulary is strict), unresolved_links, orphans (no inbound wikilink; log and archive ignored), stale_active, archive_status_mismatch, duplicate_stems, superseded_target_missing. With --park-stale the open task notes untouched for longer than stale_after_days are set to backlog (priority low, source veckolint <date>) before the report and listed under parked, so active always means touched within the window; reviving one is set_status(path, "active").

uvx notes-vault-mcp lint
uvx notes-vault-mcp lint --write "Log/lint-$(date +%F).md"
uvx notes-vault-mcp lint --write Log/lint.md --park-stale

Hooks

Two Claude Code hooks, both reading the hook JSON on stdin and both exiting 0 whatever happens.

session-start prints the context bundle for the working directory, then — for each system note it returned — the commits touching that note's path since the note was last updated. That is the answer to "is this note still true?" before the agent believes it.

stop blocks the end of a session that left work unrecorded: commits from the last 24 hours whose sha does not appear in the repo log, and open task notes older than the schema's stale_after_days that the session read or wrote (found through the vault tool calls in the session transcript). It returns {"decision": "block", "reason": ...}, or nothing at all when the vault is up to date. Stale notes the session did not touch are the next session's business: context lists them under triage with the three ways to settle each (close, a dated line, or park in the backlog), not at every stop. Set VAULT_STOP_HOOK=off to silence the hook.

{
  "hooks": {
    "SessionStart": [
      { "hooks": [{ "type": "command", "command": "uvx notes-vault-mcp hook session-start" }] }
    ],
    "Stop": [
      { "hooks": [{ "type": "command", "command": "uvx notes-vault-mcp hook stop" }] }
    ]
  }
}

Other commands

notes-vault-mcp serve --transport stdio          # the default
notes-vault-mcp sync --rebuild                   # drop the index and read every note again
notes-vault-mcp search "bokning" --limit 5       # the same ranking, from a shell
notes-vault-mcp changelog myapp 2026-08 --repo-path ~/projects/myapp
notes-vault-mcp changelog myapp 2026-08 --repo-path ~/projects/myapp --write
notes-vault-mcp changelog --all                  # this month (and last month during its first week)

changelog prints the log lines, the git commits grouped by day, and the repo's notes dated inside the period. With --write it keeps that as a period page, Log/<repo>-<period>.md, between the markers <!-- changelog:generated --> and <!-- /changelog:generated -->; prose above the markers (a summary written by an agent at month end) is left alone, and the page's status follows the calendar. --all does it for every repo the session-start hook has seen on this machine, and the stop hook runs that once a day, so the pages stay current without a cron.

Backlog

notes-vault-mcp backlog --area myapp --priority high

A backlog item is a task note with status: backlog, an area, a one-line summary, an optional priority (urgent, high, medium, low) and an optional source (who said it and when, or a sha). backlog_add files one from a conversation the moment something is deferred; backlog lists them by priority then age, filtered by area or family; context shows the ones relevant to the current repo apart from the open tasks. Picking an item up is setting its status to active; finishing it is close. Lint leaves backlog notes alone however old they get, and flags a complete note that was never closed. init writes Backlog.base next to the other Obsidian bases.

HTTP transport

VAULT_TOKEN=$(openssl rand -hex 32) notes-vault-mcp serve --transport http --host 0.0.0.0 --port 8765

Streamable HTTP on /mcp, with four ways to authenticate: --auth bearer (the default), --auth oidc, --auth builtin and --auth forwarded.

bearer is the LAN mode. Every request must carry Authorization: Bearer $VAULT_TOKEN; anything else gets 401 before it reaches the server. VAULT_TOKEN is mandatory in this mode — the command refuses to start without it. It is a single token with full access, and claude.ai cannot use it.

The Host header follows what the server listens on. Bound to localhost, only localhost, 127.0.0.1 and [::1] are answered, anything else gets 421; bound to another address, every Host is answered. To keep the check on while serving a name of your own, a Kubernetes Service or a LAN hostname, list it in VAULT_ALLOWED_HOSTS or set VAULT_PUBLIC_URL: then that name and localhost are answered and the rest get 421.

oidc and builtin speak OAuth, which is what a Claude connector needs. See Remote: claude.ai, Claude Desktop and mobile.

forwarded is for a server that sits behind a proxy which already did the OAuth: it trusts a signed identity header and serves one vault per subject. See Behind a proxy: one vault per person.

Remote: claude.ai, Claude Desktop and mobile

Run the server over HTTPS with --auth builtin or --auth oidc and claude.ai can add it as a custom connector. Connect it once on the web and the same connector appears in Claude Desktop and in the mobile app.

One server instance serves one vault.

Prerequisites

  • A vault: an S3-compatible bucket with a key scoped to it, or a folder on the host.
  • Docker, or uv on the host.
  • A domain pointing at the machine, with TLS in front of the server. The examples under deploy/ put Caddy there, which fetches the certificate itself.
  • For --auth oidc: an OpenID Connect provider you already run.

The two modes

--auth builtin --auth oidc
Who logs in one owner, against a password this server holds anyone the provider admits
Client registration dynamic, nothing to configure in claude.ai dynamic when the provider supports it, otherwise a client id and secret pasted into claude.ai
Read-only clients two checkboxes on the login page group membership
State on disk auth.sqlite under VAULT_AUTH_DIR none

Environment

Variable Mode Meaning
VAULT_PUBLIC_URL oidc, builtin Required. The https address clients reach, path included when the server is not at the root. No default: the server refuses to start without it, and refuses anything that is not https:// (or http://localhost for a local trial).
VAULT_AUTH_DIR builtin Where auth.sqlite lives — the owner's password hash, the registered clients, the hashed tokens. Default ~/.cache/notes-vault-mcp/auth.
VAULT_OIDC_ISSUER oidc Required. The provider's issuer URL.
VAULT_OIDC_AUDIENCE oidc The client id the provider puts in aud. Checked when set, ignored when empty.
VAULT_OIDC_READ_GROUP oidc Group granting vault:read. Default vault.
VAULT_OIDC_WRITE_GROUP oidc Group granting vault:read and vault:write. Default vault-writers.
VAULT_OIDC_SCOPES oidc Scopes the resource metadata advertises, so the client asks the provider for scopes it knows. Default openid profile email groups. When the access token carries no groups, the server asks the provider's userinfo endpoint.
VAULT_TOKEN bearer The static token.
VAULT_ALLOWED_HOSTS all Extra Host header values the server answers on, space or comma separated, for a Service name or a LAN hostname next to the public one.
VAULT_CACHE_DIR all Where the index lives. Default ~/.cache/notes-vault-mcp.
S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY all The vault, unless VAULT_PATH names a local folder.

The MCP endpoint is VAULT_PUBLIC_URL + /mcp. That is the URL you paste into claude.ai.

Scopes

  • vault:read — search, read_file, list_files, context, lint, backlog.
  • vault:write — write_file, append_file, move_file, delete_file, close, log_append, backlog_add.

A write tool called with a read-only token fails with an error saying the token may only read the vault, rather than half-writing or silently doing nothing.

Built-in login, from zero

cd deploy/caddy-builtin
cp .env.example .env
$EDITOR .env
docker compose up -d
docker compose exec vault notes-vault-mcp owner set-password

Then, in claude.ai: Settings → Connectors → Add custom connector → the /mcp URL, e.g. https://vault.example.com/mcp. Nothing else is needed: the server registers the client itself. The browser lands on the server's login page, which asks for the owner password and shows two checkboxes, read and write. Clear write to hand out a read-only connector.

What has been connected, and how to disconnect it:

docker compose exec vault notes-vault-mcp tokens list
docker compose exec vault notes-vault-mcp tokens revoke <client_id>

The password hash and the tokens live in auth.sqlite in the vault-data volume. Back that volume up; losing it means every client has to connect again.

OIDC, from zero

cd deploy/caddy-oidc
cp .env.example .env
$EDITOR .env
docker compose up -d

The server is a resource server here: it validates the provider's access tokens and never sees a password. A user in the write group gets vault:read and vault:write, a user in the read group only vault:read, and anyone in neither is refused.

Add the connector in claude.ai the same way. With a provider that supports dynamic client registration you are done. With one that does not (Pocket ID, Authentik), register the client in the provider first and paste its client id and secret under the connector's Advanced settings; the redirect URI to allow in the provider is the one claude.ai shows in that dialog.

Pocket ID

claude.ai sends the MCP resource (the /mcp URL) as an OAuth resource parameter, and Pocket ID only accepts a resource it knows as an API. So:

  1. Settings → APIs → Add API: a name and the resource identifier, exactly VAULT_PUBLIC_URL + /mcp. Add two permissions: key vault:read and key vault:write, with the names your family will see on the consent screen.
  2. Settings → User Groups: create vault (read) and vault-writers (write) and put users in them.
  3. Settings → OIDC Clients → Add: name claude.ai, callback URL https://claude.ai/api/mcp/auth_callback, PKCE on, public client off, allowed user groups vault. Under API access, grant the API with both permissions (user-delegated). Copy the client id and secret.
  4. On the server: VAULT_OIDC_ISSUER is Pocket ID's base URL, VAULT_OIDC_AUDIENCE is the resource identifier from step 1 (Pocket ID puts it in aud), and VAULT_OIDC_SCOPES is openid profile email groups vault:read vault:write.
  5. In claude.ai, add the connector with the client id and secret under Advanced settings.

The user's groups decide what a token may do; the client's permissions only matter when the provider reports no groups at all, in the token or through userinfo.

Kubernetes

deploy/k8s/ holds plain manifests: a Deployment with a PVC mounted at /data, a Service, an Ingress and an example Secret.

cp deploy/k8s/secret.example.yaml secret.yaml
$EDITOR secret.yaml
$EDITOR deploy/k8s/ingress.yaml
kubectl apply -f secret.yaml -f deploy/k8s/deployment.yaml -f deploy/k8s/service.yaml -f deploy/k8s/ingress.yaml

The Deployment runs --auth builtin; for OIDC change the --auth argument and fill the VAULT_OIDC_* keys in the Secret. The readiness probe hits /.well-known/oauth-protected-resource/mcp, the one route that answers without a token in both modes; when VAULT_PUBLIC_URL carries a path, that path sits in the probe URL too. Set the owner password once the pod is up:

kubectl exec -it deploy/notes-vault-mcp -- notes-vault-mcp owner set-password

Fill in ingressClassName and point secretName at a TLS certificate — an existing secret, or one cert-manager issues.

Behind a proxy: one vault per person

--auth forwarded is for an organisation that already runs an OAuth authorization server in front of its MCP servers and forwards the caller's identity as a signed token in a header (the pattern Cloudflare Access uses with Cf-Access-Jwt-Assertion). The server verifies that token with the proxy's public key, takes the subject from it, and serves that subject's own vault: a prefix per person in one bucket (or a folder per person under VAULT_PATH), with its own index, its own .vault/schema.yml and a welcome note, all created on the first request. Nothing is provisioned by hand; granting access at the proxy is the whole onboarding.

notes-vault-mcp serve --transport http --host 0.0.0.0 --port 8765 --auth forwarded

The contract with the proxy: a request without a valid identity gets 403 with no WWW-Authenticate challenge (advertising an authorization server here would point clients past the proxy), the transport is stateless with plain JSON responses so a buffering proxy needs no Mcp-Session-Id, the Host header is not checked, and /up answers ok without a token for readiness probes. The OAuth metadata routes are not served in this mode; the proxy owns them.

Variable Meaning
VAULT_PUBLIC_URL Required. The address clients reach the server on; its hostname is the aud the token must carry, its path (if any) prefixes every route.
VAULT_IDENTITY_PUBLIC_KEY Required. The proxy's PEM public key (RS256 or ES256), raw or base64 on one line.
VAULT_IDENTITY_ISSUER Required. The iss in the proxy's tokens.
VAULT_IDENTITY_HEADER The header carrying the token. Default X-Forwarded-Identity.
VAULT_IDENTITY_TYP When set, the token's typ must match.
VAULT_IDENTITY_WRITE_CLAIMS Entitlement names (in the token's entitlements list or scope) that grant vault:read and vault:write. Default vault:write.
VAULT_IDENTITY_READ_CLAIMS Entitlement names that grant vault:read only. Default vault:read. A token with neither gets 403 insufficient_scope.
VAULT_SUBJECT_PREFIX Where the vaults live under the bucket or folder: <prefix>/<sub>/. Default users.
VAULT_AUTH_DIR Where personal.sqlite keeps the hashed personal tokens.
VAULT_CACHE_DIR One SQLite index per subject lives here; give it a volume.

The token needs iss, aud, sub and exp; email and name are used when present. Subjects that are not plain identifiers are hashed before they name a folder.

Obsidian through the same server

Each vault is also served over WebDAV at VAULT_PUBLIC_URL/dav/, for Obsidian's Remotely Save plugin, which cannot do OAuth. The person asks Claude to run obsidian_access; the server mints a personal token, returns it once and stores only its hash. Remotely Save gets the /dav/ address, any username (the email is the convention) and that token as the password. Running obsidian_access again rotates the token. The proxy must pass /dav/* straight through, with the client's Authorization header and the WebDAV verbs and headers (PROPFIND, MKCOL, MOVE, COPY, Depth, Destination, Overwrite) intact. The welcome note written on the first request carries these instructions for the person.

Development

uv sync
uv run pytest
uv run ruff check .

The test suite runs against a fixture vault under tests/fixtures/vault/ and a moto-mocked S3 bucket. It never touches a real bucket.

License

MIT. See LICENSE.

Metadata

Release files for notes-vault-mcp 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for notes-vault-mcp 0.5.0
File Size Uploaded
notes_vault_mcp-0.5.0.tar.gz 177.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for notes-vault-mcp 0.5.0
File Interpreter ABI Platform
notes_vault_mcp-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 254.8 kB

Release files / notes_vault_mcp-0.5.0.tar.gz

Download URL notes_vault_mcp-0.5.0.tar.gz
Size 177.2 kB
Tags Source
SHA-256 checksum
How to use checksums
57e13f7bac26ab7ffe2c792335d32e4a0068d50635f9cc2cd6c1c41276483322
BLAKE2b-256 checksum
How to use checksums
1d0a3c7d3863439b55d6839e5fc90401d145644e7d83a13995591fde132e8c70
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.

Transparency log

Release files / notes_vault_mcp-0.5.0-py3-none-any.whl

Download URL notes_vault_mcp-0.5.0-py3-none-any.whl
Size 77.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0cfeb4b239f0cfe0ac9b4a40d525de0f082724f0b45b392a4b168aebf7198fb4
BLAKE2b-256 checksum
How to use checksums
cafd0f9502fe44e5c9b6e3157f496450536add1f76a698a65047a79ccae4c9bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.

Transparency log

Release history Release notifications | RSS feed

0.5.1

2 release files

This release

0.5.0 This release

2 release files

0.4.5

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page