Same pattern as the ENABLE_LOGIN_FLOW removal in the previous commit:
the deployment mode (MCP_DEPLOYMENT_MODE) is the single source of truth
for selecting an auth flow. The ENABLE_MULTI_USER_BASIC_AUTH env-var
alias is redundant with `MCP_DEPLOYMENT_MODE=multi_user_basic`.
Unlike the ENABLE_LOGIN_FLOW removal — where silent removal was safe
because Login Flow v2 is the auto-detection default — silent removal
here would be a surprise: a user with only ENABLE_MULTI_USER_BASIC_AUTH=true
in their .env would auto-detect into LOGIN_FLOW after upgrade (wrong
runtime mode). Mitigation: detect_auth_mode now reads os.environ
directly for both legacy aliases and raises ValueError with a one-line
migration message if either is set. Applied retroactively to
ENABLE_LOGIN_FLOW as well — loud is better than silent.
- nextcloud_mcp_server/config.py:
- Drop the dynaconf env-var alias entry for ENABLE_MULTI_USER_BASIC_AUTH.
- Update the `enable_multi_user_basic_auth` field docstring to mark it
as derived / not user-settable.
- `_is_multi_user_mode()` (early-config helper, runs before Settings
is built) switched to checking MCP_DEPLOYMENT_MODE directly. Now
consistent with the canonical detection in detect_auth_mode.
- nextcloud_mcp_server/config_validators.py:
- Drop the auto-detection branch (`if settings.enable_multi_user_basic_auth`).
Selection of MULTI_USER_BASIC is now exclusively via the explicit
MCP_DEPLOYMENT_MODE branch.
- Add `enable_multi_user_basic_auth` to `_sync_derived_flags` alongside
`enable_login_flow` — both flags are now derived from the resolved mode.
- Drop `enable_multi_user_basic_auth` from
`MODE_REQUIREMENTS[MULTI_USER_BASIC].required` and from the
`forbidden` lists of SINGLE_USER_BASIC and LOGIN_FLOW (no longer
user input → no meaningful forbidden check).
- Add loud-deprecation `ValueError` block at the top of detect_auth_mode
that errors with a clear migration message when ENABLE_MULTI_USER_BASIC_AUTH
or ENABLE_LOGIN_FLOW is found in os.environ.
- tests/unit/test_config_validators.py:
- Switch ~10 fixtures from `enable_multi_user_basic_auth=True` to
`deployment_mode="multi_user_basic"` (mirrors `enable_login_flow`
treatment from the previous commit).
- Switch two `patch.dict(os.environ, {"ENABLE_MULTI_USER_BASIC_AUTH": "true"})`
blocks to use MCP_DEPLOYMENT_MODE.
- Rename `test_forbidden_multi_user_basic_auth` to
`test_forbidden_multi_user_basic_when_credentials_present` — the
scenario is now an explicit-mode + credentials conflict, not an
env-var-flag conflict.
- Add `test_legacy_enable_multi_user_basic_auth_env_var_errors` and
`test_legacy_enable_login_flow_env_var_errors` to exercise the new
loud-deprecation ValueError path.
- docker-compose.yml: mcp-multi-user-basic profile switched to
`MCP_DEPLOYMENT_MODE=multi_user_basic`.
- env.sample: replaced `#ENABLE_MULTI_USER_BASIC_AUTH=true` example with
`#MCP_DEPLOYMENT_MODE=multi_user_basic`.
- docs/authentication.md, configuration.md, troubleshooting.md,
auth-flows.md, webhook-management-guide.md,
configuration-migration-v2.md, ADR-025: replaced env-var examples
with the canonical MCP_DEPLOYMENT_MODE form.
- docs/ADR-020: marked partly superseded by ADR-022.
- CLAUDE.md: Multi-User BasicAuth section updated to set
MCP_DEPLOYMENT_MODE.
- nextcloud_mcp_server/vector/oauth_sync.py: module docstring updated.
BREAKING CHANGE: ENABLE_MULTI_USER_BASIC_AUTH is no longer read from
the environment, and setting it now raises a startup ValueError with
a migration message. Replace `ENABLE_MULTI_USER_BASIC_AUTH=true` with
`MCP_DEPLOYMENT_MODE=multi_user_basic`. The same loud-deprecation
check is also applied to the recently-removed ENABLE_LOGIN_FLOW —
replace with `MCP_DEPLOYMENT_MODE=login_flow` (or drop both;
`login_flow` is the auto-detect default when no other auth env vars
are set).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
564 lines
22 KiB
Markdown
564 lines
22 KiB
Markdown
# 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)
|
||
|
||
### 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**: `BaseNextcloudClient` for all API clients
|
||
- **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)
|
||
|
||
# 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)
|
||
```
|
||
|
||
### 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 <service> 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
|
||
|
||
### 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
|
||
|
||
## 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
|