Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
- [x] search_files matches % and _ in the query literally instead of as database wildcards (2026-09-26)
- [x] update_circle_member_level explains when Circles cannot transfer ownership (SQLite or PostgreSQL before nextcloud/circles#2916) instead of passing on the database error (2026-09-26)
- [x] Team folders (Nextcloud 35 with the Team folders app): create_circle makes one only with team_folder=true; delete_circle, leave_circle and delete_collective(delete_team) refuse to delete a team folder and its files unless delete_team_folder=true (2026-09-29)
- [x] Multi-user mode (NEXTCLOUD_MCP_MULTIUSER=true, HTTP only): every request brings its own Basic login (app password); per-login client pool (LRU + idle TTL); X-Nextcloud-MCP-Permissions lowers the level per request; initialize/tools/list without a login for client discovery (2026-10-07)

### In Progress

Expand Down Expand Up @@ -123,7 +124,8 @@
| Cospend | 16 | 41 |
| Flow | 5 | 58 |
| Pagination | — | 28 |
| **Total** | **223** | **1827** |
| Multi-User | — | 64 |
| **Total** | **223** | **1891** |

The test counts are what pytest collects, recomputed with `python scripts/sync_progress.py --write`,
which also fails when a test file is not assigned to a row.
Expand Down
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export NEXTCLOUD_MCP_UPLOAD_ROOT= # unset (default). If set to an absolute
# inside that directory (symlinks resolved).
```

To serve many Nextcloud accounts from one HTTP server, see [Multi-User Mode](#multi-user-mode-http-only).

### Getting an App Password

1. Log into your Nextcloud instance
Expand Down Expand Up @@ -170,6 +172,66 @@ nc-mcp-server --transport http
# Listens on http://0.0.0.0:8100 by default
```

### Multi-User Mode (HTTP only)

By default the server acts as the one account in `NEXTCLOUD_USER`. With `NEXTCLOUD_MCP_MULTIUSER=true`,
one HTTP server can act for many Nextcloud accounts instead: the server holds no credentials, and every
request brings its own login.

```bash
export NEXTCLOUD_URL=https://your-nextcloud.example.com
export NEXTCLOUD_MCP_MULTIUSER=true
export NEXTCLOUD_MCP_PERMISSIONS=write # the highest level any request may use
# NEXTCLOUD_USER and NEXTCLOUD_PASSWORD must NOT be set
nc-mcp-server --transport http
```

| Variable | Default | Meaning |
|----------|---------|---------|
| `NEXTCLOUD_MCP_MULTIUSER` | `false` | Take the Nextcloud login from each HTTP request. Needs `--transport http`. |
| `NEXTCLOUD_MCP_MULTIUSER_CACHE_SIZE` | `64` | Number of per-login clients kept (least recently used ones are dropped first). |
| `NEXTCLOUD_MCP_MULTIUSER_TTL` | `900` | Seconds an unused login's client is kept. |

**Authentication.** Every request needs `Authorization: Basic base64(<login>:<app-password>)`. The login
can be the user ID or another login name, such as the e-mail address. The first request of a login asks
Nextcloud for the user ID (`GET /ocs/v2.php/cloud/user`); this also checks the credentials. The server
answers before any tool runs:

| Situation | HTTP status |
|-----------|-------------|
| No `Authorization` header (except discovery, see below), malformed or repeated `Authorization` header, or a login that Nextcloud rejects (401/403) | `401` with `WWW-Authenticate: Basic` |
| Invalid or repeated `X-Nextcloud-MCP-Permissions` header | `400` |
| Nextcloud not reachable, a 5xx or an unexpected answer while the login is checked | `502` |
| `GET` or `DELETE` without a login | `405` |

There is no fallback account: a request that has no valid login never runs as anyone.

**Discovery without a login.** Some MCP clients list the tools at startup, before they know which user
will call them. A `POST` **without** an `Authorization` header is allowed when 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 call happens. `tools/call` and all other methods still need a login.
A request with a wrong `Authorization` header is refused; it is never treated as discovery.

**Client pool.** The server keeps one Nextcloud client per login (LRU with an idle TTL, see the variables
above). Two logins never share a client. The cache key is an HMAC of login and password with a random key
per process, so the pool does not keep the password or a plain hash of it as a key. A client that is
dropped from the pool is closed after a grace period, so a tool call that is still running can finish.

**Per-request permission cap.** A request can lower, but never raise, the server's permission level with
the header `X-Nextcloud-MCP-Permissions: read|write|destructive`. Example: a read-only service account that
uses the same server.

**Security notes.**

- Each request is authenticated on its own. The MCP session ID is not a credential, and the user of a
request comes only from that request's header.
- Basic Auth sends the app password with every request. Use TLS (for example a reverse proxy) or a private
network between the MCP client and this server.
- Anyone who can reach the server with a valid app password can use it as that user, up to the server's
permission level. Give each user an app password of their own, and revoke it in Nextcloud to remove access.
- `NEXTCLOUD_MCP_UPLOAD_ROOT` is not available in this mode, because it would give every user access to the
server's local files.

### Stdio Mode (default)

```bash
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ reportUnusedFunction = false
reportPrivateUsage = false
executionEnvironments = [
{ root = "tests/test_client_retry.py", reportAttributeAccessIssue = false, reportUnknownMemberType = false },
# starlette's TestClient (httpx) has no complete types here
{ root = "tests/test_multiuser.py", reportUnknownMemberType = false, reportUnknownVariableType = false, reportUnknownArgumentType = false },
{ root = "tests/integration/test_multiuser.py", reportUnknownMemberType = false, reportUnknownVariableType = false, reportUnknownArgumentType = false },
{ root = "src/nc_mcp_server/tools/calendar.py", reportUnknownMemberType = false, reportUnknownVariableType = false, reportUnknownArgumentType = false },
{ root = "src/nc_mcp_server/tools/contacts.py", reportUnknownMemberType = false, reportUnknownVariableType = false, reportUnknownArgumentType = false },
{ root = "src/nc_mcp_server/tools/tasks.py", reportUnknownMemberType = false, reportUnknownVariableType = false, reportUnknownArgumentType = false },
Expand Down
1 change: 1 addition & 0 deletions scripts/sync_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"Circles": ["integration/test_circles.py", "test_circles.py"],
"Cospend": ["integration/test_cospend.py"],
"Flow": ["integration/test_flows.py", "test_flows.py"],
"Multi-User": ["integration/test_multiuser.py", "test_multiuser.py"],
}


Expand Down
15 changes: 12 additions & 3 deletions src/nc_mcp_server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

import argparse

from .server import create_server
import uvicorn

from .config import Config
from .server import create_http_app, create_server


def main() -> None:
Expand All @@ -15,10 +18,16 @@ def main() -> None:
)
args = parser.parse_args()

mcp = create_server()
config = Config.from_env()
if config.multiuser and args.transport != "http":
parser.error("NEXTCLOUD_MCP_MULTIUSER=true needs --transport http (logins come from HTTP headers)")
mcp = create_server(config)

if args.transport == "http":
mcp.run(transport="streamable-http")
if config.multiuser:
uvicorn.run(create_http_app(mcp, config), host=config.host, port=config.port, log_level="info")
else:
mcp.run(transport="streamable-http")
else:
mcp.run(transport="stdio")

Expand Down
2 changes: 1 addition & 1 deletion src/nc_mcp_server/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ async def _send(self, session: niquests.AsyncSession, method: str, url: str, **k

def _build_session(self) -> niquests.AsyncSession:
kwargs: dict[str, object] = {
"auth": (self._config.user, self._config.password),
"auth": (self._config.auth_login, self._config.password),
"timeout": 30,
"headers": {
"OCS-APIRequest": "true",
Expand Down
65 changes: 58 additions & 7 deletions src/nc_mcp_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ class Config:
NEXTCLOUD_MCP_UPLOAD_ROOT: Absolute path to a local directory. When set, enables the
upload_file_from_path tool, restricted to files under this directory (symlinks
are resolved before the containment check). Unset by default — tool disabled.
NEXTCLOUD_MCP_MULTIUSER: Set to 'true' to serve many Nextcloud accounts from one HTTP
server. Every request then carries its own ``Authorization: Basic <login:app-password>``
header; NEXTCLOUD_USER and NEXTCLOUD_PASSWORD must not be set. Requires --transport http.
NEXTCLOUD_MCP_MULTIUSER_CACHE_SIZE: Clients kept for recent logins (default: 64).
NEXTCLOUD_MCP_MULTIUSER_TTL: Seconds an unused login's client is kept (default: 900).
"""

nextcloud_url: str = field(default="")
Expand All @@ -36,6 +41,17 @@ class Config:
retry_max: int = field(default=3)
is_app_password: bool = field(default=False)
upload_root: str = field(default="")
multiuser: bool = field(default=False)
multiuser_cache_size: int = field(default=64)
multiuser_ttl: float = field(default=900.0)
# Login name for Basic Auth when it differs from the user ID (e.g. a login by e-mail address).
# Empty means the user ID. Set per login in multi-user mode; the user ID builds DAV paths.
login: str = field(default="")

@property
def auth_login(self) -> str:
"""The name sent with Basic Auth: the login name if known, else the user ID."""
return self.login or self.user

@classmethod
def from_env(cls) -> "Config":
Expand All @@ -59,13 +75,10 @@ def from_env(cls) -> "Config":
except ValueError:
raise ValueError(f"Invalid NEXTCLOUD_MCP_RETRY_MAX='{retry_raw}'. Expected integer >= 0.") from None

app_pw_raw = os.environ.get("NEXTCLOUD_MCP_APP_PASSWORD", "").strip().lower()
if app_pw_raw in ("", "false", "0", "no"):
is_app_password = False
elif app_pw_raw in ("true", "1", "yes"):
is_app_password = True
else:
raise ValueError(f"Invalid NEXTCLOUD_MCP_APP_PASSWORD='{app_pw_raw}'. Expected: true/false, 1/0, yes/no.")
is_app_password = _env_bool("NEXTCLOUD_MCP_APP_PASSWORD")
multiuser = _env_bool("NEXTCLOUD_MCP_MULTIUSER")
cache_size = _env_number("NEXTCLOUD_MCP_MULTIUSER_CACHE_SIZE", "64", int, minimum=1)
ttl = _env_number("NEXTCLOUD_MCP_MULTIUSER_TTL", "900", float, minimum=1)

upload_root_raw = os.environ.get("NEXTCLOUD_MCP_UPLOAD_ROOT", "").strip()
if upload_root_raw:
Expand All @@ -88,13 +101,31 @@ def from_env(cls) -> "Config":
retry_max=max(0, retry_max),
is_app_password=is_app_password,
upload_root=upload_root,
multiuser=multiuser,
multiuser_cache_size=int(cache_size),
multiuser_ttl=float(ttl),
)

def validate(self) -> None:
"""Raise ValueError if required config is missing."""
missing: list[str] = []
if not self.nextcloud_url:
missing.append("NEXTCLOUD_URL")
if self.multiuser:
# No fallback account: a request without its own login must fail, not run as someone else.
if self.user or self.password:
raise ValueError(
"NEXTCLOUD_MCP_MULTIUSER=true takes the login from each request; "
"unset NEXTCLOUD_USER and NEXTCLOUD_PASSWORD."
)
if self.upload_root:
raise ValueError("NEXTCLOUD_MCP_UPLOAD_ROOT is not supported with NEXTCLOUD_MCP_MULTIUSER=true.")
if missing:
raise ValueError(
f"Missing required environment variables: {', '.join(missing)}. "
f"Set them before starting the MCP server."
)
return
if not self.user:
missing.append("NEXTCLOUD_USER")
if not self.password:
Expand All @@ -104,3 +135,23 @@ def validate(self) -> None:
f"Missing required environment variables: {', '.join(missing)}. "
f"Set them before starting the MCP server."
)


def _env_bool(name: str) -> bool:
raw = os.environ.get(name, "").strip().lower()
if raw in ("", "false", "0", "no"):
return False
if raw in ("true", "1", "yes"):
return True
raise ValueError(f"Invalid {name}='{raw}'. Expected: true/false, 1/0, yes/no.")


def _env_number(name: str, default: str, kind: type[int] | type[float], minimum: float) -> float:
raw = os.environ.get(name, default).strip()
try:
value = kind(raw)
except ValueError:
raise ValueError(f"Invalid {name}='{raw}'. Expected a number >= {minimum:g}.") from None
if value < minimum:
raise ValueError(f"Invalid {name}='{raw}'. Expected a number >= {minimum:g}.")
return value
Loading