Adds a `DATABASE_URL` setting that lets `RefreshTokenStorage` run against
any SQLAlchemy async backend, primarily `postgresql+asyncpg://...` for
HA k8s deployments. Default behavior is unchanged: when `DATABASE_URL` is
unset the server falls back to the existing `TOKEN_STORAGE_DB` path /
ephemeral SQLite tempfile.
Why
---
Today every MCP pod needs its own PVC to hold the SQLite file, which
pins the Deployment to one replica and blocks horizontal scaling. With
this change, operators can point all replicas at a shared Postgres
(CNPG, RDS, etc.) and the pods become stateless. Encryption stays in
Python (Fernet); the database only sees ciphertext.
What changed
------------
- `config.get_database_url()` resolves DATABASE_URL → TOKEN_STORAGE_DB →
ephemeral tempfile in that priority order.
- `RefreshTokenStorage` builds a process-shared `AsyncEngine` in
`initialize()`. SQLite gets NullPool; Postgres gets pool_size=10,
max_overflow=20, pool_pre_ping=True. 30 aiosqlite call sites adapted
via a thin `_DBConn` / `_Cursor` / `_Row` / `_ExecuteCtx` shim so
existing method bodies need no churn beyond the connection
context-manager swap.
- 7 `INSERT OR REPLACE` statements rewritten as portable
`INSERT ... ON CONFLICT (...) DO UPDATE` (SQLite ≥ 3.24, Postgres ≥ 9.5).
- `sqlite_master` legacy-detection lookup replaced with SQLAlchemy
inspector so the path works against either backend.
- File-permission hardening + parent-dir creation gated on
`is_sqlite_url(...)` — centralized backends manage their own filesystem.
- Alembic migrations 001/002/003/005 converted from raw `op.execute(SQL)`
to portable `op.create_table()` / `op.create_index()` with SQLAlchemy
types. All timestamp columns are `sa.BigInteger` so Postgres allocates
BIGINT (unix epochs don't fit in INT4). SQLite treats BIGINT as
INTEGER, so existing deployments at revision 006 see no schema drift.
- `migrations.py` + CLI take URLs; `db {upgrade,downgrade,current,history}`
gain `--database-url / -u` alongside the legacy `--database-path / -d`.
`get_current_revision()` uses SQLAlchemy inspector instead of raw
sqlite3, so the CLI works against Postgres too.
- `docker-compose.yml` adds a `postgres-test` service under the
`postgres` profile (pinned `postgres:16-alpine` digest) for
integration testing.
- Unit storage tests parametrized over backends via shared
`tests/fixtures/storage_backend.py` — every test in
`test_app_password_storage.py` and `test_webhook_storage.py` runs
once per backend that is available. Postgres is opted in by
`TEST_DATABASE_URL`.
- New `tests/integration/test_storage_postgres.py` (5 tests, marked
`postgres` + `integration`) covers refresh-token, app-password,
OAuth-session, webhook, and audit-log paths end-to-end on Postgres.
- New `docs/ADR-026-pluggable-database-backend.md` records the decision;
`docs/configuration.md` documents `DATABASE_URL` with examples.
Out of scope
------------
- No SQLite → Postgres data migration tool (clean cutover; tokens reissue
on next login, webhooks re-register on next sync tick).
- This repo does not provision Postgres. The matching helm chart change
lives in cbcoutinho/helm-charts (database.url / existingSecret values).
Verification
------------
- `uv run pytest tests/unit/` — 1012 passed, SQLite path unchanged.
- `docker compose --profile postgres up -d postgres-test`
- `TEST_DATABASE_URL=... uv run pytest tests/integration/test_storage_postgres.py -m postgres -v`
— 5 passed.
- `TEST_DATABASE_URL=... uv run pytest tests/unit/test_app_password_storage.py
tests/unit/test_webhook_storage.py` — 50 passed (25 per backend).
- `uv run ruff check && uv run ruff format --check && uv run ty check -- nextcloud_mcp_server` — clean.
Tracked on Astrolabe Cloud POC board, card #99.
---
_This PR was generated with the help of AI, and reviewed by a Human_
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.9 KiB
TOML
139 lines
4.9 KiB
TOML
[project]
|
|
name = "nextcloud-mcp-server"
|
|
version = "0.86.4"
|
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
|
authors = [
|
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
|
]
|
|
readme = "README.md"
|
|
license = {text = "AGPL-3.0-only"}
|
|
requires-python = ">=3.11"
|
|
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
|
dependencies = [
|
|
"mcp[cli] (>=1.27,<1.28)",
|
|
"httpx (>=0.28.1,<0.29.0)",
|
|
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
|
"icalendar (>=7.1.0,<7.2.0)",
|
|
"pythonvcard4>=0.2.0",
|
|
"pydantic>=2.11.4",
|
|
"click>=8.1.8",
|
|
"caldav>=3.0.1,<4.0",
|
|
"recurring-ical-events>=3.8.0,<4.0", # Client-side recurrence expansion (preserves TZID/floating semantics)
|
|
"pyjwt[crypto]>=2.8.0",
|
|
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
|
"alembic>=1.14.0", # Database migrations
|
|
"authlib>=1.6.5",
|
|
"qdrant-client>=1.17.0",
|
|
"fastembed>=0.7.3", # BM25 sparse vector embeddings for hybrid search
|
|
"anthropic>=0.42.0", # For RAG evaluation with Anthropic LLMs
|
|
"boto3>=1.35.0", # For Amazon Bedrock provider (optional)
|
|
# Observability dependencies
|
|
"prometheus-client>=0.21.0", # Prometheus metrics
|
|
"opentelemetry-api>=1.28.2", # OpenTelemetry API
|
|
"opentelemetry-sdk>=1.28.2", # OpenTelemetry SDK
|
|
"starlette<1.0", # Pin until we address starlette 1.0 breaking changes (#648)
|
|
"opentelemetry-instrumentation-asgi>=0.49b2", # Auto-instrument ASGI/Starlette
|
|
"opentelemetry-instrumentation-httpx>=0.49b2", # Auto-instrument httpx client
|
|
"opentelemetry-instrumentation-logging>=0.49b2", # Logging integration
|
|
"opentelemetry-exporter-otlp-proto-grpc>=1.28.2", # OTLP gRPC exporter
|
|
"python-json-logger>=3.2.0", # Structured JSON logging
|
|
"jinja2>=3.1.6",
|
|
"langchain-text-splitters>=1.0.0",
|
|
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
|
|
"pymupdf>=1.26.6",
|
|
"pymupdf4llm>=0.2.2",
|
|
"openai>=2.8.1",
|
|
"dynaconf>=3.2.13,<4.0",
|
|
"mistralai>=2.4.5",
|
|
"sqlalchemy[asyncio]>=2.0",
|
|
"asyncpg>=0.29",
|
|
]
|
|
classifiers = [
|
|
"Development Status :: 4 - Beta",
|
|
"Intended Audience :: Developers",
|
|
"License :: OSI Approved :: GNU Affero General Public License v3",
|
|
"Programming Language :: Python :: 3",
|
|
"Programming Language :: Python :: 3.11",
|
|
"Programming Language :: Python :: 3.12",
|
|
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
"Topic :: Communications",
|
|
"Topic :: Internet :: WWW/HTTP",
|
|
]
|
|
|
|
[project.urls]
|
|
Homepage = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
|
Documentation = "https://github.com/cbcoutinho/nextcloud-mcp-server#readme"
|
|
Repository = "https://github.com/cbcoutinho/nextcloud-mcp-server"
|
|
"Bug Tracker" = "https://github.com/cbcoutinho/nextcloud-mcp-server/issues"
|
|
Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHANGELOG.md"
|
|
|
|
[tool.pytest.ini_options]
|
|
anyio_mode = "auto"
|
|
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
|
|
log_cli = 1
|
|
log_cli_level = "ERROR"
|
|
log_level = "ERROR"
|
|
markers = [
|
|
"unit: Fast unit tests with mocked dependencies",
|
|
"integration: Integration tests requiring Docker containers",
|
|
"smoke: Critical path smoke tests for quick validation",
|
|
"keycloak: OAuth tests that utilize keycloak external identity provider",
|
|
"login_flow: Login Flow v2 integration tests (ADR-022)",
|
|
"multi_user_basic: Multi-user BasicAuth pass-through tests (ADR-020)",
|
|
"postgres: Tests requiring the docker-compose postgres-test service (ADR-026)",
|
|
]
|
|
testpaths = [
|
|
"tests",
|
|
]
|
|
# Timeout settings to prevent tests from hanging indefinitely
|
|
timeout = 180 # 3 minutes default timeout per test (includes fixture setup)
|
|
timeout_func_only = false # Timeout includes fixture setup/teardown
|
|
|
|
[tool.commitizen]
|
|
name = "cz_conventional_commits"
|
|
tag_format = "v$version"
|
|
version_scheme = "pep440"
|
|
version_provider = "uv"
|
|
update_changelog_on_bump = true
|
|
major_version_zero = true
|
|
|
|
[tool.ruff.lint]
|
|
extend-select = ["I", "PLC0415", "G004"]
|
|
|
|
[tool.ruff.lint.per-file-ignores]
|
|
"tests/**" = ["PLC0415"]
|
|
|
|
[build-system]
|
|
requires = ["uv_build>=0.11.5,<0.12.0"]
|
|
build-backend = "uv_build"
|
|
|
|
[tool.uv.build-backend]
|
|
module-name = "nextcloud_mcp_server"
|
|
module-root = ""
|
|
|
|
[dependency-groups]
|
|
dev = [
|
|
"commitizen>=4.8.2",
|
|
"datasets>=3.3.0", # For BeIR nfcorpus dataset loading
|
|
"ipython>=9.2.0",
|
|
"playwright>=1.49.1",
|
|
"pytest>=8.3.5",
|
|
"pytest-cov>=6.1.1",
|
|
"pytest-mock>=3.15.1",
|
|
"pytest-playwright-asyncio>=0.7.1",
|
|
"pytest-timeout>=2.3.1",
|
|
"ruff>=0.11.13",
|
|
"reportlab>=4.0.0",
|
|
"ty>=0.0.1a25",
|
|
"pytest-otel>=2.0.1",
|
|
]
|
|
|
|
[project.scripts]
|
|
nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli"
|
|
|
|
[[tool.uv.index]]
|
|
name = "testpypi"
|
|
url = "https://test.pypi.org/simple/"
|
|
publish-url = "https://test.pypi.org/legacy/"
|
|
explicit = true
|