# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Coding Conventions ### async/await Patterns - **Use anyio for all async operations** - Provides structured concurrency - pytest runs in `anyio` mode (`anyio_mode = "auto"` in pyproject.toml) - Use `anyio.create_task_group()` for concurrent execution (NOT `asyncio.gather()`) - Use `anyio.Lock()` for synchronization primitives (NOT `asyncio.Lock()`) - Use `anyio.run()` for entry points (NOT `asyncio.run()`) - Prefer standard async/await syntax without explicit library imports when possible - Examples: app.py, search/hybrid.py, search/verification.py, auth/token_broker.py ### Type Hints - **Use Python 3.10+ union syntax**: `str | None` instead of `Optional[str]` - **Use lowercase generics**: `dict[str, Any]` instead of `Dict[str, Any]` - **Type all function signatures** - Parameters and return types - **Type checker**: `ty` is configured for static type checking ```bash uv run ty check -- nextcloud_mcp_server ``` ### Code Quality - **Before committing or pushing, invoke the `pre-push-review` skill**: - Runs `ruff check`, `ruff format --check`, `ty check`, and unit tests, then audits the branch diff against this repo's recurring PR-review patterns (mined from PRs #733–#750). - Output is a labelled punch list (🔴 blocking / 🟡 important / 🟢 nit). The main loop fixes; the skill reports. - Skill location: `.claude/skills/pre-push-review/SKILL.md`. Invoke via the `Skill` tool with `skill="pre-push-review"`, or when the user types `/pre-push-review`. - Skip only for tiny diffs (typo, README tweak, single-line dependency bump) or when the user explicitly says "just push it". - **Manual fallback** (if the skill is unavailable): ```bash uv run ruff check uv run ruff format uv run ty check -- nextcloud_mcp_server uv run pytest tests/unit/ -x -q ``` - **Ruff configuration** in pyproject.toml (extends select: ["I"] for import sorting) #### SonarCloud quality gate (the `sonar` CLI) CI runs a **required `SonarCloud Code Analysis` check** with a Quality Gate (new-code **Reliability Rating ≥ A**, etc.). It catches issues `ruff`/`ty` never will — e.g. `python:S1244` (float `==`, use `pytest.approx`), `python:S5778` (a `pytest.raises` block with more than one statement that can throw — hoist setup calls out of the `with`), `python:S8572` (`logging.error(..., exc)` in an `except` → `logging.exception(...)`). **A green `ruff`/`ty` says nothing about the Sonar gate**, so check it explicitly rather than guessing (a wrong guess cost a review round on PR #996). Use the authenticated **SonarQube CLI** (`sonar`, on `PATH` via `~/.local/share/sonarqube-cli/bin`; confirm with `sonar auth status` → org `cbcoutinho`). Project key: **`cbcoutinho_nextcloud-mcp-server`**. - **Pre-push (local, no network gate):** scan changed files for hardcoded secrets — the one local scan available on our plan: ```bash sonar analyze secrets $(git diff --name-only origin/master...HEAD) ``` `sonar verify --file` / `sonar analyze sqaa` (server-side Agentic Analysis) are a **paid feature not enabled** on this org — they 403, so don't rely on them for a local quality scan. - **After pushing a PR (SonarCloud analyzes in CI):** list exactly what Sonar flagged on the PR's new code and read the gate — do this *inside the review loop*, before merge, and fix the findings: ```bash sonar list issues --project cbcoutinho_nextcloud-mcp-server \ --pull-request --statuses OPEN --format table sonar api GET "/api/qualitygates/project_status?projectKey=cbcoutinho_nextcloud-mcp-server&pullRequest=" ``` (`sonar api` endpoints **must start with `/`**.) This is faster and more precise than reading the SonarCloud dashboard or the reviewer's guess at which line tripped the gate. ### Error Handling - **Use custom decorators**: `@retry_on_429` for rate limiting (see base_client.py) - **Standard exceptions**: `HTTPStatusError` from httpx, `McpError` for MCP-specific errors - **Logging patterns**: - `logger.debug()` for expected 404s and normal operations - `logger.warning()` for retries and non-critical issues - `logger.error()` for actual errors ### Testing Patterns - **Use existing fixtures** from `tests/conftest.py` (2888 lines of test infrastructure) - **Session-scoped fixtures** handle anyio/pytest-asyncio incompatibility - **Mocked unit tests** use `mocker.AsyncMock(spec=httpx.AsyncClient)` - **pytest-timeout**: 180s default per test - **Mark tests appropriately**: `@pytest.mark.unit`, `@pytest.mark.integration`, `@pytest.mark.oauth`, `@pytest.mark.smoke` ### Architectural Patterns - **Base classes**: **All app API clients MUST extend `BaseNextcloudClient`** and issue requests through its `_make_request`. That method centralizes `_resolve_url` (prepends `/index.php` to bare `/apps/...` paths — the universal entry point that works without pretty-URL rewriting, issue #732), `@retry_on_429`, tracing, and `raise_for_status`. Consequences: - **Write bare `/apps//...` paths — never hardcode `/index.php`.** The base class adds it; `/ocs/...` and `/remote.php/dav/...` paths pass through unchanged. - Don't reimplement `_make_request` in a subclass — a private copy silently skips `_resolve_url`/retry/tracing (and, if it omits `raise_for_status`, breaks 429 retry). Override only for a genuinely different transport. - **Known exception**: `CalendarClient` talks CalDAV via its own `caldav` session, not the shared Nextcloud HTTP client, so it does not extend `BaseNextcloudClient`. This is intentional — do not "fix" it. - **Pydantic responses**: All MCP tools return Pydantic models inheriting from `BaseResponse` - **Decorators**: `@require_scopes`, `@require_provisioning` for access control - **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!) - **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()` - **Token acquisition**: `get_client()` resolves credentials per deployment mode (see Deployment Modes below) ### MCP Tool Annotations (ADR-017) **All tools MUST include annotations** following these patterns: ```python from mcp.types import ToolAnnotations # Read-only tools (list, search, get) @mcp.tool( title="Human Readable Name", annotations=ToolAnnotations( readOnlyHint=True, openWorldHint=True, # Nextcloud is external to MCP server ), ) # Create operations @mcp.tool( title="Create Resource", annotations=ToolAnnotations( idempotentHint=False, # Creates new resources each time openWorldHint=True, ), ) # Update operations (with etag/version control) @mcp.tool( title="Update Resource", annotations=ToolAnnotations( idempotentHint=False, # ETag changes = different inputs openWorldHint=True, ), ) # Delete operations @mcp.tool( title="Delete Resource", annotations=ToolAnnotations( destructiveHint=True, # Permanently deletes data idempotentHint=True, # Same end state if called repeatedly openWorldHint=True, ), ) # HTTP PUT without version control (special case) @mcp.tool( title="Write File", annotations=ToolAnnotations( idempotentHint=True, # Same content = same end state openWorldHint=True, ), ) ``` **Key Principles**: - **Idempotency**: Same inputs → same result. ETags change after updates, making them non-idempotent - **Destructive**: Operations that permanently delete/overwrite data - **Open World**: All Nextcloud tools access external service (openWorldHint=True) - **Titles**: Use human-readable names, not snake_case function names **See**: `docs/ADR-017-mcp-tool-annotations.md` for detailed rationale and examples ### Project Structure - `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs - `nextcloud_mcp_server/server/` - MCP tool/resource definitions - `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication - `nextcloud_mcp_server/models/` - Pydantic response models - `nextcloud_mcp_server/providers/` - Unified LLM provider infrastructure (embeddings + generation) - `tests/` - Layered test suite (unit, smoke, integration, load) ### Provider Architecture (ADR-015) **Unified Provider System** for embeddings and text generation: **Location:** `nextcloud_mcp_server/providers/` - `base.py` - `Provider` ABC with optional capabilities - `registry.py` - Auto-detection and factory pattern - `ollama.py` - Ollama provider (embeddings + generation) - `anthropic.py` - Anthropic provider (generation only) - `bedrock.py` - Amazon Bedrock provider (embeddings + generation) - `simple.py` - Simple in-memory provider (embeddings only, fallback) **Usage:** ```python from nextcloud_mcp_server.providers import get_provider provider = get_provider() # Auto-detects from environment # Check capabilities if provider.supports_embeddings: embeddings = await provider.embed_batch(texts) if provider.supports_generation: text = await provider.generate("prompt", max_tokens=500) ``` **Environment Variables:** Bedrock: - `AWS_REGION` - AWS region (e.g., "us-east-1") - `BEDROCK_EMBEDDING_MODEL` - Embedding model ID (e.g., "amazon.titan-embed-text-v2:0") - `BEDROCK_GENERATION_MODEL` - Generation model ID (e.g., "anthropic.claude-3-sonnet-20240229-v1:0") - `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - Optional, uses AWS credential chain Ollama: - `OLLAMA_BASE_URL` - API URL (e.g., "http://localhost:11434") - `OLLAMA_EMBEDDING_MODEL` - Embedding model (default: "nomic-embed-text") - `OLLAMA_GENERATION_MODEL` - Generation model (e.g., "llama3.2:1b") - `OLLAMA_VERIFY_SSL` - SSL verification (default: "true") Simple (fallback, no config needed): - `SIMPLE_EMBEDDING_DIMENSION` - Dimension (default: 384) **Auto-Detection Priority:** Bedrock → Ollama → Simple **Backward Compatibility:** - Old code using `nextcloud_mcp_server.embedding.get_embedding_service()` still works - `EmbeddingService` now wraps `get_provider()` internally **For Details:** See `docs/ADR-015-unified-provider-architecture.md` ## Development Commands (Quick Reference) ### Testing ```bash # Fast feedback (recommended) uv run pytest tests/unit/ -v # Unit tests (~5s) uv run pytest -m smoke -v # Smoke tests (~30-60s) # Integration tests uv run pytest -m "integration and not oauth" -v # Without OAuth (~2-3min) uv run pytest -m oauth -v # OAuth only (~3min) uv run pytest # Full suite (~4-5min) # Contract tests (Pact — ADR-029; broker-gated, skip without PACT_BROKER) uv run pytest -m contract tests/contract/ -v # Coverage uv run pytest --cov # Specific tests after changes uv run pytest tests/server/test_mcp.py -k "notes" -v uv run pytest tests/client/notes/test_notes_api.py -v ``` **Important**: After code changes, rebuild the correct container: - Single-user tests: `docker compose up --build -d mcp` - Login Flow tests: `docker compose up --build -d mcp-login-flow` - Keycloak tests: `docker compose up --build -d mcp-keycloak` ### Running the Server ```bash # Local development export $(grep -v '^#' .env | xargs) uv run mcp run --transport sse nextcloud_mcp_server.app:mcp # Docker development (rebuilds after code changes) docker compose up --build -d mcp # Single-user (port 8000) docker compose up --build -d mcp-login-flow # Login Flow v2 (port 8004) docker compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002) ``` ### Astrolabe submodule mount (do NOT mount by default) The `third_party/astrolabe` submodule mount in `docker-compose.yml` (`./third_party/astrolabe:/opt/apps/astrolabe:ro`) is **commented out by default and should stay that way**. With it unmounted, the stack installs the most recently **published** Astrolabe version from the Nextcloud app store — which is the correct baseline for almost all work, including CI. Only uncomment the mount when developing features that are **tightly coupled** to unreleased Astrolabe changes and need the local submodule build integration- tested in CI. Re-comment it before the change is considered done — a left-on mount silently pins CI to the local checkout instead of the published app, and breaks for anyone without the submodule built. (See PR #872.) ### Environment Setup ```bash uv sync # Install dependencies uv sync --group dev # Install with dev dependencies ``` ### Load Testing ```bash # Quick test (default: 10 workers, 30 seconds) uv run python -m tests.load.benchmark # Custom concurrency and duration uv run python -m tests.load.benchmark -c 20 -d 60 # Export results for analysis uv run python -m tests.load.benchmark --output results.json --verbose ``` **Expected Performance**: 50-200 RPS for mixed workload, p50 <100ms, p95 <500ms, p99 <1000ms. ## Database Inspection **Credentials**: root/password, nextcloud/password, database: `nextcloud` **Do NOT use `docker compose exec db mariadb` or `docker compose exec sqlite3` directly.** Use the wrapper scripts below instead -- they handle credentials, output formatting, and avoid repeated docker exec approvals. ### MariaDB (Nextcloud) Use `scripts/dbquery.py` for all MariaDB queries: ```bash # Basic query ./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users" # Vertical output (one column per line) - useful for wide tables ./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1" # With different credentials ./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES" ``` **Important Tables**: - `oc_oidc_clients` - OAuth client registrations (DCR) - `oc_oidc_client_scopes` - Client allowed scopes - `oc_oidc_access_tokens` - Issued access tokens - `oc_oidc_authorization_codes` - Authorization codes - `oc_oidc_registration_tokens` - RFC 7592 registration tokens - `oc_oidc_redirect_uris` - Redirect URIs ### SQLite (MCP Services) Use `scripts/sqlitequery.py` for all SQLite queries: ```bash # List tables ./scripts/sqlitequery.py ".tables" # Query specific service ./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens" ./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients" ./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords" # With column headers ./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5" # JSON output ./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions" # View schema ./scripts/sqlitequery.py -s oauth ".schema refresh_tokens" ``` **Services**: `mcp` (default), `oauth`, `keycloak`, `basic` **SQLite Tables**: - `refresh_tokens` - OAuth refresh tokens with user profiles - `audit_logs` - Security audit trail - `oauth_clients` - DCR OAuth client credentials - `oauth_sessions` - OAuth flow session state - `registered_webhooks` - Webhook registrations - `app_passwords` - Multi-user BasicAuth passwords - `alembic_version` - Migration tracking ## Architecture Quick Reference **For detailed architecture, see:** - `docs/comparison-context-agent.md` - Overall architecture - `docs/login-flow-v2.md` - OAuth/OIDC integration patterns and architecture - `docs/ADR-004-progressive-consent.md` - Progressive consent implementation **Core Components**: - `nextcloud_mcp_server/app.py` - FastMCP server entry point - `nextcloud_mcp_server/client/` - HTTP clients (Notes, Calendar, Contacts, Tables, WebDAV) - `nextcloud_mcp_server/server/` - MCP tool/resource definitions - `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication **Supported Apps**: Notes, Calendar (CalDAV + VTODO tasks), Contacts (CardDAV), Tables, WebDAV, Deck, Cookbook **Key Patterns**: 1. `NextcloudClient` orchestrates all app-specific clients 2. `BaseNextcloudClient` provides common HTTP functionality + retry logic 3. MCP tools use context pattern: `get_client(ctx)` → `NextcloudClient` 4. All operations are async using httpx ### Deployment Modes The server supports three deployment modes, controlled by environment variables and docker compose profiles: **1. Single-User** (profile: `single-user`) - Set `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD` (app password) - One shared Nextcloud identity for all MCP requests - Stateless, no persistent storage needed - Best for: personal instances, local development **2. Multi-User BasicAuth** (profile: `multi-user-basic`) - Set `MCP_DEPLOYMENT_MODE=multi_user_basic` - Each MCP client provides credentials via HTTP Authorization header - Per-request client creation from extracted credentials - Best for: internal deployments where users manage their own Nextcloud credentials **3. Login Flow v2** (profile: `login-flow`) - Browser-based app password acquisition via Nextcloud's native Login Flow v2 API - Per-user app passwords stored encrypted in SQLite - Application-level scope enforcement (defense-in-depth) - Works with any Nextcloud 16+ instance (no special apps required) - Best for: production multi-user deployments, OAuth MCP integration - See `docs/ADR-022-login-flow-v2.md` for architecture details ## MCP Response Patterns (CRITICAL) **Never return raw `List[Dict]` from MCP tools** - FastMCP mangles them into dicts with numeric string keys. **Correct Pattern**: 1. Client methods return `List[Dict]` (raw data) 2. MCP tools convert to Pydantic models and wrap in response object 3. Response models inherit from `BaseResponse`, include `results` field + metadata **Reference implementations**: - `nextcloud_mcp_server/models/notes.py:80` - `SearchNotesResponse` - `nextcloud_mcp_server/models/webdav.py:113` - `SearchFilesResponse` - `nextcloud_mcp_server/server/{notes,webdav}.py` - Tool examples **Testing**: Extract `data["results"]` from MCP responses, not `data` directly. ## MCP Sampling for RAG (ADR-008) **What is MCP Sampling?** MCP sampling allows servers to request LLM completions from their clients. This enables Retrieval-Augmented Generation (RAG) patterns where the server retrieves context and the client's LLM generates answers. **When to use sampling:** - Generating natural language answers from retrieved documents - Synthesizing information from multiple sources - Creating summaries with citations **Implementation Pattern** (see ADR-008 for details): ```python from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent @mcp.tool() @require_scopes("notes.read") async def nc_notes_semantic_search_answer( query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500 ) -> SamplingSearchResponse: # 1. Retrieve documents search_response = await nc_notes_semantic_search(query, ctx, limit) # 2. Check for no results (don't waste sampling call) if not search_response.results: return SamplingSearchResponse( query=query, generated_answer="No relevant documents found.", sources=[], total_found=0, success=True ) # 3. Construct prompt with retrieved context prompt = f"{query}\n\nDocuments:\n{format_sources(search_response.results)}\n\nProvide answer with citations." # 4. Request LLM completion via sampling try: result = await ctx.session.create_message( messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], max_tokens=max_answer_tokens, temperature=0.7, model_preferences=ModelPreferences( hints=[ModelHint(name="claude-3-5-sonnet")], intelligencePriority=0.8, speedPriority=0.5, ), include_context="thisServer", ) return SamplingSearchResponse( query=query, generated_answer=result.content.text, sources=search_response.results, model_used=result.model, stop_reason=result.stopReason, success=True ) except Exception as e: # Fallback: Return documents without generated answer return SamplingSearchResponse( query=query, generated_answer=f"[Sampling unavailable: {e}]\n\nFound {len(search_response.results)} documents.", sources=search_response.results, search_method="semantic_sampling_fallback", success=True ) ``` **Key Points**: - **No server-side LLM**: Server has no API keys, client controls which model is used - **Graceful degradation**: Tool always returns useful results even if sampling fails - **User control**: MCP clients SHOULD prompt users to approve sampling requests - **No results optimization**: Skip sampling call when no documents found - **Fixed prompts**: Prompts are not user-configurable to avoid injection risks **Reference**: See `nc_notes_semantic_search_answer` in `nextcloud_mcp_server/server/notes.py:517` and ADR-008 for complete implementation. ## Testing Best Practices (MANDATORY) ### Always Run Tests - **Run tests to completion** before considering any task complete - **Rebuild the correct container** after code changes (see Development Commands above) - **If tests require modifications**, ask for permission before proceeding ### End-to-end + contract coverage for API surface (MANDATORY — review gate) Any PR that **adds or changes API surface** — an MCP tool, an HTTP `/api/v1/*` route, or a cross-service provider/consumer boundary — MUST ship, in the same PR, with: - **End-to-end** coverage exercising the real flow against a running stack (`tests/integration/` full-stack Docker tests, or `tests/smoke/`; there is no dedicated `e2e` marker yet — extend the integration tier), and - **Contract (Pact)** coverage for every cross-service boundary it touches. This service participates in Pact contract tests (ADR-029) under `tests/contract/` — run `uv run pytest -m contract tests/contract/`. It is: - a **consumer** of `astrolabe-cloud-gateway` (sync `POST /v1/ocr` and batch `POST /v1/ocr/batch` + `GET /v1/ocr/batch/{job_id}`) and of `astrolabe` (OCS capabilities + credentials-status), and - a **provider** of the `/api/v1/*` API that the `astrolabe` Nextcloud app consumes (verified via `test_mcp_provider_verification.py`). **Reviewers (including the Claude review bot) must flag any API-surface PR that lacks e2e + contract coverage** as a required change under "Test coverage". If a tier does not exist yet, say so explicitly in the PR and open/track a follow-up card (Deck board 11) — never leave the gap silent. Known gaps (tracked on board 11): - Provider verification currently covers only the **public** endpoints (`/api/v1/status`, `/api/v1/vector-sync/status`). The authenticated surface (`/search`, `/webhooks` CRUD, `/apps`, `/chunk-context`, `/pdf-preview`, `/vector-sync/purge`, per-user app-password/session routes) is **unverified** pending the ADR-029 phase-4 Bearer-token/provider-state hook. - The gateway **embeddings** consumer pact (`/v1/embeddings` + `/v1/models`) is not yet written — only OCR is pacted. - There is **no dedicated `e2e` tier** (no `e2e` marker/dir); full-stack behavior is spread across `tests/integration/` + `tests/smoke/`. - `can-i-deploy` runs in non-blocking **shadow mode**. ### Use Existing Fixtures See `tests/conftest.py` for 2888 lines of test infrastructure: - `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container) - `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-login-flow` container) - `nc_client` - Direct NextcloudClient for setup/cleanup - `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-cleanup ### Writing Mocked Unit Tests For client-layer response parsing tests, use mocked HTTP responses: ```python async def test_notes_api_get_note(mocker): """Test that get_note correctly parses the API response.""" mock_response = create_mock_note_response( note_id=123, title="Test Note", content="Test content", category="Test", etag="abc123" ) mock_make_request = mocker.patch.object( NotesClient, "_make_request", return_value=mock_response ) client = NotesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser") note = await client.get_note(note_id=123) assert note["id"] == 123 mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123") ``` **Mock helpers in `tests/conftest.py`**: `create_mock_response()`, `create_mock_note_response()`, `create_mock_error_response()` **When to use**: Response parsing, error handling, request parameter building **When NOT to use**: CalDAV/CardDAV/WebDAV protocols, OAuth flows, end-to-end MCP testing ### OAuth Testing OAuth tests use **Playwright browser automation** to complete flows programmatically. **Test Environment**: - Three MCP containers: `mcp` (single-user), `mcp-login-flow` (Login Flow v2), `mcp-keycloak` (external IdP) - OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables - Playwright configuration: `--browser firefox --headed` for debugging - Install browsers: `uv run playwright install firefox` **OAuth fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client`, `alice_oauth_token`, `bob_oauth_token`, etc. **Shared OAuth Client**: All test users authenticate using a single OAuth client (created via DCR, deleted at session end via RFC 7592). Matches production behavior. **Run OAuth tests**: ```bash uv run pytest -m oauth -v # All OAuth tests uv run pytest tests/server/oauth/ --browser firefox -v uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v ``` ### Keycloak OAuth Testing **Validates ADR-002 architecture** for external identity providers and offline access patterns. **Architecture**: `MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs` **Setup**: ```bash docker compose up -d keycloak app mcp-keycloak curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration docker compose exec app php occ user_oidc:provider keycloak ``` **Credentials**: admin/admin (Keycloak realm: `nextcloud-mcp`) **For detailed Keycloak setup, see**: - `docs/login-flow-v2.md` - OAuth/OIDC configuration (set `OIDC_DISCOVERY_URL` to a Keycloak realm) - `docs/ADR-002-vector-sync-authentication.md` - Offline access architecture - `docs/keycloak-multi-client-validation.md` - Realm-level validation ### LDAP Testing (GH #980 reproduction) The `ldap` lane reproduces GH #980 (DAV paths built from the loginName instead of the canonical Nextcloud UID). It runs an OpenLDAP server (`vegardit/openldap`) whose user `alice` logs in as `alice` but is mapped by `user_ldap` to a UID derived from the LDAP `entryUUID` — so `loginName != UID` **and** `/remote.php/dav/files/alice/` does not resolve to her real home. This is the only backend that reproduces #980 live: login-by-email resolves to the real home (email is a path alias) and `user_oidc` hardcodes `loginName == UID`. The bug is exercised in **two deployment modes**, each its own CI lane and marker (the markers are mutually exclusive so the lanes don't overlap): - **`ldap`** (`-m ldap`) — drives the **multi-user BasicAuth** MCP service (port 8003) as `alice`; no browser needed. - **`login_flow_ldap`** (`-m login_flow_ldap`) — drives the **login-flow** MCP service (port 8004): identity via OIDC, API access via a per-user app password obtained by completing Login Flow v2 as `alice` (Playwright). Proves the same principal resolution holds when the username comes from the OIDC/app-password path rather than a BasicAuth header. Both assert the same WebDAV round-trip lands in alice's real home via `BaseNextcloudClient._ensure_principal_id` (`current-user-principal` discovery). **Setup**: ```bash # ldap lane: openldap + the multi-user-basic MCP service (port 8003). # user_ldap is auto-configured by app-hooks/post-installation/15-setup-ldap-backend.sh # (gated on the openldap service; a no-op for every other profile). docker compose --profile ldap --profile multi-user-basic up --build -d uv run pytest -m ldap -v # login_flow_ldap lane: openldap + the login-flow MCP service (port 8004). docker compose --profile ldap --profile login-flow up --build -d uv run playwright install chromium --with-deps uv run pytest -m login_flow_ldap -v ``` > The post-installation hook only runs on a **fresh** Nextcloud install. If you > add the `ldap` profile to an already-installed dev stack, run the hook by hand: > `docker compose exec app bash /docker-entrypoint-hooks.d/post-installation/15-setup-ldap-backend.sh` **Credentials**: LDAP admin `uid=admin,dc=example,dc=org` / `ldap_admin_pw`; user `alice` / `AlicePass123!` (see `ldap/bootstrap.ldif`). **Status**: #980's fix (`BaseNextcloudClient._ensure_principal_id`) has landed, so both `tests/server/ldap/test_ldap_dav_principal.py` and `tests/server/login_flow/test_ldap_dav_principal.py` are **passing regression guards** (not xfail) — a failure means the principal-discovery fix regressed in that deployment mode. ## Integration Testing with Docker **Nextcloud**: `docker compose exec app php occ ...` for occ commands **MariaDB**: Use `./scripts/dbquery.py` for queries (see Database Inspection above) **SQLite**: Use `./scripts/sqlitequery.py` for MCP service databases ### Querying Nextcloud Application Logs **Use this pattern** to inspect Nextcloud application logs during debugging: ```bash # View recent log entries docker compose exec app cat /var/www/html/data/nextcloud.log | jq | tail # Filter by app docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.app == "astrolabe")' | tail # Filter by log level (0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=FATAL) docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.level >= 3)' | tail # Search for specific messages docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.message | contains("OAuth"))' | tail -20 # View full exception traces docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.exception != null)' | tail -5 ``` **Log Structure**: Each entry is a JSON object with fields: `reqId`, `level`, `time`, `remoteAddr`, `user`, `app`, `method`, `url`, `message`, `userAgent`, `version`, `exception` **For detailed setup, see**: - `docs/installation.md` - Installation guide - `docs/configuration.md` - Configuration options - `docs/authentication.md` - Authentication modes - `docs/running.md` - Running the server **For additional information regarding MCP during development, see**: - `../../Software/modelcontextprotocol/` - MCP spec - `../../Software/python-sdk/` - Python MCP SDK