Skip to main content
Glama

ncmcp — self-hosted Nextcloud MCP server

CI

A self-hosted remote MCP server that exposes your Nextcloud (Files, Calendar, Notes, server-side search, and sharing/links) 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.

One tool goes a step further than "access to your account": create_share_link can mint a public URL that works for anyone, without a Nextcloud login. It's off unless you explicitly enable it — see Sharing and links.

Related MCP server: Google Workspace MCP Server

What it exposes

25 tools (26 with public sharing enabled — see below), executing as your authenticated Nextcloud identity:

Area

Tools

Files (WebDAV)

list_files, read_file, write_file, delete_file, create_folder, move_file, copy_file

Documents

find_in_document, extract_tables, render_page — see Reading documents below

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; results carry a path/fileid so they chain directly into read_file

Sharing & links

get_file_link, list_shares, delete_share, and (opt-in) create_share_link — see Sharing and links below

Reading documents

read_file extracts real text from PDFs, Office documents (.docx/.xlsx/.pptx/.odt) and plain text — never raw binary or mojibake. For multi-page documents, page_range (e.g. "1-12") limits extraction to those pages, and each page's result reports a text_density / has_table_structure diagnostic so Claude can decide when to escalate:

  • find_in_document locates a phrase across a whole document without pulling all of it into context — use this before read_file on anything long.

  • extract_tables re-extracts a PDF page with pdfplumber's layout analysis instead of linear text — for pages where OCR read the prose fine but scrambled a table's reading order.

  • render_page rasterizes PDF pages to images — the last resort when the text layer itself looks broken (a scanned page with no OCR, or a very low text_density) and there's no OCR available on this Nextcloud instance to fall back on. Costs far more tokens than text, capped at 5 pages per call by default; never called automatically.

Extracted text is cached (keyed by file id + etag, invalidated when the file changes) so re-reading an unchanged document doesn't re-run extraction. See Environment variables for the size/timeout/cache guardrails.

There are two different things "share this" can mean, and this server keeps them separate:

  • get_file_link returns Nextcloud's own web link ({base_url}/index.php/f/{fileid}) — it opens in the Files UI (or app) for a user who is already logged in and already has access. It grants nobody anything they didn't already have, so it's always available. search_content and read_file results also carry this same link directly, under link.

  • create_share_link creates an actual Nextcloud public share: a URL that works for anyone who has it, with no Nextcloud login at all. This is the most outward-facing thing the server can do, so it's off by default — set NCMCP_ENABLE_PUBLIC_SHARES=true to enable it; until then, this tool simply isn't in the list Claude sees. When enabled, links it creates are read-only (no upload) and expire after NCMCP_SHARE_DEFAULT_EXPIRE_DAYS days (default 7) unless a call explicitly asks for expire_days=0 (never expires) or a different number of days. A password can be set per link.

  • list_shares / delete_share enumerate and revoke public links and are always available, independent of NCMCP_ENABLE_PUBLIC_SHARES — so you can always find and kill a link, including ones created from Nextcloud's own web UI, even with creation turned off.

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.

  • uv if 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

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 off and a generous proxy_read_timeout.

  • Forwarded headersX-Forwarded-Proto/X-Forwarded-Host/Host set 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

  1. claude.ai → Settings → Connectors → Add custom connector

  2. Server URL: https://<your-subdomain>/mcp

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

NCMCP_MAX_EXTRACT_FILE_SIZE_MB

no

Largest file the document-reading tools will download/extract; default 50

NCMCP_EXTRACT_TIMEOUT_SECONDS

no

Time limit for a single extraction; default 30

NCMCP_MAX_RENDER_PAGES

no

Most pages render_page will rasterize in one call; default 5

NCMCP_EXTRACT_CACHE_TTL_DAYS

no

How long cached extracted text is kept; default 30

NCMCP_EXTRACT_CACHE_SIZE_CAP_MB

no

Extraction cache size before least-recently-used entries are evicted; default 200

NCMCP_ENABLE_PUBLIC_SHARES

no

Enables create_share_link (unauthenticated public URLs); false by default — see Sharing and links

NCMCP_SHARE_DEFAULT_EXPIRE_DAYS

no

Default expiry for links create_share_link makes when a call doesn't specify one; default 7. 0 in a call means never expires

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; plain is 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 via NCMCP_DEV_REDIRECT_URIS for 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.

  • Public link sharing is the one feature that grants access to someone who is not the authenticated user, so create_share_link is off by default (NCMCP_ENABLE_PUBLIC_SHARES), and when enabled defaults to read-only and to expiring after NCMCP_SHARE_DEFAULT_EXPIRE_DAYS. list_shares/delete_share are never gated, so revocation is always available even with creation turned off.

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.

create_share_link isn't in Claude's tool list — either NCMCP_ENABLE_PUBLIC_SHARES isn't set to true on this deployment, or it was just changed: restart the container, then reconnect the connector in Claude.ai — the tool list is fetched once at connect time and won't refresh on its own after a server-side change.

Sharing fails with a 403/404 even though creating the same link works fine in the Nextcloud web UI — an admin policy is rejecting the request: link sharing enforced-off, required password, or a maximum expiry shorter than what was requested (Nextcloud → Settings → Sharing). The error this server returns includes Nextcloud's own message, which names which one.

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.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

  • Hosted Google Calendar MCP server for AI agents. No self-hosting or Google Cloud setup.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/janLo/nextcloud-simple-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server