A multi-tenant Model Context Protocol server for persistent documents — so AI agents can save, find, and edit work that survives across sessions and tools.
MCP DocStore gives AI agents a shared, durable place to keep documents. An agent in one session (say, a Claude.ai artifact) writes a document; an agent in another (say, Claude Code) retrieves and edits it later. Documents live in projects, each document carries a short overview for quick scanning plus a longer markdown body, and every edit is versioned so nothing is lost.
It is an OAuth resource server: it validates bearer tokens from your existing identity provider and resolves each caller to a tenant by email domain or address. Data is isolated per tenant, with org-wide, private, and shared projects.
- Projects & documents —
org(whole-tenant) orprivateprojects; documents with overview + markdown body + tags. - Sharing — share a project with individual users (by email) or with groups drawn from
the token's
groupsclaim; read or write. - Versioned edits — full-replace, section (markdown heading) edits, append, and section
delete. Mutating edits use optimistic concurrency (
base_version); every edit snapshots the prior version. Inspect history withlist_snapshots/get_snapshot/diff_versionsand roll back withrestore_snapshot. - Full-text search — keyword search (powered by Bleve) scoped to exactly what the caller may see; no query syntax to learn.
- Archiving — archive/unarchive projects to hide them from lists and search while keeping them reachable by id.
- Safe destructive ops —
delete_project,delete_document, andrestore_snapshotare confirmation-guarded via MCP elicitation (with aconfirm: truefallback for clients that can't prompt). - Multi-tenant & config-seeded — tenants and their admins are declared in config; a user belongs to exactly one tenant.
| Group | Tools |
|---|---|
| Projects | list_projects, create_project, get_project, update_project, archive_project, unarchive_project, delete_project |
| Sharing | share_project, unshare_project, list_project_shares |
| Documents | list_documents, create_document, get_document, get_section, edit_document, append_document, delete_section, delete_document |
| History | list_snapshots, get_snapshot, diff_versions, restore_snapshot |
| Search | search_documents |
Each tool is annotated with read-only / destructive / closed-world hints so clients can reason about safety.
Copy config.example.yaml and edit it:
listen_addr: ":8080"
public_url: "https://docs.example.com" # public base URL; used in protected-resource metadata, WWW-Authenticate, and icon URLs
snapshot_retention: 10
bleve_index_path: "./data/index.bleve"
database:
driver: sqlite # sqlite | mysql | postgres
dsn: "file:./data/docstore.db?_pragma=foreign_keys(1)"
oidc:
issuer: "https://idp.example.com"
audience: "mcp-docstore" # the "aud" this resource server requires
email_claim: "email"
groups_claim: "groups"
tenants:
- key: acme
name: "Acme Corp"
match:
domains: ["acme.com", "acme.io"]
emails: ["contractor@gmail.com"]
admins: ["alice@acme.com"] # tenant admins (declarative; reconciled at login)Tenant admins have full read/write over every project in their own tenant. The admins list
is the single source of truth and is reconciled on each login.
# build
go build -o mcp-docstore .
# serve (Streamable HTTP MCP endpoint at "/", metadata at /.well-known/oauth-protected-resource)
./mcp-docstore --config config.yaml
# rebuild the search index from the database (after a schema change or index loss)
./mcp-docstore --config config.yaml rebuild-indexOn first boot with an empty index, the server builds it from the database automatically.
Images are published to GHCR for linux/amd64 and linux/arm64: ghcr.io/fishwaldo/mcp-docstore (tags :X.Y.Z, :X.Y, :latest).
Persistent state — the SQLite database and the Bleve index — lives in the container at /data (a declared volume, owned by the non-root runtime user 65532). Point your config there and mount a volume so data survives restarts:
# config.yaml (paths under the mounted /data volume)
bleve_index_path: "/data/index.bleve"
database:
driver: sqlite
dsn: "file:/data/docstore.db?_pragma=foreign_keys(1)"docker run -p 8080:8080 \
-v mcp-docstore-data:/data \
-v "$PWD/config.yaml:/etc/mcp-docstore/config.yaml:ro" \
ghcr.io/fishwaldo/mcp-docstore --config /etc/mcp-docstore/config.yaml
# rebuild the index in the same container/volume
docker run --rm \
-v mcp-docstore-data:/data \
-v "$PWD/config.yaml:/etc/mcp-docstore/config.yaml:ro" \
ghcr.io/fishwaldo/mcp-docstore --config /etc/mcp-docstore/config.yaml rebuild-indexNotes:
- The container runs as non-root (uid 65532). A Docker named volume (as above) is initialized with the right ownership automatically. If you use a bind mount instead (
-v $PWD/data:/data),chown 65532:65532the host directory first, or the server can't write to it. - With an external MySQL/Postgres backend,
/dataholds only the Bleve index (rebuildable viarebuild-index), not your documents.
Build it yourself:
docker build -t mcp-docstore .Every request to the MCP endpoint must carry Authorization: Bearer <JWT>. The server verifies
the token against the configured OIDC issuer (signature, exp, iss, and aud), resolves the
email to a tenant, and rejects unknown identities with 401 plus a WWW-Authenticate challenge
pointing at the RFC 9728 protected-resource metadata.
Layered, with each package owning one job:
| Package | Responsibility |
|---|---|
internal/config |
Viper config load + validation |
internal/tenant |
email/domain → tenant resolution + admin lookup |
internal/auth |
OIDC token verification, identity resolution (SDK TokenVerifier) |
internal/ent |
generated ent data layer |
internal/store |
repository: the access rule, tenant scoping, optimistic concurrency, snapshots |
internal/docs |
goldmark markdown section editing + diffs |
internal/search |
Bleve index: build, query, rebuild |
internal/index |
the only bridge between store and search (keeps the index in sync) |
internal/mcp |
MCP tool definitions + thin handlers + elicitation |
cmd/server |
boot, HTTP wiring, CLI |
Built on the Go MCP SDK.
go test ./... # full suite (uses in-memory SQLite)
go test -race ./internal/mcp/... ./cmd/... # race detector on the concurrent paths
go generate ./... # regenerate ent after a schema changeMIT © 2026 Justin Hammond. Dependencies are permissively licensed (MIT / BSD / Apache-2.0).