Skip to main content
Glama
README.md
# ncmcp — self-hosted Nextcloud MCP server

[![CI](https://github.com/janLo/nextcloud-simple-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/janLo/nextcloud-simple-mcp/actions/workflows/ci.yml)

A self-hosted remote [MCP](https://modelcontextprotocol.io) 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](#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](#sharing-and-links).

## 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](#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](#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](#environment-variables) for the size/timeout/cache guardrails.

### Sharing and links

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`

```sh
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`:

```sh
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](#troubleshooting) for why this matters
more than it sounds like it should.

### 3. Run it

```sh
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`](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 headers** — `X-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](#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](https://github.com/nextcloud/server/issues/33164),
fixed by [PR #33166](https://github.com/nextcloud/server/pull/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`](https://github.com/nextcloud/server/blob/master/core/src/views/LoginFlowGrant.vue))
reintroduced the same bug — its submit button has no disabled/loading state at all. Filed
as [nextcloud/server#62327](https://github.com/nextcloud/server/issues/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:
```sh
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](#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:

```sh
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

```sh
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

```sh
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.