refactor: integrate token exchange into unified get_client() pattern

Resolves the token exchange implementation gap where get_session_client()
was implemented but never used by tools. Unifies token acquisition into a
single async get_client() method that handles both pass-through and token
exchange modes transparently.

Core Changes:
- Make get_client() async and merge token exchange logic into it
- Remove scopes parameter from token exchange (Nextcloud doesn't support OAuth scopes)
- Update all 8 tool modules to use await get_client(ctx)
- Fix provisioning decorator to skip checks in BasicAuth mode

Token Acquisition Modes:
1. BasicAuth: Returns shared client (no token operations)
2. OAuth pass-through (default): Verifies and passes Flow 1 token to Nextcloud
3. OAuth token exchange (opt-in): Exchanges Flow 1 token for ephemeral token via RFC 8693

Key Architectural Clarifications:
- Progressive Consent (Flow 1/2) = Authorization architecture
- Token Exchange = Token acquisition pattern during tool execution
- Refresh tokens from Flow 2 are NEVER used for tool calls (only background jobs)
- Nextcloud scopes are "soft-scopes" enforced by MCP server, not IdP

Documentation Updates:
- ADR-004: Added comprehensive token acquisition patterns section
- CRITICAL-TOKEN-EXCHANGE-PATTERN.md: Updated to reflect implementation status
- CLAUDE.md: Updated architectural patterns with async get_client()

Testing:
- All 36 unit tests passing
- All 4 smoke tests passing (BasicAuth mode)
- Linting issues fixed (ruff)

Configuration:
ENABLE_TOKEN_EXCHANGE=false (default) - pass-through mode
ENABLE_TOKEN_EXCHANGE=true (opt-in) - token exchange mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-03 20:33:56 +01:00
co-authored by Claude
parent 636bfd416f
commit 71e77e95bc
18 changed files with 1819 additions and 647 deletions
+182 -453
View File
@@ -2,544 +2,273 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands ## Coding Conventions
### Testing ### async/await Patterns
- **Use anyio + asyncio hybrid** - Both libraries are available
- pytest runs in `anyio` mode (`anyio_mode = "auto"` in pyproject.toml)
- asyncio used in auth modules (refresh_token_storage.py, token_exchange.py, token_broker.py)
- anyio used in calendar.py, client_registration.py, app.py
- Prefer standard async/await syntax without explicit library imports when possible
The test suite is organized in layers for fast feedback: ### Type Hints
- **Use Python 3.10+ union syntax**: `str | None` instead of `Optional[str]`
```bash - **Use lowercase generics**: `dict[str, Any]` instead of `Dict[str, Any]`
# FAST FEEDBACK (recommended for development) - **Type all function signatures** - Parameters and return types
# Unit tests only - ~5 seconds - **No explicit type checker configured** - Ruff handles linting only
uv run pytest tests/unit/ -v
# Smoke tests - critical path validation - ~30-60 seconds
uv run pytest -m smoke -v
# INTEGRATION TESTS
# Integration tests without OAuth - ~2-3 minutes
uv run pytest -m "integration and not oauth" -v
# Full test suite - ~4-5 minutes
uv run pytest
# OAuth tests only (slowest, requires Playwright) - ~3 minutes
uv run pytest -m oauth -v
# COVERAGE
# Run tests with coverage
uv run pytest --cov
# LEGACY COMMANDS (still work)
# Run all integration tests
uv run pytest -m integration -v
# Skip integration tests
uv run pytest -m "not integration" -v
```
! Hint: If the tests are failing due to missing environment variables, then usually the correct .env has not been created or not correctly configured yet.
### Load Testing
```bash
# Run benchmark with default settings (10 workers, 30 seconds)
uv run python -m tests.load.benchmark
# Quick test with custom concurrency and duration
uv run python -m tests.load.benchmark --concurrency 20 --duration 60
# Extended load test (50 workers for 5 minutes)
uv run python -m tests.load.benchmark -c 50 -d 300
# Export results to JSON for analysis
uv run python -m tests.load.benchmark -c 20 -d 60 --output results.json
# Test OAuth server on port 8001
uv run python -m tests.load.benchmark --url http://127.0.0.1:8001/mcp
# Verbose mode with detailed logging
uv run python -m tests.load.benchmark -c 10 -d 30 --verbose
```
**Load Testing Features:**
- **Mixed workload** simulating realistic MCP usage (40% reads, 20% writes, 15% search, 25% other operations)
- **Real-time progress** bar with live RPS and error counts
- **Detailed metrics**:
- Throughput (requests/second)
- Latency percentiles (p50, p90, p95, p99)
- Per-operation breakdown
- Error rates and types
- **Automatic cleanup** of test data
- **JSON export** for CI/CD integration
- **Server health checks** before starting
**Understanding Results:**
- **Requests/Second (RPS)**: Higher is better. Expected baseline: 50-200 RPS for mixed workload
- **Latency**:
- p50 (median): Should be <100ms for most operations
- p95: Should be <500ms
- p99: Should be <1000ms
- **Error Rate**: Should be <1% under normal load
**Common Bottlenecks:**
1. Nextcloud backend API response times (most common)
2. Database connection limits
3. HTTP client connection pooling
4. Network I/O between containers
### Code Quality ### Code Quality
```bash - **Run ruff before committing**:
# Format and lint code ```bash
uv run ruff check uv run ruff check
uv run ruff format uv run ruff format
```
- **Ruff configuration** in pyproject.toml (extends select: ["I"] for import sorting)
# Type checking ### Error Handling
# No explicit type checker configured - this is a Python project using ruff for linting - **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()` handles both pass-through and token exchange modes
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
### 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
- `tests/` - Layered test suite (unit, smoke, integration, load)
## 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`
- OAuth tests: `docker-compose up --build -d mcp-oauth`
- Keycloak tests: `docker-compose up --build -d mcp-keycloak`
### Running the Server ### Running the Server
```bash ```bash
# Local development - load environment variables and run # Local development
export $(grep -v '^#' .env | xargs) export $(grep -v '^#' .env | xargs)
mcp run --transport sse nextcloud_mcp_server.app:mcp mcp run --transport sse nextcloud_mcp_server.app:mcp
# Docker development environment with Nextcloud instance # Docker development (rebuilds after code changes)
docker-compose up docker-compose up --build -d mcp # Single-user (port 8000)
docker-compose up --build -d mcp-oauth # Nextcloud OAuth (port 8001)
# After code changes, rebuild and restart the appropriate MCP server container: docker-compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002)
# For basic auth changes (most common) - uses admin credentials
docker-compose up --build -d mcp
# For OAuth changes - uses OAuth authentication with JWT tokens
docker-compose up --build -d mcp-oauth
# Build Docker image
docker build -t nextcloud-mcp-server .
``` ```
**Important: MCP Server Containers**
- **`mcp`** (port 8000): Uses basic auth with admin credentials. Use this for most development and testing.
- **`mcp-oauth`** (port 8001): Uses OAuth authentication with JWT tokens. Use this when working on OAuth-specific features or tests.
- JWT tokens are used for testing (faster validation, scopes embedded in token)
- The server can handle both JWT and opaque tokens via the token verifier
### Environment Setup ### Environment Setup
```bash ```bash
# Install dependencies uv sync # Install dependencies
uv sync uv sync --group dev # Install with dev dependencies
# Install development dependencies
uv sync --group dev
``` ```
### Database Inspection ### Load Testing
**Docker Compose Database Credentials:**
- Root user: `root` / password: `password`
- App user: `nextcloud` / password: `password`
- Database: `nextcloud`
**Common Database Commands:**
```bash ```bash
# Connect to database as root (most common for inspection) # 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`
```bash
# Connect to database
docker compose exec db mariadb -u root -ppassword nextcloud docker compose exec db mariadb -u root -ppassword nextcloud
# Check OAuth clients # Check OAuth clients
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;" docker compose exec db mariadb -u root -ppassword nextcloud -e \
"SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;"
# Check OAuth client scopes # Check OAuth client scopes
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';" docker compose exec db mariadb -u root -ppassword nextcloud -e \
"SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';"
# Check OAuth access tokens # Check OAuth access tokens
docker compose exec db mariadb -u root -ppassword nextcloud -e "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;" docker compose exec db mariadb -u root -ppassword nextcloud -e \
"SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;"
``` ```
**Important Tables:** **Important Tables**:
- `oc_oidc_clients` - OAuth client registrations (DCR clients) - `oc_oidc_clients` - OAuth client registrations (DCR)
- `oc_oidc_client_scopes` - Client allowed scopes - `oc_oidc_client_scopes` - Client allowed scopes
- `oc_oidc_access_tokens` - Issued access tokens - `oc_oidc_access_tokens` - Issued access tokens
- `oc_oidc_authorization_codes` - Authorization codes - `oc_oidc_authorization_codes` - Authorization codes
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens for client management - `oc_oidc_registration_tokens` - RFC 7592 registration tokens
- `oc_oidc_redirect_uris` - Redirect URIs for each client - `oc_oidc_redirect_uris` - Redirect URIs
## Architecture Overview ## Architecture Quick Reference
This is a Python MCP (Model Context Protocol) server that provides LLM integration with Nextcloud. The architecture follows a layered pattern: **For detailed architecture, see:**
- `docs/comparison-context-agent.md` - Overall architecture
- `docs/oauth-architecture.md` - OAuth integration patterns
- `docs/ADR-004-progressive-consent.md` - Progressive consent implementation
### Core Components **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
- **`nextcloud_mcp_server/app.py`** - Main MCP server entry point using FastMCP framework **Supported Apps**: Notes, Calendar (CalDAV + VTODO tasks), Contacts (CardDAV), Tables, WebDAV, Deck, Cookbook
- **`nextcloud_mcp_server/client/`** - HTTP client implementations for different Nextcloud APIs
- **`nextcloud_mcp_server/server/`** - MCP tool/resource definitions that expose client functionality
- **`nextcloud_mcp_server/controllers/`** - Business logic controllers (e.g., notes search)
### Client Architecture **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
- **`NextcloudClient`** - Main orchestrating client that manages all app-specific clients ## MCP Response Patterns (CRITICAL)
- **`BaseNextcloudClient`** - Abstract base class providing common HTTP functionality and retry logic
- **App-specific clients**: `NotesClient`, `CalendarClient`, `ContactsClient`, `TablesClient`, `WebDAVClient`
### Server Integration **Never return raw `List[Dict]` from MCP tools** - FastMCP mangles them into dicts with numeric string keys.
Each Nextcloud app has a corresponding server module that: **Correct Pattern**:
1. Defines MCP tools using `@mcp.tool()` decorators
2. Defines MCP resources using `@mcp.resource()` decorators
3. Uses the context pattern to access the `NextcloudClient` instance
### Supported Nextcloud Apps
- **Notes** - Full CRUD operations and search
- **Calendar** - CalDAV integration with events, recurring events, attendees, and **tasks (VTODO)**
- **Calendar Operations**: List, create, delete calendars
- **Event Operations**: Full CRUD, recurring events, attendees, reminders, bulk operations
- **Task Operations (VTODO)**: Full CRUD for CalDAV tasks with:
- Status tracking (NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED)
- Priority levels (0-9, 1=highest, 9=lowest)
- Due dates, start dates, completion tracking
- Percent complete (0-100%)
- Categories and filtering
- Search across all calendars
- **Note**: Calendar implementation uses caldav library's AsyncDavClient
- **Contacts** - CardDAV integration with address book operations
- **Tables** - Row-level operations on Nextcloud Tables
- **WebDAV** - Complete file system access
### Key Patterns
1. **Environment-based configuration** - Uses `NextcloudClient.from_env()` to load credentials from environment variables
2. **Async/await throughout** - All operations are async using httpx
3. **Retry logic** - `@retry_on_429` decorator handles rate limiting
4. **Context injection** - MCP context provides access to the authenticated client instance
5. **Modular design** - Each Nextcloud app is isolated in its own client/server pair
### MCP Response Patterns
**CRITICAL: Never return raw `List[Dict]` from MCP tools - always wrap in Pydantic response models**
FastMCP serialization issue: raw lists get mangled into dicts with numeric string keys.
**Pattern:**
1. Client methods return `List[Dict]` (raw data) 1. Client methods return `List[Dict]` (raw data)
2. MCP tools convert to Pydantic models and wrap in response object 2. MCP tools convert to Pydantic models and wrap in response object
3. Response models inherit from `BaseResponse`, include `results` field + metadata 3. Response models inherit from `BaseResponse`, include `results` field + metadata
**Reference implementations:** **Reference implementations**:
- `SearchNotesResponse` in `nextcloud_mcp_server/models/notes.py:80` - `nextcloud_mcp_server/models/notes.py:80` - `SearchNotesResponse`
- `SearchFilesResponse` in `nextcloud_mcp_server/models/webdav.py:113` - `nextcloud_mcp_server/models/webdav.py:113` - `SearchFilesResponse`
- Tool examples: `nextcloud_mcp_server/server/{notes,webdav}.py` - `nextcloud_mcp_server/server/{notes,webdav}.py` - Tool examples
**Testing:** Extract `data["results"]` from MCP responses, not `data` directly. **Testing**: Extract `data["results"]` from MCP responses, not `data` directly.
### Testing Structure ## Testing Best Practices (MANDATORY)
The test suite follows a layered architecture for fast feedback: ### 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
tests/ See `tests/conftest.py` for 2888 lines of test infrastructure:
├── unit/ # Fast unit tests (~5s total) - `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container)
│ ├── test_scope_decorator.py - `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-oauth` container)
│ └── test_response_models.py - `nc_client` - Direct NextcloudClient for setup/cleanup
├── smoke/ # Critical path tests (~30-60s) - `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-cleanup
│ └── test_smoke.py
├── integration/
│ ├── client/ # Direct API layer tests
│ │ ├── notes/
│ │ ├── calendar/
│ │ └── ...
│ └── server/ # MCP tool layer tests
│ ├── oauth/ # OAuth-specific tests (slow, ~3min)
│ │ ├── test_oauth_core.py
│ │ ├── test_scope_authorization.py
│ │ └── ...
│ ├── test_mcp.py
│ └── ...
└── load/ # Performance tests
```
**Test Markers:** ### Writing Mocked Unit Tests
- `@pytest.mark.unit` - Fast unit tests with mocked dependencies For client-layer response parsing tests, use mocked HTTP responses:
- `@pytest.mark.integration` - Integration tests requiring Docker containers
- `@pytest.mark.oauth` - OAuth tests requiring Playwright (slowest)
- `@pytest.mark.smoke` - Critical path smoke tests
**Fixtures** in `tests/conftest.py` - Shared test setup and utilities
- **Important**: Integration tests run against live Docker containers. After making code changes:
- For basic auth tests: rebuild with `docker-compose up --build -d mcp`
- For OAuth tests: rebuild with `docker-compose up --build -d mcp-oauth`
#### Testing Best Practices
- **MANDATORY: Always run tests after implementing features or fixing bugs**
- Run tests to completion before considering any task complete
- If tests require modifications to pass, ask for permission before proceeding
- **Rebuild the correct container** after code changes:
- For basic auth tests (most common): `docker-compose up --build -d mcp`
- For OAuth tests: `docker-compose up --build -d mcp-oauth`
- **Use existing fixtures** from `tests/conftest.py` to avoid duplicate setup work:
- `nc_mcp_client` - MCP client session for tool/resource testing (uses `mcp` container)
- `nc_mcp_oauth_client` - MCP client session for OAuth testing (uses `mcp-oauth` container)
- `nc_client` - Direct NextcloudClient for setup/cleanup operations
- `temporary_note` - Creates and cleans up test notes automatically
- `temporary_addressbook` - Creates and cleans up test address books
- `temporary_contact` - Creates and cleans up test contacts
- **Test specific functionality** after changes:
- For Notes changes: `uv run pytest tests/server/test_mcp.py -k "notes" -v`
- For specific API changes: `uv run pytest tests/client/notes/test_notes_api.py -v`
- For OAuth changes: `uv run pytest tests/server/test_oauth*.py -v` (remember to rebuild `mcp-oauth` container)
- **Avoid creating standalone test scripts** - use pytest with proper fixtures instead
#### Writing Mocked Unit Tests
For client-layer tests that verify response parsing logic, use mocked HTTP responses instead of real network calls:
**Pattern:**
```python ```python
import httpx
import pytest
from nextcloud_mcp_server.client.notes import NotesClient
from tests.conftest import create_mock_note_response
async def test_notes_api_get_note(mocker): async def test_notes_api_get_note(mocker):
"""Test that get_note correctly parses the API response.""" """Test that get_note correctly parses the API response."""
# Create mock response using helper functions
mock_response = create_mock_note_response( mock_response = create_mock_note_response(
note_id=123, note_id=123, title="Test Note", content="Test content",
title="Test Note", category="Test", etag="abc123"
content="Test content",
category="Test",
etag="abc123",
) )
# Mock the _make_request method
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object( mock_make_request = mocker.patch.object(
NotesClient, "_make_request", return_value=mock_response NotesClient, "_make_request", return_value=mock_response
) )
# Create client and test client = NotesClient(mocker.AsyncMock(spec=httpx.AsyncClient), "testuser")
client = NotesClient(mock_client, "testuser")
note = await client.get_note(note_id=123) note = await client.get_note(note_id=123)
# Verify the response was parsed correctly
assert note["id"] == 123 assert note["id"] == 123
assert note["title"] == "Test Note"
# Verify the correct API endpoint was called
mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123") mock_make_request.assert_called_once_with("GET", "/apps/notes/api/v1/notes/123")
``` ```
**Mock Response Helpers in `tests/conftest.py`:** **Mock helpers in `tests/conftest.py`**: `create_mock_response()`, `create_mock_note_response()`, `create_mock_error_response()`
- `create_mock_response()` - Generic HTTP response builder
- `create_mock_note_response()` - Pre-configured note response
- `create_mock_error_response()` - Error responses (404, 412, etc.)
**Benefits:** **When to use**: Response parsing, error handling, request parameter building
- ⚡ Fast execution (~0.1s vs minutes for integration tests) **When NOT to use**: CalDAV/CardDAV/WebDAV protocols, OAuth flows, end-to-end MCP testing
- 🔒 No Docker dependency
- 🎯 Tests focus on response parsing logic
- ♻️ Repeatable and deterministic
**When to use:** ### OAuth Testing
- Testing client methods that parse JSON responses OAuth tests use **Playwright browser automation** to complete flows programmatically.
- Testing error handling (404, 412, etc.)
- Testing request parameter building
**When NOT to use (keep as integration tests):** **Test Environment**:
- Complex protocol interactions (CalDAV, CardDAV, WebDAV) - Three MCP containers: `mcp` (single-user), `mcp-oauth` (Nextcloud OIDC), `mcp-keycloak` (external IdP)
- Multi-component workflows (Notes + WebDAV attachments) - OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
- OAuth flows - Playwright configuration: `--browser firefox --headed` for debugging
- End-to-end MCP tool testing - Install browsers: `uv run playwright install firefox`
**Reference Implementation:** **OAuth fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client`, `alice_oauth_token`, `bob_oauth_token`, etc.
- See `tests/client/notes/test_notes_api.py` for complete examples
- Mark unit tests with `pytestmark = pytest.mark.unit`
- Run with: `uv run pytest tests/unit/ tests/client/notes/test_notes_api.py -v`
#### OAuth/OIDC Testing **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.
OAuth integration tests use **automated Playwright browser automation** to complete the OAuth flow programmatically.
**OAuth Testing Setup:** **Run OAuth tests**:
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
- **Created fresh for each test session** via Dynamic Client Registration (DCR)
- Matches production MCP server behavior (one client, multiple user tokens)
- Each user gets their own unique access token
- **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592)
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
- **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests.
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
- Uses `pytest-playwright-asyncio` for async Playwright fixtures
- **Playwright configuration**: Use pytest CLI args like `--browser firefox --headed` to customize
- **Install browsers**: `uv run playwright install firefox` (or `chromium`, `webkit`)
**Example Commands:**
```bash ```bash
# Run all OAuth tests with Playwright automation using Firefox uv run pytest -m oauth -v # All OAuth tests
uv run pytest tests/server/oauth/ --browser firefox -v uv run pytest tests/server/oauth/ --browser firefox -v
# Run specific OAuth test file with visible browser for debugging
uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed -v
# Run with Chromium (default) - use -m oauth marker for all OAuth tests
uv run pytest -m oauth -v
``` ```
**Test Environment:** ### Keycloak OAuth Testing
- **Two MCP server containers are available:** **Validates ADR-002 architecture** for external identity providers and offline access patterns.
- `mcp` (port 8000): Uses basic auth with admin credentials - for most testing
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
**CI/CD Notes:** **Architecture**: `MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs`
- Playwright tests run in CI/CD environments
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
#### Keycloak OAuth/OIDC Testing (ADR-002 Integration) **Setup**:
The MCP server supports using **Keycloak as an external OAuth/OIDC identity provider** instead of Nextcloud's built-in OIDC app. This validates the ADR-002 architecture for background jobs and external identity providers.
**Architecture:**
```
MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs
```
**Key Benefits:**
-**No admin credentials needed** - All API access uses user's Keycloak token
-**External identity provider** - Demonstrates integration with enterprise IdPs
-**ADR-002 validation** - Tests offline_access and refresh token patterns
-**User provisioning** - Nextcloud automatically provisions users from Keycloak
**Setup and Testing:**
```bash ```bash
# 1. Start Keycloak and MCP server with Keycloak OAuth
docker-compose up -d keycloak app mcp-keycloak docker-compose up -d keycloak app mcp-keycloak
# 2. Verify Keycloak realm is available
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
# 3. Verify user_oidc provider is configured
docker compose exec app php occ user_oidc:provider keycloak docker compose exec app php occ user_oidc:provider keycloak
# 4. Generate encryption key for refresh token storage (optional, for offline access)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Set in environment: export TOKEN_ENCRYPTION_KEY='<key>'
# 5. Test OAuth flow manually
# Get token from Keycloak:
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=mcp-client" \
-d "client_secret=mcp-secret-change-in-production" \
-d "username=admin" \
-d "password=admin" \
-d "scope=openid profile email offline_access" | jq -r .access_token)
# Use token with Nextcloud API (validated by user_oidc):
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/ocs/v2.php/cloud/capabilities
# 6. Connect MCP client
# Point client to: http://localhost:8002
# Complete OAuth flow using Keycloak credentials: admin/admin
``` ```
**Three MCP Server Containers:** **Credentials**: admin/admin (Keycloak realm: `nextcloud-mcp`)
- **`mcp`** (port 8000): Basic auth with admin credentials
- **`mcp-oauth`** (port 8001): Nextcloud OIDC provider (JWT tokens)
- **`mcp-keycloak`** (port 8002): Keycloak OIDC provider (external IdP)
**Keycloak Configuration:** **For detailed Keycloak setup, see**:
- **Realm**: `nextcloud-mcp` (auto-imported from `keycloak/realm-export.json`) - `docs/oauth-setup.md` - OAuth configuration
- **Client**: `mcp-client` (pre-configured with PKCE, offline_access) - `docs/ADR-002-vector-sync-authentication.md` - Offline access architecture
- **Admin user**: `admin/admin` (created in realm export) - `docs/audience-validation-setup.md` - Token audience validation
- **Redirect URIs**: `http://localhost:*/callback`, `http://127.0.0.1:*/callback` - `docs/keycloak-multi-client-validation.md` - Realm-level validation
**Environment Variables** (Generic OIDC - works with any provider): ## Integration Testing with Docker
```bash
# Generic OIDC configuration (provider-agnostic)
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
OIDC_CLIENT_ID=nextcloud-mcp-server # OAuth client ID
OIDC_CLIENT_SECRET=mcp-secret-... # OAuth client secret
# Nextcloud API configuration **Nextcloud**: `docker compose exec app php occ ...` for occ commands
NEXTCLOUD_HOST=http://app:80 # Nextcloud API (token validation in external IdP mode) **MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
# Refresh tokens and token exchange (ADR-002) **For detailed setup, see**:
ENABLE_OFFLINE_ACCESS=true # Enable refresh tokens - `docs/installation.md` - Installation guide
TOKEN_ENCRYPTION_KEY=<fernet-key> # Encrypt refresh tokens - `docs/configuration.md` - Configuration options
TOKEN_STORAGE_DB=/app/data/tokens.db # Token storage path - `docs/authentication.md` - Authentication modes
- `docs/running.md` - Running the server
# OAuth scopes (optional - uses defaults if not specified)
NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write ...
```
**Provider Mode Detection:**
- **External IdP mode**: If `OIDC_DISCOVERY_URL` issuer ≠ `NEXTCLOUD_HOST` → Uses external provider (Keycloak, Auth0, Okta, etc.)
- **Integrated mode**: If `OIDC_DISCOVERY_URL` not set or issuer = `NEXTCLOUD_HOST` → Uses Nextcloud OIDC app
**Nextcloud user_oidc Configuration:**
The `user_oidc` app is automatically configured by `app-hooks/post-installation/15-setup-keycloak-provider.sh`:
```bash
# Configured with:
--check-bearer=1 # Validate bearer tokens
--bearer-provisioning=1 # Auto-provision users
--unique-uid=1 # Hash user IDs
--scope="openid profile email offline_access"
```
**Troubleshooting:**
```bash
# Check Keycloak is running
docker-compose ps keycloak
docker-compose logs keycloak
# Check user_oidc provider configuration
docker compose exec app php occ user_oidc:provider keycloak
# Check MCP server logs
docker-compose logs -f mcp-keycloak
# Check Nextcloud logs for token validation
docker compose exec app tail -f /var/www/html/data/nextcloud.log
# Verify Keycloak is accessible from Nextcloud container
docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
```
**ADR-002 Offline Access Testing:**
The Keycloak integration enables testing ADR-002's primary authentication pattern (offline access with refresh tokens):
1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`)
2. **Token refresh**: Access tokens refreshed automatically when expired
3. **Background workers**: Can access APIs using stored refresh tokens
4. **No admin credentials**: All operations use user's OAuth tokens
**Note**: Service account tokens (client_credentials grant) were considered but rejected as they create Nextcloud user accounts and violate OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section.
See `docs/ADR-002-vector-sync-authentication.md` for architectural details.
**Audience Validation:**
Tokens include `aud: ["mcp-server", "nextcloud"]` claims for proper security:
- MCP server validates tokens are intended for it
- Nextcloud validates tokens include it as audience
- Prevents token misuse across services
See `docs/audience-validation-setup.md` for configuration details and `docs/keycloak-multi-client-validation.md` for realm-level validation behavior.
### Configuration Files
- **`pyproject.toml`** - Python project configuration using uv for dependency management
- **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection
- **`docker-compose.yml`** - Complete development environment with Nextcloud + database
## Integration testing with docker
### Nextcloud
- The `app` container is running nextcloud.
- Use `docker compose exec app php occ ...` to get a list of available commands
### Mariadb
- The `db` container is running mariadb
- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials
+154
View File
@@ -1324,6 +1324,160 @@ grant_type=urn:ietf:params:oauth:grant-type:token-exchange
- Configure audience claim per API - Configure audience claim per API
- Use inline hooks for dynamic audiences - Use inline hooks for dynamic audiences
## Token Acquisition Patterns for MCP Tool Calls
### Progressive Consent vs Token Exchange
**IMPORTANT**: Progressive Consent and Token Exchange are complementary patterns that serve different purposes:
- **Progressive Consent** = Authorization architecture (when and why users grant access)
- Flow 1: MCP client authenticates to MCP server
- Flow 2: MCP server provisions Nextcloud access
- Results in stored refresh tokens for background jobs
- **Token Exchange** = Token acquisition pattern (how tokens are obtained during tool execution)
- Pass-through mode: Verify and pass Flow 1 token to Nextcloud
- Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
- Results in short-lived, operation-specific tokens
**Key Principle**: Refresh tokens from Progressive Consent (Flow 2) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs. This maintains clear separation between user-initiated operations and offline background work.
### Two Token Acquisition Modes
The MCP server supports two modes for obtaining Nextcloud tokens during tool execution:
#### Mode 1: Pass-Through (Default - ENABLE_TOKEN_EXCHANGE=false)
**How it works:**
1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call
2. MCP server validates token audience and scopes
3. MCP server passes the same token to Nextcloud
4. Nextcloud validates token with IdP
**Characteristics:**
- Simple, stateless operation
- Single token flows through the system
- Lower latency (no token exchange round-trip)
- Token lifetime determined by IdP's Flow 1 token settings
**Use case**: Simple deployments where Flow 1 tokens are trusted to access Nextcloud directly.
#### Mode 2: Token Exchange (Opt-In - ENABLE_TOKEN_EXCHANGE=true)
**How it works:**
1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call
2. MCP server validates token audience and scopes
3. MCP server exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
4. MCP server uses ephemeral token for Nextcloud API call
5. Ephemeral token is discarded (not cached)
**Characteristics:**
- Enhanced security through token delegation
- Ephemeral tokens with minimal lifetime (5 minutes default)
- Token exchange provides audit trail
- Fallback to refresh grant if RFC 8693 not supported
- Tokens never cached or stored
**Use case**: High-security environments requiring token delegation, audit trails, and minimal token lifetimes.
### Implementation in get_client()
The token acquisition mode is handled transparently by `get_client()`:
```python
async def get_client(ctx: Context) -> NextcloudClient:
"""
Get the appropriate Nextcloud client based on authentication mode.
This function handles three modes:
1. BasicAuth mode: Returns shared client from lifespan context
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
Verifies Flow 1 token and passes it to Nextcloud
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
"""
settings = get_settings()
lifespan_ctx = ctx.request_context.lifespan_context
# BasicAuth mode
if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client
# OAuth mode
if hasattr(lifespan_ctx, "nextcloud_host"):
if settings.enable_token_exchange:
# Token exchange mode
return await get_session_client_from_context(ctx, lifespan_ctx.nextcloud_host)
else:
# Pass-through mode (default)
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
```
### Nextcloud Scope Limitation
**CRITICAL**: Nextcloud does not support OAuth scopes natively. The scopes used in this architecture (e.g., "notes:read", "calendar:write") are **soft-scopes** enforced by the MCP server via the `@require_scopes` decorator, **not by the IdP or Nextcloud**.
**Implications:**
1. Token exchange requests don't pass scopes to the IdP (Nextcloud doesn't validate them)
2. The MCP server's `@require_scopes` decorator handles authorization checks
3. All Nextcloud tokens have equivalent permissions at the Nextcloud level
4. Fine-grained access control is enforced by the MCP server, not Nextcloud
**Why this matters:**
- You cannot request a "notes-only" token from the IdP
- Token exchange provides audit and delegation benefits, not scope restriction
- Scopes are a convenience for MCP server authorization logic, not a security boundary
### Background Job Pattern
Background jobs use a **completely different** token acquisition pattern:
```python
class BackgroundSyncWorker:
async def sync_user_data(self, user_id: str):
"""Background workers use refresh tokens from Flow 2, never from tool calls."""
# Get refresh token stored during Flow 2 (Progressive Consent)
refresh_token = await self.storage.get_refresh_token(user_id)
# Use refresh token to get Nextcloud access token
response = await self.idp_client.refresh_token(
refresh_token=refresh_token,
audience='nextcloud'
)
# Use access token for background operations
client = NextcloudClient.from_token(
base_url=self.nextcloud_url,
token=response.access_token,
username=user_id
)
await self.sync_notes(user_id, client)
```
**Key differences:**
- Uses refresh tokens from Flow 2 (Progressive Consent provisioning)
- Tokens can be cached for efficiency (longer-lived operations)
- No user interaction possible (offline)
- Different scopes than tool calls (e.g., "notes:sync" vs "notes:read")
### When to Enable Token Exchange
**Enable token exchange when:**
- You need audit trails showing token delegation
- You want minimal token lifetimes for security
- Your IdP supports RFC 8693
- You operate in a high-security environment
**Use pass-through mode when:**
- Simplicity is more important than token delegation
- Your IdP doesn't support RFC 8693
- You trust Flow 1 tokens to access Nextcloud directly
- Lower latency is a priority
**Both modes maintain the same security boundary**: Refresh tokens from Flow 2 are never used for tool calls, only for background jobs.
## Decision Outcome ## Decision Outcome
The **Progressive Consent Architecture with Dual OAuth Flows** provides a secure, enterprise-ready solution for offline access while maintaining strict security boundaries and user transparency. By using separate OAuth flows for client authentication and resource provisioning, we achieve: The **Progressive Consent Architecture with Dual OAuth Flows** provides a secure, enterprise-ready solution for offline access while maintaining strict security boundaries and user transparency. By using separate OAuth flows for client authentication and resource provisioning, we achieve:
+140 -82
View File
@@ -1,28 +1,40 @@
# CRITICAL: Token Exchange Pattern for ADR-004 # Token Acquisition Patterns for ADR-004 Progressive Consent
## Problem Statement ## Overview
The current implementation of ADR-004 Progressive Consent does **NOT** correctly implement the token exchange pattern. This is a **critical architectural flaw** that must be corrected. ADR-004 Progressive Consent establishes the authorization architecture (Flow 1 for client auth, Flow 2 for resource provisioning). This document describes **how tokens are acquired for different operational contexts** within that architecture.
## Current (Incorrect) Implementation **Key Principle**: Refresh tokens from Flow 2 (Progressive Consent) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs.
### What Happens Now: ## Implementation Status
**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
The MCP server supports two token acquisition modes:
1. **Pass-through mode** (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
2. **Token exchange mode** (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security with token delegation
Both modes maintain the critical separation: **refresh tokens are never used for tool calls**.
## Current Default (Pass-Through Mode)
### What Happens (ENABLE_TOKEN_EXCHANGE=false):
1. Client gets Flow 1 token (`aud: "mcp-server"`) 1. Client gets Flow 1 token (`aud: "mcp-server"`)
2. Client calls MCP tool 2. Client calls MCP tool
3. Server validates Flow 1 token 3. Server validates Flow 1 token
4. **WRONG**: Server uses stored refresh token to get Nextcloud token 4. Server passes Flow 1 token to Nextcloud
5. **WRONG**: Same refresh token used for all sessions and background jobs 5. Nextcloud validates token with IdP
6. Refresh tokens (from Flow 2) used **only** for background jobs
### Problems: ### Characteristics:
- ❌ No separation between session tokens and background tokens - ✅ Simple, stateless operation
- ❌ Refresh tokens are reused across different contexts - ✅ Clear separation: Flow 1 tokens for sessions, refresh tokens for background
- ❌ Session tokens could have different scope requirements than background tokens - ✅ Lower latency (no token exchange round-trip)
- ❌ No on-demand delegation during tool calls - ✅ Works with any OAuth IdP
- ❌ Violates principle of least privilege
## Correct Implementation Required ## Optional Token Exchange Mode
### Token Exchange Pattern ### Token Exchange Pattern (ENABLE_TOKEN_EXCHANGE=true)
**MCP Session (Foreground Operations)**: **MCP Session (Foreground Operations)**:
@@ -119,116 +131,136 @@ Implement RFC 8693 Token Exchange:
async def exchange_token_for_delegation( async def exchange_token_for_delegation(
flow1_token: str, flow1_token: str,
requested_scopes: list[str], requested_audience: str = "nextcloud",
requested_audience: str = "nextcloud" requested_scopes: list[str] | None = None
) -> tuple[str, int]: ) -> tuple[str, int]:
""" """
Exchange Flow 1 MCP token for delegated Nextcloud token. Exchange Flow 1 MCP token for delegated Nextcloud token.
This implements RFC 8693 Token Exchange for on-behalf-of delegation. This implements RFC 8693 Token Exchange for on-behalf-of delegation.
IMPORTANT: Nextcloud doesn't support OAuth scopes natively. Scopes are
soft-scopes enforced by the MCP server via @require_scopes decorator,
not by the IdP or Nextcloud. Therefore, requested_scopes are not passed
to the IdP during token exchange.
Args: Args:
flow1_token: The MCP session token (aud: "mcp-server") flow1_token: The MCP session token (aud: "mcp-server")
requested_scopes: Scopes needed for this operation
requested_audience: Target audience (usually "nextcloud") requested_audience: Target audience (usually "nextcloud")
requested_scopes: Ignored (Nextcloud doesn't support scopes)
Returns: Returns:
Tuple of (delegated_token, expires_in) Tuple of (delegated_token, expires_in)
""" """
# 1. Validate Flow 1 token # 1. Validate Flow 1 token (audience check)
# 2. Check user has provisioned Nextcloud access (Flow 2) # 2. Check user has provisioned Nextcloud access (Flow 2)
# 3. Request token exchange from IdP # 3. Request token exchange from IdP (without scopes - Nextcloud doesn't support them)
# 4. Return ephemeral delegated token # 4. Return ephemeral delegated token
``` ```
### 2. Context-Aware Token Broker ### 2. Unified get_client() Pattern
Update Token Broker to distinguish contexts: The token acquisition mode is handled transparently by `get_client()`:
```python ```python
class TokenBrokerService: # nextcloud_mcp_server/context.py
async def get_session_token(
self,
flow1_token: str,
required_scopes: list[str]
) -> str:
"""Get ephemeral token for MCP session (on-demand)."""
# Exchange Flow 1 token for delegated token
# DO NOT use stored refresh token
# Return short-lived token
async def get_background_token( async def get_client(ctx: Context) -> NextcloudClient:
self, """
user_id: str, Get the appropriate Nextcloud client based on authentication mode.
required_scopes: list[str]
) -> str: This function handles three modes:
"""Get token for background job (uses refresh token).""" 1. BasicAuth mode: Returns shared client from lifespan context
# Use stored refresh token from Flow 2 2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
# Different scope requirements Verifies Flow 1 token and passes it to Nextcloud
# Longer-lived token 3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
"""
settings = get_settings()
lifespan_ctx = ctx.request_context.lifespan_context
# BasicAuth mode - use shared client (no token exchange)
if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client
# OAuth mode (has 'nextcloud_host' attribute)
if hasattr(lifespan_ctx, "nextcloud_host"):
# Check if token exchange is enabled
if settings.enable_token_exchange:
# Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
return await get_session_client_from_context(
ctx, lifespan_ctx.nextcloud_host
)
else:
# Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
``` ```
### 3. Update MCP Tool Pattern ### 3. MCP Tool Pattern (No Changes Required!)
Tools should request token exchange: Tools use the same pattern regardless of token acquisition mode:
```python ```python
@mcp.tool() @mcp.tool()
@require_scopes("notes:read") @require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud
@require_provisioning @require_provisioning
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content.""" """Search notes by title or content."""
# Extract Flow 1 token from context # get_client() handles both pass-through and token exchange modes
flow1_token = ctx.authorization.token client = await get_client(ctx)
# Get Token Broker
broker = get_token_broker()
# CRITICAL: Exchange for delegated token
nextcloud_token = await broker.get_session_token(
flow1_token=flow1_token,
required_scopes=["notes:read"] # Minimal scopes for this operation
)
# Create Nextcloud client with delegated token
client = await create_nextcloud_client(
host=NEXTCLOUD_HOST,
token=nextcloud_token # Ephemeral delegated token
)
# Execute operation # Execute operation
results = await client.notes_search_notes(query=query) results = await client.notes.search_notes(query=query)
# Token automatically expires - NOT stored # In token exchange mode, ephemeral token is automatically discarded
# In pass-through mode, Flow 1 token was validated and passed through
return SearchNotesResponse(results=results) return SearchNotesResponse(results=results)
``` ```
**Key Benefit**: Tools don't need to know which mode is active. The token acquisition pattern is configured at the server level via `ENABLE_TOKEN_EXCHANGE`.
### 4. Background Job Pattern ### 4. Background Job Pattern
Background jobs use a **different token acquisition pattern** - they use refresh tokens from Flow 2:
```python ```python
# Background worker # Background worker
async def sync_notes_job(user_id: str): async def sync_notes_job(user_id: str):
"""Background job to sync notes.""" """Background job to sync notes."""
broker = get_token_broker() # Get refresh token stored during Flow 2 (Progressive Consent)
token_storage = get_token_storage()
refresh_token = await token_storage.get_refresh_token(user_id)
# CRITICAL: Use background token pattern if not refresh_token:
background_token = await broker.get_background_token( logger.warning(f"No refresh token for user {user_id}")
user_id=user_id, return
required_scopes=["notes:sync", "files:sync"] # Background-specific scopes
# Use refresh token to get Nextcloud access token
idp_client = get_idp_client()
response = await idp_client.refresh_token(
refresh_token=refresh_token,
audience='nextcloud'
) )
# Create client with background token # Create client with background token (can be cached)
client = await create_nextcloud_client( client = NextcloudClient.from_token(
host=NEXTCLOUD_HOST, base_url=NEXTCLOUD_HOST,
token=background_token token=response.access_token,
username=user_id
) )
# Perform background sync # Perform background sync
await client.notes.sync_all() await client.notes.sync_all()
``` ```
**Key differences from tool calls:**
- Uses refresh tokens from Flow 2 (Progressive Consent provisioning)
- Tokens can be cached for efficiency (longer-lived operations)
- No user interaction possible (offline)
- Never triggered during MCP tool execution
## Security Benefits ## Security Benefits
### Proper Token Exchange: ### Proper Token Exchange:
@@ -276,15 +308,41 @@ async def sync_notes_job(user_id: str):
## Status ## Status
**Current Status**: ❌ CRITICAL ISSUE - Token exchange not implemented **Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
**Target Status**: ✅ Proper token exchange with session/background separation **Modes Available**:
**Priority**: **P0 - Blocker for production use** - ✅ Pass-through mode (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
- ✅ Token exchange mode (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security
## Next Actions **Implementation Complete**:
-`token_exchange.py` module with RFC 8693 support
- ✅ Fallback to refresh grant when RFC 8693 not supported
-`get_client()` unified pattern (handles both modes transparently)
- ✅ Tokens never cached in token exchange mode (ephemeral)
- ✅ Background jobs use separate pattern (refresh tokens from Flow 2)
1. [ ] Implement `token_exchange.py` module with RFC 8693 support ## Configuration
2. [ ] Update `TokenBrokerService` with session vs background methods
3. [ ] Refactor MCP tools to use token exchange pattern To enable token exchange mode:
4. [ ] Add integration tests for token exchange
5. [ ] Document background job patterns ```bash
6. [ ] Update ADR-004 with implementation details # docker-compose.yml or .env
ENABLE_TOKEN_EXCHANGE=true
```
When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ephemeral Nextcloud tokens. When disabled (default), Flow 1 tokens are passed through to Nextcloud.
## Nextcloud Scope Limitation
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
This means:
- Token exchange provides audit and delegation benefits, not scope restriction
- All Nextcloud tokens have equivalent permissions at the Nextcloud level
- Fine-grained access control is enforced by MCP server, not Nextcloud
## Next Actions (Optional Enhancements)
1. [ ] Add integration tests for token exchange mode with actual MCP tools
2. [ ] Document background job patterns for scheduled sync operations
3. [ ] Add metrics for token exchange performance
4. [ ] Consider making token exchange the default in future major version
@@ -6,6 +6,8 @@ from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from ..client import NextcloudClient from ..client import NextcloudClient
from ..config import get_settings
from .token_exchange import exchange_token_for_delegation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -63,3 +65,85 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
logger.error(f"Failed to extract OAuth context: {e}") logger.error(f"Failed to extract OAuth context: {e}")
logger.error("This may indicate the server is not running in OAuth mode") logger.error("This may indicate the server is not running in OAuth mode")
raise raise
async def get_session_client_from_context(
ctx: Context, base_url: str
) -> NextcloudClient:
"""
Create NextcloudClient using RFC 8693 token exchange for session operations.
This implements the token exchange pattern where:
1. Extract Flow 1 token from context (aud: "mcp-server")
2. Exchange it for ephemeral Nextcloud token via RFC 8693
3. Create client with delegated token (NOT stored)
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
by the MCP server via @require_scopes decorator, not by the IdP. Therefore,
we don't pass scopes to the token exchange - the MCP server already validated
permissions before calling this function.
Args:
ctx: MCP request context containing session info
base_url: Nextcloud base URL
Returns:
NextcloudClient configured with ephemeral delegated token
Raises:
AttributeError: If context doesn't contain expected OAuth session data
RuntimeError: If token exchange fails
"""
settings = get_settings()
# Check if token exchange is enabled
if not settings.enable_token_exchange:
logger.info("Token exchange disabled, falling back to standard OAuth flow")
return get_client_from_context(ctx, base_url)
try:
# Extract Flow 1 token from context
if hasattr(ctx.request_context.request, "user") and hasattr(
ctx.request_context.request.user, "access_token"
):
access_token: AccessToken = ctx.request_context.request.user.access_token
flow1_token = access_token.token
username = access_token.resource # Username stored during verification
logger.debug(f"Retrieved Flow 1 token for user: {username}")
else:
logger.error("No Flow 1 token found in request context")
raise AttributeError("No access token found in OAuth request context")
if not username:
logger.error("No username found in access token resource field")
raise ValueError("Username not available in OAuth token context")
logger.info("Exchanging Flow 1 token for ephemeral Nextcloud token")
# Perform RFC 8693 token exchange
# Note: We don't pass scopes since Nextcloud doesn't enforce them.
# The MCP server's @require_scopes decorator handles authorization.
delegated_token, expires_in = await exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=None, # Nextcloud doesn't support scopes
requested_audience="nextcloud",
)
logger.info(
f"Token exchange successful. Ephemeral token expires in {expires_in}s"
)
# Create client with ephemeral delegated token
# This token is NOT stored and will be discarded after use
return NextcloudClient.from_token(
base_url=base_url, token=delegated_token, username=username
)
except AttributeError as e:
logger.error(f"Failed to extract OAuth context: {e}")
raise
except Exception as e:
logger.error(f"Token exchange failed: {e}")
# Fall back to standard OAuth flow if token exchange fails
logger.info("Falling back to standard OAuth flow")
return get_client_from_context(ctx, base_url)
@@ -55,6 +55,15 @@ def require_provisioning(func: Callable) -> Callable:
) )
) )
# Check if we're in BasicAuth mode - if so, skip provisioning check
# In BasicAuth mode, there's no OAuth and no provisioning needed
lifespan_ctx = ctx.request_context.lifespan_context
if hasattr(lifespan_ctx, "client"):
# BasicAuth mode - no provisioning needed, just proceed
logger.debug("BasicAuth mode detected - skipping provisioning check")
return await func(*args, **kwargs)
# OAuth mode - check provisioning
# Get user_id from authorization token # Get user_id from authorization token
user_id = None user_id = None
if hasattr(ctx, "authorization") and ctx.authorization: if hasattr(ctx, "authorization") and ctx.authorization:
+169
View File
@@ -11,6 +11,7 @@ The Token Broker provides:
- Short-lived token caching (5-minute TTL) - Short-lived token caching (5-minute TTL)
- Master refresh token rotation - Master refresh token rotation
- Audience-specific token validation - Audience-specific token validation
- Session vs background token separation (RFC 8693)
""" """
import asyncio import asyncio
@@ -23,6 +24,7 @@ import jwt
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -150,6 +152,10 @@ class TokenBrokerService:
""" """
Get a valid Nextcloud access token for the user. Get a valid Nextcloud access token for the user.
DEPRECATED: This method uses the old pattern of stored refresh tokens
for all operations. Use get_session_token() or get_background_token()
instead for proper session/background separation.
This method: This method:
1. Checks the cache for a valid token 1. Checks the cache for a valid token
2. If not cached, checks for stored refresh token 2. If not cached, checks for stored refresh token
@@ -192,10 +198,119 @@ class TokenBrokerService:
await self.cache.invalidate(user_id) await self.cache.invalidate(user_id)
return None return None
async def get_session_token(
self,
flow1_token: str,
required_scopes: list[str],
requested_audience: str = "nextcloud",
) -> Optional[str]:
"""
Get ephemeral token for MCP session operations (on-demand).
This implements the correct Progressive Consent pattern where:
1. Client provides Flow 1 token (aud: "mcp-server")
2. Server exchanges it for ephemeral Nextcloud token
3. Token is NOT stored, only used for current operation
Key properties:
- On-demand generation during tool execution
- Ephemeral (not stored, discarded after use)
- Limited scopes (only what tool needs)
- Short-lived (5 minutes)
Args:
flow1_token: The MCP session token (aud: "mcp-server")
required_scopes: Minimal scopes needed for this operation
requested_audience: Target audience (usually "nextcloud")
Returns:
Ephemeral Nextcloud access token or None if exchange fails
"""
try:
# Perform RFC 8693 token exchange
delegated_token, expires_in = await exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=required_scopes,
requested_audience=requested_audience,
)
# NOTE: We intentionally do NOT cache session tokens
# They are ephemeral and should be discarded after use
logger.info(
f"Generated ephemeral session token with scopes: {required_scopes}, "
f"expires in {expires_in}s"
)
return delegated_token
except Exception as e:
logger.error(f"Failed to get session token: {e}")
return None
async def get_background_token(
self, user_id: str, required_scopes: list[str]
) -> Optional[str]:
"""
Get token for background job operations (uses stored refresh token).
This is for background/offline operations that run without user interaction.
Uses the stored refresh token from Flow 2 provisioning.
Key properties:
- Uses stored refresh token from Flow 2
- Different scopes than session tokens
- Longer-lived for background operations
- Can be cached for efficiency
Args:
user_id: The user identifier
required_scopes: Scopes needed for background operation
Returns:
Nextcloud access token for background operations or None if not provisioned
"""
# Check cache first (background tokens can be cached)
cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}"
cached_token = await self.cache.get(cache_key)
if cached_token:
return cached_token
# Get stored refresh token
refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data:
logger.info(f"No refresh token found for user {user_id}")
return None
try:
# Decrypt refresh token
encrypted_token = refresh_data["refresh_token"]
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
# Get token with specific scopes for background operation
access_token, expires_in = await self._refresh_access_token_with_scopes(
refresh_token, required_scopes
)
# Cache the background token
await self.cache.set(cache_key, access_token, expires_in)
logger.info(
f"Generated background token for user {user_id} with scopes: {required_scopes}"
)
return access_token
except Exception as e:
logger.error(f"Failed to get background token for user {user_id}: {e}")
await self.cache.invalidate(cache_key)
return None
async def _refresh_access_token(self, refresh_token: str) -> Tuple[str, int]: async def _refresh_access_token(self, refresh_token: str) -> Tuple[str, int]:
""" """
Exchange refresh token for new access token. Exchange refresh token for new access token.
DEPRECATED: Use _refresh_access_token_with_scopes() for scope-specific requests.
Args: Args:
refresh_token: The refresh token refresh_token: The refresh token
@@ -236,6 +351,60 @@ class TokenBrokerService:
logger.info(f"Refreshed access token (expires in {expires_in}s)") logger.info(f"Refreshed access token (expires in {expires_in}s)")
return access_token, expires_in return access_token, expires_in
async def _refresh_access_token_with_scopes(
self, refresh_token: str, required_scopes: list[str]
) -> Tuple[str, int]:
"""
Exchange refresh token for new access token with specific scopes.
This method implements scope downscoping for least privilege.
Args:
refresh_token: The refresh token
required_scopes: Minimal scopes needed for this operation
Returns:
Tuple of (access_token, expires_in_seconds)
"""
config = await self._get_oidc_config()
token_endpoint = config["token_endpoint"]
client = await self._get_http_client()
# Always include basic OpenID scopes
scopes = list(set(["openid", "profile", "email"] + required_scopes))
# Request new access token with specific scopes
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": " ".join(scopes),
}
response = await client.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code != 200:
logger.error(
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
)
raise Exception(f"Token refresh failed: {response.status_code}")
token_data = response.json()
access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
# Validate audience
await self._validate_token_audience(access_token, "nextcloud")
logger.info(
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
)
return access_token, expires_in
async def _validate_token_audience(self, token: str, expected_audience: str): async def _validate_token_audience(self, token: str, expected_audience: str):
""" """
Validate that token has correct audience claim. Validate that token has correct audience claim.
+445
View File
@@ -0,0 +1,445 @@
"""RFC 8693 Token Exchange implementation for ADR-004 Progressive Consent.
This module implements the token exchange pattern to convert Flow 1 MCP tokens
(aud: "mcp-server") into ephemeral delegated Nextcloud tokens (aud: "nextcloud")
for session operations.
Key Properties:
- On-demand generation during tool execution
- Ephemeral tokens (NOT stored, discarded after use)
- Limited scopes (only what tool needs)
- Short-lived (5 minutes default)
"""
import logging
import time
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urljoin
import httpx
import jwt
from ..config import get_settings
from .refresh_token_storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
class TokenExchangeService:
"""Implements RFC 8693 OAuth 2.0 Token Exchange."""
# RFC 8693 Token Type Identifiers
TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token"
TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt"
TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"
def __init__(
self,
oidc_discovery_url: Optional[str] = None,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
nextcloud_host: Optional[str] = None,
):
"""Initialize token exchange service.
Args:
oidc_discovery_url: OIDC discovery endpoint URL
client_id: OAuth client ID for token exchange
client_secret: OAuth client secret
nextcloud_host: Nextcloud instance URL
"""
settings = get_settings()
self.oidc_discovery_url = oidc_discovery_url or settings.oidc_discovery_url
self.client_id = client_id or settings.oidc_client_id
self.client_secret = client_secret or settings.oidc_client_secret
self.nextcloud_host = nextcloud_host or settings.nextcloud_host
self._token_endpoint: Optional[str] = None
self._jwks_uri: Optional[str] = None
self._discovery_cache: Optional[Dict[str, Any]] = None
self._discovery_cache_time: float = 0
self._discovery_cache_ttl: float = 3600 # 1 hour
# Initialize storage for checking provisioning
self.storage = RefreshTokenStorage()
# Create HTTP client
self.http_client = httpx.AsyncClient(
timeout=30.0,
follow_redirects=True,
)
async def __aenter__(self):
"""Async context manager entry."""
await self.storage.initialize()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def close(self):
"""Close HTTP client and storage."""
await self.http_client.aclose()
# RefreshTokenStorage doesn't have a close method
async def _discover_endpoints(self) -> Dict[str, Any]:
"""Discover OIDC endpoints from discovery URL.
Returns:
Discovery document containing endpoint URLs
"""
# Check cache
if (
self._discovery_cache
and (time.time() - self._discovery_cache_time) < self._discovery_cache_ttl
):
return self._discovery_cache
if not self.oidc_discovery_url:
# Fallback to Nextcloud OIDC if no discovery URL
self.oidc_discovery_url = urljoin(
self.nextcloud_host, "/.well-known/openid-configuration"
)
try:
response = await self.http_client.get(self.oidc_discovery_url)
response.raise_for_status()
self._discovery_cache = response.json()
self._discovery_cache_time = time.time()
# Cache frequently used endpoints
self._token_endpoint = self._discovery_cache.get("token_endpoint")
self._jwks_uri = self._discovery_cache.get("jwks_uri")
return self._discovery_cache
except Exception as e:
logger.error(f"Failed to discover OIDC endpoints: {e}")
raise
async def exchange_token_for_delegation(
self,
flow1_token: str,
requested_scopes: list[str],
requested_audience: str = "nextcloud",
) -> Tuple[str, int]:
"""Exchange Flow 1 MCP token for delegated Nextcloud token.
This implements RFC 8693 Token Exchange for on-behalf-of delegation.
Args:
flow1_token: The MCP session token (aud: "mcp-server")
requested_scopes: Scopes needed for this operation
requested_audience: Target audience (usually "nextcloud")
Returns:
Tuple of (delegated_token, expires_in)
Raises:
ValueError: If token validation fails
RuntimeError: If provisioning not completed or exchange fails
"""
# 1. Validate Flow 1 token audience
await self._validate_flow1_token(flow1_token)
# 2. Extract user ID from token
user_id = self._extract_user_id(flow1_token)
# 3. Check user has provisioned Nextcloud access (Flow 2)
if not await self._check_provisioning(user_id):
raise RuntimeError(
"Nextcloud access not provisioned. "
"User must complete Flow 2 provisioning first."
)
# 4. Get stored refresh token for user (from Flow 2)
refresh_token = await self._get_user_refresh_token(user_id)
if not refresh_token:
raise RuntimeError(
"No refresh token found. User must complete provisioning."
)
# 5. Perform token exchange with IdP
delegated_token, expires_in = await self._perform_token_exchange(
subject_token=flow1_token,
refresh_token=refresh_token,
requested_scopes=requested_scopes,
requested_audience=requested_audience,
)
# 6. Log the exchange for audit trail
logger.info(
f"Token exchange completed for user {user_id}: "
f"scopes={requested_scopes}, audience={requested_audience}, "
f"expires_in={expires_in}s"
)
return delegated_token, expires_in
async def _validate_flow1_token(self, token: str):
"""Validate that token has correct audience for MCP server.
Args:
token: JWT token to validate
Raises:
ValueError: If token is invalid or has wrong audience
"""
try:
# Decode without verification first to check audience
# In production, should verify signature against JWKS
payload = jwt.decode(token, options={"verify_signature": False})
# Check audience
audience = payload.get("aud", [])
if isinstance(audience, str):
audience = [audience]
if "mcp-server" not in audience:
raise ValueError(
f"Invalid token audience. Expected 'mcp-server', got {audience}"
)
# Check expiration
exp = payload.get("exp", 0)
if exp < time.time():
raise ValueError("Token has expired")
except jwt.DecodeError as e:
raise ValueError(f"Invalid JWT token: {e}")
def _extract_user_id(self, token: str) -> str:
"""Extract user ID from JWT token.
Args:
token: JWT token
Returns:
User ID from token
"""
try:
payload = jwt.decode(token, options={"verify_signature": False})
# Try standard claims in order of preference
user_id = (
payload.get("sub")
or payload.get("preferred_username")
or payload.get("email")
or payload.get("name")
)
if not user_id:
raise ValueError("No user identifier in token")
return user_id
except jwt.DecodeError as e:
raise ValueError(f"Failed to extract user ID: {e}")
async def _check_provisioning(self, user_id: str) -> bool:
"""Check if user has completed Flow 2 provisioning.
Args:
user_id: User identifier
Returns:
True if provisioned, False otherwise
"""
token_data = await self.storage.get_refresh_token(user_id)
return token_data is not None
async def _get_user_refresh_token(self, user_id: str) -> Optional[str]:
"""Get stored refresh token for user from Flow 2 provisioning.
Args:
user_id: User identifier
Returns:
Refresh token if found, None otherwise
"""
token_data = await self.storage.get_refresh_token(user_id)
if token_data:
return token_data.get("refresh_token")
return None
async def _perform_token_exchange(
self,
subject_token: str,
refresh_token: str,
requested_scopes: list[str],
requested_audience: str,
) -> Tuple[str, int]:
"""Perform RFC 8693 token exchange with IdP.
Args:
subject_token: The token being exchanged (Flow 1 token)
refresh_token: User's stored refresh token for delegation
requested_scopes: Minimal scopes for this operation
requested_audience: Target audience
Returns:
Tuple of (access_token, expires_in)
"""
# Discover token endpoint
discovery = await self._discover_endpoints()
token_endpoint = discovery.get("token_endpoint")
if not token_endpoint:
raise RuntimeError("No token endpoint found in discovery")
# Build token exchange request per RFC 8693
data = {
# Token exchange grant type
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
# The token we're exchanging (Flow 1 MCP token)
"subject_token": subject_token,
"subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
# Use refresh token as actor token (proves we have delegation rights)
"actor_token": refresh_token,
"actor_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
# Requested token properties
"requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN,
"audience": requested_audience,
"scope": " ".join(requested_scopes),
}
# Add client credentials if configured
if self.client_id and self.client_secret:
data["client_id"] = self.client_id
data["client_secret"] = self.client_secret
try:
# Attempt RFC 8693 token exchange
response = await self.http_client.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code == 400:
# Token exchange might not be supported, fall back to refresh grant
logger.info(
"Token exchange not supported, falling back to refresh grant"
)
return await self._fallback_refresh_grant(
refresh_token=refresh_token,
requested_scopes=requested_scopes,
token_endpoint=token_endpoint,
)
response.raise_for_status()
result = response.json()
access_token = result.get("access_token")
expires_in = result.get("expires_in", 300) # Default 5 minutes
if not access_token:
raise RuntimeError("No access token in exchange response")
return access_token, expires_in
except httpx.HTTPStatusError as e:
logger.error(f"Token exchange failed: {e.response.text}")
raise RuntimeError(f"Token exchange failed: {e}")
except Exception as e:
logger.error(f"Token exchange error: {e}")
raise
async def _fallback_refresh_grant(
self, refresh_token: str, requested_scopes: list[str], token_endpoint: str
) -> Tuple[str, int]:
"""Fallback to standard refresh token grant if token exchange not supported.
This is less secure than token exchange but provides compatibility.
Args:
refresh_token: User's stored refresh token
requested_scopes: Minimal scopes for this operation
token_endpoint: Token endpoint URL
Returns:
Tuple of (access_token, expires_in)
"""
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": " ".join(requested_scopes), # Request minimal scopes
}
# Add client credentials if configured
if self.client_id and self.client_secret:
data["client_id"] = self.client_id
data["client_secret"] = self.client_secret
try:
response = await self.http_client.post(
token_endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
result = response.json()
access_token = result.get("access_token")
expires_in = result.get("expires_in", 300) # Default 5 minutes
if not access_token:
raise RuntimeError("No access token in refresh response")
# Log that we're using fallback
logger.warning(
f"Using refresh grant fallback for token exchange. "
f"Scopes: {requested_scopes}"
)
return access_token, expires_in
except httpx.HTTPStatusError as e:
logger.error(f"Refresh grant failed: {e.response.text}")
raise RuntimeError(f"Refresh grant failed: {e}")
except Exception as e:
logger.error(f"Refresh grant error: {e}")
raise
# Singleton instance
_token_exchange_service: Optional[TokenExchangeService] = None
async def get_token_exchange_service() -> TokenExchangeService:
"""Get or create the singleton token exchange service.
Returns:
TokenExchangeService instance
"""
global _token_exchange_service
if _token_exchange_service is None:
_token_exchange_service = TokenExchangeService()
await _token_exchange_service.storage.initialize()
return _token_exchange_service
async def exchange_token_for_delegation(
flow1_token: str, requested_scopes: list[str], requested_audience: str = "nextcloud"
) -> Tuple[str, int]:
"""Convenience function to exchange tokens.
Args:
flow1_token: The MCP session token (aud: "mcp-server")
requested_scopes: Scopes needed for this operation
requested_audience: Target audience (usually "nextcloud")
Returns:
Tuple of (delegated_token, expires_in)
"""
service = await get_token_exchange_service()
return await service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=requested_scopes,
requested_audience=requested_audience,
)
+57 -1
View File
@@ -1,6 +1,7 @@
import logging.config import logging.config
import os import os
from typing import Any from dataclasses import dataclass
from typing import Any, Optional
LOGGING_CONFIG = { LOGGING_CONFIG = {
"version": 1, "version": 1,
@@ -118,3 +119,58 @@ def get_document_processor_config() -> dict[str, Any]:
} }
return config return config
@dataclass
class Settings:
"""Application settings from environment variables."""
# OAuth/OIDC settings
oidc_discovery_url: Optional[str] = None
oidc_client_id: Optional[str] = None
oidc_client_secret: Optional[str] = None
# Nextcloud settings
nextcloud_host: Optional[str] = None
nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None
# Progressive Consent settings
enable_progressive_consent: bool = False
enable_token_exchange: bool = False
enable_offline_access: bool = False
# Token settings
token_encryption_key: Optional[str] = None
token_storage_db: Optional[str] = None
def get_settings() -> Settings:
"""Get application settings from environment variables.
Returns:
Settings object with configuration values
"""
return Settings(
# OAuth/OIDC settings
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
oidc_client_id=os.getenv("OIDC_CLIENT_ID"),
oidc_client_secret=os.getenv("OIDC_CLIENT_SECRET"),
# Nextcloud settings
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
# Progressive Consent settings
enable_progressive_consent=(
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
),
enable_token_exchange=(
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
),
enable_offline_access=(
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
),
# Token settings
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
)
+28 -7
View File
@@ -3,14 +3,22 @@
from mcp.server.fastmcp import Context from mcp.server.fastmcp import Context
from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
def get_client(ctx: Context) -> NextcloudClient: async def get_client(ctx: Context) -> NextcloudClient:
""" """
Get the appropriate Nextcloud client based on authentication mode. Get the appropriate Nextcloud client based on authentication mode.
In BasicAuth mode, returns the shared client from lifespan context. This function handles three modes:
In OAuth mode, creates a new client per-request using the OAuth context. 1. BasicAuth mode: Returns shared client from lifespan context
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
Verifies Flow 1 token and passes it to Nextcloud
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
by the MCP server via @require_scopes decorator, not by the IdP.
This function automatically detects the authentication mode by checking This function automatically detects the authentication mode by checking
the type of the lifespan context. the type of the lifespan context.
@@ -28,21 +36,34 @@ def get_client(ctx: Context) -> NextcloudClient:
```python ```python
@mcp.tool() @mcp.tool()
async def my_tool(ctx: Context): async def my_tool(ctx: Context):
client = get_client(ctx) client = await get_client(ctx)
return await client.capabilities() return await client.capabilities()
``` ```
""" """
settings = get_settings()
lifespan_ctx = ctx.request_context.lifespan_context lifespan_ctx = ctx.request_context.lifespan_context
# Try BasicAuth mode first (has 'client' attribute) # BasicAuth mode - use shared client (no token exchange)
if hasattr(lifespan_ctx, "client"): if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client return lifespan_ctx.client
# OAuth mode (has 'nextcloud_host' attribute) # OAuth mode (has 'nextcloud_host' attribute)
if hasattr(lifespan_ctx, "nextcloud_host"): if hasattr(lifespan_ctx, "nextcloud_host"):
from nextcloud_mcp_server.auth import get_client_from_context # Check if token exchange is enabled
if settings.enable_token_exchange:
from nextcloud_mcp_server.auth.context_helper import (
get_session_client_from_context,
)
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) # Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
return await get_session_client_from_context(
ctx, lifespan_ctx.nextcloud_host
)
else:
# Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud
from nextcloud_mcp_server.auth import get_client_from_context
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
# Unknown context type # Unknown context type
raise AttributeError( raise AttributeError(
+16 -16
View File
@@ -22,7 +22,7 @@ def configure_calendar_tools(mcp: FastMCP):
@require_scopes("calendar:read") @require_scopes("calendar:read")
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse: async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
"""List all available calendars for the user""" """List all available calendars for the user"""
client = get_client(ctx) client = await get_client(ctx)
calendars_data = await client.calendar.list_calendars() calendars_data = await client.calendar.list_calendars()
calendars = [Calendar(**cal_data) for cal_data in calendars_data] calendars = [Calendar(**cal_data) for cal_data in calendars_data]
@@ -79,7 +79,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
Dict with event creation result Dict with event creation result
""" """
client = get_client(ctx) client = await get_client(ctx)
event_data = { event_data = {
"title": title, "title": title,
@@ -139,7 +139,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
List of events matching the filters List of events matching the filters
""" """
client = get_client(ctx) client = await get_client(ctx)
# Convert YYYY-MM-DD format dates to datetime objects # Convert YYYY-MM-DD format dates to datetime objects
start_datetime = None start_datetime = None
@@ -214,7 +214,7 @@ def configure_calendar_tools(mcp: FastMCP):
ctx: Context, ctx: Context,
): ):
"""Get detailed information about a specific event""" """Get detailed information about a specific event"""
client = get_client(ctx) client = await get_client(ctx)
event_data, etag = await client.calendar.get_event(calendar_name, event_uid) event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
return event_data return event_data
@@ -248,7 +248,7 @@ def configure_calendar_tools(mcp: FastMCP):
etag: str = "", etag: str = "",
): ):
"""Update any aspect of an existing event""" """Update any aspect of an existing event"""
client = get_client(ctx) client = await get_client(ctx)
# Build update data with only non-None values # Build update data with only non-None values
event_data = {} event_data = {}
@@ -299,7 +299,7 @@ def configure_calendar_tools(mcp: FastMCP):
ctx: Context, ctx: Context,
): ):
"""Delete a calendar event""" """Delete a calendar event"""
client = get_client(ctx) client = await get_client(ctx)
return await client.calendar.delete_event(calendar_name, event_uid) return await client.calendar.delete_event(calendar_name, event_uid)
@mcp.tool() @mcp.tool()
@@ -342,7 +342,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
Dict with meeting creation result Dict with meeting creation result
""" """
client = get_client(ctx) client = await get_client(ctx)
# Combine date and time for start_datetime # Combine date and time for start_datetime
start_datetime = f"{date}T{time}:00" start_datetime = f"{date}T{time}:00"
@@ -377,7 +377,7 @@ def configure_calendar_tools(mcp: FastMCP):
limit: int = 10, limit: int = 10,
): ):
"""Get upcoming events in next N days""" """Get upcoming events in next N days"""
client = get_client(ctx) client = await get_client(ctx)
now = dt.datetime.now() now = dt.datetime.now()
end_datetime = now + dt.timedelta(days=days_ahead) end_datetime = now + dt.timedelta(days=days_ahead)
@@ -447,7 +447,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
List of available time slots with start/end times and duration List of available time slots with start/end times and duration
""" """
client = get_client(ctx) client = await get_client(ctx)
# Parse attendees # Parse attendees
attendee_list = [] attendee_list = []
@@ -549,7 +549,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
Summary of operation results including counts and details Summary of operation results including counts and details
""" """
client = get_client(ctx) client = await get_client(ctx)
if operation not in ["update", "delete", "move"]: if operation not in ["update", "delete", "move"]:
raise ValueError("Operation must be 'update', 'delete', or 'move'") raise ValueError("Operation must be 'update', 'delete', or 'move'")
@@ -772,7 +772,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
Result of the calendar management operation Result of the calendar management operation
""" """
client = get_client(ctx) client = await get_client(ctx)
if action == "list": if action == "list":
return await client.calendar.list_calendars() return await client.calendar.list_calendars()
@@ -839,7 +839,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
List of todos matching the filters List of todos matching the filters
""" """
client = get_client(ctx) client = await get_client(ctx)
# Build filters dictionary # Build filters dictionary
filters = {} filters = {}
@@ -890,7 +890,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
Dict with todo creation result Dict with todo creation result
""" """
client = get_client(ctx) client = await get_client(ctx)
todo_data = { todo_data = {
"summary": summary, "summary": summary,
@@ -939,7 +939,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
Dict with todo update result Dict with todo update result
""" """
client = get_client(ctx) client = await get_client(ctx)
# Build update data with only non-None values # Build update data with only non-None values
todo_data = {} todo_data = {}
@@ -981,7 +981,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
Dict with deletion status Dict with deletion status
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.calendar.delete_todo(calendar_name, todo_uid) return await client.calendar.delete_todo(calendar_name, todo_uid)
@mcp.tool() @mcp.tool()
@@ -1005,7 +1005,7 @@ def configure_calendar_tools(mcp: FastMCP):
Returns: Returns:
List of todos matching the filters from all calendars List of todos matching the filters from all calendars
""" """
client = get_client(ctx) client = await get_client(ctx)
# Build filters dictionary # Build filters dictionary
filters = {} filters = {}
+7 -7
View File
@@ -14,14 +14,14 @@ def configure_contacts_tools(mcp: FastMCP):
@require_scopes("contacts:read") @require_scopes("contacts:read")
async def nc_contacts_list_addressbooks(ctx: Context): async def nc_contacts_list_addressbooks(ctx: Context):
"""List all addressbooks for the user.""" """List all addressbooks for the user."""
client = get_client(ctx) client = await get_client(ctx)
return await client.contacts.list_addressbooks() return await client.contacts.list_addressbooks()
@mcp.tool() @mcp.tool()
@require_scopes("contacts:read") @require_scopes("contacts:read")
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str): async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook.""" """List all contacts in the specified addressbook."""
client = get_client(ctx) client = await get_client(ctx)
return await client.contacts.list_contacts(addressbook=addressbook) return await client.contacts.list_contacts(addressbook=addressbook)
@mcp.tool() @mcp.tool()
@@ -35,7 +35,7 @@ def configure_contacts_tools(mcp: FastMCP):
name: The name of the addressbook. name: The name of the addressbook.
display_name: The display name of the addressbook. display_name: The display name of the addressbook.
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.contacts.create_addressbook( return await client.contacts.create_addressbook(
name=name, display_name=display_name name=name, display_name=display_name
) )
@@ -44,7 +44,7 @@ def configure_contacts_tools(mcp: FastMCP):
@require_scopes("contacts:write") @require_scopes("contacts:write")
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str): async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
"""Delete an addressbook.""" """Delete an addressbook."""
client = get_client(ctx) client = await get_client(ctx)
return await client.contacts.delete_addressbook(name=name) return await client.contacts.delete_addressbook(name=name)
@mcp.tool() @mcp.tool()
@@ -59,7 +59,7 @@ def configure_contacts_tools(mcp: FastMCP):
uid: The unique ID for the contact. uid: The unique ID for the contact.
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}. contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.contacts.create_contact( return await client.contacts.create_contact(
addressbook=addressbook, uid=uid, contact_data=contact_data addressbook=addressbook, uid=uid, contact_data=contact_data
) )
@@ -68,7 +68,7 @@ def configure_contacts_tools(mcp: FastMCP):
@require_scopes("contacts:write") @require_scopes("contacts:write")
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str): async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact.""" """Delete a contact."""
client = get_client(ctx) client = await get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid) return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@mcp.tool() @mcp.tool()
@@ -84,7 +84,7 @@ def configure_contacts_tools(mcp: FastMCP):
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}. contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
etag: Optional ETag for optimistic concurrency control. etag: Optional ETag for optimistic concurrency control.
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.contacts.update_contact( return await client.contacts.update_contact(
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
) )
+16 -16
View File
@@ -33,7 +33,7 @@ def configure_cookbook_tools(mcp: FastMCP):
async def cookbook_get_version(): async def cookbook_get_version():
"""Get the Cookbook app and API version""" """Get the Cookbook app and API version"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
client = get_client(ctx) client = await get_client(ctx)
version_data = await client.cookbook.get_version() version_data = await client.cookbook.get_version()
return Version(**version_data) return Version(**version_data)
@@ -41,7 +41,7 @@ def configure_cookbook_tools(mcp: FastMCP):
async def cookbook_get_config(): async def cookbook_get_config():
"""Get the Cookbook app configuration""" """Get the Cookbook app configuration"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
client = get_client(ctx) client = await get_client(ctx)
config_data = await client.cookbook.get_config() config_data = await client.cookbook.get_config()
return CookbookConfig(**config_data) return CookbookConfig(**config_data)
@@ -49,7 +49,7 @@ def configure_cookbook_tools(mcp: FastMCP):
async def nc_cookbook_get_recipe_resource(recipe_id: int): async def nc_cookbook_get_recipe_resource(recipe_id: int):
"""Get a recipe by ID using resource URI""" """Get a recipe by ID using resource URI"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
client = get_client(ctx) client = await get_client(ctx)
try: try:
recipe_data = await client.cookbook.get_recipe(recipe_id) recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data) return Recipe(**recipe_data)
@@ -77,7 +77,7 @@ def configure_cookbook_tools(mcp: FastMCP):
This extracts recipe data from websites that use schema.org Recipe markup. This extracts recipe data from websites that use schema.org Recipe markup.
Many popular recipe sites support this standard.""" Many popular recipe sites support this standard."""
client = get_client(ctx) client = await get_client(ctx)
try: try:
recipe_data = await client.cookbook.import_recipe(url) recipe_data = await client.cookbook.import_recipe(url)
recipe = Recipe(**recipe_data) recipe = Recipe(**recipe_data)
@@ -131,7 +131,7 @@ def configure_cookbook_tools(mcp: FastMCP):
@require_scopes("cookbook:read") @require_scopes("cookbook:read")
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse: async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
"""Get all recipes in the database""" """Get all recipes in the database"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
recipes_data = await client.cookbook.list_recipes() recipes_data = await client.cookbook.list_recipes()
recipes = [RecipeStub(**r) for r in recipes_data] recipes = [RecipeStub(**r) for r in recipes_data]
@@ -156,7 +156,7 @@ def configure_cookbook_tools(mcp: FastMCP):
@require_scopes("cookbook:read") @require_scopes("cookbook:read")
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe: async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
"""Get a specific recipe by its ID""" """Get a specific recipe by its ID"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
recipe_data = await client.cookbook.get_recipe(recipe_id) recipe_data = await client.cookbook.get_recipe(recipe_id)
return Recipe(**recipe_data) return Recipe(**recipe_data)
@@ -199,7 +199,7 @@ def configure_cookbook_tools(mcp: FastMCP):
Optional: All other recipe fields following schema.org/Recipe format. Optional: All other recipe fields following schema.org/Recipe format.
Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes).""" Times should be in ISO8601 duration format (e.g., 'PT30M' for 30 minutes)."""
client = get_client(ctx) client = await get_client(ctx)
recipe_data = {"name": name} recipe_data = {"name": name}
if description: if description:
@@ -276,7 +276,7 @@ def configure_cookbook_tools(mcp: FastMCP):
"""Update an existing recipe. """Update an existing recipe.
Provide only the fields you want to update. Unspecified fields remain unchanged.""" Provide only the fields you want to update. Unspecified fields remain unchanged."""
client = get_client(ctx) client = await get_client(ctx)
# First get the current recipe # First get the current recipe
try: try:
@@ -352,7 +352,7 @@ def configure_cookbook_tools(mcp: FastMCP):
) -> DeleteRecipeResponse: ) -> DeleteRecipeResponse:
"""Delete a recipe permanently""" """Delete a recipe permanently"""
logger.info("Deleting recipe %s", recipe_id) logger.info("Deleting recipe %s", recipe_id)
client = get_client(ctx) client = await get_client(ctx)
try: try:
message = await client.cookbook.delete_recipe(recipe_id) message = await client.cookbook.delete_recipe(recipe_id)
return DeleteRecipeResponse( return DeleteRecipeResponse(
@@ -386,7 +386,7 @@ def configure_cookbook_tools(mcp: FastMCP):
query: str, ctx: Context query: str, ctx: Context
) -> SearchRecipesResponse: ) -> SearchRecipesResponse:
"""Search for recipes by keywords, tags, and categories""" """Search for recipes by keywords, tags, and categories"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
recipes_data = await client.cookbook.search_recipes(query) recipes_data = await client.cookbook.search_recipes(query)
recipes = [RecipeStub(**r) for r in recipes_data] recipes = [RecipeStub(**r) for r in recipes_data]
@@ -422,7 +422,7 @@ def configure_cookbook_tools(mcp: FastMCP):
"""Get all known categories. """Get all known categories.
Note: A category name of '*' indicates recipes with no category.""" Note: A category name of '*' indicates recipes with no category."""
client = get_client(ctx) client = await get_client(ctx)
try: try:
categories_data = await client.cookbook.list_categories() categories_data = await client.cookbook.list_categories()
categories = [Category(**c) for c in categories_data] categories = [Category(**c) for c in categories_data]
@@ -451,7 +451,7 @@ def configure_cookbook_tools(mcp: FastMCP):
"""Get all recipes in a specific category. """Get all recipes in a specific category.
Use '_' as the category name to get recipes with no category.""" Use '_' as the category name to get recipes with no category."""
client = get_client(ctx) client = await get_client(ctx)
try: try:
recipes_data = await client.cookbook.get_recipes_in_category(category) recipes_data = await client.cookbook.get_recipes_in_category(category)
recipes = [RecipeStub(**r) for r in recipes_data] recipes = [RecipeStub(**r) for r in recipes_data]
@@ -483,7 +483,7 @@ def configure_cookbook_tools(mcp: FastMCP):
@require_scopes("cookbook:read") @require_scopes("cookbook:read")
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse: async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
"""Get all known keywords/tags""" """Get all known keywords/tags"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
keywords_data = await client.cookbook.list_keywords() keywords_data = await client.cookbook.list_keywords()
keywords = [Keyword(**k) for k in keywords_data] keywords = [Keyword(**k) for k in keywords_data]
@@ -510,7 +510,7 @@ def configure_cookbook_tools(mcp: FastMCP):
keywords: list[str], ctx: Context keywords: list[str], ctx: Context
) -> ListRecipesResponse: ) -> ListRecipesResponse:
"""Get all recipes that have specific keywords/tags""" """Get all recipes that have specific keywords/tags"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
recipes_data = await client.cookbook.get_recipes_with_keywords(keywords) recipes_data = await client.cookbook.get_recipes_with_keywords(keywords)
recipes = [RecipeStub(**r) for r in recipes_data] recipes = [RecipeStub(**r) for r in recipes_data]
@@ -552,7 +552,7 @@ def configure_cookbook_tools(mcp: FastMCP):
folder: Recipe folder path in user's files folder: Recipe folder path in user's files
update_interval: Automatic rescan interval in minutes update_interval: Automatic rescan interval in minutes
print_image: Whether to print images with recipes""" print_image: Whether to print images with recipes"""
client = get_client(ctx) client = await get_client(ctx)
config_data = {} config_data = {}
if folder is not None: if folder is not None:
@@ -587,7 +587,7 @@ def configure_cookbook_tools(mcp: FastMCP):
"""Trigger a rescan of all recipes into the caching database. """Trigger a rescan of all recipes into the caching database.
This rebuilds the search index and should be used after manual file changes.""" This rebuilds the search index and should be used after manual file changes."""
client = get_client(ctx) client = await get_client(ctx)
try: try:
message = await client.cookbook.reindex() message = await client.cookbook.reindex()
return ReindexResponse(status_code=200, message=message) return ReindexResponse(status_code=200, message=message)
+33 -33
View File
@@ -31,7 +31,7 @@ def configure_deck_tools(mcp: FastMCP):
"""List all Nextcloud Deck boards""" """List all Nextcloud Deck boards"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
await ctx.warning("This message is deprecated, use the deck_get_board instead") await ctx.warning("This message is deprecated, use the deck_get_board instead")
client = get_client(ctx) client = await get_client(ctx)
boards = await client.deck.get_boards() boards = await client.deck.get_boards()
return [board.model_dump() for board in boards] return [board.model_dump() for board in boards]
@@ -42,7 +42,7 @@ def configure_deck_tools(mcp: FastMCP):
await ctx.warning( await ctx.warning(
"This resource is deprecated, use the deck_get_board tool instead" "This resource is deprecated, use the deck_get_board tool instead"
) )
client = get_client(ctx) client = await get_client(ctx)
board = await client.deck.get_board(board_id) board = await client.deck.get_board(board_id)
return board.model_dump() return board.model_dump()
@@ -53,7 +53,7 @@ def configure_deck_tools(mcp: FastMCP):
await ctx.warning( await ctx.warning(
"This resource is deprecated, use the deck_get_stacks tool instead" "This resource is deprecated, use the deck_get_stacks tool instead"
) )
client = get_client(ctx) client = await get_client(ctx)
stacks = await client.deck.get_stacks(board_id) stacks = await client.deck.get_stacks(board_id)
return [stack.model_dump() for stack in stacks] return [stack.model_dump() for stack in stacks]
@@ -64,7 +64,7 @@ def configure_deck_tools(mcp: FastMCP):
await ctx.warning( await ctx.warning(
"This resource is deprecated, use the deck_get_stack tool instead" "This resource is deprecated, use the deck_get_stack tool instead"
) )
client = get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
return stack.model_dump() return stack.model_dump()
@@ -75,7 +75,7 @@ def configure_deck_tools(mcp: FastMCP):
await ctx.warning( await ctx.warning(
"This resource is deprecated, use the deck_get_cards tool instead" "This resource is deprecated, use the deck_get_cards tool instead"
) )
client = get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards: if stack.cards:
return [card.model_dump() for card in stack.cards] return [card.model_dump() for card in stack.cards]
@@ -88,7 +88,7 @@ def configure_deck_tools(mcp: FastMCP):
await ctx.warning( await ctx.warning(
"This resource is deprecated, use the deck_get_card tool instead" "This resource is deprecated, use the deck_get_card tool instead"
) )
client = get_client(ctx) client = await get_client(ctx)
card = await client.deck.get_card(board_id, stack_id, card_id) card = await client.deck.get_card(board_id, stack_id, card_id)
return card.model_dump() return card.model_dump()
@@ -99,7 +99,7 @@ def configure_deck_tools(mcp: FastMCP):
await ctx.warning( await ctx.warning(
"This resource is deprecated, use the deck_get_labels tool instead" "This resource is deprecated, use the deck_get_labels tool instead"
) )
client = get_client(ctx) client = await get_client(ctx)
board = await client.deck.get_board(board_id) board = await client.deck.get_board(board_id)
return [label.model_dump() for label in board.labels] return [label.model_dump() for label in board.labels]
@@ -110,7 +110,7 @@ def configure_deck_tools(mcp: FastMCP):
await ctx.warning( await ctx.warning(
"This resource is deprecated, use the deck_get_label tool instead" "This resource is deprecated, use the deck_get_label tool instead"
) )
client = get_client(ctx) client = await get_client(ctx)
label = await client.deck.get_label(board_id, label_id) label = await client.deck.get_label(board_id, label_id)
return label.model_dump() return label.model_dump()
@@ -120,7 +120,7 @@ def configure_deck_tools(mcp: FastMCP):
@require_scopes("deck:read") @require_scopes("deck:read")
async def deck_get_boards(ctx: Context) -> list[DeckBoard]: async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
"""Get all Nextcloud Deck boards""" """Get all Nextcloud Deck boards"""
client = get_client(ctx) client = await get_client(ctx)
boards = await client.deck.get_boards() boards = await client.deck.get_boards()
return boards return boards
@@ -128,7 +128,7 @@ def configure_deck_tools(mcp: FastMCP):
@require_scopes("deck:read") @require_scopes("deck:read")
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
"""Get details of a specific Nextcloud Deck board""" """Get details of a specific Nextcloud Deck board"""
client = get_client(ctx) client = await get_client(ctx)
board = await client.deck.get_board(board_id) board = await client.deck.get_board(board_id)
return board return board
@@ -136,7 +136,7 @@ def configure_deck_tools(mcp: FastMCP):
@require_scopes("deck:read") @require_scopes("deck:read")
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]: async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
"""Get all stacks in a Nextcloud Deck board""" """Get all stacks in a Nextcloud Deck board"""
client = get_client(ctx) client = await get_client(ctx)
stacks = await client.deck.get_stacks(board_id) stacks = await client.deck.get_stacks(board_id)
return stacks return stacks
@@ -144,7 +144,7 @@ def configure_deck_tools(mcp: FastMCP):
@require_scopes("deck:read") @require_scopes("deck:read")
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
"""Get details of a specific Nextcloud Deck stack""" """Get details of a specific Nextcloud Deck stack"""
client = get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
return stack return stack
@@ -154,7 +154,7 @@ def configure_deck_tools(mcp: FastMCP):
ctx: Context, board_id: int, stack_id: int ctx: Context, board_id: int, stack_id: int
) -> list[DeckCard]: ) -> list[DeckCard]:
"""Get all cards in a Nextcloud Deck stack""" """Get all cards in a Nextcloud Deck stack"""
client = get_client(ctx) client = await get_client(ctx)
stack = await client.deck.get_stack(board_id, stack_id) stack = await client.deck.get_stack(board_id, stack_id)
if stack.cards: if stack.cards:
return stack.cards return stack.cards
@@ -166,7 +166,7 @@ def configure_deck_tools(mcp: FastMCP):
ctx: Context, board_id: int, stack_id: int, card_id: int ctx: Context, board_id: int, stack_id: int, card_id: int
) -> DeckCard: ) -> DeckCard:
"""Get details of a specific Nextcloud Deck card""" """Get details of a specific Nextcloud Deck card"""
client = get_client(ctx) client = await get_client(ctx)
card = await client.deck.get_card(board_id, stack_id, card_id) card = await client.deck.get_card(board_id, stack_id, card_id)
return card return card
@@ -174,7 +174,7 @@ def configure_deck_tools(mcp: FastMCP):
@require_scopes("deck:read") @require_scopes("deck:read")
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]: async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
"""Get all labels in a Nextcloud Deck board""" """Get all labels in a Nextcloud Deck board"""
client = get_client(ctx) client = await get_client(ctx)
board = await client.deck.get_board(board_id) board = await client.deck.get_board(board_id)
return board.labels return board.labels
@@ -182,7 +182,7 @@ def configure_deck_tools(mcp: FastMCP):
@require_scopes("deck:read") @require_scopes("deck:read")
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel: async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
"""Get details of a specific Nextcloud Deck label""" """Get details of a specific Nextcloud Deck label"""
client = get_client(ctx) client = await get_client(ctx)
label = await client.deck.get_label(board_id, label_id) label = await client.deck.get_label(board_id, label_id)
return label return label
@@ -199,7 +199,7 @@ def configure_deck_tools(mcp: FastMCP):
title: The title of the new board title: The title of the new board
color: The hexadecimal color of the new board (e.g. FF0000) color: The hexadecimal color of the new board (e.g. FF0000)
""" """
client = get_client(ctx) client = await get_client(ctx)
board = await client.deck.create_board(title, color) board = await client.deck.create_board(title, color)
return CreateBoardResponse(id=board.id, title=board.title, color=board.color) return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
@@ -217,7 +217,7 @@ def configure_deck_tools(mcp: FastMCP):
title: The title of the new stack title: The title of the new stack
order: Order for sorting the stacks order: Order for sorting the stacks
""" """
client = get_client(ctx) client = await get_client(ctx)
stack = await client.deck.create_stack(board_id, title, order) stack = await client.deck.create_stack(board_id, title, order)
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order) return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@@ -238,7 +238,7 @@ def configure_deck_tools(mcp: FastMCP):
title: New title for the stack title: New title for the stack
order: New order for the stack order: New order for the stack
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.update_stack(board_id, stack_id, title, order) await client.deck.update_stack(board_id, stack_id, title, order)
return StackOperationResponse( return StackOperationResponse(
success=True, success=True,
@@ -258,7 +258,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id: The ID of the board board_id: The ID of the board
stack_id: The ID of the stack stack_id: The ID of the stack
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.delete_stack(board_id, stack_id) await client.deck.delete_stack(board_id, stack_id)
return StackOperationResponse( return StackOperationResponse(
success=True, success=True,
@@ -291,7 +291,7 @@ def configure_deck_tools(mcp: FastMCP):
description: Description of the card description: Description of the card
duedate: Due date of the card (ISO-8601 format) duedate: Due date of the card (ISO-8601 format)
""" """
client = get_client(ctx) client = await get_client(ctx)
card = await client.deck.create_card( card = await client.deck.create_card(
board_id, stack_id, title, type, order, description, duedate board_id, stack_id, title, type, order, description, duedate
) )
@@ -333,7 +333,7 @@ def configure_deck_tools(mcp: FastMCP):
archived: Whether the card should be archived archived: Whether the card should be archived
done: Completion date for the card (ISO-8601 format) done: Completion date for the card (ISO-8601 format)
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.update_card( await client.deck.update_card(
board_id, board_id,
stack_id, stack_id,
@@ -367,7 +367,7 @@ def configure_deck_tools(mcp: FastMCP):
stack_id: The ID of the stack stack_id: The ID of the stack
card_id: The ID of the card card_id: The ID of the card
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.delete_card(board_id, stack_id, card_id) await client.deck.delete_card(board_id, stack_id, card_id)
return CardOperationResponse( return CardOperationResponse(
success=True, success=True,
@@ -389,7 +389,7 @@ def configure_deck_tools(mcp: FastMCP):
stack_id: The ID of the stack stack_id: The ID of the stack
card_id: The ID of the card card_id: The ID of the card
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.archive_card(board_id, stack_id, card_id) await client.deck.archive_card(board_id, stack_id, card_id)
return CardOperationResponse( return CardOperationResponse(
success=True, success=True,
@@ -411,7 +411,7 @@ def configure_deck_tools(mcp: FastMCP):
stack_id: The ID of the stack stack_id: The ID of the stack
card_id: The ID of the card card_id: The ID of the card
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.unarchive_card(board_id, stack_id, card_id) await client.deck.unarchive_card(board_id, stack_id, card_id)
return CardOperationResponse( return CardOperationResponse(
success=True, success=True,
@@ -440,7 +440,7 @@ def configure_deck_tools(mcp: FastMCP):
order: New position in the target stack order: New position in the target stack
target_stack_id: The ID of the target stack target_stack_id: The ID of the target stack
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.reorder_card( await client.deck.reorder_card(
board_id, stack_id, card_id, order, target_stack_id board_id, stack_id, card_id, order, target_stack_id
) )
@@ -465,7 +465,7 @@ def configure_deck_tools(mcp: FastMCP):
title: The title of the new label title: The title of the new label
color: The color of the new label (hex format without #) color: The color of the new label (hex format without #)
""" """
client = get_client(ctx) client = await get_client(ctx)
label = await client.deck.create_label(board_id, title, color) label = await client.deck.create_label(board_id, title, color)
return CreateLabelResponse(id=label.id, title=label.title, color=label.color) return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@@ -486,7 +486,7 @@ def configure_deck_tools(mcp: FastMCP):
title: New title for the label title: New title for the label
color: New color for the label (hex format without #) color: New color for the label (hex format without #)
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.update_label(board_id, label_id, title, color) await client.deck.update_label(board_id, label_id, title, color)
return LabelOperationResponse( return LabelOperationResponse(
success=True, success=True,
@@ -506,7 +506,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id: The ID of the board board_id: The ID of the board
label_id: The ID of the label label_id: The ID of the label
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.delete_label(board_id, label_id) await client.deck.delete_label(board_id, label_id)
return LabelOperationResponse( return LabelOperationResponse(
success=True, success=True,
@@ -529,7 +529,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card card_id: The ID of the card
label_id: The ID of the label to assign label_id: The ID of the label to assign
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id) await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
return CardOperationResponse( return CardOperationResponse(
success=True, success=True,
@@ -552,7 +552,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card card_id: The ID of the card
label_id: The ID of the label to remove label_id: The ID of the label to remove
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id) await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
return CardOperationResponse( return CardOperationResponse(
success=True, success=True,
@@ -576,7 +576,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card card_id: The ID of the card
user_id: The user ID to assign user_id: The user ID to assign
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id) await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
return CardOperationResponse( return CardOperationResponse(
success=True, success=True,
@@ -599,7 +599,7 @@ def configure_deck_tools(mcp: FastMCP):
card_id: The ID of the card card_id: The ID of the card
user_id: The user ID to unassign user_id: The user ID to unassign
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id) await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
return CardOperationResponse( return CardOperationResponse(
success=True, success=True,
+10 -10
View File
@@ -29,7 +29,7 @@ def configure_notes_tools(mcp: FastMCP):
ctx: Context = ( ctx: Context = (
mcp.get_context() mcp.get_context()
) # https://github.com/modelcontextprotocol/python-sdk/issues/244 ) # https://github.com/modelcontextprotocol/python-sdk/issues/244
client = get_client(ctx) client = await get_client(ctx)
settings_data = await client.notes.get_settings() settings_data = await client.notes.get_settings()
return NotesSettings(**settings_data) return NotesSettings(**settings_data)
@@ -37,7 +37,7 @@ def configure_notes_tools(mcp: FastMCP):
async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str): async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str):
"""Get a specific attachment from a note""" """Get a specific attachment from a note"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
client = get_client(ctx) client = await get_client(ctx)
# Assuming a method get_note_attachment exists in the client # Assuming a method get_note_attachment exists in the client
# This method should return the raw content and determine the mime type # This method should return the raw content and determine the mime type
content, mime_type = await client.webdav.get_note_attachment( content, mime_type = await client.webdav.get_note_attachment(
@@ -59,7 +59,7 @@ def configure_notes_tools(mcp: FastMCP):
"""Get user note using note id""" """Get user note using note id"""
ctx: Context = mcp.get_context() ctx: Context = mcp.get_context()
client = get_client(ctx) client = await get_client(ctx)
try: try:
note_data = await client.notes.get_note(note_id) note_data = await client.notes.get_note(note_id)
return Note(**note_data) return Note(**note_data)
@@ -92,7 +92,7 @@ def configure_notes_tools(mcp: FastMCP):
title: str, content: str, category: str, ctx: Context title: str, content: str, category: str, ctx: Context
) -> CreateNoteResponse: ) -> CreateNoteResponse:
"""Create a new note (requires notes:write scope)""" """Create a new note (requires notes:write scope)"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
note_data = await client.notes.create_note( note_data = await client.notes.create_note(
title=title, title=title,
@@ -149,7 +149,7 @@ def configure_notes_tools(mcp: FastMCP):
If the note has been modified by someone else since you retrieved it, If the note has been modified by someone else since you retrieved it,
the update will fail with a 412 error.""" the update will fail with a 412 error."""
logger.info("Updating note %s", note_id) logger.info("Updating note %s", note_id)
client = get_client(ctx) client = await get_client(ctx)
try: try:
note_data = await client.notes.update( note_data = await client.notes.update(
note_id=note_id, note_id=note_id,
@@ -206,7 +206,7 @@ def configure_notes_tools(mcp: FastMCP):
between the note and what will be appended.""" between the note and what will be appended."""
logger.info("Appending content to note %s", note_id) logger.info("Appending content to note %s", note_id)
client = get_client(ctx) client = await get_client(ctx)
try: try:
note_data = await client.notes.append_content( note_data = await client.notes.append_content(
note_id=note_id, content=content note_id=note_id, content=content
@@ -252,7 +252,7 @@ def configure_notes_tools(mcp: FastMCP):
@require_provisioning @require_provisioning
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
"""Search notes by title or content, returning only id, title, and category (requires notes:read scope).""" """Search notes by title or content, returning only id, title, and category (requires notes:read scope)."""
client = get_client(ctx) client = await get_client(ctx)
try: try:
search_results_raw = await client.notes_search_notes(query=query) search_results_raw = await client.notes_search_notes(query=query)
@@ -298,7 +298,7 @@ def configure_notes_tools(mcp: FastMCP):
@require_scopes("notes:read") @require_scopes("notes:read")
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
"""Get a specific note by its ID (requires notes:read scope)""" """Get a specific note by its ID (requires notes:read scope)"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
note_data = await client.notes.get_note(note_id) note_data = await client.notes.get_note(note_id)
return Note(**note_data) return Note(**note_data)
@@ -329,7 +329,7 @@ def configure_notes_tools(mcp: FastMCP):
note_id: int, attachment_filename: str, ctx: Context note_id: int, attachment_filename: str, ctx: Context
) -> dict[str, str]: ) -> dict[str, str]:
"""Get a specific attachment from a note""" """Get a specific attachment from a note"""
client = get_client(ctx) client = await get_client(ctx)
try: try:
content, mime_type = await client.webdav.get_note_attachment( content, mime_type = await client.webdav.get_note_attachment(
note_id=note_id, filename=attachment_filename note_id=note_id, filename=attachment_filename
@@ -374,7 +374,7 @@ def configure_notes_tools(mcp: FastMCP):
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
"""Delete a note permanently""" """Delete a note permanently"""
logger.info("Deleting note %s", note_id) logger.info("Deleting note %s", note_id)
client = get_client(ctx) client = await get_client(ctx)
try: try:
await client.notes.delete_note(note_id) await client.notes.delete_note(note_id)
return DeleteNoteResponse( return DeleteNoteResponse(
+5 -5
View File
@@ -45,7 +45,7 @@ def configure_sharing_tools(mcp: FastMCP):
Returns: Returns:
JSON string with share information including share ID JSON string with share information including share ID
""" """
client = get_client(ctx) client = await get_client(ctx)
share_data = await client.sharing.create_share( share_data = await client.sharing.create_share(
path=path, path=path,
share_with=share_with, share_with=share_with,
@@ -67,7 +67,7 @@ def configure_sharing_tools(mcp: FastMCP):
Returns: Returns:
JSON string confirming deletion JSON string confirming deletion
""" """
client = get_client(ctx) client = await get_client(ctx)
await client.sharing.delete_share(share_id) await client.sharing.delete_share(share_id)
return json.dumps( return json.dumps(
{"success": True, "message": f"Share {share_id} deleted"}, indent=2 {"success": True, "message": f"Share {share_id} deleted"}, indent=2
@@ -87,7 +87,7 @@ def configure_sharing_tools(mcp: FastMCP):
Returns: Returns:
JSON string with share information JSON string with share information
""" """
client = get_client(ctx) client = await get_client(ctx)
share_data = await client.sharing.get_share(share_id) share_data = await client.sharing.get_share(share_id)
return json.dumps(share_data, indent=2) return json.dumps(share_data, indent=2)
@@ -106,7 +106,7 @@ def configure_sharing_tools(mcp: FastMCP):
Returns: Returns:
JSON string with list of shares JSON string with list of shares
""" """
client = get_client(ctx) client = await get_client(ctx)
shares = await client.sharing.list_shares( shares = await client.sharing.list_shares(
path=path, shared_with_me=shared_with_me path=path, shared_with_me=shared_with_me
) )
@@ -133,7 +133,7 @@ def configure_sharing_tools(mcp: FastMCP):
Returns: Returns:
JSON string with updated share information JSON string with updated share information
""" """
client = get_client(ctx) client = await get_client(ctx)
share_data = await client.sharing.update_share( share_data = await client.sharing.update_share(
share_id=share_id, permissions=permissions share_id=share_id, permissions=permissions
) )
+6 -6
View File
@@ -14,14 +14,14 @@ def configure_tables_tools(mcp: FastMCP):
@require_scopes("tables:read") @require_scopes("tables:read")
async def nc_tables_list_tables(ctx: Context): async def nc_tables_list_tables(ctx: Context):
"""List all tables available to the user""" """List all tables available to the user"""
client = get_client(ctx) client = await get_client(ctx)
return await client.tables.list_tables() return await client.tables.list_tables()
@mcp.tool() @mcp.tool()
@require_scopes("tables:read") @require_scopes("tables:read")
async def nc_tables_get_schema(table_id: int, ctx: Context): async def nc_tables_get_schema(table_id: int, ctx: Context):
"""Get the schema/structure of a specific table including columns and views""" """Get the schema/structure of a specific table including columns and views"""
client = get_client(ctx) client = await get_client(ctx)
return await client.tables.get_table_schema(table_id) return await client.tables.get_table_schema(table_id)
@mcp.tool() @mcp.tool()
@@ -33,7 +33,7 @@ def configure_tables_tools(mcp: FastMCP):
offset: int | None = None, offset: int | None = None,
): ):
"""Read rows from a table with optional pagination""" """Read rows from a table with optional pagination"""
client = get_client(ctx) client = await get_client(ctx)
return await client.tables.get_table_rows(table_id, limit, offset) return await client.tables.get_table_rows(table_id, limit, offset)
@mcp.tool() @mcp.tool()
@@ -43,7 +43,7 @@ def configure_tables_tools(mcp: FastMCP):
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42} Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.tables.create_row(table_id, data) return await client.tables.create_row(table_id, data)
@mcp.tool() @mcp.tool()
@@ -53,12 +53,12 @@ def configure_tables_tools(mcp: FastMCP):
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99} Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.tables.update_row(row_id, data) return await client.tables.update_row(row_id, data)
@mcp.tool() @mcp.tool()
@require_scopes("tables:write") @require_scopes("tables:write")
async def nc_tables_delete_row(row_id: int, ctx: Context): async def nc_tables_delete_row(row_id: int, ctx: Context):
"""Delete a row from a table""" """Delete a row from a table"""
client = get_client(ctx) client = await get_client(ctx)
return await client.tables.delete_row(row_id) return await client.tables.delete_row(row_id)
+11 -11
View File
@@ -28,7 +28,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
DirectoryListing with files, total_count, directories_count, files_count, and total_size DirectoryListing with files, total_count, directories_count, files_count, and total_size
""" """
client = get_client(ctx) client = await get_client(ctx)
items = await client.webdav.list_directory(path) items = await client.webdav.list_directory(path)
# Convert to FileInfo models # Convert to FileInfo models
@@ -76,7 +76,7 @@ def configure_webdav_tools(mcp: FastMCP):
result = await nc_webdav_read_file("Images/photo.jpg") result = await nc_webdav_read_file("Images/photo.jpg")
logger.info(result['encoding']) # 'base64' logger.info(result['encoding']) # 'base64'
""" """
client = get_client(ctx) client = await get_client(ctx)
content, content_type = await client.webdav.read_file(path) content, content_type = await client.webdav.read_file(path)
# Check if this is a parseable document (PDF, DOCX, etc.) # Check if this is a parseable document (PDF, DOCX, etc.)
@@ -143,7 +143,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
Dict with status_code indicating success Dict with status_code indicating success
""" """
client = get_client(ctx) client = await get_client(ctx)
# Handle base64 encoded content # Handle base64 encoded content
if content_type and "base64" in content_type.lower(): if content_type and "base64" in content_type.lower():
@@ -167,7 +167,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
Dict with status_code (201 for created, 405 if already exists) Dict with status_code (201 for created, 405 if already exists)
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.webdav.create_directory(path) return await client.webdav.create_directory(path)
@mcp.tool() @mcp.tool()
@@ -181,7 +181,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
Dict with status_code indicating result (404 if not found) Dict with status_code indicating result (404 if not found)
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.webdav.delete_resource(path) return await client.webdav.delete_resource(path)
@mcp.tool() @mcp.tool()
@@ -199,7 +199,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False) Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.webdav.move_resource( return await client.webdav.move_resource(
source_path, destination_path, overwrite source_path, destination_path, overwrite
) )
@@ -219,7 +219,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False) Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
""" """
client = get_client(ctx) client = await get_client(ctx)
return await client.webdav.copy_resource( return await client.webdav.copy_resource(
source_path, destination_path, overwrite source_path, destination_path, overwrite
) )
@@ -249,7 +249,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
SearchFilesResponse with list of matching files SearchFilesResponse with list of matching files
""" """
client = get_client(ctx) client = await get_client(ctx)
# Build where conditions based on filters # Build where conditions based on filters
conditions = [] conditions = []
@@ -355,7 +355,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
SearchFilesResponse with list of matching files SearchFilesResponse with list of matching files
""" """
client = get_client(ctx) client = await get_client(ctx)
results = await client.webdav.find_by_name( results = await client.webdav.find_by_name(
pattern=pattern, scope=scope, limit=limit pattern=pattern, scope=scope, limit=limit
) )
@@ -382,7 +382,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
SearchFilesResponse with list of matching files SearchFilesResponse with list of matching files
""" """
client = get_client(ctx) client = await get_client(ctx)
results = await client.webdav.find_by_type( results = await client.webdav.find_by_type(
mime_type=mime_type, scope=scope, limit=limit mime_type=mime_type, scope=scope, limit=limit
) )
@@ -408,7 +408,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns: Returns:
SearchFilesResponse with list of favorite files SearchFilesResponse with list of favorite files
""" """
client = get_client(ctx) client = await get_client(ctx)
results = await client.webdav.list_favorites(scope=scope, limit=limit) results = await client.webdav.list_favorites(scope=scope, limit=limit)
file_infos = [FileInfo(**result) for result in results] file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse( return SearchFilesResponse(
+447
View File
@@ -0,0 +1,447 @@
"""Unit tests for RFC 8693 Token Exchange (ADR-004).
Tests the critical token exchange pattern that separates:
- Session tokens (ephemeral, on-demand)
- Background tokens (stored refresh tokens)
"""
import os
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.token_exchange import TokenExchangeService
pytestmark = pytest.mark.unit
@pytest.fixture
async def token_storage():
"""Create test token storage."""
import tempfile
from cryptography.fernet import Fernet
# Generate valid Fernet key
encryption_key = Fernet.generate_key()
# Create temporary database file
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
db_path = tmp.name
storage = RefreshTokenStorage(db_path=db_path, encryption_key=encryption_key)
await storage.initialize()
yield storage
# Cleanup
if os.path.exists(db_path):
os.unlink(db_path)
@pytest.fixture
async def token_exchange_service(token_storage):
"""Create test token exchange service."""
service = TokenExchangeService(
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
client_id="test-client",
client_secret="test-secret",
nextcloud_host="http://test-nextcloud",
)
service.storage = token_storage
yield service
await service.http_client.aclose()
@pytest.fixture
async def token_broker(token_storage):
"""Create test token broker service."""
# Use the same encryption key as storage
from cryptography.fernet import Fernet
encryption_key = Fernet.generate_key()
broker = TokenBrokerService(
storage=token_storage,
oidc_discovery_url="http://test-idp/.well-known/openid-configuration",
nextcloud_host="http://test-nextcloud",
encryption_key=encryption_key,
cache_ttl=300,
cache_early_refresh=30,
)
yield broker
await broker.close()
def create_test_jwt(
user_id: str = "testuser", audience: str = "mcp-server", expires_in: int = 3600
) -> str:
"""Create a test JWT token."""
import time
payload = {
"sub": user_id,
"aud": audience,
"exp": int(time.time()) + expires_in,
"iat": int(time.time()),
"iss": "http://test-idp",
}
# For testing, we don't sign the token (uses 'none' algorithm)
# In production, tokens would be properly signed
return jwt.encode(payload, "", algorithm="none")
class TestTokenExchange:
"""Test RFC 8693 token exchange implementation."""
@pytest.mark.asyncio
async def test_validate_flow1_token_success(self, token_exchange_service):
"""Test validation of Flow 1 token with correct audience."""
# Create token with correct audience
flow1_token = create_test_jwt(audience="mcp-server")
# Should not raise an exception
await token_exchange_service._validate_flow1_token(flow1_token)
@pytest.mark.asyncio
async def test_validate_flow1_token_wrong_audience(self, token_exchange_service):
"""Test validation fails with wrong audience."""
# Create token with wrong audience
flow1_token = create_test_jwt(audience="nextcloud")
with pytest.raises(ValueError, match="Invalid token audience"):
await token_exchange_service._validate_flow1_token(flow1_token)
@pytest.mark.asyncio
async def test_validate_flow1_token_expired(self, token_exchange_service):
"""Test validation fails with expired token."""
# Create expired token
flow1_token = create_test_jwt(audience="mcp-server", expires_in=-3600)
with pytest.raises(ValueError, match="Token has expired"):
await token_exchange_service._validate_flow1_token(flow1_token)
@pytest.mark.asyncio
async def test_extract_user_id(self, token_exchange_service):
"""Test extraction of user ID from token."""
flow1_token = create_test_jwt(user_id="alice")
user_id = token_exchange_service._extract_user_id(flow1_token)
assert user_id == "alice"
@pytest.mark.asyncio
async def test_check_provisioning_not_provisioned(self, token_exchange_service):
"""Test provisioning check when user not provisioned."""
result = await token_exchange_service._check_provisioning("unknown_user")
assert result is False
@pytest.mark.asyncio
async def test_check_provisioning_is_provisioned(
self, token_exchange_service, token_storage
):
"""Test provisioning check when user is provisioned."""
# Store a refresh token for user
await token_storage.store_refresh_token(
user_id="alice", refresh_token="encrypted_refresh_token", flow_type="flow2"
)
result = await token_exchange_service._check_provisioning("alice")
assert result is True
@pytest.mark.asyncio
async def test_exchange_token_not_provisioned(self, token_exchange_service):
"""Test token exchange fails when user not provisioned."""
flow1_token = create_test_jwt(user_id="unprovisioneduser")
with pytest.raises(RuntimeError, match="Nextcloud access not provisioned"):
await token_exchange_service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=["notes:read"],
requested_audience="nextcloud",
)
@pytest.mark.asyncio
async def test_exchange_token_with_fallback(
self, token_exchange_service, token_storage
):
"""Test token exchange with refresh grant fallback."""
# Store a refresh token for user
await token_storage.store_refresh_token(
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
)
# Create Flow 1 token
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Mock HTTP client for token endpoint
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "delegated_token_12345",
"token_type": "Bearer",
"expires_in": 300, # 5 minutes
}
with patch.object(
token_exchange_service.http_client, "post", return_value=mock_response
):
# Mock discovery endpoint
with patch.object(
token_exchange_service,
"_discover_endpoints",
return_value={"token_endpoint": "http://test-idp/token"},
):
# Perform exchange
(
token,
expires_in,
) = await token_exchange_service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=["notes:read"],
requested_audience="nextcloud",
)
assert token == "delegated_token_12345"
assert expires_in == 300
class TestTokenBroker:
"""Test Token Broker session/background separation."""
@pytest.mark.asyncio
async def test_get_session_token(self, token_broker, token_storage):
"""Test getting ephemeral session token via exchange."""
# Store refresh token for user
await token_storage.store_refresh_token(
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
)
# Create Flow 1 token
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Mock token exchange
with patch(
"nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation",
return_value=("ephemeral_token_xyz", 300),
):
token = await token_broker.get_session_token(
flow1_token=flow1_token,
required_scopes=["notes:read"],
requested_audience="nextcloud",
)
assert token == "ephemeral_token_xyz"
# Verify token is NOT cached (ephemeral)
cached = await token_broker.cache.get("alice")
assert cached is None # Should not be in cache
@pytest.mark.asyncio
async def test_get_background_token(self, token_broker, token_storage):
"""Test getting background token with stored refresh."""
# Store encrypted refresh token for user
from cryptography.fernet import Fernet
fernet = Fernet(b"test-key-" + b"0" * 32)
encrypted_token = fernet.encrypt(b"background_refresh_token").decode()
await token_storage.store_refresh_token(
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
)
# Mock OIDC config and token response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "background_token_abc",
"token_type": "Bearer",
"expires_in": 3600, # 1 hour
}
with patch.object(
token_broker,
"_get_oidc_config",
return_value={"token_endpoint": "http://test/token"},
):
with patch.object(token_broker, "_get_http_client") as mock_client:
mock_client.return_value.post = AsyncMock(return_value=mock_response)
# Mock audience validation
with patch.object(
token_broker, "_validate_token_audience", return_value=None
):
token = await token_broker.get_background_token(
user_id="alice", required_scopes=["notes:sync", "files:sync"]
)
assert token == "background_token_abc"
# Verify token IS cached (background tokens can be cached)
cache_key = "alice:background:files:sync,notes:sync"
cached = await token_broker.cache.get(cache_key)
assert cached == "background_token_abc"
@pytest.mark.asyncio
async def test_session_background_separation(self, token_broker, token_storage):
"""Test that session and background tokens are kept separate."""
# Store refresh token
from cryptography.fernet import Fernet
fernet = Fernet(b"test-key-" + b"0" * 32)
encrypted_token = fernet.encrypt(b"master_refresh_token").decode()
await token_storage.store_refresh_token(
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
)
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Mock different tokens for session vs background
session_token = "ephemeral_session_123"
background_token = "cached_background_456"
# Get session token
with patch(
"nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation",
return_value=(session_token, 300),
):
session_result = await token_broker.get_session_token(
flow1_token=flow1_token, required_scopes=["notes:read"]
)
assert session_result == session_token
# Get background token
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": background_token,
"expires_in": 3600,
}
with patch.object(
token_broker,
"_get_oidc_config",
return_value={"token_endpoint": "http://test/token"},
):
with patch.object(token_broker, "_get_http_client") as mock_client:
mock_client.return_value.post = AsyncMock(return_value=mock_response)
with patch.object(
token_broker, "_validate_token_audience", return_value=None
):
background_result = await token_broker.get_background_token(
user_id="alice", required_scopes=["notes:sync"]
)
assert background_result == background_token
# Verify they are different tokens
assert session_result != background_result
# Verify session token not cached
assert await token_broker.cache.get("alice") is None
# Verify background token IS cached
cache_key = "alice:background:notes:sync"
assert await token_broker.cache.get(cache_key) == background_token
class TestScopeDownscoping:
"""Test that tokens request only necessary scopes."""
@pytest.mark.asyncio
async def test_session_token_minimal_scopes(
self, token_exchange_service, token_storage
):
"""Test session tokens request minimal scopes."""
# Store refresh token
await token_storage.store_refresh_token(
user_id="alice", refresh_token="test_refresh_token", flow_type="flow2"
)
flow1_token = create_test_jwt(user_id="alice", audience="mcp-server")
# Track what scopes are requested
requested_scopes = None
async def mock_post(url, data, headers=None):
nonlocal requested_scopes
requested_scopes = data.get("scope", "").split()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "scoped_token",
"expires_in": 300,
}
return mock_response
with patch.object(
token_exchange_service.http_client, "post", side_effect=mock_post
):
with patch.object(
token_exchange_service,
"_discover_endpoints",
return_value={"token_endpoint": "http://test/token"},
):
await token_exchange_service.exchange_token_for_delegation(
flow1_token=flow1_token,
requested_scopes=["notes:read"], # Only read scope
requested_audience="nextcloud",
)
# Verify only requested scope was included
assert "notes:read" in requested_scopes
assert "notes:write" not in requested_scopes
assert "calendar:write" not in requested_scopes
@pytest.mark.asyncio
async def test_background_token_different_scopes(self, token_broker, token_storage):
"""Test background tokens can request different scopes than session."""
from cryptography.fernet import Fernet
fernet = Fernet(b"test-key-" + b"0" * 32)
encrypted_token = fernet.encrypt(b"refresh_token").decode()
await token_storage.store_refresh_token(
user_id="alice", refresh_token=encrypted_token, flow_type="flow2"
)
# Track requested scopes
requested_scopes = None
async def mock_post(url, data, headers=None):
nonlocal requested_scopes
requested_scopes = data.get("scope", "").split()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "background_sync_token",
"expires_in": 3600,
}
return mock_response
with patch.object(
token_broker,
"_get_oidc_config",
return_value={"token_endpoint": "http://test/token"},
):
with patch.object(token_broker, "_get_http_client") as mock_client:
mock_client.return_value.post = mock_post
with patch.object(
token_broker, "_validate_token_audience", return_value=None
):
await token_broker.get_background_token(
user_id="alice",
required_scopes=["notes:sync", "files:sync", "calendar:sync"],
)
# Verify sync scopes were requested
assert "notes:sync" in requested_scopes
assert "files:sync" in requested_scopes
assert "calendar:sync" in requested_scopes
# Basic OIDC scopes should also be included
assert "openid" in requested_scopes
assert "profile" in requested_scopes