ncmcp
A self-hosted MCP server that exposes Nextcloud files, calendar, notes, and search as tools for Claude.ai, authenticating via Nextcloud OAuth.
README
ncmcp — self-hosted Nextcloud MCP server
A self-hosted remote MCP server that exposes your Nextcloud (Files, Calendar, Notes, and server-side search) as tools for a Claude.ai custom connector. It runs beside your Nextcloud, behind your reverse proxy, and authenticates users against your own Nextcloud via an OAuth façade — no app passwords in config, revocation via Nextcloud's Security settings.
Auth model — read this
Authenticating through your Nextcloud OAuth grants this server full access to your Nextcloud account. Nextcloud does not enforce OAuth scopes on its own APIs, so this is not least-privilege access. The value of the design is UX and lifecycle: you log in on your own Nextcloud, nothing is pasted into config files, and you can revoke access at any time from Nextcloud → Settings → Security → Devices & sessions.
The Nextcloud tokens this server holds on your behalf are encrypted at rest (see Security) — but encryption at rest protects against someone reading the disk, not against the server itself being compromised while running. Treat this server the way you'd treat any process with full access to your Nextcloud account, because that's what it has.
What it exposes
19 tools, executing as your authenticated Nextcloud identity:
| Area | Tools |
|---|---|
| Files (WebDAV) | list_files, read_file, write_file, delete_file, create_folder, move_file, copy_file |
| Calendar (CalDAV) | list_calendars, list_events, get_event, create_event, update_event, delete_event |
| Notes | list_notes, get_note, create_note, update_note, delete_note |
| Search | search_content — server-side, backed by your instance's fulltextsearch/Elasticsearch install |
Contacts (CardDAV) and Mail are deliberately out of scope for now — see the project plan if you're picking this back up to add them.
Prerequisites
- A running Nextcloud instance you administer, reachable at a stable URL.
- A place to run this container beside it (same host or same network), with a reverse
proxy (nginx) you control, on a dedicated subdomain — not a subpath. OAuth/MCP
discovery relies on
/.well-known/...paths probed at the host root; a subpath means proxying root-level paths into a subpath app, a reliable source of unhelpful "generic auth error" failures. Don't do that to yourself. - Docker + Docker Compose.
uvif you want to run tests or the verification scripts locally (not required just to run the server via Compose).
Setup
1. Register an OAuth 2.0 client in Nextcloud
Admin settings → Security → OAuth 2.0 clients → Add client:
- Name: anything, e.g.
ncmcp - Redirection URI:
<NCMCP_PUBLIC_BASE_URL>/nextcloud/callback— exactly this, no wildcards. Nextcloud matches it verbatim.- Local testing:
http://127.0.0.1:8000/nextcloud/callback - Real deployment:
https://mcp.yourdomain.example/nextcloud/callback
- Local testing:
Nextcloud shows the Client Identifier and Secret once. Copy both.
2. Configure .env
cp .env.example .env
Every variable is documented inline in .env.example; the reference table below repeats
the important ones. At minimum you need NEXTCLOUD_BASE_URL,
NEXTCLOUD_OAUTH_CLIENT_ID/_SECRET from step 1, and a generated NCMCP_ENCRYPTION_KEY:
python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
For a real deployment, also set NCMCP_PUBLIC_BASE_URL to your actual https:// subdomain
— this must exactly match the host in your reverse proxy config and the redirect URI you
just registered in Nextcloud. See Troubleshooting for why this matters
more than it sounds like it should.
3. Run it
docker compose up -d
curl http://127.0.0.1:8000/health
The container binds only to 127.0.0.1:8000 — it is not reachable except through your
reverse proxy. Put that proxy in front of it (see below) before pointing Claude at it.
docker compose up -d builds the image locally from the Dockerfile. CI also publishes a
pre-built image to ghcr.io/janlo/nextcloud-simple-mcp on every push to main (tagged
latest) and on version tags, if you'd rather pull than build — replace build: . with
image: ghcr.io/janlo/nextcloud-simple-mcp:latest in docker-compose.yml.
Reverse proxy
Reference nginx config: deploy/nginx.conf. The essentials it covers:
- SSE-safe settings — MCP's streamable-HTTP transport holds long-lived streaming
connections; nginx's default response buffering will stall them, and a short read timeout
will kill idle-but-healthy sessions. The config sets
proxy_buffering offand a generousproxy_read_timeout. - Forwarded headers —
X-Forwarded-Proto/X-Forwarded-Host/Hostset correctly. - TLS is not this project's job — the config assumes your existing certificate setup for the subdomain; it contains no Let's Encrypt/certbot/ACME logic.
The #1 cause of silent OAuth failure is a wrong or inconsistent issuer URL.
NCMCP_PUBLIC_BASE_URL must exactly match https://<your-subdomain> (scheme + host, no
trailing slash, no path) — that value is baked into every issuer/resource/redirect URL this
server generates, and Claude checks it byte-for-byte. Get it wrong and you'll typically see
a vague "couldn't reach the MCP server" or "authorization failed" from Claude with nothing
useful in this server's own logs, because from here the request looks completely normal.
Adding the connector in Claude.ai
- claude.ai → Settings → Connectors → Add custom connector
- Server URL:
https://<your-subdomain>/mcp - Connect — you'll be redirected to your own Nextcloud's login page, not a Claude-hosted one. Log in, approve access, and you're bounced back to Claude with the connector shown as connected.
Claude registers itself as a client automatically (Dynamic Client Registration) — there's nothing to pre-register on the Claude side beyond the connector URL above.
Environment variables
| Variable | Required | Purpose |
|---|---|---|
NEXTCLOUD_BASE_URL |
yes | Your Nextcloud instance, no trailing slash |
NEXTCLOUD_OAUTH_CLIENT_ID / _SECRET |
yes | From the OAuth client registered in Setup step 1 |
NCMCP_ENCRYPTION_KEY |
yes | Fernet key encrypting Nextcloud tokens at rest — generate per Setup step 2 |
NCMCP_PUBLIC_BASE_URL |
yes for real deploys | The public URL this server is reachable at; defaults to http://127.0.0.1:8000 for local testing |
NCMCP_DATA_DIR |
no | Where the SQLite token store lives; Compose sets /data (the named volume) |
NCMCP_REFRESH_TOKEN_TTL_DAYS |
no | How long our issued refresh tokens live before Claude must re-login; default 90 |
NCMCP_DEV_REDIRECT_URIS |
no | Extra redirect URIs DCR may register, comma-separated — only for local test scripts, leave empty in production |
NCMCP_HOST / NCMCP_PORT |
no | Bind address inside the container; defaults 0.0.0.0:8000, matches the Compose port mapping |
NCMCP_LOG_LEVEL |
no | info by default |
Security
- Encryption at rest: Nextcloud access/refresh tokens and each connector's DCR client
secret are encrypted with Fernet (
NCMCP_ENCRYPTION_KEY) before being written to SQLite. Our own issued tokens (handed to Claude) are not encrypted — they're also the lookup keys, Fernet ciphertext isn't deterministic, and they're short-lived/rotated/scoped only to this server, unlike the underlying Nextcloud credentials. - Never logged: the app's own log lines are minimal by design. uvicorn's default access log is disabled and replaced with a middleware that logs method/path/status only — never the query string, which for this server's OAuth routes can carry Nextcloud's one-time authorization code. Logs from this server are safe to paste into a bug report or share when asking for help.
- Per-request isolation: which Nextcloud user a tool call acts as comes from a
contextvars-based mechanism set per-request by the MCP SDK's own auth middleware — not a global variable or shared cache. Two concurrent users' requests cannot see each other's tokens; this is covered by an automated test (tests/test_context.py). - PKCE: S256 only, enforced by the MCP SDK itself;
plainis never accepted. - Redirect URI allow-listing: Dynamic Client Registration only accepts Claude's fixed
callback (
https://claude.ai/api/mcp/auth_callback) plus whatever you explicitly add viaNCMCP_DEV_REDIRECT_URISfor local testing. - Refresh token rotation: every refresh call issues a new refresh token and invalidates the old one (and its paired access token) in the same operation.
Troubleshooting
Connector stuck retrying initialize — usually a streaming/buffering issue between
Claude and your reverse proxy, not this server. Confirm proxy_buffering off and a generous
proxy_read_timeout are actually applied (check with curl -v through the proxy, not just
directly against 127.0.0.1:8000).
Generic auth prompt loop (Connect → login → back to "Connect" again) — almost always a
mismatch between what Claude expects back and what this server sent. Check:
docker compose logs ncmcp for the Nextcloud callback OK; redirecting browser to ... line
and confirm the host matches NCMCP_PUBLIC_BASE_URL exactly.
"Zugriff verboten" / "Status-Token stimmen nicht überein" on Nextcloud's own grant
page — broken in Chrome, works in Firefox. This is a Nextcloud bug, not this project:
its OAuth grant page's submit button isn't disabled after the first click, so in Chrome the
form gets submitted twice from a single page load, and the second submission invalidates
the single-use state token from the first. Confirmed via server-side log correlation (this
server's own logs, both nginx access logs, and Nextcloud's application log): the grant page
loads exactly once, two POST /login/flow requests fire seconds apart with no reload in
between, the first always succeeds, and this server completes correctly on every attempt
regardless — the failure is entirely client-side, in Chrome's interaction with Nextcloud's
page, after this server has already done its job. The same steps complete successfully
every time in Firefox.
This is a regression of a bug reported and fixed once
(nextcloud/server#33164,
fixed by PR #33166 in 2022) — but the fix
only touched the old core/js/login/grant.js, which no longer exists. Nextcloud's later
rewrite of this page as a Vue component
(core/src/views/LoginFlowGrant.vue)
reintroduced the same bug — its submit button has no disabled/loading state at all. Filed
as nextcloud/server#62327; no
server-side workaround is possible (Nextcloud's own frontend, outside this project's
control) — use Firefox until it's fixed.
Wrong issuer URL — see the callout above. Verify directly:
curl https://<your-subdomain>/.well-known/oauth-authorization-server
The issuer, authorization_endpoint, and token_endpoint values must all be your real
public URL, not 127.0.0.1 or a mismatched scheme.
DAV 401s from Nextcloud — the Phase 0 spike (spikes/verify-bearer-dav.py) exists
specifically to isolate this from application bugs. Re-run it against your instance; if DAV
endpoints reject a fresh Bearer token there too, the problem is Nextcloud/proxy
configuration (a known cause: a webserver stripping the Authorization header — Apache
needs mod_headers/mod_env and AllowOverride All for this; nginx doesn't have this
issue).
Reading the logs: docker compose logs -f ncmcp. They're safe to share — see
Security above — no tokens or authorization codes ever appear in them.
Backup & restore
The token store is a single SQLite file (plus -wal/-shm companions) inside the named
ncmcp-data Docker volume. Back it up like any file:
docker run --rm -v nextcloud-remote-mcp_ncmcp-data:/data -v "$PWD":/backup alpine \
tar czf /backup/ncmcp-data-backup.tar.gz -C /data .
You also need NCMCP_ENCRYPTION_KEY from .env to make a restored backup useful — without
it the stored Nextcloud tokens are unreadable ciphertext. Losing the store (or the key)
isn't catastrophic: every connected user just needs to reconnect in Claude and log in to
Nextcloud again.
Upgrading
uv lock --upgrade # bump dependency versions
uv sync --extra dev
uv run pytest
docker compose up -d --build
This project doesn't depend on any external "Nextcloud tool" package — the DAV/REST clients
live in src/ncmcp/nextcloud/ and are ours to maintain directly.
Development
uv sync --extra dev # create venv, install deps
uv run pytest # run tests
uv run ncmcp # run the server directly (defaults to 0.0.0.0:8000)
Two verification scripts exist outside the automated test suite, both requiring a real Nextcloud instance and a browser login:
spikes/verify-bearer-dav.py— the original Phase 0 check that Nextcloud OAuth tokens work as DAV Bearer credentials.scripts/e2e-oauth-flow.py— drives the entire OAuth façade end to end (discovery → DCR → PKCE authorize → real Nextcloud login → token exchange → live tool calls → refresh rotation) using the real MCP SDK client, the same code path Claude uses.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.