Skip to content

Add multi-user mode: a Nextcloud login per HTTP request - #87

Open
BjoernSchotte wants to merge 1 commit into
cloud-py-api:mainfrom
BjoernSchotte:upstream-pr/multiuser-mode
Open

BjoernSchotte wants to merge 1 commit into
cloud-py-api:mainfrom
BjoernSchotte:upstream-pr/multiuser-mode

Conversation

@BjoernSchotte

Copy link
Copy Markdown

Motivation

Today one server process acts as one Nextcloud account (NEXTCLOUD_USER / NEXTCLOUD_PASSWORD). A team that
wants to give several people an MCP server must run one process per person, or share one account and lose
Nextcloud's own permissions, audit trail and per-user data.

This PR adds an opt-in multi-user mode for the HTTP transport: one server, no stored credentials, and every
request brings its own Nextcloud login. Each user's tools then run with that user's own Nextcloud rights.

Design

  • NEXTCLOUD_MCP_MULTIUSER=true (needs --transport http). NEXTCLOUD_USER and NEXTCLOUD_PASSWORD must not
    be set, so there is no account to fall back to.
  • A small ASGI middleware (multiuser.py) wraps the Streamable HTTP app. It reads
    Authorization: Basic <login:app-password>, resolves a new login once to its user ID with
    GET /ocs/v2.php/cloud/user (this also checks the credentials) and pins that login's client and config to
    the request.
  • get_client() and get_config() return the client and config of the running request. No tool had to
    change.
    The binding is read from the request scope that the MCP layer passes into the tool call, with a
    context variable as fallback.
  • The login can differ from the user ID (for example a login by e-mail address). Basic Auth now sends the
    login name (Config.auth_login), and WebDAV paths keep using the user ID. In single-user mode both are the
    same value.
  • Client pool: one NextcloudClient per login, LRU (NEXTCLOUD_MCP_MULTIUSER_CACHE_SIZE, default 64) with
    an idle TTL (NEXTCLOUD_MCP_MULTIUSER_TTL, default 900 s). Two logins never share a client. Concurrent first
    requests of one login share one lookup. A client that is dropped from the pool is closed after a grace
    period, so a running tool call can finish. On lifespan shutdown all clients are closed.
  • Per-request permission cap: X-Nextcloud-MCP-Permissions: read|write|destructive can lower, but never
    raise, the server's NEXTCLOUD_MCP_PERMISSIONS for that request (for example a read-only service account
    behind the same server). This plugs into permissions.get_permission_level() through a small provider hook.
  • Discovery without a login: some MCP clients call initialize and tools/list at startup, before they
    know which user will call. A POST without Authorization header is allowed only if all of its JSON-RPC
    messages are initialize, ping, tools/list, notifications/initialized or notifications/cancelled.
    The answer is the static tool list, and no Nextcloud client is created.

HTTP semantics

Situation Status
No Authorization header (except discovery), malformed or repeated header, or a login that Nextcloud rejects (401/403) 401 + WWW-Authenticate: Basic
Invalid or repeated X-Nextcloud-MCP-Permissions 400
Nextcloud not reachable, or an error status or unexpected answer from cloud/user 502
GET/DELETE without a login 405 (Allow: POST)

Security considerations

  • No fallback account: a request without a valid login never runs as anyone. Config validation refuses
    NEXTCLOUD_USER/NEXTCLOUD_PASSWORD in this mode.
  • Each request is authenticated on its own. The MCP session ID is not a credential, and the user of a call
    comes only from that request's header. A test runs parallel requests of two logins and checks that they
    never swap clients.
  • A wrong Authorization header is refused. It is never downgraded to unauthenticated discovery. The
    discovery body is read with a 64 KiB limit before the check; larger bodies get 401.
  • The pool key is an HMAC-SHA256 of login and password with a random key per process, so neither the
    password nor a plain hash of it is kept as a dict key. Credentials.__repr__ hides the password.
    A changed app password gives a new client. A revoked app password fails at once: Nextcloud refuses each
    call of the cached client (a tool error). The middleware itself answers 401 once that client was idle
    for the TTL and is looked up again.
  • Authorization values with control characters or over 4 KiB are rejected.
  • NEXTCLOUD_MCP_UPLOAD_ROOT is refused in this mode, because it would give every user access to the
    server's local files.
  • Basic Auth sends the app password with every request. The README asks for TLS or a private network
    between client and server.

