This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- Use anyio for all async operations - Provides structured concurrency
- pytest runs in
anyiomode (anyio_mode = "auto"in pyproject.toml) - Use
anyio.create_task_group()for concurrent execution (NOTasyncio.gather()) - Use
anyio.Lock()for synchronization primitives (NOTasyncio.Lock()) - Use
anyio.run()for entry points (NOTasyncio.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
- pytest runs in
- Use Python 3.10+ union syntax:
str | Noneinstead ofOptional[str] - Use lowercase generics:
dict[str, Any]instead ofDict[str, Any] - Type all function signatures - Parameters and return types
- Type checker:
tyis configured for static type checkinguv run ty check -- nextcloud_mcp_server
- Before committing or pushing, invoke the
pre-push-reviewskill:- 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 theSkilltool withskill="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".
- Runs
- Manual fallback (if the skill is unavailable):
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)
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:
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:
sonar list issues --project cbcoutinho_nextcloud-mcp-server \ --pull-request <PR#> --statuses OPEN --format table sonar api GET "/api/qualitygates/project_status?projectKey=cbcoutinho_nextcloud-mcp-server&pullRequest=<PR#>"
(
sonar apiendpoints 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.
- Use custom decorators:
@retry_on_429for rate limiting (see base_client.py) - Standard exceptions:
HTTPStatusErrorfrom httpx,McpErrorfor MCP-specific errors - Logging patterns:
logger.debug()for expected 404s and normal operationslogger.warning()for retries and non-critical issueslogger.error()for actual errors
- 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
- Base classes: All app API clients MUST extend
BaseNextcloudClientand issue requests through its_make_request. That method centralizes_resolve_url(prepends/index.phpto bare/apps/...paths — the universal entry point that works without pretty-URL rewriting, issue #732),@retry_on_429, tracing, andraise_for_status. Consequences:- Write bare
/apps/<app>/...paths — never hardcode/index.php. The base class adds it;/ocs/...and/remote.php/dav/...paths pass through unchanged. - Don't reimplement
_make_requestin a subclass — a private copy silently skips_resolve_url/retry/tracing (and, if it omitsraise_for_status, breaks 429 retry). Override only for a genuinely different transport. - Known exception:
CalendarClienttalks CalDAV via its owncaldavsession, not the shared Nextcloud HTTP client, so it does not extendBaseNextcloudClient. This is intentional — do not "fix" it.
- Write bare
- Pydantic responses: All MCP tools return Pydantic models inheriting from
BaseResponse - Decorators:
@require_scopes,@require_provisioningfor 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)
All tools MUST include annotations following these patterns:
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
nextcloud_mcp_server/client/- HTTP clients for Nextcloud APIsnextcloud_mcp_server/server/- MCP tool/resource definitionsnextcloud_mcp_server/auth/- OAuth/OIDC authenticationnextcloud_mcp_server/models/- Pydantic response modelsnextcloud_mcp_server/providers/- Unified LLM provider infrastructure (embeddings + generation)tests/- Layered test suite (unit, smoke, integration, load)
Unified Provider System for embeddings and text generation:
Location: nextcloud_mcp_server/providers/
base.py-ProviderABC with optional capabilitiesregistry.py- Auto-detection and factory patternollama.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:
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 EmbeddingServicenow wrapsget_provider()internally
For Details: See docs/ADR-015-unified-provider-architecture.md
# 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)
# 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 -vImportant: 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
# 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)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.)
uv sync # Install dependencies
uv sync --group dev # Install with dev dependencies# 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 --verboseExpected Performance: 50-200 RPS for mixed workload, p50 <100ms, p95 <500ms, p99 <1000ms.
Credentials: root/password, nextcloud/password, database: nextcloud
Do NOT use docker compose exec db mariadb or docker compose exec <service> sqlite3 directly. Use the wrapper scripts below instead -- they handle credentials, output formatting, and avoid repeated docker exec approvals.
Use scripts/dbquery.py for all MariaDB queries:
# 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 scopesoc_oidc_access_tokens- Issued access tokensoc_oidc_authorization_codes- Authorization codesoc_oidc_registration_tokens- RFC 7592 registration tokensoc_oidc_redirect_uris- Redirect URIs
Use scripts/sqlitequery.py for all SQLite queries:
# 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 profilesaudit_logs- Security audit trailoauth_clients- DCR OAuth client credentialsoauth_sessions- OAuth flow session stateregistered_webhooks- Webhook registrationsapp_passwords- Multi-user BasicAuth passwordsalembic_version- Migration tracking
For detailed architecture, see:
docs/comparison-context-agent.md- Overall architecturedocs/login-flow-v2.md- OAuth/OIDC integration patterns and architecturedocs/ADR-004-progressive-consent.md- Progressive consent implementation
Core Components:
nextcloud_mcp_server/app.py- FastMCP server entry pointnextcloud_mcp_server/client/- HTTP clients (Notes, Calendar, Contacts, Tables, WebDAV)nextcloud_mcp_server/server/- MCP tool/resource definitionsnextcloud_mcp_server/auth/- OAuth/OIDC authentication
Supported Apps: Notes, Calendar (CalDAV + VTODO tasks), Contacts (CardDAV), Tables, WebDAV, Deck, Cookbook
Key Patterns:
NextcloudClientorchestrates all app-specific clientsBaseNextcloudClientprovides common HTTP functionality + retry logic- MCP tools use context pattern:
get_client(ctx)→NextcloudClient - All operations are async using httpx
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.mdfor architecture details
Never return raw List[Dict] from MCP tools - FastMCP mangles them into dicts with numeric string keys.
Correct Pattern:
- Client methods return
List[Dict](raw data) - MCP tools convert to Pydantic models and wrap in response object
- Response models inherit from
BaseResponse, includeresultsfield + metadata
Reference implementations:
nextcloud_mcp_server/models/notes.py:80-SearchNotesResponsenextcloud_mcp_server/models/webdav.py:113-SearchFilesResponsenextcloud_mcp_server/server/{notes,webdav}.py- Tool examples
Testing: Extract data["results"] from MCP responses, not data directly.
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):
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.
- 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
See tests/conftest.py for 2888 lines of test infrastructure:
nc_mcp_client- MCP client for tool/resource testing (usesmcpcontainer)nc_mcp_oauth_client- MCP client for OAuth testing (usesmcp-login-flowcontainer)nc_client- Direct NextcloudClient for setup/cleanuptemporary_note,temporary_addressbook,temporary_contact- Auto-cleanup
For client-layer response parsing tests, use mocked HTTP responses:
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 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_PASSWORDenvironment variables - Playwright configuration:
--browser firefox --headedfor 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:
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 -vValidates 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:
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 keycloakCredentials: admin/admin (Keycloak realm: nextcloud-mcp)
For detailed Keycloak setup, see:
docs/login-flow-v2.md- OAuth/OIDC configuration (setOIDC_DISCOVERY_URLto a Keycloak realm)docs/ADR-002-vector-sync-authentication.md- Offline access architecturedocs/keycloak-multi-client-validation.md- Realm-level validation
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 reproduction test drives the multi-user BasicAuth MCP service (port 8003)
as alice, so no browser is needed.
Setup:
# openldap (ldap profile) + 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 # xfails until #980's client fix landsThe post-installation hook only runs on a fresh Nextcloud install. If you add the
ldapprofile 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).
xfail note: tests/server/ldap/test_ldap_dav_principal.py is
xfail(strict=True) — it fails (RED) on master because the bug is present, and
xpasses (GREEN) once #980's BaseNextcloudClient._ensure_principal_id discovery
lands. strict=True turns the unexpected pass into a failure, signalling that
the marker should be dropped when #980 merges.
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
Use this pattern to inspect Nextcloud application logs during debugging:
# 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 -5Log 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 guidedocs/configuration.md- Configuration optionsdocs/authentication.md- Authentication modesdocs/running.md- Running the server
For additional information regarding MCP during development, see:
../../Software/modelcontextprotocol/- MCP spec../../Software/python-sdk/- Python MCP SDK