A self-hosted, single-container notes & file workspace with flat-file storage, per-Space Git history, Magic-Link sharing, passkey auth, and a native Model Context Protocol server for Claude Code / Cursor.
Most note tools force you to pick a side: either a SaaS that owns your data, or a self-hosted thing that's a pain to back up. notation keeps everything in plain files under a Docker volume, versions every Space with git, lets you share individual workspaces with permissions, and exposes a Model Context Protocol endpoint so Claude Code (or Cursor, or any MCP-speaking client) can edit your notes directly — all behind a single binary.
┌───────────────────────┐ ┌──────────────────────────┐
│ Browser (admin SPA) │ │ Claude Code / Cursor │
│ Magic-link visitors │ │ (MCP over HTTP+JSON-RPC)│
└──────────┬────────────┘ └────────────┬─────────────┘
│ │
└──────────────┬───────────────┘
▼
┌──────────────────────┐
│ notation container │
│ Go 1.24 + Vite SPA │
└──────────┬───────────┘
▼
/data/spaces/<id>/
├── files/ ← user content (git-versioned)
│ └── .git/
└── .notation/ ← shares.json, mcp-tokens.json,
comments.jsonl, audit.log,
admin.json, server-secret
- Features
- Quick start
- Configuration
- Authentication
- Spaces & Git versioning
- Magic Links
- MCP integration
- Supported file types
- Markdown features
- Keyboard shortcuts
- Security
- Backup & recovery
- Development
- Architecture
- License
|
Each Space is a folder on disk with its own git repo. Auto-commit on save, manual snapshots, restore any past version side-by-side via Monaco diff editor. |
Monaco-based Markdown editor with toolbar, |
|
Share a Space with read, comment or edit permission. Optional expiry. Tokens hashed at rest, rate-limited, audit-logged. |
WebAuthn-first sign-in. Bootstrap token printed to stderr on first boot, single-use claim, then passkeys take over. Optional Authelia mode. |
|
Native HTTP MCP endpoint per Space. Bearer-token auth. Ships with |
Markdown, code (syntax-highlighted), images, PDF, video, audio, XLSX, DOCX, CSV — all in-browser, lazy-loaded, sanitised. |
|
Select text in the viewer → comment is pinned to the quote. Hovering the comment flashes the highlight in the document. Threaded replies. |
|
docker run -d --name notation \
-p 8080:8080 \
-v $PWD/notation-data:/data \
-e NOTATION_BASE_URL="https://notes.example.com" \
ghcr.io/atomfritte/notation:latest # or: build the image locallyWatch the logs for the bootstrap token banner, then open the URL in your browser:
docker logs notation═══════════════════════════════════════════════════════════════════
🔑 notation — admin bootstrap token (one-time, rotates per boot)
═══════════════════════════════════════════════════════════════════
Sw7Yk2-Bf3pQ8x_Lm9R4tHnGc6vAj1eUoZ_5KdMyXbN
Open https://notes.example.com and paste the token to claim the
admin account. Once you register a passkey, future restarts won't
print a token.
═══════════════════════════════════════════════════════════════════
Paste it on the Claim screen → register a passkey → you're in.
services:
notation:
image: ghcr.io/atomfritte/notation:latest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./notation-data:/data
environment:
NOTATION_BASE_URL: "https://notes.example.com"
NOTATION_TRUST_PROXY: "1" # set if behind Traefik/Caddy/nginxgit clone https://github.com/atomfritte/notation.git
cd notation
docker build -t notation:local .
docker run -d --name notation -p 8080:8080 -v $PWD/data:/data notation:localAll configuration is via environment variables. None are required for a basic local run.
| Variable | Default | Description |
|---|---|---|
NOTATION_BIND |
:8080 |
Listen address. |
NOTATION_DATA_DIR |
/data |
Where Spaces, auth records, audit logs live. Mount as a volume. |
NOTATION_BASE_URL |
(empty) | Public URL of the deployment. Used for share URLs, derives the WebAuthn rp_id, and tells the server when to mark cookies Secure. |
NOTATION_MAX_UPLOAD_BYTES |
67108864 (64 MiB) |
Per-file upload limit. |
NOTATION_COMMIT_DEBOUNCE_MS |
5000 |
Window during which subsequent saves coalesce into one git commit. |
| Variable | Default | Description |
|---|---|---|
NOTATION_SHARE_PATH |
/s |
URL prefix for the Magic-Link app + assets. Must be Authelia-bypassed if you put Authelia in front. |
NOTATION_MCP_PATH |
/mcp |
URL prefix for the MCP HTTP endpoints. Also Authelia-bypass. |
| Variable | Default | Description |
|---|---|---|
NOTATION_AUTH_MODE |
session |
One of session (notation's own passkey + cookie auth), authelia (trust Remote-User), or both (defense-in-depth). |
NOTATION_RP_ID |
derived from NOTATION_BASE_URL |
WebAuthn relying-party id (the bare host). Passkeys are bound to this — changing it invalidates all registered credentials. |
NOTATION_SESSION_LIFETIME_HOURS |
720 |
Session cookie lifetime in hours (default 30 days). |
NOTATION_TRUST_PROXY |
0 |
Set to 1 when behind a header-rewriting reverse proxy; otherwise X-Forwarded-For is ignored to prevent IP spoofing. |
NOTATION_ADMIN_HEADER |
Remote-User |
Authelia header name. Only used when AUTH_MODE includes authelia. |
NOTATION_ADMIN_GROUPS_HEADER |
Remote-Groups |
Authelia groups header. |
NOTATION_ADMIN_GROUP |
(empty) | If set, the user must be a member of this group. |
NOTATION_DEV_BYPASS_AUTH |
0 |
Do NOT set in production. Bypasses all auth, intended only for local Vite-against-backend development. |
notation works fine standalone, but a typical deployment puts it behind Traefik / Caddy / nginx for TLS termination.
# Traefik labels — adjust the hostname
labels:
- "traefik.enable=true"
- "traefik.http.routers.notation.rule=Host(`notes.example.com`)"
- "traefik.http.routers.notation.entrypoints=websecure"
- "traefik.http.routers.notation.tls.certresolver=le"
- "traefik.http.services.notation.loadbalancer.server.port=8080"If you keep Authelia (perimeter SSO) and want notation's own session layer, set NOTATION_AUTH_MODE=both. The Magic-Link path (/s/*) and MCP path (/mcp/*) must always bypass Authelia — they have their own token auth.
notation runs through a small state machine on every page load:
stateDiagram-v2
[*] --> NoAdmin: fresh /data
NoAdmin --> Claim: state.needs_claim
Claim --> PasskeySetup: bootstrap token verified, session issued
PasskeySetup --> Ready: 1+ passkey registered
Ready --> Login: session expired
Login --> Active: WebAuthn assertion verified
Active --> Ready: logout / expire
Active --> [*]: app
- On every boot, if no admin is yet claimed, a 32-byte random token is generated, hashed (SHA-256) and printed to stderr in a banner.
- The token rotates on every restart until claimed, so the most recent container log always shows the live one.
- Claim is rate-limited: 10 failures per IP locks for 1 hour. Constant-time hash compare.
- Powered by
go-webauthn(server) and@simplewebauthn/browser(client). - Discoverable-credential flow: the browser shows the system passkey picker. No usernames, no passwords.
- Multiple passkeys per admin. You cannot delete the last one (would lock you out).
- Sessions are HMAC-signed cookies (
HttpOnly,Secure,SameSite=Strict), 30-day sliding window. CSRF token lives inside the cookie payload and is verified via theX-CSRF-Tokenheader.
docker exec -it notation rm /data/.notation/admin.json
docker restart notation
docker logs notation # ← new bootstrap token in the bannerSpaces, Magic Links, MCP tokens, and audit logs are untouched.
A Space is a folder under /data/spaces/<id>/. The structure is intentionally simple:
/data/spaces/<id>/
├── files/ ← everything users see
│ ├── .git/
│ ├── note.md
│ └── attachments/
│ └── pic.png
└── .notation/ ← server-side metadata
├── meta.json
├── shares.json (hashed)
├── mcp-tokens.json (hashed)
├── comments.jsonl
└── audit.log (hash-chained)
- Auto-commit on save with a 5-second debounce window. Subsequent edits within the window become one commit.
- The git repo lives inside
files/so.notation/(secrets, audit) never enter version history. - The author of each commit identifies the source:
admin:<name>/guest:<share-id>/mcp:<token-id>. - Per-file history viewer in the admin UI: pick two commits, see a Monaco side-by-side diff; pick one, get a single-click Restore.
Click the Sharing tab in any Space → create a link with a permission level + optional expiry.
| Permission | Can read | Can comment | Can edit |
|---|---|---|---|
read |
✅ | — | — |
comment |
✅ | ✅ | — |
edit |
✅ | ✅ | ✅ |
- Tokens are 32 random bytes (base64url, ~43 chars), only their SHA-256 hash is persisted.
- Constant-time lookup across Spaces.
- Path
<NOTATION_SHARE_PATH>/<token>— Authelia-bypassed. - Per-IP rate limit (5 rps avg, 20 burst).
- Every action is appended to the Space's
audit.logwith the actorshare:<id>:<perm>.
The viewer at the share URL is the same React app as the admin's, in read-only / comment / edit mode depending on permission. Visitors get the same Markdown viewer (Mermaid, KaTeX, syntax highlight), file-type previews, anchored comments, threaded replies.
Each Space exposes an HTTP+JSON-RPC Model Context Protocol endpoint at <NOTATION_MCP_PATH>/<space-id>. Auth is Authorization: Bearer <token>. Tokens are scoped to one Space.
claude mcp add notation-myspace "https://notes.example.com/mcp/myspace" \
--transport http \
--scope project \
--header "Authorization: Bearer <token>"Or paste into .mcp.json:
{
"mcpServers": {
"notation-myspace": {
"type": "http",
"url": "https://notes.example.com/mcp/myspace",
"headers": { "Authorization": "Bearer <token>" }
}
}
}~/.cursor/mcp.json (global) or .cursor/mcp.json (project):
{
"mcpServers": {
"notation-myspace": {
"url": "https://notes.example.com/mcp/myspace",
"headers": { "Authorization": "Bearer <token>" }
}
}
}curl -X POST "https://notes.example.com/mcp/myspace" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq| Tool | Purpose |
|---|---|
list_files |
Flat list of files with size + modtime |
get_tree |
Nested directory tree as JSON |
glob |
Find paths matching **/*.md etc. |
outline |
Heading outline of a markdown file (level + line) |
read_file |
Full file contents |
write_file |
Create or overwrite (auto-commit on save) |
create_file |
Atomic create — fails if exists |
delete_file |
Remove a file |
rename_file |
Move / rename within the Space |
mkdir |
Create a directory |
search |
Case-insensitive substring search |
grep |
Regex search with ** glob + N lines of context |
git_log |
Recent commits |
git_diff |
Diff of one commit by hash |
Generate a token from the Integration tab in the admin sidebar — the modal also shows ready-to-copy snippets for Claude Code, Cursor, and a raw curl call.
⚠️ Tokens are shown once at creation. Save them; we only store the hash.
| Extension | View | Edit | Notes |
|---|---|---|---|
.md .markdown .mdx |
Markdown viewer (Mermaid, KaTeX, syntax highlight, wiki-links, anchored comments) | CodeMirror | Default for new pages |
.png .jpg .jpeg .gif .webp .avif .bmp .ico |
<img> inline |
— | Range-served |
.svg |
Download (inline disabled for security) | — | XSS-protective |
.pdf |
<iframe> browser PDF viewer |
— | |
.mp4 .webm .mov .m4v .ogv |
<video controls> with Range/seek |
— | http.ServeContent on the backend |
.mp3 .wav .ogg .m4a .aac .flac .opus |
<audio controls> |
— | |
.xlsx .xls .ods .csv .tsv |
SheetJS table view (multi-sheet tabs) | CodeMirror (CSV/TSV only) | DOMPurify-sanitised, lazy chunk |
.docx |
Mammoth → semantic HTML in .prose styling |
— | DOMPurify-sanitised, lazy chunk |
.json .yaml .ts .go .py .rs .sh .sql … |
highlight.js code view | CodeMirror | 30+ languages |
| anything else | Download link | — | Forced attachment disposition |
The viewer is the same on the admin side and through Magic Links.
- Wiki-links:
[[other-page]],[[other-page#section]],[[other-page|display text]] - Heading anchors: every heading is linkable,
rehype-slug-generated ids match what the editor's picker inserts - Mermaid: fenced code with
mermaidlanguage, lazy-loaded - KaTeX:
$inline$and$$display$$math - Syntax highlight:
rehype-highlightwith the github-dark theme - Code-block Copy button: hover any code block
- Anchored comments: select text → floating Comment button → comment pinned to the quote; hovering the sidebar comment flashes the highlight in the document
- Threaded replies: one level deep, indented
- Outline / TOC: sidebar panel with IntersectionObserver-driven active-section tracking
- Backlinks: which other files link here (backed by the
searchtool)
| Shortcut | Action |
|---|---|
Cmd / Ctrl + K |
Jump-to-page palette (fuzzy match across the Space) |
Cmd / Ctrl + Shift + F |
Full-text search modal |
Cmd / Ctrl + \ |
Toggle sidebar |
Alt + N |
New page |
Cmd / Ctrl + S |
Save current edit |
Cmd / Ctrl + B |
Bold (editor) |
Cmd / Ctrl + I |
Italic (editor) |
Cmd / Ctrl + E |
Highlight current selection (editor) |
[[ |
Open the wiki-link picker (in editor) |
Security is a first-class concern. The design assumes both multi-actor (different Magic-Link visitors, MCP clients writing files) and defense in depth.
Every file op (read_file, write_file, delete, rename, …) goes through Go 1.24's os.Root — a kernel-level sandbox that refuses path escapes via .. and won't follow symlinks out of the Space. SafeJoin still runs first for string-level validation (.., NUL bytes, dotfiles, backslashes).
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self';
media-src 'self' blob:;
worker-src 'self' blob:;
frame-src 'self';
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
object-src 'none';
- Markdown:
rehype-sanitizeruns afterrehype-raw, with a tight allowlist (<mark>,<details>,<summary>,<kbd>,<sub>,<sup>). - DOCX & XLSX: parsed client-side, then output runs through
DOMPurifywith explicit tag/attribute allowlists before anydangerouslySetInnerHTML.
- Bootstrap token: 32-byte CSPRNG, SHA-256 stored, constant-time compare, rate-limited, lockout after 10 failed attempts.
- Sessions: HMAC-SHA256 signed cookies with a CSRF token in the payload.
HttpOnly,Secure(HTTPS deploys),SameSite=Strict. - WebAuthn passkeys for everyday login. Per-IP rate limit + lockout on login.
All state-changing admin / auth requests check X-CSRF-Token against the value embedded in the signed session cookie. GET/HEAD/OPTIONS are exempt.
JSONL per Space, hash-chained (each entry's prev_hash = SHA-256 of the previous line). Tampering breaks the chain — AuditLog.Verify will flag the first line that doesn't match.
Non-text, non-image, non-PDF, non-media MIMEs are served with Content-Disposition: attachment + application/octet-stream. X-Content-Type-Options: nosniff everywhere. HTML / SVG can't render inline from a user upload.
The request logger redacts 43-char base64url tokens out of /s/* and /mcp/* paths before writing to stdout, so docker logs never contain a live share or MCP token.
| Endpoint group | Limit |
|---|---|
/s/api/* (share) |
5 rps avg, burst 20, per IP |
/mcp/* |
5 rps avg, burst 20, per IP |
/api/admin/* |
50 rps avg, burst 200, per IP |
/api/auth/claim |
hard lockout after 10 fails (1 h) |
/api/auth/passkey/login/* |
same |
- The reverse proxy strips client-supplied
X-Forwarded-Forheaders before forwarding (otherwise setNOTATION_TRUST_PROXY=0). - The container's filesystem is not shared with untrusted processes.
- Browsers honour CSP (any major modern one).
Everything is in /data. To back up:
docker stop notation
tar czf notation-backup-$(date +%F).tar.gz -C /path/to notation-data/
docker start notationTo migrate to a new host: copy the tarball over, extract into the mounted volume on the new host, start the container. The bootstrap token will not be re-issued (the admin record is intact). Sign in with your registered passkey on the new domain — note that passkeys are bound to the original rp_id, so the new host must serve from the same hostname (or you'll need to re-bootstrap).
To reset just the admin (keep all Spaces / shares / MCP tokens):
rm /data/.notation/admin.json
docker restart notation# Backend
cd backend
go mod tidy
NOTATION_DATA_DIR=./.data NOTATION_DEV_BYPASS_AUTH=1 go run ./cmd/notation# Frontend (separate terminal)
cd frontend
npm install
npm run devVisit http://localhost:5173/ — Vite proxies API calls to the Go backend on :8080. With NOTATION_DEV_BYPASS_AUTH=1 you skip the bootstrap + passkey flow entirely (a fake dev-admin session is injected).
cd backend && go test ./...notation/
├── backend/
│ ├── cmd/notation/main.go ← entry point + startup banner
│ └── internal/
│ ├── auth/ ← Authelia header parsing (legacy mode)
│ ├── authstore/ ← admin.json + server-secret on disk
│ ├── config/ ← env-var loader + RPID derivation
│ ├── gitrepo/ ← per-Space git wrapper (auto-commit)
│ ├── http/ ← chi router, middleware, handlers
│ │ ├── admin.go ← admin REST API
│ │ ├── share.go ← magic-link REST API
│ │ ├── webauthn.go ← passkey ceremonies
│ │ ├── authhandlers.go ← /state, /claim, /logout
│ │ ├── files.go ← write-file response helper
│ │ ├── middleware.go ← request log, CSP, security headers
│ │ ├── csrf.go ← double-submit CSRF middleware
│ │ ├── session.go ← HMAC-signed cookie issue/verify
│ │ └── router.go ← route registration
│ ├── mcphandler/ ← MCP JSON-RPC server + tool dispatch
│ ├── mcptoken/ ← MCP bearer-token store
│ ├── share/ ← magic-link store + audit log + limiter
│ └── space/ ← Space CRUD, file ops via os.Root,
│ grep / glob / outline
├── frontend/
│ └── src/
│ ├── admin/
│ │ ├── components/ ← FileTree, MarkdownView, Editor,
│ │ │ AuthGate, HistoryView, …
│ │ ├── components/viewers/← SpreadsheetView, WordView,
│ │ │ VideoView, AudioView
│ │ ├── components/auth/ ← Claim, PasskeySetup, PasskeyLogin
│ │ ├── lib/ ← api.ts, auth.ts, fileTypes.ts
│ │ └── pages/SpaceView.tsx
│ └── share/ ← read-only / share-aware app shell
├── Dockerfile ← multi-stage build
└── docker-compose.example.yml
flowchart LR
subgraph Browser
Admin[Admin SPA]
Share[Share SPA]
end
subgraph MCP_Clients[MCP clients]
Claude[Claude Code]
Cursor[Cursor]
end
RP[Reverse Proxy<br/>Traefik / Caddy]
Admin -->|HTTPS + session cookie + CSRF| RP
Share -->|HTTPS + share-token in path| RP
Claude -->|HTTPS + Bearer| RP
Cursor -->|HTTPS + Bearer| RP
RP --> notation[notation container<br/>Go 1.24 binary + embedded SPA]
notation -->|atomic FS ops via os.Root| Volume[(/data volume)]
notation -->|exec git| Git[git CLI]
The Go binary serves:
- the React SPA bundle (embedded via
go:embed), - the admin REST API (
/api/admin/*, session + CSRF), - the auth API (
/api/auth/*, bootstrap + passkey), - the share API (
/s/api/<token>/*), - the MCP endpoint (
/mcp/<space-id>, Bearer auth, JSON-RPC 2.0).
Everything is a single net/http server. No daemon, no database, no message broker.
MIT — see LICENSE for the full text.
Built on the shoulders of:
- chi — Go HTTP router
- go-webauthn — WebAuthn server
- @simplewebauthn/browser — WebAuthn client
- React + Vite + Tailwind
- Monaco Editor — both the markdown editor and the side-by-side diff view
- SheetJS — XLSX parsing
- Mammoth.js — DOCX → HTML
- Mermaid + KaTeX — diagrams & math
- rehype + remark — markdown pipeline
- DOMPurify — HTML sanitiser
- lucide-react — icons