Configuration

Documented in the README under "Multi-User Mode (HTTP only)":

export NEXTCLOUD_URL=https://your-nextcloud.example.com
export NEXTCLOUD_MCP_MULTIUSER=true
export NEXTCLOUD_MCP_PERMISSIONS=write          # highest level any request may use
export NEXTCLOUD_MCP_MULTIUSER_CACHE_SIZE=64    # optional
export NEXTCLOUD_MCP_MULTIUSER_TTL=900          # optional, seconds
nc-mcp-server --transport http

Tests

  • tests/test_multiuser.py (unit tests, 59): header parsing, permission caps, config validation, client pool
    (LRU, TTL, password change, rejected logins not cached, shared first lookup, close of cached and retired
    clients), state binding, and the middleware over the real Streamable HTTP app (401/400/502/405, permission
    cap, parallel logins never swap, discovery allowed and refused cases, lifespan shutdown closes the pool).
  • tests/integration/test_multiuser.py (integration): two real users with app passwords from Nextcloud, one
    logging in by e-mail address, parallel calls, 401 cases and the read-only cap.
  • Locally: ruff check, ruff format --check, isort --check src/, pyright are clean, and
    756 unit tests pass on Python 3.12. The integration tests were not run locally, because there was no
    Nextcloud test instance.
  • PROGRESS.md gets a "Multi-User" row, recounted with scripts/sync_progress.py --write.

Backwards compatibility

The default is unchanged. Without NEXTCLOUD_MCP_MULTIUSER, stdio and HTTP still use NEXTCLOUD_USER and
NEXTCLOUD_PASSWORD, and create_server() creates the global client as before. create_server(config) and the
new create_http_app(mcp, config) are the only changed entry points; __main__ only uses uvicorn directly when
multi-user mode is on. The NEXTCLOUD_MCP_APP_PASSWORD parsing moved into a shared helper with the same
accepted values and error message format.

AI disclosure: this change was written with Claude Code (Claude Opus 5.5); commits carry a Co-Authored-By trailer.

🤖 Generated with Claude Code

NEXTCLOUD_MCP_MULTIUSER=true lets one HTTP server act for many Nextcloud
accounts. The server holds no credentials of its own. An ASGI middleware
requires "Authorization: Basic <login:app-password>" on every request,
resolves a new login to its user ID once (GET /ocs/v2.php/cloud/user, which
also checks the credentials) and pins that login's client to the request.
get_client() and get_config() return the request's client and config, so
no tool had to change.

- No header, a malformed or repeated one, or a login that Nextcloud rejects
  gives 401; Nextcloud unreachable gives 502. There is no fallback account.
- Discovery without a login: a POST without Authorization header whose
  JSON-RPC messages are all initialize, ping, tools/list or notifications
  runs without a Nextcloud client. tools/call still needs a login, and a
  wrong header is never downgraded to discovery.
- Clients are cached per login (LRU, NEXTCLOUD_MCP_MULTIUSER_CACHE_SIZE,
  idle TTL NEXTCLOUD_MCP_MULTIUSER_TTL). The cache key is an HMAC of login
  and password with a per-process random key. Lifespan shutdown closes all
  cached and retired clients.
- X-Nextcloud-MCP-Permissions lowers, never raises, the level per request.
- Basic Auth now sends the login name when it differs from the user ID
  (login by e-mail address); WebDAV paths keep using the user ID.

Without NEXTCLOUD_MCP_MULTIUSER nothing changes: stdio and HTTP still use
NEXTCLOUD_USER and NEXTCLOUD_PASSWORD.

Tests: unit tests for header parsing, config, the client pool and the
middleware over the real Streamable HTTP app (parallel logins never swap);
integration tests with two users, one logging in by e-mail address.

Signed-off-by: Björn Schotte <bjoern.schotte@googlemail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 7, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 87d86f4d-2b8d-40cd-8ba7-dfd02379feda
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant