Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 167e49788e | |||
| 857d8f2152 | |||
| 72232f937a | |||
| 4b026e9aa0 | |||
| 31799ffd9a | |||
| 5cc598e1b1 | |||
| a6c76c5cc1 | |||
| a854656d3c | |||
| bb5d4f464f | |||
| e32c8f4aec | |||
| ee183e1c1c | |||
| 1a57f97d3a | |||
| e96c02e4d4 | |||
| 7b8c3f93a8 | |||
| fdd82f59e2 | |||
| 4dbb2eb468 | |||
| 8f45e996e8 | |||
| dc93da2ea0 | |||
| 31ff8a71bf | |||
| bd012831cf | |||
| 4ceaf45ffd | |||
| 21b878a2e7 | |||
| 218f0bd366 | |||
| afee3e8bb4 | |||
| 050a00d8c8 | |||
| f59b6a6cfb | |||
| a766f4be32 | |||
| ee053d559c | |||
| 71326384da | |||
| 11cdab475f | |||
| 281d28c7cd | |||
| 0c9a9ea24d | |||
| dfa6d08ba7 | |||
| c5395041d3 | |||
| c1e135c4a2 | |||
| 50cda2209f | |||
| d34e17a68b | |||
| 77e491beea | |||
| 7812ac0ee7 | |||
| 659087e4c7 | |||
| bdb1ba2051 | |||
| 7d9ab5559c | |||
| 877c4c91e0 | |||
| 5deb3132c3 | |||
| 9fab6cb550 | |||
| 28c2debf3e | |||
| 461971a1a8 | |||
| ed9a8677fe | |||
| e8c499938f | |||
| 4d8b6fca49 | |||
| 67eb4455fd | |||
| 7052c19de0 | |||
| 921854ce87 | |||
| 3e988acb97 |
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
|
||||
@@ -1,3 +1,49 @@
|
||||
## v0.26.1 (2025-11-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.21,<1.22
|
||||
|
||||
## v0.26.0 (2025-11-08)
|
||||
|
||||
### Feat
|
||||
|
||||
- add real elicitation integration test with python-sdk MCP client
|
||||
- unify session architecture and enhance login status visibility
|
||||
|
||||
### Fix
|
||||
|
||||
- Consolidate OAuth callbacks and implement PKCE for all flows
|
||||
|
||||
## v0.25.0 (2025-11-05)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- All OAuth deployments must be reconfigured to specify
|
||||
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
|
||||
choose between multi-audience or token exchange mode.
|
||||
|
||||
### Feat
|
||||
|
||||
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
|
||||
|
||||
### Fix
|
||||
|
||||
- Implement proper OAuth resource parameters and PRM-based discovery
|
||||
- Simplify token verifier to be RFC 7519 compliant
|
||||
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
|
||||
- Correct OAuth token audience validation for multi-audience mode
|
||||
|
||||
### Refactor
|
||||
|
||||
- Eliminate duplicate validation logic in UnifiedTokenVerifier
|
||||
|
||||
## v0.24.1 (2025-11-04)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.20,<1.21
|
||||
|
||||
## v0.24.0 (2025-11-04)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -167,23 +167,35 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
|
||||
### Progressive Consent Architecture (ADR-004)
|
||||
|
||||
**Status**: Always enabled in OAuth mode (default)
|
||||
**Important**: Progressive consent is a *mechanism* for granting access, not a feature flag. The architecture is always present in OAuth mode. Whether provisioning tools are available is controlled by `ENABLE_OFFLINE_ACCESS`.
|
||||
|
||||
**What is Progressive Consent?**
|
||||
- Dual OAuth flow architecture that separates client authentication (Flow 1) from resource provisioning (Flow 2)
|
||||
- Flow 1: MCP client authenticates directly to IdP with resource scopes (notes:*, calendar:*, etc.)
|
||||
- Token audience: "mcp-server"
|
||||
- Client receives resource-scoped token for MCP session
|
||||
- Flow 2: Server explicitly provisions Nextcloud access via separate login
|
||||
- Flow 2: Server explicitly provisions Nextcloud access via separate login (only when `ENABLE_OFFLINE_ACCESS=true`)
|
||||
- Server requests: openid, profile, email, offline_access
|
||||
- Token audience: "nextcloud"
|
||||
- Server receives refresh token for offline access
|
||||
- Client never sees this token
|
||||
- Provides clear separation between session tokens and offline access tokens
|
||||
|
||||
**Modes:**
|
||||
- **Pass-through mode** (`ENABLE_OFFLINE_ACCESS=false`, default):
|
||||
- No Flow 2 provisioning
|
||||
- Server uses client's token to access Nextcloud (pass-through)
|
||||
- No provisioning tools available
|
||||
- Suitable for stateless, client-driven operations
|
||||
- **Offline access mode** (`ENABLE_OFFLINE_ACCESS=true`):
|
||||
- Flow 2 provisioning available
|
||||
- Server stores refresh tokens for background operations
|
||||
- Provisioning tools available: `provision_nextcloud_access`, `check_logged_in`
|
||||
- Suitable for background jobs and server-initiated operations
|
||||
|
||||
**When to use OAuth mode:**
|
||||
- Multi-user deployments
|
||||
- Background jobs requiring offline access
|
||||
- Background jobs requiring offline access (with `ENABLE_OFFLINE_ACCESS=true`)
|
||||
- Enhanced security with separate authorization contexts
|
||||
- Explicit user control over resource access
|
||||
|
||||
@@ -212,6 +224,82 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
|
||||
**Testing**: Extract `data["results"]` from MCP responses, not `data` directly.
|
||||
|
||||
## MCP Sampling for RAG (ADR-008)
|
||||
|
||||
**What is MCP Sampling?**
|
||||
MCP sampling allows servers to request LLM completions from their clients. This enables Retrieval-Augmented Generation (RAG) patterns where the server retrieves context and the client's LLM generates answers.
|
||||
|
||||
**When to use sampling:**
|
||||
- Generating natural language answers from retrieved documents
|
||||
- Synthesizing information from multiple sources
|
||||
- Creating summaries with citations
|
||||
|
||||
**Implementation Pattern** (see ADR-008 for details):
|
||||
|
||||
```python
|
||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_semantic_search_answer(
|
||||
query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500
|
||||
) -> SamplingSearchResponse:
|
||||
# 1. Retrieve documents
|
||||
search_response = await nc_notes_semantic_search(query, ctx, limit)
|
||||
|
||||
# 2. Check for no results (don't waste sampling call)
|
||||
if not search_response.results:
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found.",
|
||||
sources=[], total_found=0, success=True
|
||||
)
|
||||
|
||||
# 3. Construct prompt with retrieved context
|
||||
prompt = f"{query}\n\nDocuments:\n{format_sources(search_response.results)}\n\nProvide answer with citations."
|
||||
|
||||
# 4. Request LLM completion via sampling
|
||||
try:
|
||||
result = await ctx.session.create_message(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
|
||||
max_tokens=max_answer_tokens,
|
||||
temperature=0.7,
|
||||
model_preferences=ModelPreferences(
|
||||
hints=[ModelHint(name="claude-3-5-sonnet")],
|
||||
intelligencePriority=0.8,
|
||||
speedPriority=0.5,
|
||||
),
|
||||
include_context="thisServer",
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=result.content.text,
|
||||
sources=search_response.results,
|
||||
model_used=result.model,
|
||||
stop_reason=result.stopReason,
|
||||
success=True
|
||||
)
|
||||
except Exception as e:
|
||||
# Fallback: Return documents without generated answer
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=f"[Sampling unavailable: {e}]\n\nFound {len(search_response.results)} documents.",
|
||||
sources=search_response.results,
|
||||
search_method="semantic_sampling_fallback",
|
||||
success=True
|
||||
)
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- **No server-side LLM**: Server has no API keys, client controls which model is used
|
||||
- **Graceful degradation**: Tool always returns useful results even if sampling fails
|
||||
- **User control**: MCP clients SHOULD prompt users to approve sampling requests
|
||||
- **No results optimization**: Skip sampling call when no documents found
|
||||
- **Fixed prompts**: Prompts are not user-configurable to avoid injection risks
|
||||
|
||||
**Reference**: See `nc_notes_semantic_search_answer` in `nextcloud_mcp_server/server/notes.py:517` and ADR-008 for complete implementation.
|
||||
|
||||
## Testing Best Practices (MANDATORY)
|
||||
|
||||
### Always Run Tests
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.9.7-python3.11-alpine@sha256:0006b77df7ebf46e68959fdc8d3af9d19f1adfae8c2e7e77907ad257e5d05be4
|
||||
FROM ghcr.io/astral-sh/uv:0.9.8-python3.11-alpine@sha256:6c842c49ad032f46b62f32a7e7779f45f12671a8e0d82ea24c766ab62d58b396
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
@@ -12,5 +12,6 @@ COPY . .
|
||||
RUN uv sync --locked --no-dev
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -19,7 +19,8 @@ The Nextcloud MCP (Model Context Protocol) server allows Large Language Models l
|
||||
| **Deployment** | Standalone (Docker, VM, K8s) | Inside Nextcloud (ExApp via AppAPI) |
|
||||
| **Primary Users** | Claude Code, IDEs, external developers | Nextcloud end users via Assistant app |
|
||||
| **Authentication** | OAuth2/OIDC or Basic Auth | Session-based (integrated) |
|
||||
| **Notes Support** | ✅ Full CRUD + search (7 tools) | ❌ Not implemented |
|
||||
| **Notes Support** | ✅ Full CRUD + keyword search (7 tools) | ❌ Not implemented |
|
||||
| **Semantic Search** | ✅ Multi-app vector search (2+ tools) | ❌ Not implemented |
|
||||
| **Calendar** | ✅ Full CalDAV + tasks (20+ tools) | ✅ Events, free/busy, tasks (4 tools) |
|
||||
| **Contacts** | ✅ Full CardDAV (8 tools) | ✅ Find person, current user (2 tools) |
|
||||
| **Files (WebDAV)** | ✅ Full filesystem access (12 tools) | ✅ Read, folder tree, sharing (3 tools) |
|
||||
@@ -200,7 +201,7 @@ For a complete list of all supported OAuth scopes and their descriptions, see [O
|
||||
|
||||
| App | Tools | Read Scope | Write Scope | Operations |
|
||||
|-----|-------|-----------|-------------|------------|
|
||||
| **Notes** | 7 | `notes:read` | `notes:write` | Create, read, update, delete, search notes |
|
||||
| **Notes** | 7 | `notes:read` | `notes:write` | Create, read, update, delete, search notes (keyword search) |
|
||||
| **Calendar** | 20+ | `calendar:read` `todo:read` | `calendar:write` `todo:write` | Events, todos (tasks), calendars, recurring events, attendees |
|
||||
| **Contacts** | 8 | `contacts:read` | `contacts:write` | Create, read, update, delete contacts and address books |
|
||||
| **Files (WebDAV)** | 12 | `files:read` | `files:write` | List, read, upload, delete, move files; **OCR/document processing** |
|
||||
@@ -208,6 +209,7 @@ For a complete list of all supported OAuth scopes and their descriptions, see [O
|
||||
| **Cookbook** | 13 | `cookbook:read` | `cookbook:write` | Recipes, import from URLs, search, categories |
|
||||
| **Tables** | 5 | `tables:read` | `tables:write` | Row operations on Nextcloud Tables |
|
||||
| **Sharing** | 10+ | `sharing:read` | `sharing:write` | Create, manage, delete shares |
|
||||
| **Semantic Search** | 2+ | `semantic:read` | `semantic:write` | Vector-powered semantic search across **all apps** (notes, calendar, deck, files, contacts), background indexing |
|
||||
|
||||
#### Document Processing (Optional)
|
||||
|
||||
|
||||
@@ -35,5 +35,6 @@ php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='t
|
||||
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
|
||||
php /var/www/html/occ config:app:set oidc allow_user_settings --value='enabled'
|
||||
php /var/www/html/occ config:app:set oidc default_token_type --value='jwt'
|
||||
php /var/www/html/occ config:app:set oidc default_resource_identifier --value='http://localhost:8080'
|
||||
|
||||
echo "OIDC app installed and configured successfully"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
php /var/www/html/occ config:app:set --value false firstrunwizard wizard_enabled
|
||||
@@ -0,0 +1 @@
|
||||
charts/
|
||||
@@ -0,0 +1,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 0.9.0
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.33.0
|
||||
digest: sha256:c53b7a604d202460f60408a62025ae837cad8d4da970b1e5bb404e2b41289f94
|
||||
generated: "2025-11-08T23:44:59.709689907+01:00"
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.24.0
|
||||
appVersion: "0.24.0"
|
||||
version: 0.26.1
|
||||
appVersion: "0.26.1"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -21,3 +21,12 @@ home: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
sources:
|
||||
- https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
icon: https://raw.githubusercontent.com/nextcloud/server/master/core/img/logo/logo.svg
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
version: "0.9.0"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.enabled
|
||||
- name: ollama
|
||||
version: "1.33.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -14,8 +14,12 @@ This Helm chart deploys the Nextcloud MCP (Model Context Protocol) Server on a K
|
||||
### Quick Start with Basic Authentication
|
||||
|
||||
```bash
|
||||
# Add the Helm repository
|
||||
helm repo add nextcloud-mcp https://cbcoutinho.github.io/nextcloud-mcp-server
|
||||
helm repo update
|
||||
|
||||
# Install with basic auth (recommended for most users)
|
||||
helm install nextcloud-mcp ./helm/nextcloud-mcp-server \
|
||||
helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
|
||||
--set nextcloud.host=https://cloud.example.com \
|
||||
--set auth.basic.username=myuser \
|
||||
--set auth.basic.password=mypassword
|
||||
@@ -47,7 +51,7 @@ resources:
|
||||
Install with your custom values:
|
||||
|
||||
```bash
|
||||
helm install nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
|
||||
helm install nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server -f custom-values.yaml
|
||||
```
|
||||
|
||||
### OAuth Authentication Mode (Experimental)
|
||||
@@ -202,6 +206,67 @@ The application exposes HTTP health check endpoints:
|
||||
| `documentProcessing.unstructured.apiUrl` | Unstructured API URL | `http://unstructured:8000` |
|
||||
| `documentProcessing.tesseract.enabled` | Enable Tesseract OCR | `false` |
|
||||
|
||||
#### Vector Search & Semantic Capabilities (Optional)
|
||||
|
||||
Enable semantic search capabilities by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
|
||||
|
||||
**Vector Sync Configuration:**
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `vectorSync.enabled` | Enable background vector synchronization | `false` |
|
||||
| `vectorSync.scanInterval` | Scan interval in seconds | `3600` |
|
||||
| `vectorSync.processorWorkers` | Number of concurrent processor workers | `3` |
|
||||
| `vectorSync.queueMaxSize` | Maximum queue size for pending documents | `10000` |
|
||||
|
||||
**Qdrant Vector Database:**
|
||||
|
||||
Qdrant is deployed as a subchart when `qdrant.enabled` is `true`. All configuration values are passed through to the [qdrant/qdrant](https://github.com/qdrant/qdrant-helm) chart.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `qdrant.enabled` | Deploy Qdrant as a subchart | `false` |
|
||||
| `qdrant.replicaCount` | Number of Qdrant replicas | `1` |
|
||||
| `qdrant.image.tag` | Qdrant version | `v1.12.5` |
|
||||
| `qdrant.apiKey` | Optional API key for authentication | `""` |
|
||||
| `qdrant.persistence.size` | Storage size for vector data | `10Gi` |
|
||||
| `qdrant.persistence.storageClass` | Storage class | `""` |
|
||||
| `qdrant.resources.requests.cpu` | CPU request | `200m` |
|
||||
| `qdrant.resources.requests.memory` | Memory request | `512Mi` |
|
||||
| `qdrant.resources.limits.cpu` | CPU limit | `1000m` |
|
||||
| `qdrant.resources.limits.memory` | Memory limit | `2Gi` |
|
||||
|
||||
**Ollama Embedding Service:**
|
||||
|
||||
Ollama is deployed as a subchart when `ollama.enabled` is `true`. All configuration values are passed through to the [ollama/ollama](https://github.com/otwld/ollama-helm) chart. Alternatively, set `ollama.url` to use an external Ollama instance.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `ollama.enabled` | Deploy Ollama as a subchart | `false` |
|
||||
| `ollama.url` | External Ollama URL (use with `enabled: false`) | `""` |
|
||||
| `ollama.embeddingModel` | Embedding model to use | `nomic-embed-text` |
|
||||
| `ollama.verifySsl` | Verify SSL certificates | `true` |
|
||||
| `ollama.replicaCount` | Number of Ollama replicas | `1` |
|
||||
| `ollama.ollama.models.pull` | Models to pull on startup | `["nomic-embed-text"]` |
|
||||
| `ollama.persistentVolume.enabled` | Enable persistent storage | `true` |
|
||||
| `ollama.persistentVolume.size` | Storage size for models | `20Gi` |
|
||||
| `ollama.resources.requests.cpu` | CPU request | `500m` |
|
||||
| `ollama.resources.requests.memory` | Memory request | `1Gi` |
|
||||
| `ollama.resources.limits.cpu` | CPU limit | `2000m` |
|
||||
| `ollama.resources.limits.memory` | Memory limit | `4Gi` |
|
||||
|
||||
**OpenAI Embedding Provider (Alternative):**
|
||||
|
||||
Use OpenAI or any OpenAI-compatible API instead of Ollama.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `openai.enabled` | Enable OpenAI embedding provider | `false` |
|
||||
| `openai.apiKey` | OpenAI API key | `""` |
|
||||
| `openai.existingSecret` | Use existing secret for API key | `""` |
|
||||
| `openai.secretKey` | Key in secret containing API key | `api-key` |
|
||||
| `openai.baseUrl` | Custom API endpoint (optional) | `""` |
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Basic Auth with Ingress
|
||||
@@ -379,18 +444,106 @@ affinity:
|
||||
topologyKey: kubernetes.io/hostname
|
||||
```
|
||||
|
||||
### Example 5: Semantic Search with Qdrant and Ollama
|
||||
|
||||
Deploy with vector search capabilities using embedded Qdrant and Ollama:
|
||||
|
||||
```yaml
|
||||
nextcloud:
|
||||
host: https://cloud.example.com
|
||||
|
||||
auth:
|
||||
mode: basic
|
||||
basic:
|
||||
username: admin
|
||||
password: secure-password
|
||||
|
||||
# Enable vector sync
|
||||
vectorSync:
|
||||
enabled: true
|
||||
scanInterval: 1800 # Scan every 30 minutes
|
||||
processorWorkers: 5
|
||||
|
||||
# Deploy Qdrant as a subchart
|
||||
qdrant:
|
||||
enabled: true
|
||||
persistence:
|
||||
size: 20Gi
|
||||
storageClass: fast-ssd
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 4Gi
|
||||
|
||||
# Deploy Ollama as a subchart
|
||||
ollama:
|
||||
enabled: true
|
||||
embeddingModel: nomic-embed-text
|
||||
persistentVolume:
|
||||
size: 30Gi
|
||||
storageClass: standard
|
||||
resources:
|
||||
requests:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
limits:
|
||||
cpu: 4000m
|
||||
memory: 8Gi
|
||||
```
|
||||
|
||||
Or use an external Ollama instance:
|
||||
|
||||
```yaml
|
||||
vectorSync:
|
||||
enabled: true
|
||||
|
||||
qdrant:
|
||||
enabled: true
|
||||
|
||||
# Use external Ollama instead of deploying subchart
|
||||
ollama:
|
||||
enabled: false
|
||||
url: "http://ollama.ai-services.svc.cluster.local:11434"
|
||||
embeddingModel: nomic-embed-text
|
||||
```
|
||||
|
||||
Or use OpenAI for embeddings:
|
||||
|
||||
```yaml
|
||||
vectorSync:
|
||||
enabled: true
|
||||
|
||||
qdrant:
|
||||
enabled: true
|
||||
|
||||
# Use OpenAI instead of Ollama
|
||||
openai:
|
||||
enabled: true
|
||||
apiKey: "sk-..."
|
||||
# Or use existing secret:
|
||||
# existingSecret: openai-api-key
|
||||
# secretKey: api-key
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
### To upgrade an existing deployment:
|
||||
|
||||
```bash
|
||||
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server -f custom-values.yaml
|
||||
# Update the repository
|
||||
helm repo update
|
||||
|
||||
# Upgrade with your custom values
|
||||
helm upgrade nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server -f custom-values.yaml
|
||||
```
|
||||
|
||||
### To upgrade with new values:
|
||||
|
||||
```bash
|
||||
helm upgrade nextcloud-mcp ./helm/nextcloud-mcp-server \
|
||||
helm upgrade nextcloud-mcp nextcloud-mcp/nextcloud-mcp-server \
|
||||
--set resources.limits.memory=1Gi
|
||||
```
|
||||
|
||||
|
||||
@@ -69,6 +69,33 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if .Values.vectorSync.enabled }}
|
||||
|
||||
5. Vector Search & Semantic Capabilities:
|
||||
- Vector Sync: Enabled
|
||||
- Scan Interval: {{ .Values.vectorSync.scanInterval }}s
|
||||
- Processor Workers: {{ .Values.vectorSync.processorWorkers }}
|
||||
{{- if .Values.qdrant.enabled }}
|
||||
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
|
||||
{{- else }}
|
||||
- Qdrant: Not deployed (configure external instance)
|
||||
{{- end }}
|
||||
{{- if .Values.ollama.enabled }}
|
||||
- Ollama: Deployed as subchart ({{ .Release.Name }}-ollama:11434)
|
||||
- Embedding Model: {{ .Values.ollama.embeddingModel }}
|
||||
{{- else if .Values.ollama.url }}
|
||||
- Ollama: Using external instance at {{ .Values.ollama.url }}
|
||||
- Embedding Model: {{ .Values.ollama.embeddingModel }}
|
||||
{{- else if .Values.openai.enabled }}
|
||||
- OpenAI: Enabled for embeddings
|
||||
{{- else }}
|
||||
- WARNING: No embedding provider configured (Ollama or OpenAI required)
|
||||
{{- end }}
|
||||
|
||||
Check vector sync status:
|
||||
kubectl --namespace {{ .Release.Namespace }} exec -it deploy/{{ include "nextcloud-mcp-server.fullname" . }} -- curl -s http://localhost:{{ include "nextcloud-mcp-server.port" . }}/user/page | grep "Vector Sync"
|
||||
{{- end }}
|
||||
|
||||
For more information and documentation:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
@@ -94,6 +94,17 @@ Create the name of the PVC to use for OAuth storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for Qdrant local persistent storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.qdrantPvcName" -}}
|
||||
{{- if .Values.qdrant.localPersistence.existingClaim }}
|
||||
{{- .Values.qdrant.localPersistence.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the MCP server port
|
||||
*/}}
|
||||
|
||||
@@ -140,6 +140,66 @@ spec:
|
||||
value: {{ .Values.documentProcessing.custom.types | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
# Vector Sync
|
||||
- name: VECTOR_SYNC_ENABLED
|
||||
value: {{ .Values.vectorSync.enabled | quote }}
|
||||
{{- if .Values.vectorSync.enabled }}
|
||||
- name: VECTOR_SYNC_SCAN_INTERVAL
|
||||
value: {{ .Values.vectorSync.scanInterval | quote }}
|
||||
- name: VECTOR_SYNC_PROCESSOR_WORKERS
|
||||
value: {{ .Values.vectorSync.processorWorkers | quote }}
|
||||
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
|
||||
value: {{ .Values.vectorSync.queueMaxSize | quote }}
|
||||
{{- end }}
|
||||
# Qdrant Vector Database
|
||||
{{- if eq .Values.qdrant.mode "network" }}
|
||||
# Network mode: Use dedicated Qdrant service
|
||||
{{- if .Values.qdrant.networkMode.deploySubchart }}
|
||||
- name: QDRANT_URL
|
||||
value: "http://{{ .Release.Name }}-qdrant:6333"
|
||||
{{- else if .Values.qdrant.networkMode.externalUrl }}
|
||||
- name: QDRANT_URL
|
||||
value: {{ .Values.qdrant.networkMode.externalUrl | quote }}
|
||||
{{- end }}
|
||||
{{- if or .Values.qdrant.networkMode.apiKey .Values.qdrant.networkMode.existingSecret }}
|
||||
- name: QDRANT_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.qdrant.networkMode.existingSecret | default (printf "%s-qdrant" .Release.Name) }}
|
||||
key: {{ .Values.qdrant.networkMode.secretKey }}
|
||||
{{- end }}
|
||||
{{- else if eq .Values.qdrant.mode "persistent" }}
|
||||
# Persistent local mode: File-based storage
|
||||
- name: QDRANT_LOCATION
|
||||
value: {{ .Values.qdrant.localPersistence.dataPath | quote }}
|
||||
{{- else }}
|
||||
# In-memory mode (default): Ephemeral storage
|
||||
- name: QDRANT_LOCATION
|
||||
value: ":memory:"
|
||||
{{- end }}
|
||||
- name: QDRANT_COLLECTION
|
||||
value: {{ .Values.qdrant.collection | quote }}
|
||||
# Ollama Embedding Service
|
||||
{{- if or .Values.ollama.enabled .Values.ollama.url }}
|
||||
- name: OLLAMA_BASE_URL
|
||||
value: {{ .Values.ollama.url | default (printf "http://%s-ollama:11434" .Release.Name) | quote }}
|
||||
- name: OLLAMA_EMBEDDING_MODEL
|
||||
value: {{ .Values.ollama.embeddingModel | quote }}
|
||||
- name: OLLAMA_VERIFY_SSL
|
||||
value: {{ .Values.ollama.verifySsl | quote }}
|
||||
{{- end }}
|
||||
# OpenAI Embedding Provider (alternative to Ollama)
|
||||
{{- if .Values.openai.enabled }}
|
||||
- name: OPENAI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.openai.existingSecret | default (printf "%s-openai" (include "nextcloud-mcp-server.fullname" .)) }}
|
||||
key: {{ .Values.openai.secretKey }}
|
||||
{{- if .Values.openai.baseUrl }}
|
||||
- name: OPENAI_BASE_URL
|
||||
value: {{ .Values.openai.baseUrl | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -160,6 +220,10 @@ spec:
|
||||
- name: oauth-storage
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -171,6 +235,11 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{{- if and .Values.openai.enabled (not .Values.openai.existingSecret) }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-openai
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{ .Values.openai.secretKey }}: {{ .Values.openai.apiKey | b64enc | quote }}
|
||||
{{- end }}
|
||||
@@ -15,3 +15,21 @@ spec:
|
||||
requests:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
|
||||
@@ -264,3 +264,137 @@ extraEnvFrom: []
|
||||
# name: my-configmap
|
||||
# - secretRef:
|
||||
# name: my-secret
|
||||
|
||||
# Vector Sync Configuration
|
||||
# Background synchronization of Nextcloud content into vector database for semantic search
|
||||
vectorSync:
|
||||
# Enable background vector synchronization
|
||||
enabled: false
|
||||
# Scan interval in seconds (how often to check for changes)
|
||||
scanInterval: 3600
|
||||
# Number of concurrent processor workers
|
||||
processorWorkers: 3
|
||||
# Maximum queue size for documents pending indexing
|
||||
queueMaxSize: 10000
|
||||
|
||||
# Qdrant Vector Database Configuration
|
||||
# Three deployment modes available:
|
||||
# 1. Local In-Memory: Fast, ephemeral, zero-config (mode: "memory")
|
||||
# 2. Local Persistent: File-based, survives restarts (mode: "persistent")
|
||||
# 3. Network: Dedicated Qdrant service, production-ready (mode: "network")
|
||||
qdrant:
|
||||
# Qdrant mode: "memory", "persistent", or "network"
|
||||
# - memory: In-memory storage (:memory:) - default, zero config, data lost on restart
|
||||
# - persistent: Local file storage - data persists across restarts, suitable for small/medium deployments
|
||||
# - network: Dedicated Qdrant service (see networkMode below)
|
||||
mode: "memory"
|
||||
|
||||
# Collection name for vector data
|
||||
collection: "nextcloud_content"
|
||||
|
||||
# Local persistent mode configuration (only used when mode: "persistent")
|
||||
localPersistence:
|
||||
# Enable persistent volume for local Qdrant data
|
||||
enabled: true
|
||||
# Storage class (leave empty for default)
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
# Size for local Qdrant storage
|
||||
size: 1Gi
|
||||
# Path where Qdrant data is stored (relative to /app/data)
|
||||
# Default: /app/data/qdrant
|
||||
dataPath: "/app/data/qdrant"
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# Network mode configuration (only used when mode: "network")
|
||||
networkMode:
|
||||
# Deploy Qdrant as a subchart (if true) or use external Qdrant (if false)
|
||||
deploySubchart: false
|
||||
# External Qdrant URL (used when deploySubchart: false)
|
||||
# Example: "http://qdrant.default.svc.cluster.local:6333"
|
||||
externalUrl: ""
|
||||
# Optional API key for Qdrant authentication
|
||||
apiKey: ""
|
||||
# Use existing secret for API key
|
||||
existingSecret: ""
|
||||
secretKey: "api-key"
|
||||
|
||||
# Qdrant subchart configuration (only used when mode: "network" and networkMode.deploySubchart: true)
|
||||
# All values are passed through to the qdrant/qdrant chart.
|
||||
# See https://github.com/qdrant/qdrant-helm for full configuration options.
|
||||
subchart:
|
||||
# Number of Qdrant replicas
|
||||
replicaCount: 1
|
||||
image:
|
||||
# Qdrant version
|
||||
tag: v1.12.5
|
||||
config:
|
||||
cluster:
|
||||
# Enable distributed cluster mode
|
||||
enabled: false
|
||||
# Persistent storage for vector data
|
||||
persistence:
|
||||
size: 10Gi
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
# Resource limits and requests
|
||||
resources:
|
||||
requests:
|
||||
cpu: 200m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 2Gi
|
||||
|
||||
# Ollama Embedding Service
|
||||
# Deployed as a subchart when enabled. All values are passed through to the ollama/ollama chart.
|
||||
# See https://github.com/otwld/ollama-helm for full configuration options.
|
||||
ollama:
|
||||
# Enable Ollama subchart deployment
|
||||
# Set to true to deploy Ollama as a subchart, or false to use an external Ollama instance
|
||||
enabled: false
|
||||
# External Ollama URL (use this if you have Ollama deployed elsewhere)
|
||||
# When set, use enabled: false to prevent deploying the subchart
|
||||
# Example: "http://ollama.default.svc.cluster.local:11434"
|
||||
url: ""
|
||||
# Embedding model to use
|
||||
embeddingModel: "nomic-embed-text"
|
||||
# Verify SSL certificates when connecting to Ollama
|
||||
verifySsl: true
|
||||
# Number of Ollama replicas (only used when subchart is deployed)
|
||||
replicaCount: 1
|
||||
# Ollama configuration (only used when subchart is deployed)
|
||||
ollama:
|
||||
# Models to automatically pull on startup
|
||||
models:
|
||||
pull:
|
||||
- nomic-embed-text
|
||||
# Persistent storage for models (only used when subchart is deployed)
|
||||
persistentVolume:
|
||||
enabled: true
|
||||
size: 20Gi
|
||||
storageClass: ""
|
||||
# Resource limits and requests (only used when subchart is deployed)
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 4Gi
|
||||
|
||||
# OpenAI-compatible Embedding Provider
|
||||
# Alternative to Ollama for embedding generation. Can be used with OpenAI or any compatible API.
|
||||
openai:
|
||||
# Enable OpenAI embedding provider
|
||||
enabled: false
|
||||
# OpenAI API key (only used if existingSecret is not set)
|
||||
apiKey: ""
|
||||
# Name of existing secret containing the API key
|
||||
existingSecret: ""
|
||||
# Key in the secret that contains the API key
|
||||
secretKey: "api-key"
|
||||
# Optional custom API endpoint (e.g., for Azure OpenAI or local compatible services)
|
||||
baseUrl: ""
|
||||
|
||||
+53
-6
@@ -21,7 +21,7 @@ services:
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:1e4eae55eebe094cae6f9e7b6e0b4bccf4a4fe7b7e6f6f8f57010994b3b2ee42
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:5b043f7ea2f609d5ff5635f475c30d303bec17775a5c3f7fa435e3818e669120
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:a43ab55898599157fb0e0e097dabb8ecdd1d8e3df1ae5b67c6e15a136b171a6c
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -76,11 +76,32 @@ services:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8000:8000
|
||||
volumes:
|
||||
- mcp-data:/app/data
|
||||
environment:
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_USERNAME=admin
|
||||
- NEXTCLOUD_PASSWORD=admin
|
||||
|
||||
# Vector sync configuration (ADR-007)
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=10
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
# Qdrant configuration (three modes):
|
||||
# 1. Network mode: Set QDRANT_URL=http://qdrant:6333 (requires qdrant service)
|
||||
# 2. In-memory mode: Set QDRANT_LOCATION=:memory: (default if nothing set)
|
||||
# 3. Persistent local: Set QDRANT_LOCATION=/app/data/qdrant (stored in mcp-data volume)
|
||||
- QDRANT_LOCATION=:memory:
|
||||
# - QDRANT_URL=http://qdrant:6333 # Uncomment for network mode
|
||||
# - QDRANT_API_KEY=${QDRANT_API_KEY:-my_secret_api_key} # Only for network mode
|
||||
- QDRANT_COLLECTION=nextcloud_content
|
||||
|
||||
# Ollama configuration (optional - uses SimpleEmbeddingProvider if not set)
|
||||
# - OLLAMA_BASE_URL=http://your-ollama-endpoint:port
|
||||
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# - OLLAMA_VERIFY_SSL=false
|
||||
|
||||
mcp-oauth:
|
||||
build: .
|
||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||
@@ -96,6 +117,7 @@ services:
|
||||
# OIDC_CLIENT_ID not set - uses Dynamic Client Registration (DCR)
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
|
||||
- NEXTCLOUD_RESOURCE_URI=http://localhost:8080 # ADR-005: Nextcloud resource identifier for audience validation
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
|
||||
@@ -104,8 +126,9 @@ services:
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# ADR-004: Use Hybrid Flow (server intercepts OAuth callback)
|
||||
# Set to false to enable Hybrid Flow tests - server stores refresh token and issues MCP codes
|
||||
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
|
||||
# Tokens must contain BOTH MCP and Nextcloud audiences
|
||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||
|
||||
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
||||
# Client credentials registered via RFC 7591 and stored in volume
|
||||
@@ -115,7 +138,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.2@sha256:3617b09bb4b7510a8d8d9b9fc5707399e2d70688dbcc2f8fb013a144829be1b9
|
||||
image: quay.io/keycloak/keycloak:26.4.4@sha256:c6459d5fae1b759f5d667ebdc6237ab3121379c3494e213898569014ede1846d
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -159,6 +182,7 @@ services:
|
||||
# Nextcloud API endpoint (for accessing APIs with validated token)
|
||||
- NEXTCLOUD_HOST=http://app:80
|
||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||
- NEXTCLOUD_RESOURCE_URI=nextcloud # ADR-005: Keycloak uses client IDs as audiences, not URLs
|
||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||
|
||||
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||
@@ -166,8 +190,11 @@ services:
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
|
||||
# Token exchange (RFC 8693) - convert aud:nextcloud-mcp-server → aud:nextcloud
|
||||
# ADR-005: Token exchange mode (RFC 8693)
|
||||
# Exchange MCP tokens (aud: nextcloud-mcp-server) for Nextcloud tokens (aud: http://localhost:8080)
|
||||
# Provides strict audience separation between MCP session and Nextcloud API access
|
||||
- ENABLE_TOKEN_EXCHANGE=true
|
||||
- TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens for 5 minutes (default)
|
||||
|
||||
# OAuth scopes (optional - uses defaults if not specified)
|
||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
||||
@@ -177,6 +204,24 @@ services:
|
||||
- keycloak-tokens:/app/data
|
||||
- keycloak-oauth-storage:/app/.oauth
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
- 127.0.0.1:6334:6334 # gRPC (optional)
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
environment:
|
||||
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-my_secret_api_key}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -f /qdrant/.qdrant-initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
profiles:
|
||||
- qdrant
|
||||
|
||||
volumes:
|
||||
nextcloud:
|
||||
db:
|
||||
@@ -184,3 +229,5 @@ volumes:
|
||||
oauth-tokens:
|
||||
keycloak-tokens:
|
||||
keycloak-oauth-storage:
|
||||
qdrant-data:
|
||||
mcp-data:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# ADR-003: Vector Database and Semantic Search Architecture
|
||||
|
||||
## Status
|
||||
Proposed
|
||||
Superseded by ADR-007
|
||||
|
||||
**Note**: This ADR was never implemented. The core technical decisions (Qdrant, embeddings, hybrid search) remain valid and are incorporated into ADR-007, which adds user-controlled background job management, task queuing, multi-user scheduling, and web UI integration. See [ADR-007: Background Vector Sync with User-Controlled Job Management](./ADR-007-background-vector-sync-job-management.md) for the implemented architecture.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,865 @@
|
||||
# ADR-006: Progressive Consent via URL Elicitation (SEP-1036)
|
||||
|
||||
**Status**: Partially Implemented (Interim Workaround)
|
||||
**Date**: 2025-01-05 (Updated: 2025-01-07)
|
||||
**Related**: [SEP-1036](https://github.com/modelcontextprotocol/specification/pull/887), ADR-004
|
||||
**Depends On**: ADR-005 (token validation)
|
||||
|
||||
## Context
|
||||
|
||||
### What is Progressive Consent?
|
||||
|
||||
**Progressive consent is a mechanism, not a feature**. It describes HOW users grant the MCP server access to Nextcloud resources through OAuth elicitation. The server can operate in two modes:
|
||||
|
||||
1. **Pass-through mode (ENABLE_OFFLINE_ACCESS=false)**:
|
||||
- No refresh tokens requested or stored
|
||||
- Server passes through client's access token to Nextcloud
|
||||
- No provisioning tools available
|
||||
- Suitable for stateless, client-driven operations
|
||||
|
||||
2. **Offline access mode (ENABLE_OFFLINE_ACCESS=true)**:
|
||||
- Server requests `offline_access` scope and stores refresh tokens
|
||||
- Enables background operations and server-initiated API calls
|
||||
- Provisioning tools available (`provision_nextcloud_access`, `check_logged_in`)
|
||||
- Requires explicit user consent via OAuth Flow 2
|
||||
|
||||
**Single-user mode (BasicAuth)** doesn't use progressive consent at all - credentials are directly available.
|
||||
|
||||
### Current User Experience Issues
|
||||
|
||||
The current offline access provisioning flow (ADR-004) requires users to manually visit OAuth URLs returned by MCP tools. This creates a poor user experience:
|
||||
|
||||
1. User calls `provision_nextcloud_access` tool
|
||||
2. Tool returns a URL as text in the response
|
||||
3. User must manually copy URL and open in browser
|
||||
4. No indication when provisioning is complete
|
||||
5. User must retry the original operation manually
|
||||
|
||||
### SEP-1036: URL Mode Elicitation
|
||||
|
||||
The MCP specification now supports **URL mode elicitation** ([SEP-1036](https://github.com/modelcontextprotocol/specification/pull/887)), which enables servers to:
|
||||
|
||||
- Request out-of-band user interactions via secure URLs
|
||||
- Handle sensitive operations like OAuth flows without exposing credentials to the client
|
||||
- Provide progress tracking for async operations
|
||||
- Return errors that automatically trigger elicitation flows
|
||||
|
||||
**Key benefits for progressive consent**:
|
||||
- **Automatic URL Opening**: Client opens URL in browser automatically (with user consent)
|
||||
- **Progress Tracking**: Server can notify client when provisioning is complete
|
||||
- **Error-Triggered Flows**: Server can return `ElicitationRequired` error to trigger provisioning
|
||||
- **Better UX**: User doesn't manually copy/paste URLs
|
||||
|
||||
### Current Implementation Limitations
|
||||
|
||||
The current progressive consent flow in `nextcloud_mcp_server/server/oauth_tools.py`:
|
||||
|
||||
```python
|
||||
@mcp.tool(name="provision_nextcloud_access")
|
||||
async def tool_provision_access(ctx: Context) -> ProvisioningResult:
|
||||
"""Returns OAuth URL as text - user must manually open it."""
|
||||
return ProvisioningResult(
|
||||
success=True,
|
||||
authorization_url=auth_url, # User must copy this
|
||||
message="Please visit the authorization URL..."
|
||||
)
|
||||
```
|
||||
|
||||
**Problems**:
|
||||
1. Manual URL handling (copy/paste)
|
||||
2. No progress tracking
|
||||
3. No automatic retry after provisioning
|
||||
4. Tool call required just to get URL
|
||||
5. No client integration (URL just displayed as text)
|
||||
|
||||
## Decision
|
||||
|
||||
We will **migrate progressive consent from manual tools to URL mode elicitation**, leveraging SEP-1036 for better user experience and OAuth security.
|
||||
|
||||
### New Architecture: Elicitation-Driven Consent
|
||||
|
||||
Instead of explicit tools, use **automatic elicitation** triggered by authorization errors:
|
||||
|
||||
```
|
||||
User → Calls Nextcloud Tool → Server Checks Provisioning
|
||||
↓ Not Provisioned
|
||||
Error: ElicitationRequired
|
||||
↓
|
||||
Client Shows Consent UI
|
||||
↓ User Accepts
|
||||
Client Opens OAuth URL
|
||||
↓
|
||||
User Completes OAuth
|
||||
↓
|
||||
Server Sends Progress Update
|
||||
↓
|
||||
Original Tool Call Auto-Retries
|
||||
```
|
||||
|
||||
### Mode 1: Elicitation-Required Error (Primary)
|
||||
|
||||
When a tool requires provisioning, return an **ElicitationRequired error** (-32000):
|
||||
|
||||
```python
|
||||
# In any Nextcloud tool decorated with @require_provisioning
|
||||
@mcp.tool()
|
||||
@require_provisioning # New decorator
|
||||
async def nc_notes_list_notes(ctx: Context):
|
||||
"""List notes - auto-triggers provisioning if needed."""
|
||||
# If not provisioned, decorator returns ElicitationRequired error
|
||||
# If provisioned, continues normally
|
||||
client = await get_client(ctx)
|
||||
return await client.notes.list_notes()
|
||||
```
|
||||
|
||||
**Error response structure**:
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": "Nextcloud access provisioning required",
|
||||
"data": {
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"elicitationId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"url": "https://mcp.example.com/oauth/provision?id=550e8400...",
|
||||
"message": "Grant the MCP server access to your Nextcloud account to continue."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Client behavior**:
|
||||
1. Receives error with elicitation
|
||||
2. Shows consent UI: "App wants to access Nextcloud. Open authorization page?"
|
||||
3. On user acceptance, opens URL in browser
|
||||
4. Optionally tracks progress via `elicitation/track`
|
||||
5. Auto-retries original tool call when complete
|
||||
|
||||
### Mode 2: Explicit Elicitation Request (Fallback)
|
||||
|
||||
For clients that don't support error-triggered elicitation, provide explicit tool:
|
||||
|
||||
```python
|
||||
@mcp.tool(name="request_nextcloud_access")
|
||||
async def request_access(ctx: Context) -> ElicitationResponse:
|
||||
"""Explicitly request provisioning via elicitation."""
|
||||
# Send elicitation/create request
|
||||
return await create_elicitation(
|
||||
mode="url",
|
||||
url=generate_oauth_url(),
|
||||
message="Grant access to Nextcloud",
|
||||
elicitation_id=generate_id()
|
||||
)
|
||||
```
|
||||
|
||||
**Note**: This is a fallback for compatibility. Primary flow uses error-triggered elicitation.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. New Decorator: `@require_provisioning`
|
||||
|
||||
Replace explicit provisioning checks with a decorator that returns `ElicitationRequired`:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/auth/provisioning_decorator.py
|
||||
|
||||
def require_provisioning(func):
|
||||
"""
|
||||
Decorator that ensures user has provisioned Nextcloud access.
|
||||
|
||||
If not provisioned, returns ElicitationRequired error with OAuth URL.
|
||||
Otherwise, proceeds with normal tool execution.
|
||||
"""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(ctx: Context, *args, **kwargs):
|
||||
# Extract user ID from token
|
||||
user_id = get_user_id_from_context(ctx)
|
||||
|
||||
# Check if provisioned
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
if not await storage.has_refresh_token(user_id):
|
||||
# Not provisioned - return ElicitationRequired error
|
||||
elicitation_id = str(uuid.uuid4())
|
||||
oauth_url = await generate_oauth_url_for_provisioning(
|
||||
user_id=user_id,
|
||||
elicitation_id=elicitation_id,
|
||||
ctx=ctx
|
||||
)
|
||||
|
||||
# Store elicitation for tracking
|
||||
await storage.store_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
user_id=user_id,
|
||||
status="pending",
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
raise McpError(
|
||||
code=ErrorCode.ELICITATION_REQUIRED, # -32000
|
||||
message="Nextcloud access provisioning required",
|
||||
data={
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"elicitationId": elicitation_id,
|
||||
"url": oauth_url,
|
||||
"message": (
|
||||
"Grant the MCP server access to your Nextcloud "
|
||||
"account to continue. This is a one-time setup."
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Already provisioned - proceed normally
|
||||
return await func(ctx, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
```
|
||||
|
||||
### 2. Elicitation Tracking Endpoint
|
||||
|
||||
Implement `elicitation/track` to provide progress updates:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/server/elicitation.py
|
||||
|
||||
@mcp.request_handler("elicitation/track")
|
||||
async def track_elicitation(
|
||||
elicitation_id: str,
|
||||
_meta: dict = None
|
||||
) -> dict:
|
||||
"""
|
||||
Track progress of an elicitation request.
|
||||
|
||||
Returns when elicitation is complete or times out.
|
||||
"""
|
||||
progress_token = _meta.get("progressToken") if _meta else None
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
# Poll for completion (with timeout)
|
||||
timeout = 300 # 5 minutes
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
while (datetime.now(timezone.utc) - start_time).seconds < timeout:
|
||||
elicitation = await storage.get_elicitation(elicitation_id)
|
||||
|
||||
if not elicitation:
|
||||
raise McpError(
|
||||
code=-32602, # Invalid params
|
||||
message=f"Unknown elicitation ID: {elicitation_id}"
|
||||
)
|
||||
|
||||
# Send progress notification if token provided
|
||||
if progress_token and elicitation["status"] == "pending":
|
||||
await send_progress_notification(
|
||||
progress_token=progress_token,
|
||||
progress=50,
|
||||
message="Waiting for OAuth authorization..."
|
||||
)
|
||||
|
||||
# Check if complete
|
||||
if elicitation["status"] == "complete":
|
||||
return {"status": "complete"}
|
||||
|
||||
# Check if failed
|
||||
if elicitation["status"] == "failed":
|
||||
return {
|
||||
"status": "failed",
|
||||
"error": elicitation.get("error_message")
|
||||
}
|
||||
|
||||
# Wait before polling again
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Timeout
|
||||
raise McpError(
|
||||
code=-32000,
|
||||
message="Elicitation timed out - user did not complete authorization"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. OAuth Callback Updates
|
||||
|
||||
Update the OAuth callback to mark elicitations as complete:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/auth/oauth_routes.py
|
||||
|
||||
async def oauth_callback(request: Request) -> Response:
|
||||
"""Handle OAuth callback and mark elicitation complete."""
|
||||
code = request.query_params.get("code")
|
||||
state = request.query_params.get("state")
|
||||
|
||||
# Validate and exchange code for tokens
|
||||
tokens = await exchange_authorization_code(code)
|
||||
|
||||
# Store refresh token
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=tokens["refresh_token"]
|
||||
)
|
||||
|
||||
# Mark elicitation as complete
|
||||
elicitation_id = request.query_params.get("elicitation_id")
|
||||
if elicitation_id:
|
||||
await storage.update_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
status="complete",
|
||||
completed_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
return Response(
|
||||
content="<h1>Authorization Complete!</h1>"
|
||||
"<p>You can close this window and return to the application.</p>",
|
||||
media_type="text/html"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Update All Nextcloud Tools
|
||||
|
||||
Add `@require_provisioning` decorator to all Nextcloud tools:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/server/notes.py
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_provisioning # NEW: Auto-triggers provisioning
|
||||
async def nc_notes_list_notes(
|
||||
ctx: Context,
|
||||
category: Optional[str] = None
|
||||
) -> NotesListResponse:
|
||||
"""List all notes - automatically handles provisioning."""
|
||||
client = await get_client(ctx)
|
||||
# Tool logic proceeds only if provisioned
|
||||
notes = await client.notes.list_notes(category=category)
|
||||
return NotesListResponse(results=notes)
|
||||
```
|
||||
|
||||
### 5. Capability Declaration
|
||||
|
||||
Declare URL elicitation support during initialization:
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
capabilities = {
|
||||
"elicitation": {
|
||||
"url": {} # Declare URL mode support
|
||||
# Note: We don't support "form" mode (in-band data collection)
|
||||
},
|
||||
# ... other capabilities
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Environment Variables
|
||||
|
||||
**Primary control**:
|
||||
```bash
|
||||
# ENABLE_OFFLINE_ACCESS: Controls whether server requests refresh tokens and enables provisioning tools
|
||||
# Default: false (pass-through mode)
|
||||
# Set to true to enable offline access mode with Flow 2 provisioning
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
```
|
||||
|
||||
**Future variables** (when URL elicitation is implemented):
|
||||
```bash
|
||||
# ELICITATION_CALLBACK_URL: Base URL for OAuth callbacks with elicitation tracking
|
||||
# Default: NEXTCLOUD_MCP_SERVER_URL + /oauth/callback
|
||||
ELICITATION_CALLBACK_URL=http://localhost:8000/oauth/callback
|
||||
|
||||
# ELICITATION_TIMEOUT_SECONDS: How long to wait for user to complete OAuth
|
||||
# Default: 300 (5 minutes)
|
||||
ELICITATION_TIMEOUT_SECONDS=300
|
||||
```
|
||||
|
||||
**Removed variables**:
|
||||
```bash
|
||||
# ENABLE_PROGRESSIVE_CONSENT - Removed. Progressive consent is a mechanism, not a feature toggle.
|
||||
# Use ENABLE_OFFLINE_ACCESS to control whether provisioning tools are available.
|
||||
# MCP_SERVER_CLIENT_ID - merged into OIDC_CLIENT_ID
|
||||
```
|
||||
|
||||
## User Experience Comparison
|
||||
|
||||
### Before (ADR-004 Manual Tools)
|
||||
|
||||
```
|
||||
User: "List my notes"
|
||||
Assistant: *calls nc_notes_list_notes*
|
||||
Server: Error - not provisioned
|
||||
Assistant: "You need to provision access first. Let me do that."
|
||||
Assistant: *calls provision_nextcloud_access*
|
||||
Server: {authorization_url: "https://..."}
|
||||
Assistant: "Please visit this URL: https://..."
|
||||
User: *copies URL, opens browser, completes OAuth*
|
||||
User: "OK, I'm done"
|
||||
Assistant: *calls nc_notes_list_notes again*
|
||||
Server: Success! [notes...]
|
||||
```
|
||||
|
||||
**Issues**: 4 interactions, manual URL handling, no automation
|
||||
|
||||
### After (ADR-006 Elicitation)
|
||||
|
||||
```
|
||||
User: "List my notes"
|
||||
Assistant: *calls nc_notes_list_notes*
|
||||
Server: ElicitationRequired error
|
||||
Client: Shows dialog: "Grant access to Nextcloud? [Yes] [No]"
|
||||
User: *clicks Yes*
|
||||
Client: Opens OAuth URL in browser automatically
|
||||
User: *completes OAuth*
|
||||
Server: Sends progress notification "Complete!"
|
||||
Client: Auto-retries nc_notes_list_notes
|
||||
Server: Success! [notes...]
|
||||
Assistant: "Here are your notes: ..."
|
||||
```
|
||||
|
||||
**Benefits**: 1 interaction, automatic URL opening, seamless retry
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Add Elicitation Support (v0.26.0)
|
||||
|
||||
- Implement `@require_provisioning` decorator
|
||||
- Add `elicitation/track` endpoint
|
||||
- Keep existing tools (`provision_nextcloud_access`) for compatibility
|
||||
- Update OAuth callback to track elicitations
|
||||
- Add capability declaration
|
||||
|
||||
**Breaking changes**: None (additive)
|
||||
|
||||
### Phase 2: Update Documentation (v0.27.0)
|
||||
|
||||
- Document elicitation-based flow as primary
|
||||
- Mark manual tools as deprecated
|
||||
- Update examples and guides
|
||||
|
||||
**Breaking changes**: None (documentation only)
|
||||
|
||||
### Phase 3: Remove Manual Tools (v0.28.0)
|
||||
|
||||
- Remove `provision_nextcloud_access` tool
|
||||
- Remove `check_provisioning_status` tool (status in error message)
|
||||
- Remove `revoke_nextcloud_access` (or keep for explicit revocation?)
|
||||
|
||||
**Breaking changes**: Yes (removed tools)
|
||||
|
||||
### Phase 4: Optimize (v0.29.0+)
|
||||
|
||||
- Add elicitation result caching
|
||||
- Implement retry strategies
|
||||
- Add metrics and monitoring
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Cases
|
||||
|
||||
1. **First-Time User Flow**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_elicitation_first_time_user(nc_mcp_oauth_client):
|
||||
"""Test that first tool call triggers elicitation."""
|
||||
# User has no provisioning
|
||||
with pytest.raises(McpError) as exc:
|
||||
await nc_mcp_oauth_client.call_tool("nc_notes_list_notes")
|
||||
|
||||
# Should get ElicitationRequired error
|
||||
assert exc.value.code == -32000
|
||||
assert "elicitations" in exc.value.data
|
||||
assert exc.value.data["elicitations"][0]["mode"] == "url"
|
||||
|
||||
# Verify URL is valid OAuth URL
|
||||
url = exc.value.data["elicitations"][0]["url"]
|
||||
assert "oauth" in url
|
||||
assert "elicitationId" in url
|
||||
```
|
||||
|
||||
2. **Progress Tracking**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_elicitation_progress_tracking(nc_mcp_oauth_client):
|
||||
"""Test progress tracking during OAuth flow."""
|
||||
# Trigger elicitation
|
||||
elicitation_id = trigger_elicitation()
|
||||
|
||||
# Start tracking
|
||||
track_task = asyncio.create_task(
|
||||
nc_mcp_oauth_client.track_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
progress_token="test-token"
|
||||
)
|
||||
)
|
||||
|
||||
# Simulate OAuth completion
|
||||
await asyncio.sleep(1)
|
||||
await complete_oauth_flow(elicitation_id)
|
||||
|
||||
# Track should complete
|
||||
result = await track_task
|
||||
assert result["status"] == "complete"
|
||||
```
|
||||
|
||||
3. **Auto-Retry After Provisioning**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_auto_retry_after_provisioning(nc_mcp_oauth_client):
|
||||
"""Test that client auto-retries after elicitation."""
|
||||
# Mock client that auto-retries on ElicitationRequired
|
||||
client = AutoRetryMcpClient(nc_mcp_oauth_client)
|
||||
|
||||
# First call triggers elicitation, client handles it, retries
|
||||
result = await client.call_tool_with_elicitation("nc_notes_list_notes")
|
||||
|
||||
# Should succeed after provisioning
|
||||
assert result.success
|
||||
assert "notes" in result.data
|
||||
```
|
||||
|
||||
4. **Timeout Handling**
|
||||
```python
|
||||
@pytest.mark.oauth
|
||||
async def test_elicitation_timeout(nc_mcp_oauth_client):
|
||||
"""Test timeout if user doesn't complete OAuth."""
|
||||
elicitation_id = trigger_elicitation()
|
||||
|
||||
# Track with short timeout
|
||||
with pytest.raises(McpError, match="timed out"):
|
||||
await nc_mcp_oauth_client.track_elicitation(
|
||||
elicitation_id=elicitation_id,
|
||||
timeout=5 # 5 seconds
|
||||
)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Out-of-Band OAuth Flow
|
||||
|
||||
**Benefit**: OAuth credentials never pass through MCP client
|
||||
- User enters credentials directly on IdP page
|
||||
- MCP server receives only authorization code
|
||||
- Client never sees passwords or refresh tokens
|
||||
|
||||
**Threat mitigation**:
|
||||
- **Credential theft**: Client can't intercept credentials (out-of-band)
|
||||
- **Token exposure**: Client never receives Nextcloud refresh tokens
|
||||
- **CSRF**: State parameter validates OAuth callback
|
||||
- **URL tampering**: Elicitation ID ties OAuth flow to user session
|
||||
|
||||
### Elicitation ID as Security Token
|
||||
|
||||
The `elicitationId` serves as a capability token:
|
||||
- Cryptographically random (UUID v4)
|
||||
- Single-use (invalidated after completion)
|
||||
- Time-limited (expires after timeout)
|
||||
- User-scoped (tied to user session)
|
||||
|
||||
**Validation**:
|
||||
```python
|
||||
async def validate_elicitation_id(elicitation_id: str, user_id: str) -> bool:
|
||||
"""Validate that elicitation belongs to user and is still valid."""
|
||||
elicitation = await storage.get_elicitation(elicitation_id)
|
||||
|
||||
if not elicitation:
|
||||
return False
|
||||
|
||||
# Check ownership
|
||||
if elicitation["user_id"] != user_id:
|
||||
logger.warning(f"Elicitation ID mismatch: {elicitation_id}")
|
||||
return False
|
||||
|
||||
# Check expiry
|
||||
if elicitation["expires_at"] < datetime.now(timezone.utc):
|
||||
return False
|
||||
|
||||
# Check not already used
|
||||
if elicitation["status"] != "pending":
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
### Progress Tracking Security
|
||||
|
||||
**Risk**: Progress token reuse across users
|
||||
|
||||
**Mitigation**:
|
||||
- Progress tokens tied to elicitation ID
|
||||
- Elicitation ID tied to user session
|
||||
- Server validates ownership before sending updates
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Better UX**: Automatic URL opening, no manual copy/paste
|
||||
2. **Seamless Flow**: Auto-retry after provisioning
|
||||
3. **Progress Feedback**: User knows when OAuth is complete
|
||||
4. **Spec Compliance**: Implements SEP-1036 correctly
|
||||
5. **Secure by Design**: Out-of-band OAuth prevents credential exposure
|
||||
6. **Simpler API**: No explicit provisioning tools needed
|
||||
|
||||
### Negative
|
||||
|
||||
1. **Client Dependency**: Requires client support for URL elicitation
|
||||
2. **Complexity**: More moving parts (elicitation tracking, callbacks)
|
||||
3. **Polling**: Progress tracking uses polling (not ideal)
|
||||
4. **Breaking Change**: Removes manual provisioning tools (in v0.28.0)
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Storage Requirements**: Need to store elicitation state
|
||||
2. **Timeout Management**: Must handle long-running OAuth flows
|
||||
3. **Fallback Support**: Still need compatibility for older clients
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Keep Manual Tools Only (Rejected)
|
||||
|
||||
**Pros**: Simple, no client changes needed
|
||||
**Cons**: Poor UX, doesn't leverage SEP-1036
|
||||
|
||||
**Rejection reason**: SEP-1036 provides better UX and security
|
||||
|
||||
### 2. Form Mode Elicitation (Rejected)
|
||||
|
||||
**Pros**: No browser redirect needed
|
||||
**Cons**: Would expose OAuth credentials to client (security violation)
|
||||
|
||||
**Rejection reason**: Form mode only for non-sensitive data per SEP-1036
|
||||
|
||||
### 3. Hybrid: Both Tools and Elicitation (Considered)
|
||||
|
||||
**Pros**: Maximum compatibility, gradual migration
|
||||
**Cons**: API duplication, maintenance burden, confusing for users
|
||||
|
||||
**Decision**: Support during migration (v0.26-0.27), remove in v0.28
|
||||
|
||||
### 4. WebSocket for Progress (Rejected)
|
||||
|
||||
**Pros**: Real-time updates instead of polling
|
||||
**Cons**: MCP spec uses polling pattern, adds complexity
|
||||
|
||||
**Rejection reason**: Follow spec pattern (polling via elicitation/track)
|
||||
|
||||
## Interim Implementation: Inline Form Elicitation (Pre-SEP-1036)
|
||||
|
||||
**Note**: SEP-1036 (URL mode elicitation) is not yet available in the stable MCP Python SDK. As a temporary workaround, we've implemented a simplified version using the current **inline form elicitation** API.
|
||||
|
||||
### What Changed
|
||||
|
||||
Instead of waiting for URL mode elicitation, we implemented a `check_logged_in` tool that:
|
||||
|
||||
1. Checks if the user has completed Flow 2 (resource provisioning)
|
||||
2. If logged in, returns `"yes"`
|
||||
3. If not logged in, uses **inline form elicitation** to prompt the user
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**New Tool**: `check_logged_in`
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/server/oauth_tools.py
|
||||
|
||||
class LoginConfirmation(BaseModel):
|
||||
"""Schema for login confirmation elicitation."""
|
||||
acknowledged: bool = Field(
|
||||
default=False,
|
||||
description="Check this box after completing login at the provided URL",
|
||||
)
|
||||
|
||||
@mcp.tool(name="check_logged_in")
|
||||
@require_scopes("openid")
|
||||
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||
"""Check if user is logged in and elicit login if needed."""
|
||||
# Check if already logged in
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
if status.is_provisioned:
|
||||
return "yes"
|
||||
|
||||
# Generate OAuth URL for Flow 2
|
||||
auth_url = generate_oauth_url_for_flow2(...)
|
||||
|
||||
# Use inline form elicitation (current MCP API)
|
||||
result = await ctx.elicit(
|
||||
message=f"Please log in to Nextcloud at the following URL:\n\n{auth_url}\n\nAfter completing the login, check the box below and click OK.",
|
||||
schema=LoginConfirmation,
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
# Verify login succeeded
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
return "yes" if status.is_provisioned else "Login not detected"
|
||||
elif result.action == "decline":
|
||||
return "Login declined by user."
|
||||
else:
|
||||
return "Login cancelled by user."
|
||||
```
|
||||
|
||||
**OAuth Routes** (added to `app.py`):
|
||||
|
||||
```python
|
||||
# Flow 2 routes for resource provisioning
|
||||
routes.append(
|
||||
Route("/oauth/authorize-nextcloud", oauth_authorize_nextcloud, methods=["GET"])
|
||||
)
|
||||
routes.append(
|
||||
Route("/oauth/callback-nextcloud", oauth_callback_nextcloud, methods=["GET"])
|
||||
)
|
||||
```
|
||||
|
||||
### User Experience
|
||||
|
||||
```
|
||||
User: *calls check_logged_in tool*
|
||||
|
||||
MCP Client: Displays form elicitation
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Please log in to Nextcloud at the following URL: │
|
||||
│ │
|
||||
│ http://localhost:8000/oauth/authorize-nextcloud?... │
|
||||
│ │
|
||||
│ After completing the login, check the box below and │
|
||||
│ click OK. │
|
||||
│ │
|
||||
│ ☐ Check this box after completing login │
|
||||
│ │
|
||||
│ [Accept] [Decline] [Cancel] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
User: *copies URL, opens in browser, completes OAuth*
|
||||
User: *checks box and clicks Accept*
|
||||
|
||||
MCP Server: Verifies login and returns "yes"
|
||||
```
|
||||
|
||||
### Limitations of Interim Approach
|
||||
|
||||
1. **Manual URL Handling**: User must manually copy and paste the URL (not clickable)
|
||||
2. **No Automatic Browser Opening**: Client doesn't automatically open the URL
|
||||
3. **No Progress Tracking**: Can't track OAuth completion status in real-time
|
||||
4. **URL in Message Text**: Login URL embedded in plain text message (not as structured field)
|
||||
5. **Client-Side Confirmation**: Relies on user clicking "OK" after OAuth (honor system)
|
||||
|
||||
### Why Not Use URL Mode Now?
|
||||
|
||||
The current stable MCP Python SDK (`main` branch) only supports **inline form elicitation**:
|
||||
|
||||
```python
|
||||
# Current API (no 'mode' parameter)
|
||||
class ElicitRequestParams(RequestParams):
|
||||
message: str
|
||||
requestedSchema: ElicitRequestedSchema
|
||||
# No 'mode', 'url', or 'elicitationId' fields
|
||||
```
|
||||
|
||||
URL mode elicitation (`mode: "url"`) is only available in the SEP-1036 branch, which has not been merged to `main` yet.
|
||||
|
||||
### Migration to URL Mode (When SEP-1036 Lands)
|
||||
|
||||
Once SEP-1036 is merged and available in the stable SDK, we will migrate to URL mode elicitation:
|
||||
|
||||
**Before (Current Workaround)**:
|
||||
```python
|
||||
result = await ctx.elicit(
|
||||
message=f"Please log in at: {auth_url}\n\nClick OK after login.",
|
||||
schema=LoginConfirmation,
|
||||
)
|
||||
```
|
||||
|
||||
**After (URL Mode)**:
|
||||
```python
|
||||
result = await ctx.session.elicit_url(
|
||||
message="Please log in to Nextcloud to authorize this MCP server.",
|
||||
url=auth_url,
|
||||
elicitation_id=elicitation_id,
|
||||
)
|
||||
```
|
||||
|
||||
**Benefits of migration**:
|
||||
- Automatic URL opening (with user consent)
|
||||
- Clickable URLs in client UI
|
||||
- Progress tracking via `elicitation/track`
|
||||
- Better security (URL not in message text)
|
||||
- Auto-retry support
|
||||
|
||||
### Testing
|
||||
|
||||
Integration tests validate the current inline form elicitation:
|
||||
|
||||
```python
|
||||
# tests/server/oauth/test_login_elicitation.py
|
||||
|
||||
async def test_check_logged_in_already_authenticated(nc_mcp_oauth_client):
|
||||
"""Test immediate 'yes' for authenticated users."""
|
||||
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
|
||||
assert "yes" in result.content[0].text.lower()
|
||||
|
||||
async def test_check_logged_in_url_format(nc_mcp_oauth_client):
|
||||
"""Test that login URL (when needed) contains correct OAuth parameters."""
|
||||
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
|
||||
response_text = result.content[0].text
|
||||
|
||||
# If URL present, validate OAuth parameters
|
||||
if "http" in response_text:
|
||||
assert "response_type=code" in response_text
|
||||
assert "client_id=" in response_text
|
||||
assert "redirect_uri=" in response_text
|
||||
assert "openid" in response_text
|
||||
```
|
||||
|
||||
### Future Work
|
||||
|
||||
- **Monitor SEP-1036**: Watch for merge to MCP Python SDK `main` branch
|
||||
- **Implement URL Mode**: Once available, migrate `check_logged_in` to use `ctx.session.elicit_url()`
|
||||
- **Add Progress Tracking**: Implement `elicitation/track` endpoint for OAuth completion status
|
||||
- **Implement Error-Triggered Elicitation**: Use `@require_provisioning` decorator to return `ElicitationRequired` errors
|
||||
- **Remove Manual Workaround**: Deprecate inline form approach once URL mode is stable
|
||||
|
||||
## References
|
||||
|
||||
- [SEP-1036: URL Mode Elicitation](https://github.com/modelcontextprotocol/specification/pull/887)
|
||||
- [MCP Elicitation Specification](https://modelcontextprotocol.io/specification/draft/client/elicitation)
|
||||
- [ADR-004: Federated Authentication Architecture](./ADR-004-mcp-application-oauth.md)
|
||||
- [ADR-005: Token Audience Validation](./ADR-005-token-audience-validation.md)
|
||||
- [RFC 8252: OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252)
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Interim Implementation (Inline Form Elicitation)
|
||||
|
||||
- [x] Create `check_logged_in` tool with inline form elicitation
|
||||
- [x] Register Flow 2 OAuth routes (`/oauth/authorize-nextcloud`, `/oauth/callback-nextcloud`)
|
||||
- [x] Write integration tests for login elicitation flow
|
||||
- [x] Update ADR-006 with interim implementation documentation
|
||||
- [x] Add `LoginConfirmation` schema for elicitation
|
||||
- [ ] Run tests to validate implementation
|
||||
|
||||
### Future Work (URL Mode Elicitation - Post SEP-1036)
|
||||
|
||||
- [ ] Implement `@require_provisioning` decorator with ElicitationRequired error
|
||||
- [ ] Add `elicitation/track` request handler
|
||||
- [ ] Update OAuth callback to mark elicitations complete
|
||||
- [ ] Add elicitation storage (ID, user, status, timestamps)
|
||||
- [ ] Update all Nextcloud tools with `@require_provisioning`
|
||||
- [ ] Add URL elicitation capability declaration
|
||||
- [ ] Write tests for progress tracking
|
||||
- [ ] Update documentation with URL mode examples
|
||||
- [ ] Add migration guide for manual tools → elicitation
|
||||
- [ ] Migrate `check_logged_in` from inline form to URL mode
|
||||
- [ ] Keep manual tools with deprecation warnings (v0.26-0.27)
|
||||
- [ ] Remove manual tools (v0.28.0)
|
||||
- [ ] Update CHANGELOG.md with migration timeline
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,647 @@
|
||||
# ADR-008: MCP Sampling for Multi-App Semantic Search with RAG
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2025-01-11
|
||||
**Depends On**: ADR-007 (Background Vector Sync)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-007 established a background synchronization architecture that maintains a vector database of Nextcloud content across multiple apps (notes, calendar, deck, files, contacts), enabling semantic search via the `nc_semantic_search` tool. This tool returns a list of relevant documents with excerpts, similarity scores, and metadata—providing the raw materials for answering user questions.
|
||||
|
||||
However, users typically don't want a list of documents—they want answers to their questions. When a user asks "What are my project goals?" or "When is my next dentist appointment?", they expect a natural language response that synthesizes information from multiple sources and document types, not a ranked list of excerpts. This is the pattern of Retrieval-Augmented Generation (RAG): retrieve relevant context from all Nextcloud apps, then generate a cohesive answer.
|
||||
|
||||
The challenge is: who should generate the answer, and how?
|
||||
|
||||
**Option 1: Server-side LLM**
|
||||
The MCP server could maintain its own LLM connection (OpenAI API, Ollama, etc.), construct prompts from retrieved documents, and return generated answers directly. This approach has significant drawbacks:
|
||||
|
||||
- **Duplicate infrastructure**: MCP clients (like Claude Desktop) already have LLM capabilities. The server would duplicate this with its own LLM integration, API keys, and configuration.
|
||||
- **Cost and billing**: The server operator bears LLM costs for all users, creating billing and quota management challenges.
|
||||
- **Limited model choice**: Users are locked into whatever LLM the server configures. They cannot choose their preferred model or provider.
|
||||
- **Privacy concerns**: User queries and document contents flow through a server-controlled LLM, creating a potential privacy boundary.
|
||||
- **Configuration complexity**: Server operators must configure embedding services (for search) AND generation models (for answers), each with different API keys, rate limits, and failure modes.
|
||||
|
||||
**Option 2: Return documents, let client generate**
|
||||
The server could simply return retrieved documents and rely on the MCP client's existing LLM to generate answers. The user would call `nc_notes_semantic_search`, receive documents, and then the client would include those documents in its context when responding to the user's original question. This approach also has limitations:
|
||||
|
||||
- **Context window waste**: The client must include all document content in its context window, even if only small excerpts are relevant. For 5-10 documents, this can consume significant context space.
|
||||
- **Inconsistent behavior**: Whether the client synthesizes an answer or just displays documents depends on the client's implementation and the user's conversational style. There's no guaranteed answer generation.
|
||||
- **Poor citations**: The client may generate an answer but fail to cite which specific documents were used, making it hard to verify claims.
|
||||
- **User confusion**: Users see a tool that returns "search results" rather than "answers", requiring them to explicitly ask for synthesis.
|
||||
|
||||
**Option 3: MCP Sampling**
|
||||
The Model Context Protocol specification includes a **sampling** capability that allows MCP servers to request LLM completions from their clients. The server constructs a prompt with retrieved context, sends it to the client via `sampling/createMessage`, and the client's LLM generates a response that the server can return as a tool result.
|
||||
|
||||
This approach combines the best of both options:
|
||||
|
||||
- **No server-side LLM**: The server has no API keys, no LLM configuration, no billing concerns.
|
||||
- **User choice**: The MCP client controls which LLM is used (Claude, GPT-4, local Ollama) and who pays for it.
|
||||
- **User transparency**: MCP clients SHOULD present sampling requests to users for approval, making it clear when the server is requesting an LLM call.
|
||||
- **Consistent citations**: The server constructs a prompt that explicitly includes document references, ensuring generated answers cite sources.
|
||||
- **Single tool call**: Users call one tool (`nc_notes_semantic_search_answer`) and receive a complete answer with citations—no multi-turn conversation needed.
|
||||
|
||||
The sampling approach shifts responsibility appropriately: the MCP server is responsible for information retrieval and context construction (its expertise), while the MCP client is responsible for LLM access and user preferences (its expertise). This follows the MCP design philosophy of separating concerns between servers (data access) and clients (user interaction).
|
||||
|
||||
However, sampling introduces new considerations:
|
||||
|
||||
**Client compatibility**: Not all MCP clients implement sampling. The server must gracefully degrade when sampling is unavailable, falling back to returning documents without generated answers.
|
||||
|
||||
**Latency**: Sampling adds a full round-trip to the client and back, plus LLM generation time. A typical flow involves: (1) client calls tool, (2) server retrieves documents, (3) server requests sampling from client, (4) client generates answer, (5) server returns answer to client. This can take 2-5 seconds depending on LLM speed, compared to 100-500ms for document retrieval alone.
|
||||
|
||||
**User approval**: MCP clients SHOULD prompt users to approve sampling requests, allowing users to review the prompt before sending it to their LLM. This is a privacy and security feature (prevents servers from making arbitrary LLM requests) but adds interaction friction.
|
||||
|
||||
**Prompt engineering**: The server must construct effective prompts that guide the LLM to generate useful, well-cited answers. Unlike Option 1 where the server controls the LLM directly, the server has less control over how the prompt is interpreted.
|
||||
|
||||
Despite these considerations, MCP sampling provides the most principled solution for RAG-enhanced semantic search. It respects the client-server boundary, avoids duplicate infrastructure, and delivers the user experience users expect from semantic search tools.
|
||||
|
||||
This ADR proposes adding a new tool, `nc_semantic_search_answer`, that uses MCP sampling to generate natural language answers from retrieved Nextcloud content across all indexed apps (notes, calendar, deck, files, contacts).
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a new MCP tool `nc_semantic_search_answer` that retrieves relevant documents via vector similarity search across all indexed Nextcloud apps and uses MCP sampling to generate natural language answers. The tool will construct a prompt that includes the user's original query and excerpts from retrieved documents (notes, calendar events, deck cards, files, contacts), request an LLM completion via `ctx.session.create_message()`, and return the generated answer along with source citations.
|
||||
|
||||
The existing `nc_semantic_search` tool will remain unchanged, providing users with a choice: call the original tool for raw document results, or call the new sampling-enhanced tool for generated answers. This dual-tool approach respects different use cases—some users want to browse documents, others want direct answers.
|
||||
|
||||
### API Design
|
||||
|
||||
**Tool Signature**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search_answer(
|
||||
query: str,
|
||||
ctx: Context,
|
||||
limit: int = 5,
|
||||
score_threshold: float = 0.7,
|
||||
max_answer_tokens: int = 500,
|
||||
) -> SamplingSearchResponse
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `query`: The user's natural language question
|
||||
- `ctx`: MCP context for session access
|
||||
- `limit`: Maximum documents to retrieve (default 5)
|
||||
- `score_threshold`: Minimum similarity score 0-1 (default 0.7)
|
||||
- `max_answer_tokens`: Maximum tokens for generated answer (default 500)
|
||||
|
||||
**Response Model**:
|
||||
```python
|
||||
class SamplingSearchResponse(BaseResponse):
|
||||
query: str # Original user query
|
||||
generated_answer: str # LLM-generated answer
|
||||
sources: list[SemanticSearchResult] # Supporting documents
|
||||
total_found: int # Total matching documents
|
||||
search_method: str = "semantic_sampling"
|
||||
model_used: str | None = None # Model that generated answer
|
||||
stop_reason: str | None = None # Why generation stopped
|
||||
```
|
||||
|
||||
The response includes both the generated answer (for direct user consumption) and the source documents (for verification and citation). The `model_used` field records which LLM generated the answer, allowing users to understand which model provided the response.
|
||||
|
||||
### Sampling API Usage
|
||||
|
||||
The tool uses the MCP Python SDK's `ServerSession.create_message()` API:
|
||||
|
||||
```python
|
||||
from mcp.types import SamplingMessage, TextContent, ModelPreferences, ModelHint
|
||||
|
||||
# Construct prompt with retrieved context
|
||||
prompt = (
|
||||
f"{query}\n\n"
|
||||
f"Here are relevant documents from Nextcloud (notes, calendar events, deck cards, files, contacts):\n\n"
|
||||
f"{context}\n\n"
|
||||
f"Based on the documents above, please provide a comprehensive answer. "
|
||||
f"Cite the document numbers when referencing specific information."
|
||||
)
|
||||
|
||||
# Request LLM completion via MCP sampling
|
||||
sampling_result = await ctx.session.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=prompt),
|
||||
)
|
||||
],
|
||||
max_tokens=max_answer_tokens,
|
||||
temperature=0.7,
|
||||
model_preferences=ModelPreferences(
|
||||
hints=[ModelHint(name="claude-3-5-sonnet")],
|
||||
intelligencePriority=0.8,
|
||||
speedPriority=0.5,
|
||||
),
|
||||
include_context="thisServer",
|
||||
)
|
||||
|
||||
# Extract answer from response
|
||||
if sampling_result.content.type == "text":
|
||||
generated_answer = sampling_result.content.text
|
||||
```
|
||||
|
||||
**Key parameters**:
|
||||
- `messages`: Chat-style messages with role ("user" or "assistant") and content
|
||||
- `max_tokens`: Limits response length to control costs and latency
|
||||
- `temperature`: 0.7 balances creativity with consistency for factual answers
|
||||
- `model_preferences`: Hints suggest Claude Sonnet for balanced intelligence/speed
|
||||
- `include_context`: "thisServer" includes MCP server context in client's LLM call
|
||||
|
||||
The `include_context` parameter is particularly important. When set to "thisServer", the MCP client provides its LLM with context about the server's capabilities, tools, and resources. This allows the LLM to reference the Nextcloud MCP server when generating answers, creating more contextually appropriate responses. For example, the LLM might say "Based on your Nextcloud Notes..." rather than generic phrasing.
|
||||
|
||||
### Prompt Construction
|
||||
|
||||
The prompt construction follows a structured template:
|
||||
|
||||
```
|
||||
[User's original query]
|
||||
|
||||
Here are relevant documents from Nextcloud (notes, calendar events, deck cards, files, contacts):
|
||||
|
||||
[Document 1]
|
||||
Type: note
|
||||
Title: Project Kickoff Notes
|
||||
Category: Work
|
||||
Excerpt: The primary goal for Q1 2025 is to improve semantic search...
|
||||
Relevance Score: 0.92
|
||||
|
||||
[Document 2]
|
||||
Type: calendar_event
|
||||
Title: Team Planning Meeting
|
||||
Location: Conference Room A
|
||||
Excerpt: Scheduled for Jan 15 at 2pm. Agenda: Discuss Q1 objectives and timeline...
|
||||
Relevance Score: 0.88
|
||||
|
||||
[Document 3]
|
||||
Type: deck_card
|
||||
Title: Implement semantic search
|
||||
Labels: feature, high-priority
|
||||
Excerpt: This card tracks the semantic search implementation. Due: Jan 30...
|
||||
Relevance Score: 0.85
|
||||
|
||||
Based on the documents above, please provide a comprehensive answer.
|
||||
Cite the document numbers when referencing specific information.
|
||||
```
|
||||
|
||||
This structure ensures:
|
||||
- The user's original query is preserved verbatim
|
||||
- Documents are clearly delineated and numbered for citation
|
||||
- Metadata (title, category, score) provides context
|
||||
- Explicit instruction to cite sources encourages proper attribution
|
||||
|
||||
The prompt is intentionally simple and fixed (not configurable). Allowing users to customize the prompt would complicate the API and introduce prompt injection risks. The fixed structure ensures consistent, well-cited answers across all users.
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
Sampling may fail for several reasons:
|
||||
- Client doesn't support sampling (e.g., MCP Inspector without callbacks)
|
||||
- User declines the sampling request
|
||||
- Network errors during sampling round-trip
|
||||
- LLM generation errors
|
||||
|
||||
The tool handles all failures gracefully by falling back to returning documents without a generated answer:
|
||||
|
||||
```python
|
||||
try:
|
||||
sampling_result = await ctx.session.create_message(...)
|
||||
generated_answer = sampling_result.content.text
|
||||
except Exception as e:
|
||||
logger.warning(f"Sampling failed: {e}, returning search results only")
|
||||
generated_answer = (
|
||||
f"[Sampling unavailable: {str(e)}]\n\n"
|
||||
f"Found {total_found} relevant documents. Please review the sources below."
|
||||
)
|
||||
```
|
||||
|
||||
This ensures the tool always returns useful information—either a generated answer or the underlying documents—rather than failing completely. The user knows sampling was attempted (via the `[Sampling unavailable]` prefix) and can still access the retrieved context.
|
||||
|
||||
### No Results Handling
|
||||
|
||||
When semantic search finds no relevant documents (all below `score_threshold`), the tool returns a clear message without attempting sampling:
|
||||
|
||||
```python
|
||||
if not search_response.results:
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found in your Nextcloud content for this query.",
|
||||
sources=[],
|
||||
total_found=0,
|
||||
search_method="semantic_sampling",
|
||||
success=True,
|
||||
)
|
||||
```
|
||||
|
||||
This avoids wasting a sampling call (and user approval) when there's no content to base an answer on.
|
||||
|
||||
### User Experience Flow
|
||||
|
||||
**Typical successful flow**:
|
||||
1. User calls `nc_semantic_search_answer` with query "What are my Q1 2025 objectives?"
|
||||
2. Server retrieves 5 relevant documents via vector search (2 notes, 2 calendar events, 1 deck card)
|
||||
3. Server constructs prompt with document excerpts showing mixed content types
|
||||
4. Server sends `sampling/createMessage` request to client
|
||||
5. Client prompts user: "MCP server wants to generate an answer using these documents. Allow?"
|
||||
6. User approves (or client auto-approves based on configuration)
|
||||
7. Client sends prompt to LLM (Claude, GPT-4, etc.)
|
||||
8. LLM generates answer with citations: "Based on Document 1 (note: Project Kickoff), Document 2 (calendar: Team Planning Meeting), and Document 3 (deck card: Implement semantic search)..."
|
||||
9. Client returns answer to server
|
||||
10. Server returns `SamplingSearchResponse` with answer and sources
|
||||
11. User sees complete answer with citations across multiple Nextcloud apps
|
||||
|
||||
**Fallback flow** (sampling unavailable):
|
||||
1-3. Same as above
|
||||
4. Server attempts `ctx.session.create_message()`
|
||||
5. Client raises exception: "Sampling not supported"
|
||||
6. Server catches exception, logs warning
|
||||
7. Server returns `SamplingSearchResponse` with documents and "[Sampling unavailable]" message
|
||||
8. User sees raw documents instead of generated answer
|
||||
|
||||
**No results flow**:
|
||||
1-2. Same as above but no documents match threshold
|
||||
3. Server returns `SamplingSearchResponse` with "No relevant documents" message
|
||||
4. No sampling attempted (no prompt sent)
|
||||
5. User sees clear "not found" message
|
||||
|
||||
This three-tier approach (answer → documents → error message) ensures users always receive useful feedback appropriate to the situation.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Response Model
|
||||
|
||||
Add to `nextcloud_mcp_server/models/semantic.py` (new file for semantic search models):
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
class SamplingSearchResponse(BaseResponse):
|
||||
"""Response from semantic search with LLM-generated answer via MCP sampling.
|
||||
|
||||
This response includes both a generated natural language answer (created by
|
||||
the MCP client's LLM via sampling) and the source documents used to generate
|
||||
that answer. Users can read the answer for quick information and review
|
||||
sources for verification and deeper exploration.
|
||||
|
||||
Attributes:
|
||||
query: The original user query
|
||||
generated_answer: Natural language answer generated by client's LLM
|
||||
sources: List of semantic search results used as context
|
||||
total_found: Total number of matching documents found
|
||||
search_method: Always "semantic_sampling" for this response type
|
||||
model_used: Name of model that generated the answer (e.g., "claude-3-5-sonnet")
|
||||
stop_reason: Why generation stopped ("endTurn", "maxTokens", etc.)
|
||||
"""
|
||||
|
||||
query: str = Field(..., description="Original user query")
|
||||
generated_answer: str = Field(
|
||||
...,
|
||||
description="LLM-generated answer based on retrieved documents"
|
||||
)
|
||||
sources: list[SemanticSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Source documents with excerpts and relevance scores"
|
||||
)
|
||||
total_found: int = Field(..., description="Total matching documents")
|
||||
search_method: str = Field(
|
||||
default="semantic_sampling",
|
||||
description="Search method used"
|
||||
)
|
||||
model_used: str | None = Field(
|
||||
default=None,
|
||||
description="Model that generated the answer"
|
||||
)
|
||||
stop_reason: str | None = Field(
|
||||
default=None,
|
||||
description="Reason generation stopped"
|
||||
)
|
||||
```
|
||||
|
||||
### Tool Implementation
|
||||
|
||||
Add to `nextcloud_mcp_server/server/semantic.py` (new file for semantic search tools):
|
||||
|
||||
```python
|
||||
import logging
|
||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search_answer(
|
||||
query: str,
|
||||
ctx: Context,
|
||||
limit: int = 5,
|
||||
score_threshold: float = 0.7,
|
||||
max_answer_tokens: int = 500,
|
||||
) -> SamplingSearchResponse:
|
||||
"""
|
||||
Semantic search with LLM-generated answer using MCP sampling.
|
||||
|
||||
Retrieves relevant documents from Nextcloud across all indexed apps (notes,
|
||||
calendar, deck, files, contacts) using vector similarity search, then uses
|
||||
MCP sampling to request the client's LLM to generate a natural language
|
||||
answer based on the retrieved context.
|
||||
|
||||
This tool combines the power of semantic search (finding relevant content
|
||||
across all your Nextcloud apps) with LLM generation (synthesizing that
|
||||
content into coherent answers). The generated answer includes citations
|
||||
to specific documents with their types, allowing users to verify claims
|
||||
and explore sources.
|
||||
|
||||
The LLM generation happens client-side via MCP sampling. The MCP client
|
||||
controls which model is used, who pays for it, and whether to prompt the
|
||||
user for approval. This keeps the server simple (no LLM API keys needed)
|
||||
while giving users full control over their LLM interactions.
|
||||
|
||||
Args:
|
||||
query: Natural language question to answer (e.g., "What are my Q1 objectives?" or "When is my next dentist appointment?")
|
||||
ctx: MCP context for session access
|
||||
limit: Maximum number of documents to retrieve (default: 5)
|
||||
score_threshold: Minimum similarity score 0-1 (default: 0.7)
|
||||
max_answer_tokens: Maximum tokens for generated answer (default: 500)
|
||||
|
||||
Returns:
|
||||
SamplingSearchResponse containing:
|
||||
- generated_answer: Natural language answer with citations
|
||||
- sources: List of documents with excerpts and relevance scores
|
||||
- model_used: Which model generated the answer
|
||||
- stop_reason: Why generation stopped
|
||||
|
||||
Note: Requires MCP client to support sampling. If sampling is unavailable,
|
||||
the tool gracefully degrades to returning documents with an explanation.
|
||||
The client may prompt the user to approve the sampling request.
|
||||
|
||||
Examples:
|
||||
>>> # Query about objectives across multiple apps
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="What are my Q1 2025 project goals?",
|
||||
... ctx=ctx
|
||||
... )
|
||||
>>> print(result.generated_answer)
|
||||
"Based on Document 1 (note: Project Kickoff), Document 2 (calendar event:
|
||||
Q1 Planning Meeting), and Document 3 (deck card: Implement semantic search),
|
||||
your main goals are: 1) Improve semantic search accuracy by 20%,
|
||||
2) Deploy new embedding model, 3) Reduce indexing latency..."
|
||||
|
||||
>>> # Query about appointments
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="When is my next dentist appointment?",
|
||||
... ctx=ctx,
|
||||
... limit=10
|
||||
... )
|
||||
>>> len(result.sources) # Calendar events and related notes
|
||||
3
|
||||
"""
|
||||
# 1. Retrieve relevant documents via existing semantic search
|
||||
search_response = await nc_semantic_search(
|
||||
query=query,
|
||||
ctx=ctx,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
# 2. Handle no results case - don't waste a sampling call
|
||||
if not search_response.results:
|
||||
logger.debug(f"No documents found for query: {query}")
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found in your Nextcloud content for this query.",
|
||||
sources=[],
|
||||
total_found=0,
|
||||
search_method="semantic_sampling",
|
||||
success=True,
|
||||
)
|
||||
|
||||
# 3. Construct context from retrieved documents
|
||||
context_parts = []
|
||||
for idx, result in enumerate(search_response.results, 1):
|
||||
context_parts.append(
|
||||
f"[Document {idx}]\n"
|
||||
f"Title: {result.title}\n"
|
||||
f"Category: {result.category}\n"
|
||||
f"Excerpt: {result.excerpt}\n"
|
||||
f"Relevance Score: {result.score:.2f}\n"
|
||||
)
|
||||
|
||||
context = "\n".join(context_parts)
|
||||
|
||||
# 4. Construct prompt - reuse user's query, add context and instructions
|
||||
prompt = (
|
||||
f"{query}\n\n"
|
||||
f"Here are relevant documents from Nextcloud (notes, calendar events, deck cards, files, contacts):\n\n"
|
||||
f"{context}\n\n"
|
||||
f"Based on the documents above, please provide a comprehensive answer. "
|
||||
f"Cite the document numbers when referencing specific information."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Requesting sampling for query: {query} "
|
||||
f"({len(search_response.results)} documents retrieved)"
|
||||
)
|
||||
|
||||
# 5. Request LLM completion via MCP sampling
|
||||
try:
|
||||
sampling_result = await ctx.session.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=prompt),
|
||||
)
|
||||
],
|
||||
max_tokens=max_answer_tokens,
|
||||
temperature=0.7,
|
||||
model_preferences=ModelPreferences(
|
||||
hints=[ModelHint(name="claude-3-5-sonnet")],
|
||||
intelligencePriority=0.8,
|
||||
speedPriority=0.5,
|
||||
),
|
||||
include_context="thisServer",
|
||||
)
|
||||
|
||||
# 6. Extract answer from sampling response
|
||||
if sampling_result.content.type == "text":
|
||||
generated_answer = sampling_result.content.text
|
||||
else:
|
||||
# Handle non-text responses (shouldn't happen for text prompts)
|
||||
generated_answer = (
|
||||
f"Received non-text response of type: {sampling_result.content.type}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Unexpected content type from sampling: {sampling_result.content.type}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sampling successful: model={sampling_result.model}, "
|
||||
f"stop_reason={sampling_result.stopReason}"
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=generated_answer,
|
||||
sources=search_response.results,
|
||||
total_found=search_response.total_found,
|
||||
search_method="semantic_sampling",
|
||||
model_used=sampling_result.model,
|
||||
stop_reason=sampling_result.stopReason,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: Return documents without generated answer
|
||||
logger.warning(
|
||||
f"Sampling failed ({type(e).__name__}: {e}), "
|
||||
f"returning search results only"
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=(
|
||||
f"[Sampling unavailable: {str(e)}]\n\n"
|
||||
f"Found {search_response.total_found} relevant documents. "
|
||||
f"Please review the sources below."
|
||||
),
|
||||
sources=search_response.results,
|
||||
total_found=search_response.total_found,
|
||||
search_method="semantic_sampling_fallback",
|
||||
success=True,
|
||||
)
|
||||
```
|
||||
|
||||
### Import Updates
|
||||
|
||||
Add to top of `nextcloud_mcp_server/server/semantic.py`:
|
||||
|
||||
```python
|
||||
from mcp.types import ModelHint, ModelPreferences, SamplingMessage, TextContent
|
||||
```
|
||||
|
||||
Add to `nextcloud_mcp_server/models/semantic.py` exports:
|
||||
|
||||
```python
|
||||
__all__ = [
|
||||
"SemanticSearchResult",
|
||||
"SemanticSearchResponse",
|
||||
"SamplingSearchResponse",
|
||||
]
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
**Improved User Experience**: Users receive direct answers to questions rather than lists of documents, matching expectations from modern AI interfaces.
|
||||
|
||||
**Proper Attribution**: Generated answers include citations to source documents, allowing users to verify claims and explore deeper.
|
||||
|
||||
**No Server-Side LLM**: The server has no LLM dependencies, API keys, or billing concerns. All LLM interactions happen client-side.
|
||||
|
||||
**User Control**: MCP clients control which model is used and may prompt users to approve sampling requests, maintaining transparency and user agency.
|
||||
|
||||
**Graceful Degradation**: The tool works even when sampling is unavailable, falling back to returning documents. Existing clients continue working without changes.
|
||||
|
||||
**Consistent Architecture**: Follows MCP's client-server separation: servers provide data access, clients provide user interaction and LLM capabilities.
|
||||
|
||||
### Limitations
|
||||
|
||||
**Sampling Support Required**: Not all MCP clients implement sampling. Users with basic clients see fallback behavior (documents without answers).
|
||||
|
||||
**Added Latency**: Sampling adds 2-5 seconds to tool execution due to client round-trip and LLM generation time. Users must wait longer for answers than for raw search results.
|
||||
|
||||
**User Approval Friction**: MCP clients SHOULD prompt users to approve sampling requests. This adds an extra interaction step before answers are generated.
|
||||
|
||||
**Limited Prompt Control**: The server cannot fully control how the client's LLM interprets the prompt. Different models may generate different quality answers.
|
||||
|
||||
**No Caching**: Each query requires a new sampling call. The server doesn't cache generated answers (clients may cache if they choose).
|
||||
|
||||
**Token Costs**: LLM generation consumes tokens from the user's or client's quota. Heavy users may incur costs or hit rate limits.
|
||||
|
||||
### Performance Characteristics
|
||||
|
||||
**Typical latency**:
|
||||
- Document retrieval (vector search): 100-300ms
|
||||
- Sampling round-trip (client communication): 50-200ms
|
||||
- LLM generation (client-side): 1-4 seconds
|
||||
- **Total**: 2-5 seconds end-to-end
|
||||
|
||||
**Throughput**: Sampling is fully async. The server can handle multiple concurrent sampling requests (limited by MCP client's concurrency, not server capacity).
|
||||
|
||||
**Resource usage**: Minimal server-side. No GPU, no LLM model loading, no large memory requirements. Sampling happens entirely client-side.
|
||||
|
||||
### Security Considerations
|
||||
|
||||
**Prompt Injection Risk**: If user queries contain adversarial text designed to manipulate LLM behavior, those queries are included verbatim in the sampling prompt. Mitigation: The structured prompt format and explicit instructions ("based on documents above") constrain LLM behavior.
|
||||
|
||||
**Data Privacy**: User queries and document excerpts are sent to the client's LLM. For cloud LLMs (OpenAI, Anthropic), this means data leaves the server's control. Mitigation: MCP clients SHOULD present sampling requests to users for approval, making data flows transparent. Users choose their LLM provider.
|
||||
|
||||
**Sampling Abuse**: A malicious server could spam sampling requests to drain user quotas. Mitigation: MCP clients control approval and can rate-limit or block sampling from misbehaving servers.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Server-Side LLM Integration
|
||||
|
||||
**Approach**: Configure the MCP server with OpenAI API key or local Ollama instance. Generate answers server-side.
|
||||
|
||||
**Rejected Because**:
|
||||
- Duplicates LLM infrastructure that MCP clients already have
|
||||
- Creates billing and API key management burden for server operators
|
||||
- Locks users into server-configured models
|
||||
- Violates MCP's client-server separation principle
|
||||
|
||||
### Multi-Turn Conversation Pattern
|
||||
|
||||
**Approach**: `nc_notes_semantic_search` returns documents. User asks follow-up question. Client's LLM uses previous tool results as context.
|
||||
|
||||
**Rejected Because**:
|
||||
- Requires users to know to ask follow-up questions
|
||||
- Consumes context window with full document content
|
||||
- Inconsistent behavior across clients
|
||||
- Poor citation (LLM may not reference which documents it used)
|
||||
|
||||
### Pre-Generated Summaries
|
||||
|
||||
**Approach**: Generate and cache summaries during indexing. Return summaries instead of excerpts.
|
||||
|
||||
**Rejected Because**:
|
||||
- Summaries become stale as documents change
|
||||
- Summary quality depends on server-side LLM (same problems as server-side generation)
|
||||
- Summaries are generic, not tailored to specific queries
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
**Approach**: Use MCP sampling with streaming to return incremental answer chunks.
|
||||
|
||||
**Deferred Because**:
|
||||
- MCP sampling streaming support unclear in current specification
|
||||
- Adds significant implementation complexity
|
||||
- Tool responses in MCP are typically atomic
|
||||
- Can be added later without breaking changes
|
||||
|
||||
## Related Decisions
|
||||
|
||||
**ADR-007**: Background Vector Sync provides the semantic search infrastructure that this ADR enhances with LLM generation.
|
||||
|
||||
**ADR-004**: Progressive Consent architecture applies to sampling—users consent to sampling requests via MCP client approval prompts.
|
||||
|
||||
## References
|
||||
|
||||
- [MCP Specification - Sampling](https://modelcontextprotocol.io/docs/specification/2025-06-18/client/sampling)
|
||||
- [MCP Python SDK - ServerSession.create_message](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/server/session.py#L215)
|
||||
- [MCP Python SDK - Sampling Example](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)
|
||||
- [MCP Types - SamplingMessage](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/types.py#L1038)
|
||||
- [MCP Types - CreateMessageResult](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/types.py#L1073)
|
||||
- [Retrieval-Augmented Generation (RAG) - Lewis et al. 2020](https://arxiv.org/abs/2005.11401)
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create ADR-008 document (this file)
|
||||
- [ ] Create `nextcloud_mcp_server/models/semantic.py` for semantic search models
|
||||
- [ ] Add `SamplingSearchResponse` model to `nextcloud_mcp_server/models/semantic.py`
|
||||
- [ ] Create `nextcloud_mcp_server/server/semantic.py` for semantic search tools
|
||||
- [ ] Implement `nc_semantic_search_answer` tool in `nextcloud_mcp_server/server/semantic.py`
|
||||
- [ ] Add MCP sampling type imports (`SamplingMessage`, `TextContent`, etc.)
|
||||
- [ ] Write unit tests with mocked sampling (`tests/unit/server/test_semantic.py`)
|
||||
- [ ] Create integration tests (`tests/integration/test_sampling.py`)
|
||||
- [ ] Update `README.md` with new tool documentation in dedicated Semantic Search section
|
||||
- [ ] Update `CLAUDE.md` with sampling pattern guidance
|
||||
- [ ] Test with MCP client supporting sampling (Claude Desktop, MCP Inspector with callbacks)
|
||||
- [ ] Document client requirements and fallback behavior
|
||||
- [ ] Update oauth-architecture.md to add semantic:read scope
|
||||
- [ ] Create ADR-009 to document semantic:read scope decision
|
||||
@@ -0,0 +1,268 @@
|
||||
# ADR-009: Generic `semantic:read` OAuth Scope for Multi-App Vector Search
|
||||
|
||||
**Status**: Proposed
|
||||
**Date**: 2025-01-11
|
||||
**Depends On**: ADR-007 (Background Vector Sync), ADR-008 (MCP Sampling for Semantic Search)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-007 established a background vector synchronization architecture that indexes content from multiple Nextcloud apps (notes, calendar events, deck cards, files, contacts) into a unified vector database. ADR-008 introduced semantic search tools (`nc_semantic_search`, `nc_semantic_search_answer`) that query this vector database and use MCP sampling to generate natural language answers.
|
||||
|
||||
The question is: **What OAuth scopes should protect semantic search operations?**
|
||||
|
||||
### Option 1: App-Specific Scopes
|
||||
|
||||
Require users to have scopes for each app they want to search:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read", "calendar:read", "deck:read", "files:read", "contacts:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Search across all indexed apps"""
|
||||
```
|
||||
|
||||
**Advantages**:
|
||||
- Granular control - users explicitly consent to searching each app
|
||||
- Aligns with app-specific authorization model
|
||||
- Clear security boundary - can only search apps you can access
|
||||
|
||||
**Disadvantages**:
|
||||
- **Brittle user experience**: If a user grants only `notes:read` but the tool requires all 5 scopes, the tool becomes invisible/unusable
|
||||
- **All-or-nothing enforcement**: Can't search notes alone - must grant all scopes or none
|
||||
- **Poor progressive consent**: User can't start with notes search and later add calendar
|
||||
- **Scope inflation**: Every new app adds another required scope
|
||||
- **Mismatched semantics**: User thinks "I want to search my notes" but must grant calendar, deck, files, contacts just to make the tool appear
|
||||
|
||||
### Option 2: Single Generic Scope (Chosen)
|
||||
|
||||
Introduce a new semantic search-specific scope:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Search across all indexed apps"""
|
||||
```
|
||||
|
||||
**Advantages**:
|
||||
- **Simple authorization**: One scope grants semantic search capability
|
||||
- **Progressive enablement**: User grants `semantic:read`, searches notes initially, then enables calendar indexing later
|
||||
- **Logical grouping**: Semantic search is a cross-app feature, deserving its own scope
|
||||
- **Future-proof**: New apps can be added to vector sync without changing OAuth scopes
|
||||
- **Matches user mental model**: "I want semantic search" → grant `semantic:read` (not "I want semantic search" → grant 5 unrelated app scopes)
|
||||
|
||||
**Considerations**:
|
||||
- User could search apps they can't directly access via app-specific tools
|
||||
- **Mitigation**: Dual-phase authorization (Phase 1: scope check passes with `semantic:read`, Phase 2: verify user can access each returned document via app-specific permissions)
|
||||
- Less granular than app-specific scopes
|
||||
- **Counterpoint**: Semantic search is inherently cross-app - forcing per-app authorization defeats its purpose
|
||||
|
||||
### Option 3: Hybrid Approach (Rejected)
|
||||
|
||||
Support both: semantic search works with either `semantic:read` OR all app-specific scopes:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read", alternative_scopes=["notes:read", "calendar:read", ...])
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Search across all indexed apps"""
|
||||
```
|
||||
|
||||
**Rejected Because**:
|
||||
- Adds complexity to scope validation logic
|
||||
- Unclear to users which scopes they should grant
|
||||
- Alternative scopes still suffer from all-or-nothing problem
|
||||
- No significant benefit over Option 2 with dual-phase authorization
|
||||
|
||||
## Decision
|
||||
|
||||
We will introduce two new OAuth scopes specifically for semantic search operations:
|
||||
|
||||
- **`semantic:read`**: Query vector database, perform semantic search, generate answers
|
||||
- **`semantic:write`**: Enable/disable background vector synchronization, manage indexing settings
|
||||
|
||||
These scopes are **independent** of app-specific scopes (notes:read, calendar:read, etc.).
|
||||
|
||||
### Tool Scope Assignments
|
||||
|
||||
**Read Operations**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context, limit: int = 10, score_threshold: float = 0.7) -> SemanticSearchResponse:
|
||||
"""Semantic search across all indexed Nextcloud apps"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search_answer(query: str, ctx: Context, limit: int = 5, max_answer_tokens: int = 500) -> SamplingSearchResponse:
|
||||
"""Semantic search with LLM-generated answer via MCP sampling"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
||||
"""Get current vector synchronization status (indexed count, pending count, status)"""
|
||||
```
|
||||
|
||||
**Write Operations**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:write")
|
||||
async def nc_enable_vector_sync(ctx: Context) -> VectorSyncResponse:
|
||||
"""Enable background vector synchronization for this user"""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:write")
|
||||
async def nc_disable_vector_sync(ctx: Context) -> VectorSyncResponse:
|
||||
"""Disable background vector synchronization"""
|
||||
```
|
||||
|
||||
### Dual-Phase Authorization
|
||||
|
||||
To ensure users can only access documents they have permission to view, semantic search implements **dual-phase authorization**:
|
||||
|
||||
**Phase 1: Scope Check** (MCP Server)
|
||||
- User must have `semantic:read` scope to call semantic search tools
|
||||
- This grants permission to query the vector database
|
||||
|
||||
**Phase 2: Document Verification** (Per-Result Filtering)
|
||||
- For each returned document, verify user has access via app-specific permissions
|
||||
- Uses `DocumentVerifier` interface per app:
|
||||
- Notes: Call `/apps/notes/api/v1/notes/{id}` - if 404/403, exclude from results
|
||||
- Calendar: Call `/remote.php/dav/calendars/username/calendar/event.ics` - if 404/403, exclude
|
||||
- Deck: Call `/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}` - if 404/403, exclude
|
||||
- Files: Call `/remote.php/dav/files/username/path` with PROPFIND - if 404/403, exclude
|
||||
- Contacts: Call `/remote.php/dav/addressbooks/username/addressbook/contact.vcf` - if 404/403, exclude
|
||||
|
||||
This two-phase approach ensures:
|
||||
1. Semantic search is a **distinct capability** (like "global search") requiring explicit consent
|
||||
2. Results are **filtered** to only include documents the user can access
|
||||
3. No privilege escalation - users can't discover content they shouldn't see
|
||||
|
||||
**Implementation**: See ADR-007 Phase 3 (Document Verification) and `DocumentVerifier` interface.
|
||||
|
||||
### Scope Discovery
|
||||
|
||||
The new scopes will be:
|
||||
- **Advertised** via PRM endpoint (`/.well-known/oauth-protected-resource/mcp`)
|
||||
- **Dynamically discovered** from `@require_scopes` decorators on semantic search tools
|
||||
- **Documented** in OAuth architecture (oauth-architecture.md)
|
||||
- **Included** in default client registration scopes
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
**User Experience**:
|
||||
- Simple authorization: one scope for semantic search capability
|
||||
- Progressive enablement: grant `semantic:read`, enable indexing for apps later
|
||||
- Natural mental model: "semantic search" is a distinct feature deserving its own scope
|
||||
|
||||
**Security**:
|
||||
- Dual-phase authorization prevents privilege escalation
|
||||
- Users explicitly consent to cross-app search capability
|
||||
- Per-document verification ensures users only see accessible content
|
||||
|
||||
**Maintainability**:
|
||||
- Adding new apps to vector sync doesn't require OAuth scope changes
|
||||
- Clear separation between app access (notes:read) and search capability (semantic:read)
|
||||
- Logical grouping of related operations (search, sync status, enable/disable)
|
||||
|
||||
**Future-Proof**:
|
||||
- Can add new document types without breaking existing OAuth flows
|
||||
- Supports future semantic features (recommendations, clustering) under same scope
|
||||
- Aligns with potential future Nextcloud semantic capabilities
|
||||
|
||||
### Trade-offs
|
||||
|
||||
**Less Granular Than App-Specific Scopes**:
|
||||
- User can't grant "semantic search notes only"
|
||||
- Semantic search is all-or-nothing across enabled apps
|
||||
- **Mitigation**: Dual-phase verification ensures users only see documents they can access
|
||||
|
||||
**New Scope to Learn**:
|
||||
- Users must understand `semantic:read` is distinct from app scopes
|
||||
- MCP clients must present scope clearly during consent
|
||||
- **Mitigation**: Clear scope descriptions in OAuth consent UI and documentation
|
||||
|
||||
**Backend Complexity**:
|
||||
- Requires dual-phase authorization implementation
|
||||
- DocumentVerifier interface needed for each app
|
||||
- **Benefit**: Enforces proper security regardless of scope model
|
||||
|
||||
### Migration Impact
|
||||
|
||||
**Breaking Change**: Existing deployments using notes-specific semantic search will break.
|
||||
|
||||
**Before (OLD - Breaking)**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
async def nc_notes_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Semantic search notes"""
|
||||
```
|
||||
|
||||
**After (NEW)**:
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(query: str, ctx: Context) -> SemanticSearchResponse:
|
||||
"""Semantic search across all apps"""
|
||||
```
|
||||
|
||||
**Migration Path**:
|
||||
1. Deploy server with new `semantic:read` scope
|
||||
2. Users re-authenticate, granting `semantic:read` scope
|
||||
3. Semantic search tools become visible/usable again
|
||||
4. **No data loss**: Vector database and indexed documents remain unchanged
|
||||
|
||||
**Backward Compatibility**: None. This is an intentional breaking change to correct the scope model before broader adoption.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Keep Notes-Specific Scopes
|
||||
|
||||
**Approach**: Continue using `notes:read` for semantic search, even when searching other apps.
|
||||
|
||||
**Rejected Because**:
|
||||
- Semantically incorrect - searching calendar events is not "reading notes"
|
||||
- Confuses users - why does searching calendar require notes:read?
|
||||
- Doesn't scale - what scope for multi-app search?
|
||||
|
||||
### Create Per-App Semantic Scopes
|
||||
|
||||
**Approach**: Introduce `notes:semantic`, `calendar:semantic`, `deck:semantic`, etc.
|
||||
|
||||
**Rejected Because**:
|
||||
- Scope proliferation - doubles the number of scopes
|
||||
- Defeats purpose of unified vector search
|
||||
- Users would need to grant 5+ scopes for cross-app search
|
||||
- No clear benefit over dual-phase authorization with `semantic:read`
|
||||
|
||||
### Require All App Scopes (Already Rejected in Option 1)
|
||||
|
||||
**Approach**: Require `notes:read AND calendar:read AND deck:read AND files:read AND contacts:read`
|
||||
|
||||
**Rejected Because**: Unusable UX (see Option 1 disadvantages above)
|
||||
|
||||
## Related Decisions
|
||||
|
||||
**ADR-007**: Background Vector Sync provides the indexing architecture that semantic scopes protect. The DocumentVerifier interface from ADR-007 Phase 3 implements dual-phase authorization.
|
||||
|
||||
**ADR-008**: MCP Sampling for semantic search uses `semantic:read` to protect the sampling-enhanced search tool.
|
||||
|
||||
**ADR-004**: Progressive Consent architecture supports users granting `semantic:read` initially, then enabling per-app indexing via `semantic:write` (enable_vector_sync with app selection).
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create ADR-009 document (this file)
|
||||
- [ ] Update `oauth-architecture.md` to document `semantic:read` and `semantic:write` scopes ✅
|
||||
- [ ] Update `README.md` to show Semantic Search as separate tool category ✅
|
||||
- [ ] Update ADR-007 to reference `semantic:*` scopes instead of `sync:*` ✅
|
||||
- [ ] Update ADR-008 to use `semantic:read` instead of `notes:read` ✅
|
||||
- [ ] Implement DocumentVerifier interface for all apps (notes, calendar, deck, files, contacts)
|
||||
- [ ] Update semantic search tools to use `@require_scopes("semantic:read")`
|
||||
- [ ] Update vector sync tools to use `@require_scopes("semantic:write")`
|
||||
- [ ] Add dual-phase authorization to semantic search implementation
|
||||
- [ ] Test OAuth flow with `semantic:read` scope
|
||||
- [ ] Update scope discovery in PRM endpoint
|
||||
- [ ] Document migration path for existing deployments
|
||||
@@ -108,6 +108,158 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
|
||||
|
||||
---
|
||||
|
||||
## Semantic Search Configuration (Optional)
|
||||
|
||||
The MCP server includes semantic search capabilities powered by vector embeddings. This feature requires a vector database (Qdrant) and an embedding service.
|
||||
|
||||
### Qdrant Vector Database Modes
|
||||
|
||||
The server supports three Qdrant deployment modes:
|
||||
|
||||
1. **In-Memory Mode** (Default) - Simplest for development and testing
|
||||
2. **Persistent Local Mode** - For single-instance deployments with persistence
|
||||
3. **Network Mode** - For production with dedicated Qdrant service
|
||||
|
||||
#### 1. In-Memory Mode (Default)
|
||||
|
||||
No configuration needed! If neither `QDRANT_URL` nor `QDRANT_LOCATION` is set, the server defaults to in-memory mode:
|
||||
|
||||
```dotenv
|
||||
# No Qdrant configuration needed - defaults to :memory:
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Zero configuration
|
||||
- Fast startup
|
||||
- Perfect for testing
|
||||
|
||||
**Cons:**
|
||||
- Data lost on restart
|
||||
- Limited to available RAM
|
||||
|
||||
#### 2. Persistent Local Mode
|
||||
|
||||
For single-instance deployments that need persistence without a separate Qdrant service:
|
||||
|
||||
```dotenv
|
||||
# Local persistent storage
|
||||
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Data persists across restarts
|
||||
- No separate service needed
|
||||
- Suitable for small/medium deployments
|
||||
|
||||
**Cons:**
|
||||
- Limited to single instance
|
||||
- Shares resources with MCP server
|
||||
|
||||
#### 3. Network Mode
|
||||
|
||||
For production deployments with a dedicated Qdrant service:
|
||||
|
||||
```dotenv
|
||||
# Network mode configuration
|
||||
QDRANT_URL=http://qdrant:6333
|
||||
QDRANT_API_KEY=your-secret-api-key # Optional
|
||||
QDRANT_COLLECTION=nextcloud_content # Optional
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Scalable and performant
|
||||
- Can be shared across multiple MCP instances
|
||||
- Supports clustering and replication
|
||||
|
||||
**Cons:**
|
||||
- Requires separate Qdrant service
|
||||
- More complex deployment
|
||||
|
||||
### Vector Sync Configuration
|
||||
|
||||
Control background indexing behavior:
|
||||
|
||||
```dotenv
|
||||
# Vector sync settings (ADR-007)
|
||||
VECTOR_SYNC_ENABLED=true # Enable background indexing
|
||||
VECTOR_SYNC_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
|
||||
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
|
||||
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
|
||||
```
|
||||
|
||||
### Embedding Service Configuration
|
||||
|
||||
The server uses an embedding service to generate vector representations. Two options are available:
|
||||
|
||||
#### Ollama (Recommended)
|
||||
|
||||
Use a local Ollama instance for embeddings:
|
||||
|
||||
```dotenv
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text # Default model
|
||||
OLLAMA_VERIFY_SSL=true # Verify SSL certificates
|
||||
```
|
||||
|
||||
#### Simple Embedding Provider (Fallback)
|
||||
|
||||
If `OLLAMA_BASE_URL` is not set, the server uses a simple random embedding provider for testing. This is **not suitable for production** as it generates random embeddings with no semantic meaning.
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `QDRANT_URL` | ⚠️ Optional | - | Qdrant service URL (network mode) - mutually exclusive with `QDRANT_LOCATION` |
|
||||
| `QDRANT_LOCATION` | ⚠️ Optional | `:memory:` | Local Qdrant path (`:memory:` or `/path/to/data`) - mutually exclusive with `QDRANT_URL` |
|
||||
| `QDRANT_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
|
||||
| `QDRANT_COLLECTION` | ⚠️ Optional | `nextcloud_content` | Qdrant collection name |
|
||||
| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing |
|
||||
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
|
||||
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
|
||||
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
|
||||
| `OLLAMA_BASE_URL` | ⚠️ Optional | - | Ollama API endpoint for embeddings |
|
||||
| `OLLAMA_EMBEDDING_MODEL` | ⚠️ Optional | `nomic-embed-text` | Embedding model to use |
|
||||
| `OLLAMA_VERIFY_SSL` | ⚠️ Optional | `true` | Verify SSL certificates |
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
Enable network mode Qdrant with docker-compose:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mcp:
|
||||
environment:
|
||||
- QDRANT_URL=http://qdrant:6333
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333
|
||||
volumes:
|
||||
- qdrant-data:/qdrant/storage
|
||||
profiles:
|
||||
- qdrant # Optional service
|
||||
|
||||
volumes:
|
||||
qdrant-data:
|
||||
```
|
||||
|
||||
Start with Qdrant service:
|
||||
```bash
|
||||
docker-compose --profile qdrant up
|
||||
```
|
||||
|
||||
Or use default in-memory mode (no `--profile` needed):
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Loading Environment Variables
|
||||
|
||||
After creating your `.env` file, load the environment variables:
|
||||
|
||||
+3
-1
@@ -8,7 +8,9 @@
|
||||
| `nc_notes_update_note` | Update an existing note by ID |
|
||||
| `nc_notes_append_content` | Append content to an existing note with a clear separator |
|
||||
| `nc_notes_delete_note` | Delete a note by ID |
|
||||
| `nc_notes_search_notes` | Search notes by title or content |
|
||||
| `nc_notes_search_notes` | Search notes by title or content (keyword search) |
|
||||
| `nc_notes_semantic_search` | Search notes by meaning using vector embeddings (requires vector sync) |
|
||||
| `nc_notes_semantic_search_answer` | Search notes semantically and generate a natural language answer via MCP sampling (requires vector sync and sampling-capable MCP client) |
|
||||
|
||||
### Note Attachments
|
||||
|
||||
|
||||
@@ -634,6 +634,12 @@ The server supports the following OAuth scopes, organized by Nextcloud app:
|
||||
- `sharing:read` - List shares and read share information
|
||||
- `sharing:write` - Create, update, and delete shares
|
||||
|
||||
#### Semantic Search (Multi-App Vector Database)
|
||||
- `semantic:read` - Query vector database, perform semantic search across all indexed Nextcloud apps (notes, calendar, deck, files, contacts)
|
||||
- `semantic:write` - Enable/disable background vector synchronization, manage indexing settings
|
||||
|
||||
> **Note**: Semantic search scopes provide access to the vector database that indexes content across **all** Nextcloud apps. Unlike app-specific scopes (e.g., `notes:read`), semantic scopes grant cross-app search capabilities powered by background vector synchronization (ADR-007).
|
||||
|
||||
### Scope Discovery
|
||||
|
||||
The MCP server provides scope discovery through two mechanisms:
|
||||
|
||||
@@ -751,6 +751,40 @@
|
||||
"display.on.consent.screen": "true",
|
||||
"consent.screen.text": "Create, update, and delete tasks"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "default-audience",
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"include.in.token.scope": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"gui.order": "",
|
||||
"consent.screen.text": ""
|
||||
},
|
||||
"protocolMappers": [
|
||||
{
|
||||
"name": "mcp-server-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.client.audience": "nextcloud-mcp-server",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcp-url-audience",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": false,
|
||||
"config": {
|
||||
"included.custom.audience": "http://localhost:8002",
|
||||
"access.token.claim": "true",
|
||||
"id.token.claim": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
@@ -791,7 +825,8 @@
|
||||
"profile",
|
||||
"email",
|
||||
"roles",
|
||||
"web-origins"
|
||||
"web-origins",
|
||||
"default-audience"
|
||||
],
|
||||
"defaultOptionalClientScopes": [
|
||||
"offline_access",
|
||||
|
||||
+343
-53
@@ -8,9 +8,11 @@ from typing import TYPE_CHECKING, Optional
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import httpx
|
||||
import uvicorn
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from pydantic import AnyHttpUrl
|
||||
@@ -27,13 +29,12 @@ from nextcloud_mcp_server.auth import (
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.progressive_token_verifier import (
|
||||
ProgressiveConsentTokenVerifier,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
LOGGING_CONFIG,
|
||||
get_document_processor_config,
|
||||
get_settings,
|
||||
setup_logging,
|
||||
)
|
||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||
@@ -44,11 +45,13 @@ from nextcloud_mcp_server.server import (
|
||||
configure_cookbook_tools,
|
||||
configure_deck_tools,
|
||||
configure_notes_tools,
|
||||
configure_semantic_tools,
|
||||
configure_sharing_tools,
|
||||
configure_tables_tools,
|
||||
configure_webdav_tools,
|
||||
)
|
||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -208,6 +211,10 @@ class AppContext:
|
||||
"""Application context for BasicAuth mode."""
|
||||
|
||||
client: NextcloudClient
|
||||
document_send_stream: Optional[MemoryObjectSendStream] = None
|
||||
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
|
||||
shutdown_event: Optional[anyio.Event] = None
|
||||
scanner_wake_event: Optional[anyio.Event] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -215,12 +222,13 @@ class OAuthAppContext:
|
||||
"""Application context for OAuth mode."""
|
||||
|
||||
nextcloud_host: str
|
||||
token_verifier: (
|
||||
object # Can be NextcloudTokenVerifier or ProgressiveConsentTokenVerifier
|
||||
)
|
||||
token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant)
|
||||
refresh_token_storage: Optional["RefreshTokenStorage"] = None
|
||||
oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient
|
||||
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
|
||||
server_client_id: Optional[str] = (
|
||||
None # MCP server's OAuth client ID (static or DCR)
|
||||
)
|
||||
|
||||
|
||||
def is_oauth_mode() -> bool:
|
||||
@@ -296,8 +304,7 @@ async def load_oauth_client_credentials(
|
||||
logger.info("Dynamic client registration available")
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
redirect_uris = [
|
||||
f"{mcp_server_url}/oauth/callback", # MCP OAuth flow
|
||||
f"{mcp_server_url}/oauth/login-callback", # Browser OAuth flow for /user/page
|
||||
f"{mcp_server_url}/oauth/callback", # Unified callback (flow determined by query param)
|
||||
]
|
||||
|
||||
# MCP server DCR: Register with ALL supported scopes
|
||||
@@ -371,6 +378,9 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
|
||||
Creates a single Nextcloud client with basic authentication
|
||||
that is shared across all requests.
|
||||
|
||||
If vector sync is enabled (VECTOR_SYNC_ENABLED=true), also starts
|
||||
background tasks for automatic document indexing (ADR-007).
|
||||
"""
|
||||
logger.info("Starting MCP server in BasicAuth mode")
|
||||
logger.info("Creating Nextcloud client with BasicAuth")
|
||||
@@ -381,11 +391,77 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth mode")
|
||||
await client.close()
|
||||
settings = get_settings()
|
||||
|
||||
# Check if vector sync is enabled
|
||||
if settings.vector_sync_enabled:
|
||||
logger.info("Vector sync enabled - starting background tasks")
|
||||
|
||||
# Get username from environment for BasicAuth mode
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
if not username:
|
||||
raise ValueError(
|
||||
"NEXTCLOUD_USERNAME is required for vector sync in BasicAuth mode"
|
||||
)
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
)
|
||||
shutdown_event = anyio.Event()
|
||||
scanner_wake_event = anyio.Event()
|
||||
|
||||
# Start background tasks using anyio TaskGroup
|
||||
async with anyio.create_task_group() as tg:
|
||||
# Start scanner task
|
||||
tg.start_soon(
|
||||
scanner_task,
|
||||
send_stream,
|
||||
shutdown_event,
|
||||
scanner_wake_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
# Start processor pool (each gets a cloned receive stream)
|
||||
for i in range(settings.vector_sync_processor_workers):
|
||||
tg.start_soon(
|
||||
processor_task,
|
||||
i,
|
||||
receive_stream.clone(),
|
||||
shutdown_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Background sync tasks started: 1 scanner + {settings.vector_sync_processor_workers} processors"
|
||||
)
|
||||
|
||||
# Yield with background tasks running
|
||||
try:
|
||||
yield AppContext(
|
||||
client=client,
|
||||
document_send_stream=send_stream,
|
||||
document_receive_stream=receive_stream,
|
||||
shutdown_event=shutdown_event,
|
||||
scanner_wake_event=scanner_wake_event,
|
||||
)
|
||||
finally:
|
||||
# Shutdown signal
|
||||
logger.info("Shutting down background sync tasks")
|
||||
shutdown_event.set()
|
||||
|
||||
# TaskGroup automatically cancels all tasks on exit
|
||||
logger.info("Background sync tasks stopped")
|
||||
await client.close()
|
||||
else:
|
||||
# No vector sync - simple lifecycle
|
||||
try:
|
||||
yield AppContext(client=client)
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth mode")
|
||||
await client.close()
|
||||
|
||||
|
||||
async def setup_oauth_config():
|
||||
@@ -555,46 +631,80 @@ async def setup_oauth_config():
|
||||
else:
|
||||
client_issuer = issuer
|
||||
|
||||
# Progressive Consent mode (always enabled) - dual OAuth flows with audience separation
|
||||
logger.info("✓ Progressive Consent mode enabled - dual OAuth flows active")
|
||||
# ADR-005: Unified Token Verifier with proper audience validation
|
||||
# Get MCP server URL for audience validation
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
|
||||
|
||||
# Get encryption key for token broker
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
# Warn if resource URIs are not configured (required for ADR-005 compliance)
|
||||
if not os.getenv("NEXTCLOUD_MCP_SERVER_URL"):
|
||||
logger.warning(
|
||||
"TOKEN_ENCRYPTION_KEY not set - token broker will not be available"
|
||||
f"NEXTCLOUD_MCP_SERVER_URL not set, defaulting to: {mcp_server_url}. "
|
||||
"This should be set explicitly for proper audience validation."
|
||||
)
|
||||
if not os.getenv("NEXTCLOUD_RESOURCE_URI"):
|
||||
logger.warning(
|
||||
f"NEXTCLOUD_RESOURCE_URI not set, defaulting to: {nextcloud_resource_uri}. "
|
||||
"This should be set explicitly for proper audience validation."
|
||||
)
|
||||
|
||||
# Create token broker service
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
# Create settings for UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
token_broker = None
|
||||
if encryption_key and refresh_token_storage:
|
||||
token_broker = TokenBrokerService(
|
||||
storage=refresh_token_storage,
|
||||
oidc_discovery_url=discovery_url,
|
||||
nextcloud_host=nextcloud_host,
|
||||
encryption_key=encryption_key,
|
||||
settings = get_settings()
|
||||
# Override with discovered values if not set in environment
|
||||
if not settings.oidc_client_id:
|
||||
settings.oidc_client_id = client_id
|
||||
if not settings.oidc_client_secret:
|
||||
settings.oidc_client_secret = client_secret
|
||||
if not settings.jwks_uri:
|
||||
settings.jwks_uri = jwks_uri
|
||||
if not settings.introspection_uri:
|
||||
settings.introspection_uri = introspection_uri
|
||||
if not settings.userinfo_uri:
|
||||
settings.userinfo_uri = userinfo_uri
|
||||
if not settings.oidc_issuer:
|
||||
# Use client_issuer which handles public URL override
|
||||
settings.oidc_issuer = client_issuer
|
||||
if not settings.nextcloud_mcp_server_url:
|
||||
settings.nextcloud_mcp_server_url = mcp_server_url
|
||||
if not settings.nextcloud_resource_uri:
|
||||
settings.nextcloud_resource_uri = nextcloud_resource_uri
|
||||
|
||||
# Create Unified Token Verifier (ADR-005 compliant)
|
||||
token_verifier = UnifiedTokenVerifier(settings)
|
||||
|
||||
# Log the mode
|
||||
enable_token_exchange = (
|
||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||
)
|
||||
if enable_token_exchange:
|
||||
logger.info(
|
||||
"✓ Token Exchange mode enabled (ADR-005) - exchanging MCP tokens for Nextcloud tokens via RFC 8693"
|
||||
)
|
||||
logger.info("✓ Token Broker service initialized for audience-specific tokens")
|
||||
logger.info(f" MCP audience: {client_id} or {mcp_server_url}")
|
||||
logger.info(f" Nextcloud audience: {nextcloud_resource_uri}")
|
||||
else:
|
||||
logger.info(
|
||||
"✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences"
|
||||
)
|
||||
logger.info(f" Required MCP audience: {client_id} or {mcp_server_url}")
|
||||
logger.info(f" Required Nextcloud audience: {nextcloud_resource_uri}")
|
||||
|
||||
# Create Progressive Consent token verifier
|
||||
token_verifier = ProgressiveConsentTokenVerifier(
|
||||
token_storage=refresh_token_storage,
|
||||
token_broker=token_broker,
|
||||
oidc_discovery_url=discovery_url,
|
||||
nextcloud_host=nextcloud_host,
|
||||
encryption_key=encryption_key,
|
||||
mcp_client_id=client_id,
|
||||
introspection_uri=introspection_uri,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✓ Progressive Consent verifier configured - enforcing audience separation"
|
||||
)
|
||||
if introspection_uri:
|
||||
logger.info("✓ Opaque token introspection enabled (RFC 7662)")
|
||||
if jwks_uri:
|
||||
logger.info("✓ JWT signature verification enabled (JWKS)")
|
||||
|
||||
# Progressive Consent mode (for offline access / background jobs)
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if enable_offline_access and encryption_key and refresh_token_storage:
|
||||
logger.info("✓ Progressive Consent mode enabled - offline access available")
|
||||
|
||||
# Note: Token Broker service would be initialized here for background job support
|
||||
# Currently not used in ADR-005 implementation as it's specific to offline access patterns
|
||||
# that are separate from the real-time token exchange flow
|
||||
logger.debug("Token broker available for future offline access features")
|
||||
|
||||
# Create OAuth client for server-initiated flows (e.g., token exchange, background workers)
|
||||
oauth_client = None
|
||||
@@ -603,6 +713,8 @@ async def setup_oauth_config():
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
# Note: This redirect_uri is for OAuth client initialization, not used for actual redirects
|
||||
# since this client is used for backend token operations (exchange, refresh)
|
||||
redirect_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
# Extract base URL and realm from discovery URL
|
||||
@@ -708,6 +820,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
refresh_token_storage=refresh_token_storage,
|
||||
oauth_client=oauth_client,
|
||||
oauth_provider=oauth_provider,
|
||||
server_client_id=client_id,
|
||||
)
|
||||
finally:
|
||||
logger.info("Shutting down MCP server")
|
||||
@@ -763,16 +876,35 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
|
||||
)
|
||||
|
||||
# Register OAuth provisioning tools (only when offline access/Progressive Consent is used)
|
||||
# Register semantic search tools (cross-app feature)
|
||||
settings = get_settings()
|
||||
if settings.vector_sync_enabled:
|
||||
logger.info("Configuring semantic search tools (vector sync enabled)")
|
||||
configure_semantic_tools(mcp)
|
||||
else:
|
||||
logger.info("Skipping semantic search tools (VECTOR_SYNC_ENABLED not set)")
|
||||
|
||||
# Register OAuth provisioning tools (only when offline access is enabled)
|
||||
# With token exchange enabled (external IdP), provisioning is not needed for MCP operations
|
||||
enable_token_exchange = (
|
||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||
)
|
||||
if oauth_enabled and not enable_token_exchange:
|
||||
logger.info("Registering OAuth provisioning tools for Progressive Consent")
|
||||
enable_offline_access_for_tools = os.getenv(
|
||||
"ENABLE_OFFLINE_ACCESS", "false"
|
||||
).lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
if oauth_enabled and enable_offline_access_for_tools and not enable_token_exchange:
|
||||
logger.info("Registering OAuth provisioning tools for offline access")
|
||||
register_oauth_tools(mcp)
|
||||
elif oauth_enabled and enable_token_exchange:
|
||||
logger.info("Skipping provisioning tools registration (token exchange enabled)")
|
||||
elif oauth_enabled and not enable_offline_access_for_tools:
|
||||
logger.info(
|
||||
"Skipping provisioning tools registration (offline access not enabled)"
|
||||
)
|
||||
|
||||
# Override list_tools to filter based on user's token scopes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
@@ -837,13 +969,16 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
nextcloud_resource_uri = os.getenv(
|
||||
"NEXTCLOUD_RESOURCE_URI", nextcloud_host
|
||||
)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||
|
||||
app.state.oauth_context = {
|
||||
oauth_context_dict = {
|
||||
"storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
|
||||
@@ -854,16 +989,116 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||
"scopes": scopes,
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"nextcloud_resource_uri": nextcloud_resource_uri,
|
||||
"oauth_provider": oauth_provider,
|
||||
},
|
||||
}
|
||||
app.state.oauth_context = oauth_context_dict
|
||||
|
||||
# Also set oauth_context on browser_app for session authentication
|
||||
# browser_app is in the same function scope (defined later in create_app)
|
||||
# We need to find it in the mounted routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/user":
|
||||
route.app.state.oauth_context = oauth_context_dict
|
||||
logger.info(
|
||||
"OAuth context shared with browser_app for session auth"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
||||
# For streamable-http transport, FastMCP lifespan isn't automatically triggered
|
||||
# so we manually start background tasks here if vector sync is enabled
|
||||
import anyio as anyio_module
|
||||
|
||||
settings = get_settings()
|
||||
if not oauth_enabled and settings.vector_sync_enabled:
|
||||
logger.info("Starting background vector sync tasks for BasicAuth mode")
|
||||
|
||||
# Get username from environment
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
if not username:
|
||||
raise ValueError(
|
||||
"NEXTCLOUD_USERNAME required for vector sync in BasicAuth mode"
|
||||
)
|
||||
|
||||
# Get Nextcloud client from MCP app context
|
||||
# Create client since we're outside FastMCP lifespan
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio_module.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
)
|
||||
shutdown_event = anyio_module.Event()
|
||||
scanner_wake_event = anyio_module.Event()
|
||||
|
||||
# Store in app state for access from routes (ADR-007)
|
||||
app.state.document_send_stream = send_stream
|
||||
app.state.document_receive_stream = receive_stream
|
||||
app.state.shutdown_event = shutdown_event
|
||||
app.state.scanner_wake_event = scanner_wake_event
|
||||
|
||||
# Also share with browser_app for /user/page route
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/user":
|
||||
route.app.state.document_send_stream = send_stream
|
||||
route.app.state.document_receive_stream = receive_stream
|
||||
route.app.state.shutdown_event = shutdown_event
|
||||
route.app.state.scanner_wake_event = scanner_wake_event
|
||||
logger.info(
|
||||
"Vector sync state shared with browser_app for /user/page"
|
||||
)
|
||||
break
|
||||
|
||||
# Start background tasks using anyio TaskGroup
|
||||
async with anyio_module.create_task_group() as tg:
|
||||
# Start scanner task
|
||||
tg.start_soon(
|
||||
scanner_task,
|
||||
send_stream,
|
||||
shutdown_event,
|
||||
scanner_wake_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
# Start processor pool (each gets a cloned receive stream)
|
||||
for i in range(settings.vector_sync_processor_workers):
|
||||
tg.start_soon(
|
||||
processor_task,
|
||||
i,
|
||||
receive_stream.clone(),
|
||||
shutdown_event,
|
||||
client,
|
||||
username,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Background sync tasks started: 1 scanner + "
|
||||
f"{settings.vector_sync_processor_workers} processors"
|
||||
)
|
||||
|
||||
# Run MCP session manager and yield
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Shutdown signal
|
||||
logger.info("Shutting down background sync tasks")
|
||||
shutdown_event.set()
|
||||
await client.close()
|
||||
# TaskGroup automatically cancels all tasks on exit
|
||||
else:
|
||||
# No vector sync - just run MCP session manager
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
|
||||
# Health check endpoints for Kubernetes probes
|
||||
def health_live(request):
|
||||
@@ -883,7 +1118,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"""Readiness probe endpoint.
|
||||
|
||||
Returns 200 OK if the application is ready to serve traffic.
|
||||
Checks that required configuration is present.
|
||||
Checks that required configuration is present and Qdrant if vector sync enabled.
|
||||
"""
|
||||
checks = {}
|
||||
is_ready = True
|
||||
@@ -913,6 +1148,24 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
checks["auth_configured"] = "error: credentials not set"
|
||||
is_ready = False
|
||||
|
||||
# Check Qdrant status if vector sync is enabled
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
if vector_sync_enabled:
|
||||
try:
|
||||
qdrant_url = os.getenv("QDRANT_URL", "http://qdrant:6333")
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
response = await client.get(f"{qdrant_url}/readyz")
|
||||
if response.status_code == 200:
|
||||
checks["qdrant"] = "ok"
|
||||
else:
|
||||
checks["qdrant"] = f"error: status {response.status_code}"
|
||||
is_ready = False
|
||||
except Exception as e:
|
||||
checks["qdrant"] = f"error: {str(e)}"
|
||||
is_ready = False
|
||||
|
||||
status_code = 200 if is_ready else 503
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -997,6 +1250,38 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||
|
||||
# Add unified OAuth callback endpoint supporting both flows
|
||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||
oauth_authorize_nextcloud,
|
||||
oauth_callback,
|
||||
oauth_callback_nextcloud,
|
||||
)
|
||||
|
||||
routes.append(Route("/oauth/callback", oauth_callback, methods=["GET"]))
|
||||
logger.info(
|
||||
"OAuth unified callback enabled: /oauth/callback?flow={browser|provisioning}"
|
||||
)
|
||||
|
||||
# Add OAuth resource provisioning routes (ADR-004 Progressive Consent Flow 2)
|
||||
routes.append(
|
||||
Route(
|
||||
"/oauth/authorize-nextcloud",
|
||||
oauth_authorize_nextcloud,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
# Keep old callback endpoint as backwards-compatible alias
|
||||
routes.append(
|
||||
Route(
|
||||
"/oauth/callback-nextcloud",
|
||||
oauth_callback_nextcloud,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2, legacy)"
|
||||
)
|
||||
|
||||
# Add browser OAuth login routes (OAuth mode only)
|
||||
if oauth_enabled:
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
@@ -1008,6 +1293,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
routes.append(
|
||||
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
|
||||
)
|
||||
# Keep old callback endpoint as backwards-compatible alias
|
||||
routes.append(
|
||||
Route(
|
||||
"/oauth/login-callback",
|
||||
@@ -1020,13 +1306,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
Route("/oauth/logout", oauth_logout, methods=["GET"], name="oauth_logout")
|
||||
)
|
||||
logger.info(
|
||||
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback, /oauth/logout"
|
||||
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback (legacy), /oauth/logout"
|
||||
)
|
||||
|
||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
user_info_json,
|
||||
)
|
||||
@@ -1036,6 +1323,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
browser_routes = [
|
||||
Route("/", user_info_json, methods=["GET"]), # /user/ → user_info_json
|
||||
Route("/page", user_info_html, methods=["GET"]), # /user/page → user_info_html
|
||||
Route(
|
||||
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
||||
), # /user/revoke → revoke_session
|
||||
]
|
||||
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
|
||||
@@ -14,11 +14,11 @@ from .scope_authorization import (
|
||||
is_jwt_token,
|
||||
require_scopes,
|
||||
)
|
||||
from .token_verifier import NextcloudTokenVerifier
|
||||
from .unified_verifier import UnifiedTokenVerifier
|
||||
|
||||
__all__ = [
|
||||
"BearerAuth",
|
||||
"NextcloudTokenVerifier",
|
||||
"UnifiedTokenVerifier",
|
||||
"register_client",
|
||||
"ensure_oauth_client",
|
||||
"get_client_from_context",
|
||||
|
||||
@@ -4,9 +4,11 @@ Separate from MCP OAuth flow - these routes establish browser sessions
|
||||
for accessing admin UI endpoints like /user/page.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from base64 import urlsafe_b64encode
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
@@ -53,39 +55,36 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
|
||||
# Build OAuth authorization URL
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/login-callback"
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
# Request only basic OIDC scopes for browser session
|
||||
# Note: Nextcloud app scopes (notes:read, etc.) are for MCP client access tokens,
|
||||
# not for the MCP server's own browser authentication
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
code_challenge = ""
|
||||
code_verifier = ""
|
||||
# Generate PKCE values for ALL modes (both external and integrated IdP require PKCE)
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
# Store code_verifier in session for retrieval during callback (using state as key)
|
||||
await storage.store_oauth_session(
|
||||
session_id=state, # Use state as session ID
|
||||
client_id="browser-ui",
|
||||
client_redirect_uri="/user/page",
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
|
||||
flow_type="browser",
|
||||
ttl_seconds=600, # 10 minutes
|
||||
)
|
||||
|
||||
if oauth_client:
|
||||
# External IdP mode (Keycloak)
|
||||
# Keycloak requires PKCE, so generate code_verifier and code_challenge
|
||||
if not oauth_client.authorization_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
# Generate PKCE values
|
||||
code_verifier, code_challenge = oauth_client.generate_pkce_challenge()
|
||||
|
||||
# Store code_verifier temporarily (using state as key)
|
||||
# We'll retrieve it in the callback using the state parameter
|
||||
await storage.store_oauth_session(
|
||||
session_id=state, # Use state as session ID
|
||||
client_id="browser-ui",
|
||||
client_redirect_uri="/user/page",
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
|
||||
flow_type="browser",
|
||||
ttl_seconds=600, # 10 minutes
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_client.client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
@@ -138,6 +137,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
}
|
||||
|
||||
@@ -213,20 +214,18 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Retrieve code_verifier from session storage (if using PKCE)
|
||||
# Retrieve code_verifier from session storage (PKCE required for all modes)
|
||||
code_verifier = ""
|
||||
if oauth_client:
|
||||
# For Keycloak (external IdP), we stored the code_verifier in the session
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
if oauth_session:
|
||||
# code_verifier was stored in mcp_authorization_code field
|
||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||
# Clean up the temporary session
|
||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
if oauth_session:
|
||||
# code_verifier was stored in mcp_authorization_code field
|
||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||
# Clean up the temporary session
|
||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||
|
||||
# Exchange authorization code for tokens
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/login-callback"
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
try:
|
||||
if oauth_client:
|
||||
@@ -263,16 +262,22 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_config["client_id"],
|
||||
"client_secret": oauth_config["client_secret"],
|
||||
}
|
||||
|
||||
# Add code_verifier for PKCE (required by Nextcloud OIDC)
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": oauth_config["client_id"],
|
||||
"client_secret": oauth_config["client_secret"],
|
||||
},
|
||||
data=token_params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
@@ -336,13 +341,18 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
# Store refresh token (for background jobs ONLY)
|
||||
if refresh_token:
|
||||
logger.info(f"Storing refresh token for user_id: {user_id}")
|
||||
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=None,
|
||||
flow_type="browser", # Browser-based login flow
|
||||
provisioning_client_id=state, # Store state for unified session lookup
|
||||
)
|
||||
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
||||
logger.info(
|
||||
f" Token can now be found via provisioning_client_id={state[:16]}..."
|
||||
)
|
||||
else:
|
||||
logger.warning("No refresh token in token response - cannot store session")
|
||||
|
||||
|
||||
@@ -79,19 +79,22 @@ async def register_client(
|
||||
client_name: str = "Nextcloud MCP Server",
|
||||
redirect_uris: list[str] | None = None,
|
||||
scopes: str = "openid profile email",
|
||||
token_type: str = "Bearer",
|
||||
token_type: str | None = "Bearer",
|
||||
resource_url: str | None = None,
|
||||
) -> ClientInfo:
|
||||
"""
|
||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
|
||||
|
||||
This function supports both Nextcloud OIDC and standard OIDC providers like Keycloak.
|
||||
|
||||
Args:
|
||||
nextcloud_url: Base URL of the Nextcloud instance
|
||||
nextcloud_url: Base URL of the OIDC provider
|
||||
registration_endpoint: Full URL to the registration endpoint
|
||||
client_name: Name of the client application
|
||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||
scopes: Space-separated list of scopes to request
|
||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
|
||||
Set to None to omit this field (required for Keycloak and other standard providers).
|
||||
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||
|
||||
Returns:
|
||||
@@ -100,6 +103,11 @@ async def register_client(
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If registration fails
|
||||
ValueError: If response is invalid
|
||||
|
||||
Note:
|
||||
The token_type parameter is a Nextcloud-specific extension and is not part of RFC 7591.
|
||||
Standard OIDC providers like Keycloak do not accept this field and will return a 400 error
|
||||
if it's included. Set token_type=None when registering with Keycloak or other standard providers.
|
||||
"""
|
||||
if redirect_uris is None:
|
||||
redirect_uris = ["http://localhost:8000/oauth/callback"]
|
||||
@@ -111,9 +119,12 @@ async def register_client(
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": scopes,
|
||||
"token_type": token_type,
|
||||
}
|
||||
|
||||
# Add token_type if provided (Nextcloud-specific, not RFC 7591 standard)
|
||||
if token_type is not None:
|
||||
client_metadata["token_type"] = token_type
|
||||
|
||||
# Add resource_url if provided (RFC 9728)
|
||||
if resource_url:
|
||||
client_metadata["resource_url"] = resource_url
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Helper functions for extracting OAuth context from MCP requests."""
|
||||
"""Helper functions for extracting OAuth context from MCP requests.
|
||||
|
||||
ADR-005 compliant implementation with token exchange caching.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
@@ -11,35 +16,36 @@ from .token_exchange import exchange_token_for_audience
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token exchange cache: token_hash -> (exchanged_token, expiry_timestamp)
|
||||
_exchange_cache: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
"""
|
||||
Extract authenticated user context from MCP request and create NextcloudClient.
|
||||
Create NextcloudClient for multi-audience mode (no exchange needed).
|
||||
|
||||
This function retrieves the OAuth access token from the MCP context,
|
||||
extracts the username from the token's resource field (where we stored it
|
||||
during token verification), and creates a NextcloudClient with bearer auth.
|
||||
ADR-005 Mode 1: Use multi-audience tokens directly.
|
||||
The UnifiedTokenVerifier validated MCP audience per RFC 7519.
|
||||
Nextcloud will independently validate its own audience.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context containing session info
|
||||
base_url: Nextcloud base URL
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with bearer token auth
|
||||
NextcloudClient configured with multi-audience token
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected OAuth session data
|
||||
ValueError: If username cannot be extracted from token
|
||||
"""
|
||||
try:
|
||||
# In Starlette with FastMCP OAuth, the authenticated user info is stored in request.user
|
||||
# The FastMCP auth middleware sets request.user to an AuthenticatedUser object
|
||||
# which contains the access_token
|
||||
# Extract validated access token from MCP 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
|
||||
logger.debug("Retrieved access token from request.user for OAuth request")
|
||||
logger.debug("Retrieved multi-audience token from request.user")
|
||||
else:
|
||||
logger.error(
|
||||
"OAuth authentication failed: No access token found in request"
|
||||
@@ -47,16 +53,20 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||
raise AttributeError("No access token found in OAuth request context")
|
||||
|
||||
# Extract username from resource field (RFC 8707)
|
||||
# We stored the username here during token verification
|
||||
# UnifiedTokenVerifier stored the username here during validation
|
||||
username = access_token.resource
|
||||
|
||||
if not username:
|
||||
logger.error("No username found in access token resource field")
|
||||
raise ValueError("Username not available in OAuth token context")
|
||||
|
||||
logger.debug(f"Creating OAuth NextcloudClient for user: {username}")
|
||||
logger.debug(
|
||||
f"Creating NextcloudClient for user {username} with multi-audience token "
|
||||
f"(no exchange needed)"
|
||||
)
|
||||
|
||||
# Create client with bearer token
|
||||
# Token was validated to have MCP audience
|
||||
# Nextcloud will validate its own audience independently
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url, token=access_token.token, username=username
|
||||
)
|
||||
@@ -71,12 +81,19 @@ async def get_session_client_from_context(
|
||||
ctx: Context, base_url: str
|
||||
) -> NextcloudClient:
|
||||
"""
|
||||
Create NextcloudClient using RFC 8693 token exchange for session operations.
|
||||
Create NextcloudClient using RFC 8693 token exchange with caching.
|
||||
|
||||
ADR-005 Mode 2: Exchange MCP token for Nextcloud token via RFC 8693.
|
||||
|
||||
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)
|
||||
1. Extract MCP token from context (validated by UnifiedTokenVerifier)
|
||||
2. Check cache for existing exchanged token
|
||||
3. If not cached or expired, exchange via RFC 8693
|
||||
4. Cache the exchanged token to minimize exchange frequency
|
||||
5. Create client with exchanged token
|
||||
|
||||
CRITICAL: This is where token exchange happens, NOT in the verifier.
|
||||
The verifier already validated the MCP audience; now we exchange for Nextcloud.
|
||||
|
||||
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
|
||||
by the MCP server via @require_scopes decorator, not by the IdP. Therefore,
|
||||
@@ -88,7 +105,7 @@ async def get_session_client_from_context(
|
||||
base_url: Nextcloud base URL
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with ephemeral delegated token
|
||||
NextcloudClient configured with ephemeral exchanged token
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected OAuth session data
|
||||
@@ -96,43 +113,60 @@ async def get_session_client_from_context(
|
||||
"""
|
||||
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
|
||||
# Extract MCP 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}")
|
||||
mcp_token = access_token.token
|
||||
username = access_token.resource # Username from UnifiedTokenVerifier
|
||||
logger.debug(f"Retrieved MCP token for user: {username}")
|
||||
else:
|
||||
logger.error("No Flow 1 token found in request context")
|
||||
logger.error("No MCP 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 client token for Nextcloud API token (pure RFC 8693)")
|
||||
# Check cache for existing exchanged token
|
||||
cache_key = hashlib.sha256(mcp_token.encode()).hexdigest()
|
||||
if cache_key in _exchange_cache:
|
||||
cached_token, expiry = _exchange_cache[cache_key]
|
||||
if time.time() < expiry:
|
||||
logger.debug(
|
||||
f"Using cached exchanged token (expires in {expiry - time.time():.1f}s)"
|
||||
)
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url, token=cached_token, username=username
|
||||
)
|
||||
else:
|
||||
logger.debug("Cached token expired, removing from cache")
|
||||
del _exchange_cache[cache_key]
|
||||
|
||||
# Perform pure RFC 8693 token exchange (no refresh tokens)
|
||||
# Note: We don't pass scopes since Nextcloud doesn't enforce them.
|
||||
# The MCP server's @require_scopes decorator handles authorization.
|
||||
# Perform RFC 8693 token exchange
|
||||
logger.info(f"Exchanging MCP token for Nextcloud API token (user: {username})")
|
||||
|
||||
# Exchange for Nextcloud resource URI audience
|
||||
exchanged_token, expires_in = await exchange_token_for_audience(
|
||||
subject_token=flow1_token,
|
||||
requested_audience="nextcloud",
|
||||
subject_token=mcp_token,
|
||||
requested_audience=settings.nextcloud_resource_uri or "nextcloud",
|
||||
requested_scopes=None, # Nextcloud doesn't support scopes
|
||||
)
|
||||
|
||||
logger.info(f"Pure token exchange successful. Token expires in {expires_in}s")
|
||||
logger.info(f"Token exchange successful. Token expires in {expires_in}s")
|
||||
|
||||
# Cache the exchanged token
|
||||
# Use the minimum of exchange TTL and configured cache TTL
|
||||
cache_ttl = min(expires_in, settings.token_exchange_cache_ttl)
|
||||
_exchange_cache[cache_key] = (exchanged_token, time.time() + cache_ttl)
|
||||
logger.debug(f"Cached exchanged token for {cache_ttl}s")
|
||||
|
||||
# Clean up expired cache entries
|
||||
_cleanup_exchange_cache()
|
||||
|
||||
# Create client with exchanged token
|
||||
# This token is ephemeral (per-request) and NOT stored
|
||||
return NextcloudClient.from_token(
|
||||
base_url=base_url, token=exchanged_token, username=username
|
||||
)
|
||||
@@ -143,3 +177,21 @@ async def get_session_client_from_context(
|
||||
except Exception as e:
|
||||
logger.error(f"Token exchange failed: {e}")
|
||||
raise RuntimeError(f"Token exchange required but failed: {e}") from e
|
||||
|
||||
|
||||
def _cleanup_exchange_cache():
|
||||
"""Remove expired entries from the token exchange cache."""
|
||||
global _exchange_cache
|
||||
now = time.time()
|
||||
expired_keys = [k for k, (_, expiry) in _exchange_cache.items() if expiry <= now]
|
||||
for key in expired_keys:
|
||||
del _exchange_cache[key]
|
||||
if expired_keys:
|
||||
logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries")
|
||||
|
||||
|
||||
def clear_exchange_cache():
|
||||
"""Clear the entire token exchange cache. Useful for testing."""
|
||||
global _exchange_cache
|
||||
_exchange_cache.clear()
|
||||
logger.debug("Token exchange cache cleared")
|
||||
|
||||
@@ -90,6 +90,8 @@ class KeycloakOAuthClient:
|
||||
)
|
||||
|
||||
# Parse server URL to construct redirect URI
|
||||
# Note: This is for OAuth client initialization, not used for actual redirects
|
||||
# since this client is used for backend token operations (exchange, refresh)
|
||||
parsed_url = urlparse(server_url)
|
||||
redirect_uri = f"{parsed_url.scheme}://{parsed_url.netloc}/oauth/callback"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
OAuth 2.0 Login Routes for ADR-004 Progressive Consent Architecture
|
||||
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture)
|
||||
|
||||
Implements dual OAuth flows with explicit provisioning:
|
||||
Implements dual OAuth flows with optional offline access provisioning:
|
||||
|
||||
Flow 1: Client Authentication - MCP client authenticates directly to IdP
|
||||
- Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.)
|
||||
@@ -19,8 +19,11 @@ Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
||||
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from base64 import urlsafe_b64encode
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
@@ -118,7 +121,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Validate client_id (required for Progressive Consent Flow 1)
|
||||
# Validate client_id (required for Flow 1)
|
||||
if not client_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -168,7 +171,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
# The MCP server does NOT see the IdP authorization code!
|
||||
|
||||
logger.info(
|
||||
f"Starting Progressive Consent Flow 1 - no server session needed, "
|
||||
f"Starting Flow 1 - no server session needed, "
|
||||
f"client will handle IdP response directly at {redirect_uri}"
|
||||
)
|
||||
|
||||
@@ -188,7 +191,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
# Use client's own client_id (client must be pre-registered at IdP)
|
||||
idp_client_id = client_id
|
||||
|
||||
logger.info("Flow 1 (Progressive Consent): Direct client auth to IdP")
|
||||
logger.info("Flow 1: Direct client auth to IdP")
|
||||
logger.info(f" Client ID: {client_id}")
|
||||
logger.info(f" Client will receive IdP code directly at: {callback_uri}")
|
||||
logger.info(f" Scopes: {scopes} (resource access for MCP tools)")
|
||||
@@ -252,6 +255,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"scope": scopes,
|
||||
"state": idp_state,
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
"resource": f"{oauth_config['mcp_server_url']}/mcp", # MCP server audience
|
||||
}
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
@@ -313,12 +317,31 @@ async def oauth_authorize_nextcloud(
|
||||
)
|
||||
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
# Flow 2: Server only needs identity + offline access (no resource scopes)
|
||||
# Resource scopes are requested by client in Flow 1
|
||||
scopes = "openid profile email offline_access"
|
||||
|
||||
# Generate PKCE values (required by Nextcloud OIDC)
|
||||
code_verifier = secrets.token_urlsafe(32)
|
||||
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||
code_challenge = urlsafe_b64encode(digest).decode().rstrip("=")
|
||||
|
||||
# Store code_verifier in session for retrieval during callback
|
||||
storage = oauth_ctx["storage"]
|
||||
await storage.store_oauth_session(
|
||||
session_id=state,
|
||||
client_id=mcp_server_client_id,
|
||||
client_redirect_uri=callback_uri,
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
mcp_authorization_code=code_verifier, # Store code_verifier here temporarily
|
||||
flow_type="flow2",
|
||||
ttl_seconds=600, # 10 minutes
|
||||
)
|
||||
|
||||
# Get authorization endpoint
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
if not discovery_url:
|
||||
@@ -357,8 +380,11 @@ async def oauth_authorize_nextcloud(
|
||||
"response_type": "code",
|
||||
"scope": scopes,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Force consent to show resource access
|
||||
"access_type": "offline", # Request refresh token
|
||||
"resource": oauth_config["nextcloud_resource_uri"], # Nextcloud audience
|
||||
}
|
||||
|
||||
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
|
||||
@@ -414,6 +440,16 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
storage: RefreshTokenStorage = oauth_ctx["storage"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Retrieve code_verifier from session storage (PKCE required by Nextcloud OIDC)
|
||||
code_verifier = ""
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
if oauth_session:
|
||||
# code_verifier was stored in mcp_authorization_code field
|
||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||
logger.info(
|
||||
f"Retrieved code_verifier for Flow 2 callback (state={state[:16]}...)"
|
||||
)
|
||||
|
||||
# Exchange code for tokens
|
||||
mcp_server_client_id = os.getenv(
|
||||
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
|
||||
@@ -422,7 +458,7 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
|
||||
)
|
||||
mcp_server_url = oauth_config["mcp_server_url"]
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
@@ -431,17 +467,24 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Build token exchange params
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": mcp_server_client_id,
|
||||
"client_secret": mcp_server_client_secret,
|
||||
}
|
||||
|
||||
# Add code_verifier for PKCE (required by Nextcloud OIDC)
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": callback_uri,
|
||||
"client_id": mcp_server_client_id,
|
||||
"client_secret": mcp_server_client_secret,
|
||||
},
|
||||
data=token_params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
@@ -450,14 +493,22 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
id_token = token_data.get("id_token")
|
||||
|
||||
# Decode ID token to get user info
|
||||
logger.info("=" * 60)
|
||||
logger.info("oauth_callback_nextcloud: Extracting user_id from ID token")
|
||||
logger.info("=" * 60)
|
||||
try:
|
||||
userinfo = jwt.decode(id_token, options={"verify_signature": False})
|
||||
user_id = userinfo.get("sub")
|
||||
username = userinfo.get("preferred_username") or userinfo.get("email")
|
||||
logger.info(" ✓ ID token decode SUCCESSFUL")
|
||||
logger.info(f" Extracted user_id: {user_id}")
|
||||
logger.info(f" Username: {username}")
|
||||
logger.info(f" ID token payload keys: {list(userinfo.keys())}")
|
||||
logger.info(f"Flow 2: User {username} provisioned resource access")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to decode ID token: {e}")
|
||||
logger.error(f" ✗ ID token decode FAILED: {type(e).__name__}: {e}")
|
||||
user_id = "unknown"
|
||||
logger.error(f" Using fallback user_id: {user_id}")
|
||||
|
||||
# Store master refresh token for Flow 2
|
||||
if refresh_token:
|
||||
@@ -466,6 +517,13 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||
)
|
||||
|
||||
logger.info("Storing refresh token:")
|
||||
logger.info(f" user_id: {user_id}")
|
||||
logger.info(" flow_type: flow2")
|
||||
logger.info(" token_audience: nextcloud")
|
||||
logger.info(f" provisioning_client_id: {state[:16]}...")
|
||||
logger.info(f" scopes: {granted_scopes}")
|
||||
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
@@ -475,7 +533,8 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
scopes=granted_scopes,
|
||||
expires_at=None, # Refresh tokens typically don't expire
|
||||
)
|
||||
logger.info(f"Stored Flow 2 master refresh token for user {user_id}")
|
||||
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Return success HTML page
|
||||
success_html = """
|
||||
@@ -500,3 +559,82 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
return HTMLResponse(content=success_html, status_code=200)
|
||||
|
||||
|
||||
async def oauth_callback(request: Request):
|
||||
"""
|
||||
Unified OAuth callback endpoint supporting multiple flows.
|
||||
|
||||
This endpoint consolidates all OAuth callback handling into a single URL.
|
||||
The flow type is determined by looking up the OAuth session using the
|
||||
state parameter.
|
||||
|
||||
This simplifies IdP configuration by requiring only one callback URL
|
||||
to be registered: /oauth/callback
|
||||
|
||||
Query parameters:
|
||||
code: Authorization code from IdP
|
||||
state: CSRF protection state (also used to lookup flow type)
|
||||
error: Error code (if authorization failed)
|
||||
|
||||
Returns:
|
||||
Response from the appropriate flow handler
|
||||
"""
|
||||
# Get state parameter to lookup OAuth session
|
||||
state = request.query_params.get("state")
|
||||
if not state:
|
||||
logger.warning("Unified callback called without state parameter")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": "state parameter is required",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Lookup OAuth session to determine flow type
|
||||
oauth_ctx = request.app.state.oauth_context
|
||||
if not oauth_ctx:
|
||||
logger.error("OAuth context not available")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "server_error",
|
||||
"error_description": "OAuth not configured on server",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
storage = oauth_ctx["storage"]
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
|
||||
# Determine flow type from session, default to "browser" for backwards compatibility
|
||||
flow_type = (
|
||||
oauth_session.get("flow_type", "browser") if oauth_session else "browser"
|
||||
)
|
||||
|
||||
logger.info(f"Unified callback: flow_type={flow_type} (from session lookup)")
|
||||
|
||||
if flow_type == "flow2":
|
||||
# Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
|
||||
logger.info("Routing to Flow 2 (resource provisioning)")
|
||||
return await oauth_callback_nextcloud(request)
|
||||
|
||||
elif flow_type == "browser":
|
||||
# Browser UI Login - establish browser session for /user/page access
|
||||
logger.info("Routing to browser login flow")
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
oauth_login_callback,
|
||||
)
|
||||
|
||||
return await oauth_login_callback(request)
|
||||
|
||||
else:
|
||||
# Unknown flow type
|
||||
logger.warning(f"Unknown flow_type in OAuth session: {flow_type}")
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_request",
|
||||
"error_description": f"Unknown flow type: {flow_type}",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
"""
|
||||
Token Verifier for ADR-004 Progressive Consent Architecture.
|
||||
|
||||
This module implements token verification with strict audience separation:
|
||||
- Flow 1 tokens have aud: <mcp-client-id> for MCP authentication
|
||||
- Flow 2 tokens have aud: "nextcloud" for resource access
|
||||
- Token Broker manages the exchange between audiences
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProgressiveConsentTokenVerifier:
|
||||
"""
|
||||
Token verifier for Progressive Consent dual OAuth flows.
|
||||
|
||||
This verifier:
|
||||
1. Validates Flow 1 tokens (aud: <mcp-client-id>) for MCP authentication
|
||||
2. Checks if user has provisioned Nextcloud access (Flow 2)
|
||||
3. Uses Token Broker to obtain aud: "nextcloud" tokens when needed
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token_storage: RefreshTokenStorage | None,
|
||||
token_broker: Optional[TokenBrokerService] = None,
|
||||
oidc_discovery_url: Optional[str] = None,
|
||||
nextcloud_host: Optional[str] = None,
|
||||
encryption_key: Optional[str] = None,
|
||||
mcp_client_id: Optional[str] = None,
|
||||
introspection_uri: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the Progressive Consent token verifier.
|
||||
|
||||
Args:
|
||||
token_storage: Storage for refresh tokens
|
||||
token_broker: Token broker service (created if not provided)
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
nextcloud_host: Nextcloud server URL
|
||||
encryption_key: Fernet key for token encryption
|
||||
mcp_client_id: MCP server OAuth client ID for audience validation
|
||||
introspection_uri: OAuth introspection endpoint URL (for opaque tokens)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
"""
|
||||
self.storage = token_storage
|
||||
self.oidc_discovery_url = oidc_discovery_url or os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
)
|
||||
self.nextcloud_host = nextcloud_host or os.getenv("NEXTCLOUD_HOST")
|
||||
self.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
self.mcp_client_id = mcp_client_id or os.getenv("OIDC_CLIENT_ID")
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_secret = client_secret or os.getenv("OIDC_CLIENT_SECRET")
|
||||
|
||||
# HTTP client for introspection requests
|
||||
self._http_client: Optional[httpx.AsyncClient] = None
|
||||
if self.introspection_uri and self.mcp_client_id and self.client_secret:
|
||||
self._http_client = httpx.AsyncClient(timeout=10.0)
|
||||
logger.info(f"Introspection support enabled: {introspection_uri}")
|
||||
elif self.introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
# Create token broker if not provided
|
||||
if token_broker:
|
||||
self.token_broker = token_broker
|
||||
elif self.encryption_key and token_storage and self.nextcloud_host:
|
||||
self.token_broker = TokenBrokerService(
|
||||
storage=token_storage,
|
||||
oidc_discovery_url=self.oidc_discovery_url,
|
||||
nextcloud_host=self.nextcloud_host,
|
||||
encryption_key=self.encryption_key,
|
||||
)
|
||||
else:
|
||||
self.token_broker = None
|
||||
if not self.encryption_key:
|
||||
logger.warning("Token broker not available - encryption key missing")
|
||||
elif not token_storage:
|
||||
logger.warning("Token broker not available - token storage missing")
|
||||
elif not self.nextcloud_host:
|
||||
logger.warning("Token broker not available - nextcloud host missing")
|
||||
|
||||
async def verify_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""
|
||||
Verify a Flow 1 token (aud: <mcp-client-id>).
|
||||
|
||||
This validates that:
|
||||
1. Token has correct audience for MCP server (matches client ID)
|
||||
2. Token is not expired
|
||||
3. Token has valid signature (if verification enabled)
|
||||
|
||||
Supports both JWT and opaque tokens:
|
||||
- JWT tokens: Decoded directly from payload
|
||||
- Opaque tokens: Validated via introspection endpoint (RFC 7662)
|
||||
|
||||
Args:
|
||||
token: Access token from Flow 1 (JWT or opaque)
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None otherwise
|
||||
"""
|
||||
logger.info("🔐 verify_token called - attempting to validate token")
|
||||
logger.info(f"Token (first 50 chars): {token[:50]}...")
|
||||
logger.info(f"Expected MCP client ID: {self.mcp_client_id}")
|
||||
|
||||
# Check if token is JWT format (has 3 parts separated by dots)
|
||||
is_jwt = "." in token and token.count(".") == 2
|
||||
logger.info(f"Token format: {'JWT' if is_jwt else 'opaque'}")
|
||||
|
||||
if is_jwt:
|
||||
# Try JWT verification
|
||||
return await self._verify_jwt_token(token)
|
||||
else:
|
||||
# Fall back to introspection for opaque tokens
|
||||
return await self._verify_opaque_token(token)
|
||||
|
||||
async def _verify_jwt_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""Verify JWT token by decoding payload."""
|
||||
try:
|
||||
# Decode without signature verification (IdP handles that)
|
||||
# In production, would verify signature with IdP public key
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
logger.info(f"Token payload decoded: {payload}")
|
||||
|
||||
# CRITICAL: Verify audience is for MCP server (Flow 1)
|
||||
audiences = payload.get("aud", [])
|
||||
if isinstance(audiences, str):
|
||||
audiences = [audiences]
|
||||
|
||||
# Audience validation:
|
||||
# - Accept tokens with no audience (will validate via introspection if needed)
|
||||
# - Accept tokens with MCP client ID in audience (Keycloak multi-audience)
|
||||
# - Accept tokens with resource URL in audience (Nextcloud JWT redirect URI)
|
||||
# - Reject tokens with "nextcloud" audience only (wrong flow)
|
||||
if audiences:
|
||||
# Check if MCP client ID is in the audience (Keycloak multi-audience)
|
||||
if self.mcp_client_id in audiences:
|
||||
logger.debug(
|
||||
f"Token has audience {audiences} - MCP client ID present"
|
||||
)
|
||||
# Check if this is a Nextcloud-only token (wrong flow)
|
||||
elif audiences == ["nextcloud"]:
|
||||
logger.warning(
|
||||
f"Token rejected: Nextcloud-only audience {audiences}"
|
||||
)
|
||||
logger.error(
|
||||
"Received Nextcloud token in MCP context - "
|
||||
"client may be using wrong token"
|
||||
)
|
||||
return None
|
||||
# Otherwise accept (likely resource URL audience from Nextcloud JWT)
|
||||
else:
|
||||
logger.info(
|
||||
f"Token has audience {audiences} (resource URL or non-standard) - accepting"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Token has no audience claim - accepting for MCP server validation"
|
||||
)
|
||||
|
||||
# Check expiry
|
||||
exp = payload.get("exp", 0)
|
||||
if exp < datetime.now(timezone.utc).timestamp():
|
||||
logger.warning(
|
||||
f"❌ Token expired: exp={exp}, now={datetime.now(timezone.utc).timestamp()}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_id = payload.get("sub", "unknown")
|
||||
client_id = payload.get("client_id", "unknown")
|
||||
scopes = payload.get("scope", "").split()
|
||||
exp = payload.get("exp", None)
|
||||
|
||||
logger.info(
|
||||
f"✅ Token validation successful! user={user_id}, scopes={scopes}"
|
||||
)
|
||||
|
||||
# Create AccessToken for MCP framework
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=user_id, # Store user_id in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"❌ Invalid token (JWT decode failed): {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Token verification failed with exception: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_opaque_token(self, token: str) -> Optional[AccessToken]:
|
||||
"""
|
||||
Verify opaque token via introspection endpoint (RFC 7662).
|
||||
|
||||
Args:
|
||||
token: Opaque access token
|
||||
|
||||
Returns:
|
||||
AccessToken if active and valid, None otherwise
|
||||
"""
|
||||
if not self._http_client or not self.introspection_uri:
|
||||
logger.error(
|
||||
"❌ Cannot verify opaque token - introspection not configured. "
|
||||
"Set introspection_uri and client credentials."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
logger.info(f"Introspecting token at {self.introspection_uri}")
|
||||
|
||||
# Call introspection endpoint (requires client authentication)
|
||||
response = await self._http_client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.mcp_client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
f"❌ Introspection failed: HTTP {response.status_code} - {response.text[:200]}"
|
||||
)
|
||||
return None
|
||||
|
||||
introspection_data = response.json()
|
||||
logger.info(f"Introspection response: {introspection_data}")
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.warning("❌ Token introspection returned active=false")
|
||||
return None
|
||||
|
||||
# Extract user info
|
||||
user_id = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not user_id:
|
||||
logger.error("❌ No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
|
||||
# Extract client ID and expiration
|
||||
client_id = introspection_data.get("client_id", "unknown")
|
||||
exp = introspection_data.get("exp")
|
||||
|
||||
logger.info(f"✅ Opaque token validated! user={user_id}, scopes={scopes}")
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
expires_at=int(exp) if exp else None,
|
||||
resource=user_id,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("❌ Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"❌ Network error during introspection: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Introspection failed with exception: {e}")
|
||||
return None
|
||||
|
||||
async def check_provisioning(self, user_id: str) -> bool:
|
||||
"""
|
||||
Check if user has provisioned Nextcloud access (Flow 2).
|
||||
|
||||
Args:
|
||||
user_id: User identifier from Flow 1 token
|
||||
|
||||
Returns:
|
||||
True if user has completed Flow 2, False otherwise
|
||||
"""
|
||||
if not self.storage:
|
||||
return False
|
||||
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
return refresh_data is not None
|
||||
|
||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get a Nextcloud access token (aud: "nextcloud") for the user.
|
||||
|
||||
This uses the Token Broker to:
|
||||
1. Check for cached Nextcloud token
|
||||
2. If expired, refresh using stored master refresh token
|
||||
3. Return token with aud: "nextcloud" for API access
|
||||
|
||||
Args:
|
||||
user_id: User identifier from Flow 1 token
|
||||
|
||||
Returns:
|
||||
Nextcloud access token if provisioned, None otherwise
|
||||
"""
|
||||
if not self.token_broker:
|
||||
logger.error("Token broker not available")
|
||||
return None
|
||||
|
||||
# Check if user has provisioned access
|
||||
if not await self.check_provisioning(user_id):
|
||||
logger.info(f"User {user_id} has not provisioned Nextcloud access")
|
||||
return None
|
||||
|
||||
# Get or refresh Nextcloud token
|
||||
try:
|
||||
nextcloud_token = await self.token_broker.get_nextcloud_token(user_id)
|
||||
if nextcloud_token:
|
||||
logger.debug(f"Obtained Nextcloud token for user {user_id}")
|
||||
return nextcloud_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Nextcloud token: {e}")
|
||||
return None
|
||||
|
||||
async def validate_scopes(
|
||||
self, token: AccessToken, required_scopes: list[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Validate that token has required scopes.
|
||||
|
||||
Args:
|
||||
token: The access token
|
||||
required_scopes: List of required scopes
|
||||
|
||||
Returns:
|
||||
True if all required scopes present, False otherwise
|
||||
"""
|
||||
token_scopes = set(token.scopes) if token.scopes else set()
|
||||
required = set(required_scopes)
|
||||
|
||||
missing = required - token_scopes
|
||||
if missing:
|
||||
logger.debug(f"Token missing required scopes: {missing}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def close(self):
|
||||
"""Clean up resources."""
|
||||
if self.token_broker:
|
||||
await self.token_broker.close()
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Provisioning decorator for ADR-004 Progressive Consent Architecture.
|
||||
Provisioning decorator for ADR-004 (Offline Access Architecture).
|
||||
|
||||
This decorator ensures users have completed Flow 2 (Resource Provisioning)
|
||||
before accessing Nextcloud resources.
|
||||
before accessing Nextcloud resources when offline access is enabled.
|
||||
"""
|
||||
|
||||
import functools
|
||||
@@ -73,7 +73,7 @@ def require_provisioning(func: Callable) -> Callable:
|
||||
logger.debug("Token exchange mode detected - skipping provisioning check")
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Progressive Consent mode (offline access) - check if user has completed Flow 2 provisioning
|
||||
# Offline access mode - check if user has completed Flow 2 provisioning
|
||||
# Get user_id from authorization token
|
||||
user_id = None
|
||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||
|
||||
@@ -430,6 +430,84 @@ class RefreshTokenStorage:
|
||||
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_refresh_token_by_provisioning_client_id(
|
||||
self, provisioning_client_id: str
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Retrieve and decrypt refresh token by provisioning_client_id (state parameter).
|
||||
|
||||
This is used to check if an OAuth Flow 2 login completed successfully
|
||||
by looking up the refresh token using the state parameter that was generated
|
||||
during the authorization request.
|
||||
|
||||
Args:
|
||||
provisioning_client_id: OAuth state parameter from the authorization request
|
||||
|
||||
Returns:
|
||||
Dictionary with token data or None if not found
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
SELECT user_id, encrypted_token, expires_at, flow_type, token_audience,
|
||||
provisioned_at, provisioning_client_id, scopes
|
||||
FROM refresh_tokens WHERE provisioning_client_id = ?
|
||||
""",
|
||||
(provisioning_client_id,),
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(
|
||||
f"No refresh token found for provisioning_client_id {provisioning_client_id[:16]}..."
|
||||
)
|
||||
return None
|
||||
|
||||
(
|
||||
user_id,
|
||||
encrypted_token,
|
||||
expires_at,
|
||||
flow_type,
|
||||
token_audience,
|
||||
provisioned_at,
|
||||
prov_client_id,
|
||||
scopes_json,
|
||||
) = row
|
||||
|
||||
# Check expiration
|
||||
if expires_at is not None and expires_at < time.time():
|
||||
logger.warning(
|
||||
f"Refresh token for provisioning_client_id {provisioning_client_id[:16]}... has expired"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
|
||||
scopes = json.loads(scopes_json) if scopes_json else None
|
||||
|
||||
logger.debug(
|
||||
f"Retrieved refresh token for provisioning_client_id {provisioning_client_id[:16]}... (user_id: {user_id})"
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"refresh_token": decrypted_token,
|
||||
"expires_at": expires_at,
|
||||
"flow_type": flow_type or "hybrid",
|
||||
"token_audience": token_audience or "nextcloud",
|
||||
"provisioned_at": provisioned_at,
|
||||
"provisioning_client_id": prov_client_id,
|
||||
"scopes": scopes,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to decrypt refresh token for provisioning_client_id {provisioning_client_id[:16]}...: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def delete_refresh_token(self, user_id: str) -> bool:
|
||||
"""
|
||||
Delete refresh token for user.
|
||||
|
||||
@@ -130,13 +130,13 @@ def require_scopes(*required_scopes: str):
|
||||
token_scopes = set(access_token.scopes or [])
|
||||
required_scopes_set = set(required_scopes)
|
||||
|
||||
# Check if Progressive Consent is enabled
|
||||
enable_progressive = (
|
||||
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
|
||||
# Check if offline access is enabled
|
||||
enable_offline_access = (
|
||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# In Progressive Consent mode, check if Nextcloud scopes require provisioning
|
||||
if enable_progressive:
|
||||
# In offline access mode, check if Nextcloud scopes require provisioning
|
||||
if enable_offline_access:
|
||||
# Check if any required scopes are Nextcloud-specific
|
||||
nextcloud_scopes = [
|
||||
s
|
||||
|
||||
@@ -1,492 +0,0 @@
|
||||
"""Token verification using Nextcloud OIDC userinfo endpoint."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NextcloudTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback.
|
||||
|
||||
This verifier supports both JWT and opaque tokens:
|
||||
1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload
|
||||
2. For opaque tokens: Falls back to userinfo endpoint validation
|
||||
3. Caches successful responses to avoid repeated API calls/verifications
|
||||
|
||||
JWT validation provides:
|
||||
- Faster validation (no HTTP call needed)
|
||||
- Direct scope extraction from token payload
|
||||
- Signature verification using JWKS
|
||||
|
||||
Userinfo fallback provides:
|
||||
- Support for opaque tokens
|
||||
- Backward compatibility
|
||||
- Additional validation layer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nextcloud_host: str,
|
||||
userinfo_uri: str,
|
||||
jwks_uri: str | None = None,
|
||||
issuer: str | None = None,
|
||||
introspection_uri: str | None = None,
|
||||
client_id: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
cache_ttl: int = 3600,
|
||||
):
|
||||
"""
|
||||
Initialize the token verifier.
|
||||
|
||||
Args:
|
||||
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
|
||||
userinfo_uri: Full URL to the userinfo endpoint
|
||||
jwks_uri: Full URL to the JWKS endpoint (for JWT verification)
|
||||
issuer: Expected issuer claim value (for JWT verification)
|
||||
introspection_uri: Full URL to the introspection endpoint (for opaque tokens)
|
||||
client_id: OAuth client ID (required for introspection)
|
||||
client_secret: OAuth client secret (required for introspection)
|
||||
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||
"""
|
||||
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||
self.userinfo_uri = userinfo_uri
|
||||
self.jwks_uri = jwks_uri
|
||||
self.issuer = issuer
|
||||
self.introspection_uri = introspection_uri
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.cache_ttl = cache_ttl
|
||||
|
||||
# Cache: token -> (userinfo, expiry_timestamp)
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
# HTTP client for userinfo/introspection requests
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
# PyJWKClient for JWT verification (lazy initialization)
|
||||
self._jwks_client: PyJWKClient | None = None
|
||||
if jwks_uri:
|
||||
logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}")
|
||||
self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
||||
|
||||
# Introspection support
|
||||
if introspection_uri and client_id and client_secret:
|
||||
logger.info(f"Token introspection enabled: {introspection_uri}")
|
||||
elif introspection_uri:
|
||||
logger.warning(
|
||||
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify a bearer token using JWT verification, introspection, or userinfo endpoint.
|
||||
|
||||
This method:
|
||||
1. Checks the cache first for recent validations
|
||||
2. Attempts JWT verification if JWKS is configured and token looks like JWT
|
||||
3. Falls back to introspection for opaque tokens (if configured)
|
||||
4. Falls back to userinfo endpoint as last resort
|
||||
5. Returns AccessToken with username and scopes
|
||||
|
||||
Args:
|
||||
token: The bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None if invalid or expired
|
||||
"""
|
||||
# Check cache first
|
||||
cached = self._get_cached_token(token)
|
||||
if cached:
|
||||
logger.debug("Token found in cache")
|
||||
return cached
|
||||
|
||||
# Try JWT verification first if enabled and token looks like JWT
|
||||
is_jwt_format = self._is_jwt_format(token)
|
||||
logger.debug(
|
||||
f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}"
|
||||
)
|
||||
if self._jwks_client and is_jwt_format:
|
||||
logger.debug("Attempting JWT verification...")
|
||||
jwt_result = self._verify_jwt(token)
|
||||
if jwt_result:
|
||||
logger.info("Token validated via JWT verification")
|
||||
return jwt_result
|
||||
else:
|
||||
logger.warning("JWT verification failed, will try other methods")
|
||||
|
||||
# For opaque tokens, try introspection if available
|
||||
if self.introspection_uri and self.client_id and self.client_secret:
|
||||
logger.debug("Attempting token introspection...")
|
||||
try:
|
||||
introspection_result = await self._verify_via_introspection(token)
|
||||
if introspection_result:
|
||||
logger.info("Token validated via introspection")
|
||||
return introspection_result
|
||||
except Exception as e:
|
||||
logger.warning(f"Introspection failed: {e}")
|
||||
|
||||
# Fall back to userinfo endpoint validation (last resort)
|
||||
logger.debug("Attempting userinfo endpoint validation...")
|
||||
try:
|
||||
return await self._verify_via_userinfo(token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
def _is_jwt_format(self, token: str) -> bool:
|
||||
"""
|
||||
Check if token looks like a JWT (has 3 parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format
|
||||
"""
|
||||
return "." in token and token.count(".") == 2
|
||||
|
||||
def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify JWT token with signature validation using JWKS.
|
||||
|
||||
Args:
|
||||
token: The JWT token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None if invalid
|
||||
"""
|
||||
try:
|
||||
# Get signing key from JWKS
|
||||
assert self._jwks_client is not None # Caller should check before calling
|
||||
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Verify and decode JWT
|
||||
# Accept tokens with audience: "mcp-server" or ["mcp-server", "nextcloud"]
|
||||
# This allows:
|
||||
# 1. Tokens from MCP clients (aud: "mcp-server")
|
||||
# 2. Tokens for Nextcloud APIs (aud: "nextcloud")
|
||||
# 3. Tokens for both (aud: ["mcp-server", "nextcloud"])
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.issuer,
|
||||
audience=["mcp-server", "nextcloud"], # Accept either audience
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": True if self.issuer else False,
|
||||
"verify_aud": True, # Enable audience validation
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
|
||||
logger.debug(f"Full JWT payload: {payload}")
|
||||
|
||||
# Extract username (sub claim, with fallback to preferred_username)
|
||||
# Some OIDC providers (like Keycloak) may not include sub in access tokens
|
||||
username = payload.get("sub") or payload.get("preferred_username")
|
||||
if not username:
|
||||
logger.error(
|
||||
"No 'sub' or 'preferred_username' claim found in JWT payload"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract scopes from scope claim (space-separated string)
|
||||
scope_string = payload.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(
|
||||
f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||
)
|
||||
|
||||
# Extract expiration
|
||||
exp = payload.get("exp")
|
||||
if not exp:
|
||||
logger.warning("No 'exp' claim in JWT, using default TTL")
|
||||
exp = int(time.time() + self.cache_ttl)
|
||||
|
||||
# Cache the result
|
||||
userinfo = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||
}
|
||||
self._token_cache[token] = (userinfo, exp)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=username, # Store username in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.info("JWT token has expired")
|
||||
return None
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning(f"JWT issuer validation failed: {e}")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_introspection(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the introspection endpoint (RFC 7662).
|
||||
|
||||
This method validates opaque tokens and retrieves their scopes.
|
||||
|
||||
Args:
|
||||
token: The bearer token to introspect
|
||||
|
||||
Returns:
|
||||
AccessToken if active, None if inactive or invalid
|
||||
"""
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
response = await self._client.post(
|
||||
self.introspection_uri, # type: ignore
|
||||
data={"token": token},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
introspection_data = response.json()
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.info("Token introspection returned inactive=false")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||
)
|
||||
|
||||
# Extract username
|
||||
username = introspection_data.get("sub") or introspection_data.get(
|
||||
"username"
|
||||
)
|
||||
if not username:
|
||||
logger.error("No username found in introspection response")
|
||||
return None
|
||||
|
||||
# Extract scopes (space-separated string)
|
||||
scope_string = introspection_data.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(f"Extracted scopes from introspection: {scopes}")
|
||||
|
||||
# Extract expiration
|
||||
exp = introspection_data.get("exp")
|
||||
if exp:
|
||||
expiry = float(exp)
|
||||
else:
|
||||
logger.warning(
|
||||
"No 'exp' in introspection response, using default TTL"
|
||||
)
|
||||
expiry = time.time() + self.cache_ttl
|
||||
|
||||
# Cache the result
|
||||
cache_data = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{
|
||||
k: v
|
||||
for k, v in introspection_data.items()
|
||||
if k not in ["sub", "scope", "active"]
|
||||
},
|
||||
}
|
||||
self._token_cache[token] = (cache_data, expiry)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=introspection_data.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.warning(
|
||||
f"Token introspection failed: HTTP {response.status_code}. "
|
||||
f"This may indicate: (1) Client credentials mismatch - trying to introspect "
|
||||
f"token issued to different OAuth client, (2) Expired client credentials, "
|
||||
f"(3) Invalid token. Will fall back to userinfo endpoint. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while introspecting token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token introspection: {e}")
|
||||
return None
|
||||
|
||||
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token by calling the userinfo endpoint.
|
||||
|
||||
Args:
|
||||
token: The bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
response = await self._client.get(
|
||||
self.userinfo_uri, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
userinfo = response.json()
|
||||
logger.debug(
|
||||
f"Token validated successfully for user: {userinfo.get('sub')}"
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
expiry = time.time() + self.cache_ttl
|
||||
self._token_cache[token] = (userinfo, expiry)
|
||||
|
||||
# Create AccessToken with username in resource field (workaround for MCP SDK)
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
if not username:
|
||||
logger.error("No username found in userinfo response")
|
||||
return None
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="", # Not available from userinfo
|
||||
scopes=self._extract_scopes(userinfo),
|
||||
expires_at=int(expiry),
|
||||
resource=username, # Store username in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.info(f"Token validation failed: HTTP {response.status_code}")
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from userinfo: {response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while validating token via userinfo endpoint")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while validating token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token validation: {e}")
|
||||
return None
|
||||
|
||||
def _get_cached_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Retrieve a token from cache if not expired.
|
||||
|
||||
Args:
|
||||
token: The bearer token to look up
|
||||
|
||||
Returns:
|
||||
AccessToken if cached and valid, None otherwise
|
||||
"""
|
||||
if token not in self._token_cache:
|
||||
return None
|
||||
|
||||
userinfo, expiry = self._token_cache[token]
|
||||
|
||||
# Check if expired
|
||||
if time.time() >= expiry:
|
||||
logger.debug("Cached token expired, removing from cache")
|
||||
del self._token_cache[token]
|
||||
return None
|
||||
|
||||
# Return cached AccessToken
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="",
|
||||
scopes=self._extract_scopes(userinfo),
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
def _extract_scopes(self, userinfo: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
Extract scopes from userinfo response.
|
||||
|
||||
First attempts to read actual scopes from the 'scope' field (RFC 8693).
|
||||
If not present, infers scopes from the claims present in the response.
|
||||
|
||||
Args:
|
||||
userinfo: The userinfo response dictionary
|
||||
|
||||
Returns:
|
||||
List of scopes (actual or inferred)
|
||||
"""
|
||||
# Try to get actual scopes from userinfo response (if OIDC provider includes it)
|
||||
scope_string = userinfo.get("scope")
|
||||
if scope_string:
|
||||
scopes = scope_string.split() if isinstance(scope_string, str) else []
|
||||
if scopes:
|
||||
logger.debug(
|
||||
f"Using actual scopes from userinfo: {scopes} (scope field present)"
|
||||
)
|
||||
return scopes
|
||||
|
||||
# Fallback: Infer scopes from claims present in response
|
||||
# This maintains backward compatibility with OIDC providers that don't
|
||||
# include the scope field in userinfo responses
|
||||
logger.debug(
|
||||
"No scope field in userinfo response, inferring scopes from claims"
|
||||
)
|
||||
scopes = ["openid"] # Always present
|
||||
|
||||
if "email" in userinfo:
|
||||
scopes.append("email")
|
||||
|
||||
if any(
|
||||
key in userinfo for key in ["name", "given_name", "family_name", "picture"]
|
||||
):
|
||||
scopes.append("profile")
|
||||
|
||||
if "roles" in userinfo:
|
||||
scopes.append("roles")
|
||||
|
||||
if "groups" in userinfo:
|
||||
scopes.append("groups")
|
||||
|
||||
logger.debug(f"Inferred scopes from userinfo claims: {scopes}")
|
||||
return scopes
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the token cache."""
|
||||
self._token_cache.clear()
|
||||
logger.debug("Token cache cleared")
|
||||
|
||||
async def close(self):
|
||||
"""Cleanup resources."""
|
||||
await self._client.aclose()
|
||||
logger.debug("Token verifier closed")
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Unified Token Verifier for ADR-005 Token Audience Validation.
|
||||
|
||||
This module replaces both NextcloudTokenVerifier and ProgressiveConsentTokenVerifier
|
||||
with a single implementation that supports two compliant OAuth modes:
|
||||
|
||||
1. Multi-audience mode (default): Validates MCP audience per RFC 7519 (resource servers
|
||||
validate only their own audience). Nextcloud independently validates its own audience.
|
||||
2. Token exchange mode (opt-in): Tokens have MCP audience only, exchanged for Nextcloud tokens
|
||||
|
||||
Key Design Principles:
|
||||
- Token verification happens HERE (validates MCP audience per OAuth spec)
|
||||
- Token exchange happens in context_helper.py (when creating NextcloudClient)
|
||||
- No token passthrough allowed (complies with MCP Security Specification)
|
||||
- Token reuse IS allowed for multi-audience tokens (RFC 8707)
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
|
||||
from nextcloud_mcp_server.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnifiedTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
Unified token verifier supporting both multi-audience and token exchange modes.
|
||||
Compliant with MCP security specification - no token pass-through.
|
||||
|
||||
This verifier:
|
||||
1. Validates tokens using JWT verification with JWKS or introspection fallback
|
||||
2. Enforces proper audience validation based on configured mode
|
||||
3. Caches successful validations to avoid repeated API calls
|
||||
|
||||
Mode Selection (via ENABLE_TOKEN_EXCHANGE setting):
|
||||
- False/omit (default): Multi-audience mode - validates MCP audience only (per RFC 7519).
|
||||
Nextcloud independently validates its own audience when receiving API calls.
|
||||
- True: Exchange mode - requires MCP audience only, then exchanges for Nextcloud token
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
"""
|
||||
Initialize the unified token verifier.
|
||||
|
||||
Args:
|
||||
settings: Application settings containing OAuth configuration
|
||||
"""
|
||||
self.settings = settings
|
||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
||||
|
||||
# Common components for all modes
|
||||
self.http_client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
# JWT verification support
|
||||
self.jwks_client: PyJWKClient | None = None
|
||||
if hasattr(settings, "jwks_uri") and settings.jwks_uri:
|
||||
logger.info(f"JWT verification enabled with JWKS URI: {settings.jwks_uri}")
|
||||
self.jwks_client = PyJWKClient(settings.jwks_uri, cache_keys=True)
|
||||
|
||||
# Introspection support (for opaque tokens)
|
||||
self.introspection_uri: str | None = None
|
||||
if (
|
||||
hasattr(settings, "introspection_uri")
|
||||
and settings.introspection_uri
|
||||
and settings.oidc_client_id
|
||||
and settings.oidc_client_secret
|
||||
):
|
||||
self.introspection_uri = settings.introspection_uri
|
||||
logger.info(f"Token introspection enabled: {self.introspection_uri}")
|
||||
|
||||
# Token cache: token_hash -> (userinfo, expiry_timestamp)
|
||||
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
self.cache_ttl = 3600 # 1 hour default
|
||||
|
||||
logger.info(
|
||||
f"UnifiedTokenVerifier initialized in {self.mode} mode. "
|
||||
f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, "
|
||||
f"Nextcloud resource URI: {settings.nextcloud_resource_uri}"
|
||||
)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify token according to MCP TokenVerifier protocol.
|
||||
|
||||
Per RFC 7519, we validate only MCP audience. The mode determines what
|
||||
happens AFTER verification in context_helper.py:
|
||||
- Multi-audience mode: Use token directly (Nextcloud validates its own audience)
|
||||
- Exchange mode: Exchange for Nextcloud-audience token via RFC 8693
|
||||
|
||||
Args:
|
||||
token: Bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid with MCP audience, None otherwise
|
||||
"""
|
||||
# Check cache first
|
||||
cached = self._get_cached_token(token)
|
||||
if cached:
|
||||
logger.debug("Token found in cache")
|
||||
return cached
|
||||
|
||||
# Both modes do the same validation (MCP audience only)
|
||||
return await self._verify_mcp_audience(token)
|
||||
|
||||
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token has MCP audience.
|
||||
|
||||
Per RFC 7519 Section 4.1.3, resource servers validate only their own
|
||||
presence in the audience claim. We don't validate Nextcloud's audience -
|
||||
that's Nextcloud's responsibility when it receives the token.
|
||||
|
||||
Args:
|
||||
token: Bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid with MCP audience, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Attempt JWT verification first
|
||||
if self._is_jwt_format(token) and self.jwks_client:
|
||||
payload = await self._verify_jwt_signature(token)
|
||||
else:
|
||||
# Fall back to introspection for opaque tokens
|
||||
payload = await self._introspect_token(token)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
# Check payload is valid
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
# Validate MCP audience is present
|
||||
if not self._has_mcp_audience(payload):
|
||||
audiences = payload.get("aud", [])
|
||||
logger.error(
|
||||
f"Token rejected: Missing MCP audience. "
|
||||
f"Got {audiences}, need MCP ({self.settings.oidc_client_id} or "
|
||||
f"{self.settings.nextcloud_mcp_server_url})"
|
||||
)
|
||||
return None
|
||||
|
||||
# Log based on mode for clarity
|
||||
if self.mode == "multi-audience":
|
||||
logger.info(
|
||||
"MCP audience validated - token can be used directly "
|
||||
"(Nextcloud will validate its own audience)"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"MCP audience validated - token will be exchanged for Nextcloud access"
|
||||
)
|
||||
|
||||
return self._create_access_token(token, payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if token has MCP audience.
|
||||
|
||||
Per RFC 7519 Section 4.1.3, resource servers should only validate their own
|
||||
presence in the audience claim. We don't validate Nextcloud's audience - that's
|
||||
Nextcloud's responsibility when it receives the token.
|
||||
|
||||
Args:
|
||||
payload: Decoded token payload
|
||||
|
||||
Returns:
|
||||
True if MCP audience present, False otherwise
|
||||
"""
|
||||
audiences = payload.get("aud", [])
|
||||
if isinstance(audiences, str):
|
||||
audiences = [audiences]
|
||||
|
||||
audiences_set = set(audiences)
|
||||
|
||||
# MCP must have at least one: client_id OR server_url OR server_url/mcp
|
||||
return bool(
|
||||
self.settings.oidc_client_id in audiences_set
|
||||
or (
|
||||
self.settings.nextcloud_mcp_server_url
|
||||
and (
|
||||
self.settings.nextcloud_mcp_server_url in audiences_set
|
||||
or f"{self.settings.nextcloud_mcp_server_url}/mcp" in audiences_set
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def _is_jwt_format(self, token: str) -> bool:
|
||||
"""
|
||||
Check if token looks like a JWT (has 3 parts separated by dots).
|
||||
|
||||
Args:
|
||||
token: The token to check
|
||||
|
||||
Returns:
|
||||
True if token appears to be JWT format
|
||||
"""
|
||||
return "." in token and token.count(".") == 2
|
||||
|
||||
async def _verify_jwt_signature(self, token: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Verify JWT token with signature validation using JWKS.
|
||||
|
||||
Args:
|
||||
token: JWT token to verify
|
||||
|
||||
Returns:
|
||||
Decoded payload if valid, None if invalid
|
||||
"""
|
||||
try:
|
||||
assert self.jwks_client is not None # Caller should check before calling
|
||||
|
||||
# Get signing key from JWKS
|
||||
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
|
||||
|
||||
# Verify and decode JWT
|
||||
# Note: We don't validate audience here - that's done separately based on mode
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=self.settings.oidc_issuer
|
||||
if hasattr(self.settings, "oidc_issuer")
|
||||
else None,
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": True
|
||||
if hasattr(self.settings, "oidc_issuer")
|
||||
and self.settings.oidc_issuer
|
||||
else False,
|
||||
"verify_aud": False, # We handle audience validation separately
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(f"JWT signature verified for user: {payload.get('sub')}")
|
||||
return payload
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
logger.info("JWT token has expired")
|
||||
return None
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning(f"JWT issuer validation failed: {e}")
|
||||
return None
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning(f"JWT validation failed: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during JWT verification: {e}")
|
||||
return None
|
||||
|
||||
async def _introspect_token(self, token: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Validate token by calling the introspection endpoint (RFC 7662).
|
||||
|
||||
Args:
|
||||
token: Bearer token to introspect
|
||||
|
||||
Returns:
|
||||
Token payload if active, None if inactive or invalid
|
||||
"""
|
||||
if not self.introspection_uri:
|
||||
logger.debug("No introspection endpoint configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
response = await self.http_client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
introspection_data = response.json()
|
||||
|
||||
# Check if token is active
|
||||
if not introspection_data.get("active", False):
|
||||
logger.info("Token introspection returned inactive=false")
|
||||
return None
|
||||
|
||||
logger.debug(
|
||||
f"Token introspected successfully for user: {introspection_data.get('sub')}"
|
||||
)
|
||||
return introspection_data
|
||||
|
||||
elif response.status_code in (400, 401, 403):
|
||||
logger.warning(
|
||||
f"Token introspection failed: HTTP {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unexpected response from introspection: {response.status_code}. "
|
||||
f"Response: {response.text[:200] if response.text else 'empty'}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Timeout while introspecting token")
|
||||
return None
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Network error while introspecting token: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during token introspection: {e}")
|
||||
return None
|
||||
|
||||
def _create_access_token(
|
||||
self, token: str, payload: dict[str, Any]
|
||||
) -> AccessToken | None:
|
||||
"""
|
||||
Create AccessToken object from validated token payload.
|
||||
|
||||
Args:
|
||||
token: The bearer token
|
||||
payload: Validated token payload
|
||||
|
||||
Returns:
|
||||
AccessToken object or None if required fields missing
|
||||
"""
|
||||
# Extract username (sub claim, with fallback to preferred_username)
|
||||
username = payload.get("sub") or payload.get("preferred_username")
|
||||
if not username:
|
||||
logger.error(
|
||||
"No 'sub' or 'preferred_username' claim found in token payload"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract scopes from scope claim (space-separated string)
|
||||
scope_string = payload.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
logger.debug(
|
||||
f"Extracted scopes from token - scope claim: '{scope_string}' -> scopes list: {scopes}"
|
||||
)
|
||||
|
||||
# Extract expiration
|
||||
exp = payload.get("exp")
|
||||
if not exp:
|
||||
logger.warning("No 'exp' claim in token, using default TTL")
|
||||
exp = int(time.time() + self.cache_ttl)
|
||||
|
||||
# Cache the result
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
userinfo = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||
}
|
||||
self._token_cache[token_hash] = (userinfo, exp)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=payload.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=exp,
|
||||
resource=username, # Store username in resource field (RFC 8707)
|
||||
)
|
||||
|
||||
def _get_cached_token(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Retrieve a token from cache if not expired.
|
||||
|
||||
Args:
|
||||
token: The bearer token to look up
|
||||
|
||||
Returns:
|
||||
AccessToken if cached and valid, None otherwise
|
||||
"""
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
if token_hash not in self._token_cache:
|
||||
return None
|
||||
|
||||
userinfo, expiry = self._token_cache[token_hash]
|
||||
|
||||
# Check if expired
|
||||
if time.time() >= expiry:
|
||||
logger.debug("Cached token expired, removing from cache")
|
||||
del self._token_cache[token_hash]
|
||||
return None
|
||||
|
||||
# Return cached AccessToken
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
scope_string = userinfo.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=userinfo.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the token cache."""
|
||||
self._token_cache.clear()
|
||||
logger.debug("Token cache cleared")
|
||||
|
||||
async def close(self):
|
||||
"""Cleanup resources."""
|
||||
await self.http_client.aclose()
|
||||
logger.debug("Unified token verifier closed")
|
||||
@@ -19,6 +19,72 @@ from starlette.responses import HTMLResponse, JSONResponse
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
"""Get vector sync processing status.
|
||||
|
||||
Returns processing status information including indexed count, pending count,
|
||||
and sync status. Only available when VECTOR_SYNC_ENABLED=true.
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
Dictionary with processing status, or None if vector sync is disabled
|
||||
or components are unavailable:
|
||||
{
|
||||
"indexed_count": int, # Number of documents in Qdrant
|
||||
"pending_count": int, # Number of documents in queue
|
||||
"status": str, # "syncing" or "idle"
|
||||
}
|
||||
"""
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
if not vector_sync_enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get document queue from app state
|
||||
document_queue = getattr(request.app.state, "document_queue", None)
|
||||
if document_queue is None:
|
||||
logger.debug("document_queue not available in app state")
|
||||
return None
|
||||
|
||||
# Get pending count from queue
|
||||
pending_count = document_queue.qsize()
|
||||
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection
|
||||
count_result = await qdrant_client.count(
|
||||
collection_name=settings.qdrant_collection
|
||||
)
|
||||
indexed_count = count_result.count
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to query Qdrant for indexed count: {e}")
|
||||
# Continue with indexed_count = 0
|
||||
|
||||
# Determine status
|
||||
status = "syncing" if pending_count > 0 else "idle"
|
||||
|
||||
return {
|
||||
"indexed_count": indexed_count,
|
||||
"pending_count": pending_count,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting processing status: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
"""Get the correct userinfo endpoint based on OAuth mode.
|
||||
|
||||
@@ -141,9 +207,23 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
|
||||
|
||||
try:
|
||||
# Check if background access was granted (refresh token exists)
|
||||
# This works for both Flow 2 (elicitation) and browser login
|
||||
token_data = await storage.get_refresh_token(session_id)
|
||||
background_access_granted = token_data is not None
|
||||
|
||||
# Build background access details
|
||||
background_access_details = None
|
||||
if token_data:
|
||||
background_access_details = {
|
||||
"flow_type": token_data.get("flow_type", "unknown"),
|
||||
"provisioned_at": token_data.get("provisioned_at", "unknown"),
|
||||
"provisioning_client_id": token_data.get(
|
||||
"provisioning_client_id", "N/A"
|
||||
),
|
||||
"scopes": token_data.get("scopes", "N/A"),
|
||||
"token_audience": token_data.get("token_audience", "unknown"),
|
||||
}
|
||||
|
||||
# Retrieve cached user profile (no token operations!)
|
||||
profile_data = await storage.get_user_profile(session_id)
|
||||
|
||||
@@ -153,6 +233,7 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
|
||||
"auth_mode": "oauth",
|
||||
"session_id": session_id[:16] + "...", # Truncated for security
|
||||
"background_access_granted": background_access_granted,
|
||||
"background_access_details": background_access_details,
|
||||
}
|
||||
|
||||
# Include cached profile if available
|
||||
@@ -209,6 +290,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
user_context = await _get_user_info(request)
|
||||
|
||||
# Get vector sync processing status
|
||||
processing_status = await _get_processing_status(request)
|
||||
|
||||
# Check for error
|
||||
if "error" in user_context and user_context["error"] != "":
|
||||
# Get login URL dynamically
|
||||
@@ -291,6 +375,47 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
session_info_html = ""
|
||||
if auth_mode == "oauth" and "session_id" in user_context:
|
||||
session_id = user_context.get("session_id", "unknown")
|
||||
background_access_granted = user_context.get("background_access_granted", False)
|
||||
background_details = user_context.get("background_access_details")
|
||||
|
||||
# Build background access section
|
||||
background_html = ""
|
||||
if background_access_granted and background_details:
|
||||
flow_type = background_details.get("flow_type", "unknown")
|
||||
provisioned_at = background_details.get("provisioned_at", "unknown")
|
||||
scopes = background_details.get("scopes", "N/A")
|
||||
token_audience = background_details.get("token_audience", "unknown")
|
||||
|
||||
background_html = f"""
|
||||
<tr>
|
||||
<td><strong>Background Access</strong></td>
|
||||
<td><span style="color: #4caf50; font-weight: bold;">✓ Granted</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Flow Type</strong></td>
|
||||
<td>{flow_type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Provisioned At</strong></td>
|
||||
<td>{provisioned_at}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Token Audience</strong></td>
|
||||
<td>{token_audience}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Scopes</strong></td>
|
||||
<td><code style="font-size: 11px;">{scopes}</code></td>
|
||||
</tr>
|
||||
"""
|
||||
else:
|
||||
background_html = """
|
||||
<tr>
|
||||
<td><strong>Background Access</strong></td>
|
||||
<td><span style="color: #999;">Not Granted</span></td>
|
||||
</tr>
|
||||
"""
|
||||
|
||||
session_info_html = f"""
|
||||
<h2>Session Information</h2>
|
||||
<table>
|
||||
@@ -298,6 +423,59 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
<td><strong>Session ID</strong></td>
|
||||
<td><code>{session_id}</code></td>
|
||||
</tr>
|
||||
{background_html}
|
||||
</table>
|
||||
"""
|
||||
|
||||
# Add revoke button if background access is granted
|
||||
if background_access_granted:
|
||||
revoke_url = str(request.url_for("revoke_session_endpoint"))
|
||||
session_info_html += f"""
|
||||
<div style="margin-top: 15px;">
|
||||
<form method="post" action="{revoke_url}" onsubmit="return confirm('Are you sure you want to revoke background access? This will delete the refresh token.');">
|
||||
<button type="submit" style="padding: 8px 16px; background-color: #ff9800; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px;">
|
||||
Revoke Background Access
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Build vector sync status HTML
|
||||
vector_status_html = ""
|
||||
if processing_status:
|
||||
indexed_count = processing_status["indexed_count"]
|
||||
pending_count = processing_status["pending_count"]
|
||||
status = processing_status["status"]
|
||||
|
||||
# Format numbers with commas for readability
|
||||
indexed_count_str = f"{indexed_count:,}"
|
||||
pending_count_str = f"{pending_count:,}"
|
||||
|
||||
# Status badge color and text
|
||||
if status == "syncing":
|
||||
status_badge = (
|
||||
'<span style="color: #ff9800; font-weight: bold;">⟳ Syncing</span>'
|
||||
)
|
||||
else:
|
||||
status_badge = (
|
||||
'<span style="color: #4caf50; font-weight: bold;">✓ Idle</span>'
|
||||
)
|
||||
|
||||
vector_status_html = f"""
|
||||
<h2>Vector Sync Status</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<td><strong>Indexed Documents</strong></td>
|
||||
<td>{indexed_count_str}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Pending Documents</strong></td>
|
||||
<td>{pending_count_str}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Status</strong></td>
|
||||
<td>{status_badge}</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
|
||||
@@ -437,6 +615,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
|
||||
{host_info_html}
|
||||
{session_info_html}
|
||||
{vector_status_html}
|
||||
{idp_profile_html}
|
||||
|
||||
{f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>' if auth_mode == "oauth" else ""}
|
||||
@@ -446,3 +625,117 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def revoke_session(request: Request) -> HTMLResponse:
|
||||
"""Revoke background access (delete refresh token).
|
||||
|
||||
This endpoint allows users to revoke the refresh token that grants
|
||||
background access to Nextcloud resources. The session cookie remains
|
||||
valid for browser UI access, but background jobs will no longer work.
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
HTML response confirming revocation or showing error
|
||||
"""
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
|
||||
if not oauth_ctx:
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>OAuth mode not enabled</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
storage = oauth_ctx.get("storage")
|
||||
session_id = request.cookies.get("mcp_session")
|
||||
|
||||
if not storage or not session_id:
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>Session not found</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete the refresh token
|
||||
logger.info(f"Revoking background access for session {session_id[:16]}...")
|
||||
await storage.delete_refresh_token(session_id)
|
||||
logger.info(f"✓ Background access revoked for session {session_id[:16]}...")
|
||||
|
||||
# Redirect back to user page
|
||||
user_page_url = str(request.url_for("user_info_html"))
|
||||
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="2;url={user_page_url}">
|
||||
<title>Background Access Revoked</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}}
|
||||
.success {{
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
h1 {{
|
||||
color: #4caf50;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="success">
|
||||
<h1>✓ Background Access Revoked</h1>
|
||||
<p>Your refresh token has been deleted successfully.</p>
|
||||
<p>Browser session remains active.</p>
|
||||
<p>Redirecting back to user page...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke background access: {e}")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>Failed to revoke background access: {e}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import logging.config
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
@@ -129,20 +130,73 @@ class Settings:
|
||||
oidc_discovery_url: Optional[str] = None
|
||||
oidc_client_id: Optional[str] = None
|
||||
oidc_client_secret: Optional[str] = None
|
||||
oidc_issuer: Optional[str] = None
|
||||
|
||||
# Nextcloud settings
|
||||
nextcloud_host: Optional[str] = None
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
|
||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||
|
||||
# Token verification endpoints
|
||||
jwks_uri: Optional[str] = None
|
||||
introspection_uri: Optional[str] = None
|
||||
userinfo_uri: Optional[str] = None
|
||||
|
||||
# Progressive Consent settings (always enabled - no flag needed)
|
||||
enable_token_exchange: bool = False
|
||||
enable_offline_access: bool = False
|
||||
|
||||
# Token exchange cache settings
|
||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||
|
||||
# Token settings
|
||||
token_encryption_key: Optional[str] = None
|
||||
token_storage_db: Optional[str] = None
|
||||
|
||||
# Vector sync settings (ADR-007)
|
||||
vector_sync_enabled: bool = False
|
||||
vector_sync_scan_interval: int = 300 # seconds (5 minutes)
|
||||
vector_sync_processor_workers: int = 3
|
||||
vector_sync_queue_max_size: int = 10000
|
||||
|
||||
# Qdrant settings (mutually exclusive modes)
|
||||
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
|
||||
qdrant_api_key: Optional[str] = None
|
||||
qdrant_collection: str = "nextcloud_content"
|
||||
|
||||
# Ollama settings (for embeddings)
|
||||
ollama_base_url: Optional[str] = None
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate Qdrant configuration and set defaults."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ensure mutual exclusivity
|
||||
if self.qdrant_url and self.qdrant_location:
|
||||
raise ValueError(
|
||||
"Cannot set both QDRANT_URL and QDRANT_LOCATION. "
|
||||
"Use QDRANT_URL for network mode or QDRANT_LOCATION for local mode."
|
||||
)
|
||||
|
||||
# Default to :memory: if neither set
|
||||
if not self.qdrant_url and not self.qdrant_location:
|
||||
self.qdrant_location = ":memory:"
|
||||
logger.info("Using default Qdrant mode: in-memory (:memory:)")
|
||||
|
||||
# Warn if API key set in local mode
|
||||
if self.qdrant_location and self.qdrant_api_key:
|
||||
logger.warning(
|
||||
"QDRANT_API_KEY is set but QDRANT_LOCATION is used (local mode). "
|
||||
"API key is only relevant for network mode and will be ignored."
|
||||
)
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get application settings from environment variables.
|
||||
@@ -155,10 +209,18 @@ def get_settings() -> 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"),
|
||||
oidc_issuer=os.getenv("OIDC_ISSUER"),
|
||||
# Nextcloud settings
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||
# ADR-005: Token Audience Validation
|
||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
||||
# Token verification endpoints
|
||||
jwks_uri=os.getenv("JWKS_URI"),
|
||||
introspection_uri=os.getenv("INTROSPECTION_URI"),
|
||||
userinfo_uri=os.getenv("USERINFO_URI"),
|
||||
# Progressive Consent settings (always enabled)
|
||||
enable_token_exchange=(
|
||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||
@@ -166,7 +228,29 @@ def get_settings() -> Settings:
|
||||
enable_offline_access=(
|
||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
||||
),
|
||||
# Token exchange cache settings
|
||||
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||
# Token settings
|
||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
||||
# Vector sync settings (ADR-007)
|
||||
vector_sync_enabled=(
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
),
|
||||
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
|
||||
vector_sync_processor_workers=int(
|
||||
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
|
||||
),
|
||||
vector_sync_queue_max_size=int(
|
||||
os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000")
|
||||
),
|
||||
# Qdrant settings
|
||||
qdrant_url=os.getenv("QDRANT_URL"),
|
||||
qdrant_location=os.getenv("QDRANT_LOCATION"),
|
||||
qdrant_api_key=os.getenv("QDRANT_API_KEY"),
|
||||
qdrant_collection=os.getenv("QDRANT_COLLECTION", "nextcloud_content"),
|
||||
# Ollama settings
|
||||
ollama_base_url=os.getenv("OLLAMA_BASE_URL"),
|
||||
ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"),
|
||||
ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true",
|
||||
)
|
||||
|
||||
@@ -10,12 +10,15 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
This function handles three modes:
|
||||
ADR-005 compliant implementation supporting two 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
|
||||
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
|
||||
SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
|
||||
proper token audiences per MCP Security Best Practices specification.
|
||||
|
||||
Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced
|
||||
by the MCP server via @require_scopes decorator, not by the IdP.
|
||||
@@ -49,20 +52,22 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
# Check if token exchange is enabled
|
||||
if settings.enable_token_exchange:
|
||||
from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_session_client_from_context,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_client_from_context,
|
||||
get_session_client_from_context,
|
||||
)
|
||||
|
||||
# Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
|
||||
if settings.enable_token_exchange:
|
||||
# Mode 2: Exchange MCP token for Nextcloud token
|
||||
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||
# Now exchange it for Nextcloud audience
|
||||
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
|
||||
|
||||
# Mode 1: Multi-audience token - use directly
|
||||
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||
# Nextcloud will independently validate its own audience when receiving API calls
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
|
||||
# Unknown context type
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Embedding service package for generating vector embeddings."""
|
||||
|
||||
from .service import EmbeddingService, get_embedding_service
|
||||
from .simple_provider import SimpleEmbeddingProvider
|
||||
|
||||
__all__ = ["EmbeddingService", "get_embedding_service", "SimpleEmbeddingProvider"]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Abstract base class for embedding providers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class EmbeddingProvider(ABC):
|
||||
"""Base class for embedding providers."""
|
||||
|
||||
@abstractmethod
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
Vector embedding as list of floats
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts (optimized).
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension for this provider.
|
||||
|
||||
Returns:
|
||||
Vector dimension (e.g., 768 for nomic-embed-text)
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Ollama embedding provider."""
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from .base import EmbeddingProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaEmbeddingProvider(EmbeddingProvider):
|
||||
"""Ollama embedding provider with TLS support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
model: str = "nomic-embed-text",
|
||||
verify_ssl: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize Ollama embedding provider.
|
||||
|
||||
Args:
|
||||
base_url: Ollama API base URL (e.g., https://ollama.internal.coutinho.io:443)
|
||||
model: Embedding model name (default: nomic-embed-text)
|
||||
verify_ssl: Verify SSL certificates (default: True)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.verify_ssl = verify_ssl
|
||||
self.client = httpx.AsyncClient(verify=verify_ssl, timeout=30.0)
|
||||
self._dimension = 768 # nomic-embed-text default
|
||||
logger.info(
|
||||
f"Initialized Ollama provider: {base_url} (model={model}, verify_ssl={verify_ssl})"
|
||||
)
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
Vector embedding as list of floats
|
||||
"""
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/embeddings",
|
||||
json={"model": self.model, "prompt": text},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts (batched requests).
|
||||
|
||||
Note: Ollama doesn't have native batch API, so we send requests sequentially.
|
||||
For better performance with large batches, consider using asyncio.gather().
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
"""
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
embedding = await self.embed(text)
|
||||
embeddings.append(embedding)
|
||||
return embeddings
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension.
|
||||
|
||||
Returns:
|
||||
Vector dimension (768 for nomic-embed-text)
|
||||
"""
|
||||
return self._dimension
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client."""
|
||||
await self.client.aclose()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Embedding service with provider detection."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .base import EmbeddingProvider
|
||||
from .ollama_provider import OllamaEmbeddingProvider
|
||||
from .simple_provider import SimpleEmbeddingProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmbeddingService:
|
||||
"""Unified embedding service with automatic provider detection."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize embedding service with auto-detected provider."""
|
||||
self.provider = self._detect_provider()
|
||||
|
||||
def _detect_provider(self) -> EmbeddingProvider:
|
||||
"""
|
||||
Auto-detect available embedding provider.
|
||||
|
||||
Checks environment variables in order:
|
||||
1. OLLAMA_BASE_URL - Use Ollama provider (production)
|
||||
2. OPENAI_API_KEY - Use OpenAI provider (future)
|
||||
3. Fallback to SimpleEmbeddingProvider (testing/development)
|
||||
|
||||
Returns:
|
||||
Configured embedding provider
|
||||
"""
|
||||
# Ollama provider (production)
|
||||
ollama_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if ollama_url:
|
||||
logger.info(f"Using Ollama embedding provider: {ollama_url}")
|
||||
return OllamaEmbeddingProvider(
|
||||
base_url=ollama_url,
|
||||
model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"),
|
||||
verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true",
|
||||
)
|
||||
|
||||
# OpenAI provider (future implementation)
|
||||
# openai_key = os.getenv("OPENAI_API_KEY")
|
||||
# if openai_key:
|
||||
# return OpenAIEmbeddingProvider(api_key=openai_key)
|
||||
|
||||
# Fallback to simple provider for development/testing
|
||||
logger.warning(
|
||||
"No embedding provider configured (OLLAMA_BASE_URL or OPENAI_API_KEY not set). "
|
||||
"Using SimpleEmbeddingProvider for testing/development. "
|
||||
"For production, configure an external embedding service."
|
||||
)
|
||||
return SimpleEmbeddingProvider(dimension=384)
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
Vector embedding as list of floats
|
||||
"""
|
||||
return await self.provider.embed(text)
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
"""
|
||||
return await self.provider.embed_batch(texts)
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension.
|
||||
|
||||
Returns:
|
||||
Vector dimension
|
||||
"""
|
||||
return self.provider.get_dimension()
|
||||
|
||||
async def close(self):
|
||||
"""Close provider resources."""
|
||||
if hasattr(self.provider, "close") and callable(
|
||||
getattr(self.provider, "close")
|
||||
):
|
||||
close_method = getattr(self.provider, "close")
|
||||
await close_method()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_embedding_service: EmbeddingService | None = None
|
||||
|
||||
|
||||
def get_embedding_service() -> EmbeddingService:
|
||||
"""
|
||||
Get singleton embedding service instance.
|
||||
|
||||
Returns:
|
||||
Global EmbeddingService instance
|
||||
"""
|
||||
global _embedding_service
|
||||
if _embedding_service is None:
|
||||
_embedding_service = EmbeddingService()
|
||||
return _embedding_service
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Simple in-process embedding provider for testing.
|
||||
|
||||
This provider uses a basic TF-IDF-like approach with feature hashing to generate
|
||||
deterministic embeddings without requiring external services. Suitable for testing
|
||||
but not for production use.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
from collections import Counter
|
||||
|
||||
from .base import EmbeddingProvider
|
||||
|
||||
|
||||
class SimpleEmbeddingProvider(EmbeddingProvider):
|
||||
"""Simple deterministic embedding provider using feature hashing.
|
||||
|
||||
This implementation:
|
||||
- Tokenizes text into words
|
||||
- Uses feature hashing to map words to fixed-size vectors
|
||||
- Applies TF-IDF-like weighting
|
||||
- Normalizes vectors to unit length
|
||||
|
||||
Not suitable for production but good for testing semantic search infrastructure.
|
||||
"""
|
||||
|
||||
def __init__(self, dimension: int = 384):
|
||||
"""Initialize simple embedding provider.
|
||||
|
||||
Args:
|
||||
dimension: Embedding dimension (default: 384)
|
||||
"""
|
||||
self.dimension = dimension
|
||||
|
||||
def _tokenize(self, text: str) -> list[str]:
|
||||
"""Tokenize text into lowercase words.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
List of lowercase word tokens
|
||||
"""
|
||||
# Simple word tokenization
|
||||
text = text.lower()
|
||||
words = re.findall(r"\b\w+\b", text)
|
||||
return words
|
||||
|
||||
def _hash_word(self, word: str) -> int:
|
||||
"""Hash word to dimension index.
|
||||
|
||||
Args:
|
||||
word: Word to hash
|
||||
|
||||
Returns:
|
||||
Index in range [0, dimension)
|
||||
"""
|
||||
hash_bytes = hashlib.md5(word.encode()).digest()
|
||||
hash_int = int.from_bytes(hash_bytes[:4], byteorder="big")
|
||||
return hash_int % self.dimension
|
||||
|
||||
def _embed_single(self, text: str) -> list[float]:
|
||||
"""Generate embedding for single text.
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
Normalized embedding vector
|
||||
"""
|
||||
tokens = self._tokenize(text)
|
||||
if not tokens:
|
||||
return [0.0] * self.dimension
|
||||
|
||||
# Count term frequencies
|
||||
term_freq = Counter(tokens)
|
||||
|
||||
# Initialize vector
|
||||
vector = [0.0] * self.dimension
|
||||
|
||||
# Apply TF weighting with feature hashing
|
||||
for word, count in term_freq.items():
|
||||
idx = self._hash_word(word)
|
||||
# Simple TF weighting: log(1 + count)
|
||||
vector[idx] += math.log1p(count)
|
||||
|
||||
# Normalize to unit length
|
||||
norm = math.sqrt(sum(x * x for x in vector))
|
||||
if norm > 0:
|
||||
vector = [x / norm for x in vector]
|
||||
|
||||
return vector
|
||||
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""Generate embedding vector for text.
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
Vector embedding as list of floats
|
||||
"""
|
||||
return self._embed_single(text)
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Generate embeddings for multiple texts.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
"""
|
||||
return [self._embed_single(text) for text in texts]
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""Get embedding dimension.
|
||||
|
||||
Returns:
|
||||
Vector dimension
|
||||
"""
|
||||
return self.dimension
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Pydantic models for semantic search responses."""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .base import BaseResponse
|
||||
|
||||
|
||||
class SemanticSearchResult(BaseModel):
|
||||
"""Model for semantic search results with additional metadata."""
|
||||
|
||||
id: int = Field(description="Document ID")
|
||||
doc_type: str = Field(
|
||||
description="Document type (note, calendar_event, deck_card, etc.)"
|
||||
)
|
||||
title: str = Field(description="Document title")
|
||||
category: str = Field(
|
||||
default="", description="Document category (notes) or location (calendar)"
|
||||
)
|
||||
excerpt: str = Field(description="Excerpt from matching chunk")
|
||||
score: float = Field(description="Semantic similarity score (0-1)")
|
||||
chunk_index: int = Field(description="Index of matching chunk in document")
|
||||
total_chunks: int = Field(description="Total number of chunks in document")
|
||||
|
||||
|
||||
class SemanticSearchResponse(BaseResponse):
|
||||
"""Response model for semantic search across all indexed Nextcloud apps."""
|
||||
|
||||
results: List[SemanticSearchResult] = Field(
|
||||
description="Semantic search results with similarity scores"
|
||||
)
|
||||
query: str = Field(description="The search query used")
|
||||
total_found: int = Field(description="Total number of documents found")
|
||||
search_method: str = Field(
|
||||
default="semantic", description="Search method used (semantic or hybrid)"
|
||||
)
|
||||
|
||||
|
||||
class SamplingSearchResponse(BaseResponse):
|
||||
"""Response from semantic search with LLM-generated answer via MCP sampling.
|
||||
|
||||
This response includes both a generated natural language answer (created by
|
||||
the MCP client's LLM via sampling) and the source documents used to generate
|
||||
that answer. Users can read the answer for quick information and review
|
||||
sources for verification and deeper exploration.
|
||||
|
||||
Attributes:
|
||||
query: The original user query
|
||||
generated_answer: Natural language answer generated by client's LLM
|
||||
sources: List of semantic search results used as context
|
||||
total_found: Total number of matching documents found
|
||||
search_method: Always "semantic_sampling" for this response type
|
||||
model_used: Name of model that generated the answer (e.g., "claude-3-5-sonnet")
|
||||
stop_reason: Why generation stopped ("endTurn", "maxTokens", etc.)
|
||||
"""
|
||||
|
||||
query: str = Field(..., description="Original user query")
|
||||
generated_answer: str = Field(
|
||||
..., description="LLM-generated answer based on retrieved documents"
|
||||
)
|
||||
sources: List[SemanticSearchResult] = Field(
|
||||
default_factory=list,
|
||||
description="Source documents with excerpts and relevance scores",
|
||||
)
|
||||
total_found: int = Field(..., description="Total matching documents")
|
||||
search_method: str = Field(
|
||||
default="semantic_sampling", description="Search method used"
|
||||
)
|
||||
model_used: Optional[str] = Field(
|
||||
default=None, description="Model that generated the answer"
|
||||
)
|
||||
stop_reason: Optional[str] = Field(
|
||||
default=None, description="Reason generation stopped"
|
||||
)
|
||||
|
||||
|
||||
class VectorSyncStatusResponse(BaseResponse):
|
||||
"""Response for vector sync status.
|
||||
|
||||
Provides information about the current state of vector sync,
|
||||
including how many documents are indexed and how many are pending.
|
||||
|
||||
Attributes:
|
||||
indexed_count: Number of documents in Qdrant vector database
|
||||
pending_count: Number of documents in processing queue
|
||||
status: Current sync status ("idle" or "syncing")
|
||||
enabled: Whether vector sync is enabled
|
||||
"""
|
||||
|
||||
indexed_count: int = Field(
|
||||
default=0, description="Number of documents indexed in vector database"
|
||||
)
|
||||
pending_count: int = Field(
|
||||
default=0, description="Number of documents pending processing"
|
||||
)
|
||||
status: str = Field(
|
||||
default="disabled",
|
||||
description='Sync status: "idle", "syncing", or "disabled"',
|
||||
)
|
||||
enabled: bool = Field(default=False, description="Whether vector sync is enabled")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SemanticSearchResult",
|
||||
"SemanticSearchResponse",
|
||||
"SamplingSearchResponse",
|
||||
"VectorSyncStatusResponse",
|
||||
]
|
||||
@@ -3,6 +3,7 @@ from .contacts import configure_contacts_tools
|
||||
from .cookbook import configure_cookbook_tools
|
||||
from .deck import configure_deck_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .semantic import configure_semantic_tools
|
||||
from .sharing import configure_sharing_tools
|
||||
from .tables import configure_tables_tools
|
||||
from .webdav import configure_webdav_tools
|
||||
@@ -13,6 +14,7 @@ __all__ = [
|
||||
"configure_cookbook_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_semantic_tools",
|
||||
"configure_sharing_tools",
|
||||
"configure_tables_tools",
|
||||
"configure_webdav_tools",
|
||||
|
||||
@@ -11,16 +11,88 @@ import secrets
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"""Extract user_id from the MCP access token (Flow 1).
|
||||
|
||||
Handles both JWT and opaque tokens:
|
||||
- JWT: Decode and extract 'sub' claim
|
||||
- Opaque: Call userinfo endpoint to get 'sub'
|
||||
|
||||
Args:
|
||||
ctx: MCP context with access token
|
||||
|
||||
Returns:
|
||||
user_id extracted from token, or "default_user" as fallback
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if not access_token or not access_token.token:
|
||||
logger.warning(" ✗ No access token found via get_access_token()")
|
||||
return "default_user"
|
||||
|
||||
token = access_token.token
|
||||
is_jwt = "." in token and token.count(".") >= 2
|
||||
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
|
||||
|
||||
# Try JWT decode first
|
||||
if is_jwt:
|
||||
try:
|
||||
import jwt
|
||||
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Opaque token - call userinfo endpoint
|
||||
logger.info(" Opaque token detected, calling userinfo endpoint...")
|
||||
try:
|
||||
# Get userinfo endpoint from OIDC discovery
|
||||
oidc_discovery_uri = os.getenv(
|
||||
"OIDC_DISCOVERY_URI",
|
||||
"http://localhost:8080/.well-known/openid-configuration",
|
||||
)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
|
||||
if userinfo_endpoint:
|
||||
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
|
||||
if userinfo:
|
||||
user_id = userinfo.get("sub", "unknown")
|
||||
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
|
||||
return user_id
|
||||
else:
|
||||
logger.error(" ✗ Userinfo query failed")
|
||||
else:
|
||||
logger.error(" ✗ No userinfo_endpoint available")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Fallback
|
||||
logger.warning(" Using fallback user_id: default_user")
|
||||
return "default_user"
|
||||
|
||||
|
||||
class ProvisioningStatus(BaseModel):
|
||||
"""Status of Nextcloud provisioning for a user."""
|
||||
|
||||
@@ -57,6 +129,15 @@ class RevocationResult(BaseModel):
|
||||
message: str = Field(description="Status message for the user")
|
||||
|
||||
|
||||
class LoginConfirmation(BaseModel):
|
||||
"""Schema for login confirmation elicitation."""
|
||||
|
||||
acknowledged: bool = Field(
|
||||
default=False,
|
||||
description="Check this box after completing login at the provided URL",
|
||||
)
|
||||
|
||||
|
||||
async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningStatus:
|
||||
"""
|
||||
Check the provisioning status for Nextcloud access.
|
||||
@@ -71,14 +152,28 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
||||
Returns:
|
||||
ProvisioningStatus with current provisioning state
|
||||
"""
|
||||
logger.info(
|
||||
f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
|
||||
)
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
token_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if not token_data:
|
||||
logger.info(
|
||||
f" get_provisioning_status: ✗ No refresh token found for user_id={user_id}"
|
||||
)
|
||||
return ProvisioningStatus(is_provisioned=False)
|
||||
|
||||
logger.info(
|
||||
f" get_provisioning_status: ✓ Refresh token FOUND for user_id={user_id}"
|
||||
)
|
||||
logger.info(f" flow_type: {token_data.get('flow_type')}")
|
||||
logger.info(
|
||||
f" provisioning_client_id: {token_data.get('provisioning_client_id', 'N/A')}"
|
||||
)
|
||||
|
||||
# Convert timestamp to ISO format if present
|
||||
provisioned_at_str = None
|
||||
if token_data.get("provisioned_at"):
|
||||
@@ -106,36 +201,33 @@ def generate_oauth_url_for_flow2(
|
||||
"""
|
||||
Generate OAuth authorization URL for Flow 2 (Resource Provisioning).
|
||||
|
||||
This creates the URL that the MCP server uses to get delegated
|
||||
access to Nextcloud on behalf of the user.
|
||||
This returns the MCP server's Flow 2 authorization endpoint, which will:
|
||||
1. Generate PKCE parameters (required by Nextcloud OIDC)
|
||||
2. Store code_verifier in session
|
||||
3. Redirect to Nextcloud IdP with PKCE
|
||||
4. Handle the callback with code_verifier for token exchange
|
||||
|
||||
Args:
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
server_client_id: MCP server's OAuth client ID
|
||||
redirect_uri: Callback URL for the MCP server
|
||||
oidc_discovery_url: OIDC provider discovery URL (unused, kept for compatibility)
|
||||
server_client_id: MCP server's OAuth client ID (unused, kept for compatibility)
|
||||
redirect_uri: Callback URL for the MCP server (unused, kept for compatibility)
|
||||
state: CSRF protection state
|
||||
scopes: List of scopes to request
|
||||
scopes: List of scopes to request (unused, kept for compatibility)
|
||||
|
||||
Returns:
|
||||
Complete authorization URL for Flow 2
|
||||
MCP server's Flow 2 authorization URL with state parameter
|
||||
"""
|
||||
# Extract base URL from discovery URL
|
||||
# Format: https://example.com/.well-known/openid-configuration
|
||||
# We need: https://example.com/apps/oidc/authorize
|
||||
base_url = oidc_discovery_url.replace("/.well-known/openid-configuration", "")
|
||||
auth_endpoint = f"{base_url}/apps/oidc/authorize"
|
||||
# Use the MCP server's Flow 2 endpoint which handles PKCE internally
|
||||
# This endpoint will:
|
||||
# - Generate code_verifier and code_challenge (PKCE)
|
||||
# - Store code_verifier in session storage
|
||||
# - Redirect to Nextcloud with PKCE parameters
|
||||
# - Handle the callback with proper code_verifier
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
auth_endpoint = f"{mcp_server_url}/oauth/authorize-nextcloud"
|
||||
|
||||
# Build OAuth parameters
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": server_client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": " ".join(scopes),
|
||||
"state": state,
|
||||
# Request offline access for background operations
|
||||
"access_type": "offline",
|
||||
"prompt": "consent", # Force consent screen to show scopes
|
||||
}
|
||||
# Only pass state parameter - the endpoint handles everything else
|
||||
params = {"state": state}
|
||||
|
||||
return f"{auth_endpoint}?{urlencode(params)}"
|
||||
|
||||
@@ -190,27 +282,33 @@ async def provision_nextcloud_access(
|
||||
)
|
||||
|
||||
# Get configuration
|
||||
enable_progressive = (
|
||||
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
|
||||
enable_offline_access = (
|
||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
||||
)
|
||||
if not enable_progressive:
|
||||
if not enable_offline_access:
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=(
|
||||
"Progressive Consent is not enabled. "
|
||||
"Set ENABLE_PROGRESSIVE_CONSENT=true to use this feature."
|
||||
"Offline access is not enabled. "
|
||||
"Set ENABLE_OFFLINE_ACCESS=true to use this feature."
|
||||
),
|
||||
)
|
||||
|
||||
# Get MCP server's OAuth client credentials
|
||||
# Try environment variable first, then fall back to DCR client_id
|
||||
server_client_id = os.getenv("MCP_SERVER_CLIENT_ID")
|
||||
if not server_client_id:
|
||||
# In production, would use Dynamic Client Registration here
|
||||
# Try to get from lifespan context (DCR)
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
if hasattr(lifespan_ctx, "server_client_id"):
|
||||
server_client_id = lifespan_ctx.server_client_id
|
||||
|
||||
if not server_client_id:
|
||||
return ProvisioningResult(
|
||||
success=False,
|
||||
message=(
|
||||
"MCP server OAuth client not configured. "
|
||||
"Administrator must set MCP_SERVER_CLIENT_ID."
|
||||
"Set MCP_SERVER_CLIENT_ID environment variable or use Dynamic Client Registration."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -229,7 +327,7 @@ async def provision_nextcloud_access(
|
||||
|
||||
# Create OAuth session for Flow 2
|
||||
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
|
||||
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback-nextcloud"
|
||||
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback"
|
||||
|
||||
await storage.store_oauth_session(
|
||||
session_id=session_id,
|
||||
@@ -301,13 +399,11 @@ async def revoke_nextcloud_access(
|
||||
RevocationResult with status
|
||||
"""
|
||||
try:
|
||||
# Get user ID from context if not provided
|
||||
# Get user ID from token if not provided
|
||||
if not user_id:
|
||||
user_id = (
|
||||
ctx.context.get("user_id", "default_user") # type: ignore
|
||||
if hasattr(ctx, "context")
|
||||
else "default_user"
|
||||
)
|
||||
logger.info("Extracting user_id from access token for revoke...")
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
logger.info(f" Revoke using user_id: {user_id}")
|
||||
|
||||
# Check current status
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
@@ -390,6 +486,198 @@ async def check_provisioning_status(
|
||||
return await get_provisioning_status(ctx, user_id)
|
||||
|
||||
|
||||
async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
MCP Tool: Check if user is logged in and elicit login if needed.
|
||||
|
||||
This tool checks whether the user has completed Flow 2 (resource provisioning)
|
||||
to grant offline access to Nextcloud. If not logged in, it uses MCP elicitation
|
||||
to prompt the user to complete the login flow.
|
||||
|
||||
Args:
|
||||
ctx: MCP context with user's Flow 1 token
|
||||
user_id: Optional user identifier (extracted from token if not provided)
|
||||
|
||||
Returns:
|
||||
"yes" if logged in, or elicitation prompting for login
|
||||
"""
|
||||
try:
|
||||
# Extract user ID from the MCP access token (Flow 1 token)
|
||||
logger.info("=" * 60)
|
||||
logger.info("check_logged_in: Starting user_id extraction")
|
||||
logger.info("=" * 60)
|
||||
|
||||
if not user_id:
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
logger.info(f" Final user_id for check_logged_in: {user_id}")
|
||||
else:
|
||||
logger.info(f" user_id provided as argument: {user_id}")
|
||||
|
||||
# Check if already logged in
|
||||
logger.info(f"Checking provisioning status for user_id: {user_id}")
|
||||
status = await get_provisioning_status(ctx, user_id)
|
||||
logger.info(f" Provisioning status: is_provisioned={status.is_provisioned}")
|
||||
|
||||
if status.is_provisioned:
|
||||
logger.info(f"✓ User {user_id} is already logged in - returning 'yes'")
|
||||
logger.info("=" * 60)
|
||||
return "yes"
|
||||
|
||||
logger.info(f"✗ User {user_id} is NOT logged in - triggering elicitation")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Not logged in - generate OAuth URL for Flow 2
|
||||
enable_offline_access = (
|
||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
||||
)
|
||||
if not enable_offline_access:
|
||||
return (
|
||||
"Not logged in. Offline access is not enabled. "
|
||||
"Set ENABLE_OFFLINE_ACCESS=true to use this feature."
|
||||
)
|
||||
|
||||
# Get MCP server's OAuth client credentials
|
||||
# Try environment variable first, then fall back to DCR client_id
|
||||
server_client_id = os.getenv("MCP_SERVER_CLIENT_ID")
|
||||
if not server_client_id:
|
||||
# Try to get from lifespan context (DCR)
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
if hasattr(lifespan_ctx, "server_client_id"):
|
||||
server_client_id = lifespan_ctx.server_client_id
|
||||
|
||||
if not server_client_id:
|
||||
return (
|
||||
"Not logged in. MCP server OAuth client not configured. "
|
||||
"Set MCP_SERVER_CLIENT_ID environment variable or use Dynamic Client Registration."
|
||||
)
|
||||
|
||||
# Generate OAuth URL for Flow 2
|
||||
oidc_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||
)
|
||||
|
||||
# Generate secure state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# Store state in session for validation on callback
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
# Create OAuth session for Flow 2
|
||||
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
|
||||
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback"
|
||||
|
||||
await storage.store_oauth_session(
|
||||
session_id=session_id,
|
||||
client_redirect_uri="", # No client redirect for Flow 2
|
||||
state=state,
|
||||
flow_type="flow2",
|
||||
is_provisioning=True,
|
||||
ttl_seconds=600, # 10 minute TTL
|
||||
)
|
||||
|
||||
# Define scopes for Nextcloud access
|
||||
scopes = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access", # Critical for background operations
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
]
|
||||
|
||||
# Generate authorization URL
|
||||
auth_url = generate_oauth_url_for_flow2(
|
||||
oidc_discovery_url=oidc_discovery_url,
|
||||
server_client_id=server_client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
scopes=scopes,
|
||||
)
|
||||
|
||||
# Use elicitation to prompt user to login
|
||||
logger.info(f"Eliciting login for user {user_id} with URL: {auth_url}")
|
||||
|
||||
result = await ctx.elicit(
|
||||
message=f"Please log in to Nextcloud at the following URL:\n\n{auth_url}\n\nAfter completing the login, check the box below and click OK.",
|
||||
schema=LoginConfirmation,
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
# Check if login was successful by looking for refresh token
|
||||
# Strategy: Try multiple lookup methods to handle both flows
|
||||
logger.info("User accepted login prompt, checking for refresh token")
|
||||
logger.info(f" State parameter: {state[:16]}...")
|
||||
logger.info(f" User ID: {user_id}")
|
||||
|
||||
# First, try to find token by provisioning_client_id (Flow 2 from elicitation)
|
||||
refresh_token_data = (
|
||||
await storage.get_refresh_token_by_provisioning_client_id(state)
|
||||
)
|
||||
|
||||
if refresh_token_data:
|
||||
logger.info("✓ Refresh token found via provisioning_client_id lookup")
|
||||
logger.info(
|
||||
f" Flow type: {refresh_token_data.get('flow_type', 'unknown')}"
|
||||
)
|
||||
logger.info(
|
||||
f" Provisioned at: {refresh_token_data.get('provisioned_at', 'unknown')}"
|
||||
)
|
||||
return "yes"
|
||||
|
||||
# Fallback: Try to find token by user_id (browser login or any other flow)
|
||||
logger.info(f"✗ No token found with provisioning_client_id={state[:16]}...")
|
||||
logger.info(f" Trying fallback lookup by user_id: {user_id}")
|
||||
|
||||
refresh_token_data = await storage.get_refresh_token(user_id)
|
||||
|
||||
if refresh_token_data:
|
||||
logger.info("✓ Refresh token found via user_id lookup")
|
||||
logger.info(
|
||||
f" Flow type: {refresh_token_data.get('flow_type', 'unknown')}"
|
||||
)
|
||||
logger.info(
|
||||
f" Provisioned at: {refresh_token_data.get('provisioned_at', 'unknown')}"
|
||||
)
|
||||
logger.info(
|
||||
f" Provisioning client ID: {refresh_token_data.get('provisioning_client_id', 'NULL')}"
|
||||
)
|
||||
logger.info(
|
||||
" Note: This token was created via browser login or different flow"
|
||||
)
|
||||
return "yes"
|
||||
|
||||
# No token found by either method
|
||||
logger.warning(f"✗ No refresh token found for user {user_id}")
|
||||
logger.warning(
|
||||
f" Checked provisioning_client_id={state[:16]}... - NOT FOUND"
|
||||
)
|
||||
logger.warning(f" Checked user_id={user_id} - NOT FOUND")
|
||||
logger.warning(
|
||||
" This may indicate the user completed login but token wasn't stored"
|
||||
)
|
||||
|
||||
return (
|
||||
"Login not detected. Please ensure you completed the login "
|
||||
"at the provided URL before clicking OK."
|
||||
)
|
||||
elif result.action == "decline":
|
||||
return "Login declined by user."
|
||||
else:
|
||||
return "Login cancelled by user."
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check login status: {e}")
|
||||
return f"Error checking login status: {str(e)}"
|
||||
|
||||
|
||||
# Register MCP tools
|
||||
def register_oauth_tools(mcp):
|
||||
"""Register OAuth and provisioning tools with the MCP server."""
|
||||
@@ -428,3 +716,14 @@ def register_oauth_tools(mcp):
|
||||
ctx: Context, user_id: Optional[str] = None
|
||||
) -> ProvisioningStatus:
|
||||
return await check_provisioning_status(ctx, user_id)
|
||||
|
||||
@mcp.tool(
|
||||
name="check_logged_in",
|
||||
description=(
|
||||
"Check if you are logged in to Nextcloud. "
|
||||
"If not logged in, this tool will prompt you to complete the login flow."
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||
return await check_logged_in(ctx, user_id)
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Semantic search MCP tools using vector database."""
|
||||
|
||||
import logging
|
||||
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import (
|
||||
ErrorData,
|
||||
ModelHint,
|
||||
ModelPreferences,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.semantic import (
|
||||
SamplingSearchResponse,
|
||||
SemanticSearchResponse,
|
||||
SemanticSearchResult,
|
||||
VectorSyncStatusResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def configure_semantic_tools(mcp: FastMCP):
|
||||
"""Configure semantic search tools for MCP server."""
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search(
|
||||
query: str, ctx: Context, limit: int = 10, score_threshold: float = 0.7
|
||||
) -> SemanticSearchResponse:
|
||||
"""
|
||||
Semantic search across all indexed Nextcloud apps using vector embeddings.
|
||||
|
||||
Searches documents by meaning rather than exact keywords across notes, calendar
|
||||
events, deck cards, files, and contacts. Requires vector database synchronization
|
||||
to be enabled (VECTOR_SYNC_ENABLED=true).
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
limit: Maximum number of results to return (default: 10)
|
||||
score_threshold: Minimum similarity score (0-1, default: 0.7)
|
||||
|
||||
Returns:
|
||||
SemanticSearchResponse with matching documents and similarity scores
|
||||
"""
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Check if vector sync is enabled
|
||||
if not settings.vector_sync_enabled:
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Semantic search is not enabled. Set VECTOR_SYNC_ENABLED=true and ensure vector database is configured.",
|
||||
)
|
||||
)
|
||||
|
||||
client = await get_client(ctx)
|
||||
username = client.username
|
||||
|
||||
try:
|
||||
# Generate embedding for query
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
# Search Qdrant with user filtering
|
||||
# Note: Currently only searching notes (doc_type="note")
|
||||
# Future: Remove doc_type filter to search all apps
|
||||
qdrant_client = await get_qdrant_client()
|
||||
search_response = await qdrant_client.query_points(
|
||||
collection_name=settings.qdrant_collection,
|
||||
query=query_embedding,
|
||||
query_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match=MatchValue(value=username),
|
||||
),
|
||||
FieldCondition(
|
||||
key="doc_type",
|
||||
match=MatchValue(value="note"),
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=limit * 2, # Get extra for filtering
|
||||
score_threshold=score_threshold,
|
||||
with_payload=True,
|
||||
with_vectors=False, # Don't return vectors to save bandwidth
|
||||
)
|
||||
|
||||
# Deduplicate by document ID (multiple chunks per document)
|
||||
seen_doc_ids = set()
|
||||
results = []
|
||||
|
||||
for result in search_response.points:
|
||||
doc_id = int(result.payload["doc_id"])
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
|
||||
# Skip if we've already seen this document
|
||||
if doc_id in seen_doc_ids:
|
||||
continue
|
||||
|
||||
seen_doc_ids.add(doc_id)
|
||||
|
||||
# Verify access via Nextcloud API (dual-phase authorization)
|
||||
# Currently only supports notes, will be extended to other apps
|
||||
if doc_type == "note":
|
||||
try:
|
||||
note = await client.notes.get_note(doc_id)
|
||||
|
||||
results.append(
|
||||
SemanticSearchResult(
|
||||
id=doc_id,
|
||||
doc_type="note",
|
||||
title=result.payload["title"],
|
||||
category=note.get("category", ""),
|
||||
excerpt=result.payload["excerpt"],
|
||||
score=result.score,
|
||||
chunk_index=result.payload["chunk_index"],
|
||||
total_chunks=result.payload["total_chunks"],
|
||||
)
|
||||
)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 403:
|
||||
# User lost access, skip this document
|
||||
continue
|
||||
elif e.response.status_code == 404:
|
||||
# Document was deleted but not yet removed from vector DB
|
||||
continue
|
||||
else:
|
||||
# Log other errors but continue processing
|
||||
logger.warning(
|
||||
f"Error verifying access to note {doc_id}: {e.response.status_code}"
|
||||
)
|
||||
continue
|
||||
|
||||
return SemanticSearchResponse(
|
||||
results=results,
|
||||
query=query,
|
||||
total_found=len(results),
|
||||
search_method="semantic",
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
if "No embedding provider configured" in str(e):
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message="Embedding service not configured. Set OLLAMA_BASE_URL environment variable.",
|
||||
)
|
||||
)
|
||||
raise McpError(ErrorData(code=-1, message=f"Configuration error: {str(e)}"))
|
||||
except RequestError as e:
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Network error during search: {str(e)}")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Semantic search error: {e}", exc_info=True)
|
||||
raise McpError(
|
||||
ErrorData(code=-1, message=f"Semantic search failed: {str(e)}")
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_semantic_search_answer(
|
||||
query: str,
|
||||
ctx: Context,
|
||||
limit: int = 5,
|
||||
score_threshold: float = 0.7,
|
||||
max_answer_tokens: int = 500,
|
||||
) -> SamplingSearchResponse:
|
||||
"""
|
||||
Semantic search with LLM-generated answer using MCP sampling.
|
||||
|
||||
Retrieves relevant documents from indexed Nextcloud apps (notes, calendar, deck,
|
||||
files, contacts) using vector similarity search, then uses MCP sampling to request
|
||||
the client's LLM to generate a natural language answer based on the retrieved context.
|
||||
|
||||
This tool combines the power of semantic search (finding relevant content across
|
||||
all your Nextcloud apps) with LLM generation (synthesizing that content into
|
||||
coherent answers). The generated answer includes citations to specific documents
|
||||
with their types, allowing users to verify claims and explore sources.
|
||||
|
||||
The LLM generation happens client-side via MCP sampling. The MCP client
|
||||
controls which model is used, who pays for it, and whether to prompt the
|
||||
user for approval. This keeps the server simple (no LLM API keys needed)
|
||||
while giving users full control over their LLM interactions.
|
||||
|
||||
Args:
|
||||
query: Natural language question to answer (e.g., "What are my Q1 objectives?" or "When is my next dentist appointment?")
|
||||
ctx: MCP context for session access
|
||||
limit: Maximum number of documents to retrieve (default: 5)
|
||||
score_threshold: Minimum similarity score 0-1 (default: 0.7)
|
||||
max_answer_tokens: Maximum tokens for generated answer (default: 500)
|
||||
|
||||
Returns:
|
||||
SamplingSearchResponse containing:
|
||||
- generated_answer: Natural language answer with citations
|
||||
- sources: List of documents with excerpts and relevance scores
|
||||
- model_used: Which model generated the answer
|
||||
- stop_reason: Why generation stopped
|
||||
|
||||
Note: Requires MCP client to support sampling. If sampling is unavailable,
|
||||
the tool gracefully degrades to returning documents with an explanation.
|
||||
The client may prompt the user to approve the sampling request.
|
||||
|
||||
Examples:
|
||||
>>> # Query about objectives across multiple apps
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="What are my Q1 2025 project goals?",
|
||||
... ctx=ctx
|
||||
... )
|
||||
>>> print(result.generated_answer)
|
||||
"Based on Document 1 (note: Project Kickoff), Document 2 (calendar event:
|
||||
Q1 Planning Meeting), and Document 3 (deck card: Implement semantic search),
|
||||
your main goals are: 1) Improve semantic search accuracy by 20%,
|
||||
2) Deploy new embedding model, 3) Reduce indexing latency..."
|
||||
|
||||
>>> # Query about appointments
|
||||
>>> result = await nc_semantic_search_answer(
|
||||
... query="When is my next dentist appointment?",
|
||||
... ctx=ctx,
|
||||
... limit=10
|
||||
... )
|
||||
>>> len(result.sources) # Calendar events and related notes
|
||||
3
|
||||
"""
|
||||
# 1. Retrieve relevant documents via existing semantic search
|
||||
search_response = await nc_semantic_search(
|
||||
query=query,
|
||||
ctx=ctx,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
# 2. Handle no results case - don't waste a sampling call
|
||||
if not search_response.results:
|
||||
logger.debug(f"No documents found for query: {query}")
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer="No relevant documents found in your Nextcloud content for this query.",
|
||||
sources=[],
|
||||
total_found=0,
|
||||
search_method="semantic_sampling",
|
||||
success=True,
|
||||
)
|
||||
|
||||
# 3. Construct context from retrieved documents
|
||||
context_parts = []
|
||||
for idx, result in enumerate(search_response.results, 1):
|
||||
context_parts.append(
|
||||
f"[Document {idx}]\n"
|
||||
f"Type: {result.doc_type}\n"
|
||||
f"Title: {result.title}\n"
|
||||
f"Category: {result.category}\n"
|
||||
f"Excerpt: {result.excerpt}\n"
|
||||
f"Relevance Score: {result.score:.2f}\n"
|
||||
)
|
||||
|
||||
context = "\n".join(context_parts)
|
||||
|
||||
# 4. Construct prompt - reuse user's query, add context and instructions
|
||||
prompt = (
|
||||
f"{query}\n\n"
|
||||
f"Here are relevant documents from Nextcloud (notes, calendar events, deck cards, files, contacts):\n\n"
|
||||
f"{context}\n\n"
|
||||
f"Based on the documents above, please provide a comprehensive answer. "
|
||||
f"Cite the document numbers when referencing specific information."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Requesting sampling for query: {query} "
|
||||
f"({len(search_response.results)} documents retrieved)"
|
||||
)
|
||||
|
||||
# 5. Request LLM completion via MCP sampling
|
||||
try:
|
||||
sampling_result = await ctx.session.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text=prompt),
|
||||
)
|
||||
],
|
||||
max_tokens=max_answer_tokens,
|
||||
temperature=0.7,
|
||||
model_preferences=ModelPreferences(
|
||||
hints=[ModelHint(name="claude-3-5-sonnet")],
|
||||
intelligencePriority=0.8,
|
||||
speedPriority=0.5,
|
||||
),
|
||||
include_context="thisServer",
|
||||
)
|
||||
|
||||
# 6. Extract answer from sampling response
|
||||
if sampling_result.content.type == "text":
|
||||
generated_answer = sampling_result.content.text
|
||||
else:
|
||||
# Handle non-text responses (shouldn't happen for text prompts)
|
||||
generated_answer = f"Received non-text response of type: {sampling_result.content.type}"
|
||||
logger.warning(
|
||||
f"Unexpected content type from sampling: {sampling_result.content.type}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Sampling successful: model={sampling_result.model}, "
|
||||
f"stop_reason={sampling_result.stopReason}"
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=generated_answer,
|
||||
sources=search_response.results,
|
||||
total_found=search_response.total_found,
|
||||
search_method="semantic_sampling",
|
||||
model_used=sampling_result.model,
|
||||
stop_reason=sampling_result.stopReason,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback: Return documents without generated answer
|
||||
logger.warning(
|
||||
f"Sampling failed ({type(e).__name__}: {e}), "
|
||||
f"returning search results only"
|
||||
)
|
||||
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=(
|
||||
f"[Sampling unavailable: {str(e)}]\n\n"
|
||||
f"Found {search_response.total_found} relevant documents. "
|
||||
f"Please review the sources below."
|
||||
),
|
||||
sources=search_response.results,
|
||||
total_found=search_response.total_found,
|
||||
search_method="semantic_sampling_fallback",
|
||||
success=True,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@require_scopes("semantic:read")
|
||||
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
||||
"""Get the current vector sync status.
|
||||
|
||||
Returns information about the vector sync process, including:
|
||||
- Number of documents indexed in the vector database
|
||||
- Number of documents pending processing
|
||||
- Current sync status (idle, syncing, or disabled)
|
||||
|
||||
This is useful for determining when vector indexing is complete
|
||||
after creating or updating content across all indexed apps.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = (
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
|
||||
if not vector_sync_enabled:
|
||||
return VectorSyncStatusResponse(
|
||||
indexed_count=0,
|
||||
pending_count=0,
|
||||
status="disabled",
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
try:
|
||||
# Get document receive stream from lifespan context
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
document_receive_stream = getattr(
|
||||
lifespan_ctx, "document_receive_stream", None
|
||||
)
|
||||
|
||||
if document_receive_stream is None:
|
||||
logger.debug(
|
||||
"document_receive_stream not available in lifespan context"
|
||||
)
|
||||
return VectorSyncStatusResponse(
|
||||
indexed_count=0,
|
||||
pending_count=0,
|
||||
status="unknown",
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
# Get pending count from stream statistics
|
||||
stream_stats = document_receive_stream.statistics()
|
||||
pending_count = stream_stats.current_buffer_used
|
||||
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection
|
||||
count_result = await qdrant_client.count(
|
||||
collection_name=settings.qdrant_collection
|
||||
)
|
||||
indexed_count = count_result.count
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to query Qdrant for indexed count: {e}")
|
||||
# Continue with indexed_count = 0
|
||||
|
||||
# Determine status
|
||||
status = "syncing" if pending_count > 0 else "idle"
|
||||
|
||||
return VectorSyncStatusResponse(
|
||||
indexed_count=indexed_count,
|
||||
pending_count=pending_count,
|
||||
status=status,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting vector sync status: {e}")
|
||||
raise McpError(
|
||||
ErrorData(
|
||||
code=-1,
|
||||
message=f"Failed to retrieve vector sync status: {str(e)}",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Vector database and background sync package."""
|
||||
|
||||
from .document_chunker import DocumentChunker
|
||||
from .processor import process_document, processor_task
|
||||
from .qdrant_client import get_qdrant_client
|
||||
from .scanner import DocumentTask, scan_user_documents, scanner_task
|
||||
|
||||
__all__ = [
|
||||
"get_qdrant_client",
|
||||
"DocumentChunker",
|
||||
"scanner_task",
|
||||
"scan_user_documents",
|
||||
"DocumentTask",
|
||||
"processor_task",
|
||||
"process_document",
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Document chunking for large texts."""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentChunker:
|
||||
"""Chunk large documents for optimal embedding."""
|
||||
|
||||
def __init__(self, chunk_size: int = 512, overlap: int = 50):
|
||||
"""
|
||||
Initialize document chunker.
|
||||
|
||||
Args:
|
||||
chunk_size: Number of words per chunk (default: 512)
|
||||
overlap: Number of overlapping words between chunks (default: 50)
|
||||
"""
|
||||
self.chunk_size = chunk_size
|
||||
self.overlap = overlap
|
||||
|
||||
def chunk_text(self, content: str) -> list[str]:
|
||||
"""
|
||||
Split text into overlapping chunks.
|
||||
|
||||
Uses simple word-based chunking with configurable overlap to preserve
|
||||
context across chunk boundaries.
|
||||
|
||||
Args:
|
||||
content: Text content to chunk
|
||||
|
||||
Returns:
|
||||
List of text chunks (may be single item if content is small)
|
||||
"""
|
||||
# Simple word-based chunking
|
||||
words = content.split()
|
||||
|
||||
if len(words) <= self.chunk_size:
|
||||
return [content]
|
||||
|
||||
chunks = []
|
||||
start = 0
|
||||
|
||||
while start < len(words):
|
||||
end = start + self.chunk_size
|
||||
chunk_words = words[start:end]
|
||||
chunks.append(" ".join(chunk_words))
|
||||
start = end - self.overlap
|
||||
|
||||
logger.debug(f"Chunked document into {len(chunks)} chunks ({len(words)} words)")
|
||||
return chunks
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Processor task for vector database synchronization.
|
||||
|
||||
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import anyio
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream
|
||||
from httpx import HTTPStatusError
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def processor_task(
|
||||
worker_id: int,
|
||||
receive_stream: MemoryObjectReceiveStream[DocumentTask],
|
||||
shutdown_event: anyio.Event,
|
||||
nc_client: NextcloudClient,
|
||||
user_id: str,
|
||||
):
|
||||
"""
|
||||
Process documents from stream concurrently.
|
||||
|
||||
Each processor task runs in a loop:
|
||||
1. Receive document from stream (with timeout)
|
||||
2. Fetch content from Nextcloud
|
||||
3. Tokenize and chunk text
|
||||
4. Generate embeddings (I/O bound - external API)
|
||||
5. Upload vectors to Qdrant
|
||||
|
||||
Multiple processors run concurrently for I/O parallelism.
|
||||
|
||||
Args:
|
||||
worker_id: Worker identifier for logging
|
||||
receive_stream: Stream to receive documents from
|
||||
shutdown_event: Event signaling shutdown
|
||||
nc_client: Authenticated Nextcloud client
|
||||
user_id: User being processed
|
||||
"""
|
||||
logger.info(f"Processor {worker_id} started")
|
||||
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Get document with timeout (allows checking shutdown)
|
||||
with anyio.fail_after(1.0):
|
||||
doc_task = await receive_stream.receive()
|
||||
|
||||
# Process document
|
||||
await process_document(doc_task, nc_client)
|
||||
|
||||
except TimeoutError:
|
||||
# No documents available, continue
|
||||
continue
|
||||
|
||||
except anyio.EndOfStream:
|
||||
# Scanner finished and closed stream, exit gracefully
|
||||
logger.info(f"Processor {worker_id}: Scanner finished, exiting")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Processor {worker_id} error processing "
|
||||
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
# Continue to next document (no task_done() needed with streams)
|
||||
|
||||
logger.info(f"Processor {worker_id} stopped")
|
||||
|
||||
|
||||
async def process_document(doc_task: DocumentTask, nc_client: NextcloudClient):
|
||||
"""
|
||||
Process a single document: fetch, tokenize, embed, store in Qdrant.
|
||||
|
||||
Implements retry logic with exponential backoff for transient failures.
|
||||
|
||||
Args:
|
||||
doc_task: Document task to process
|
||||
nc_client: Authenticated Nextcloud client
|
||||
"""
|
||||
logger.debug(
|
||||
f"Processing {doc_task.doc_type}_{doc_task.doc_id} "
|
||||
f"for {doc_task.user_id} ({doc_task.operation})"
|
||||
)
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Handle deletion
|
||||
if doc_task.operation == "delete":
|
||||
await qdrant_client.delete(
|
||||
collection_name=settings.qdrant_collection,
|
||||
points_selector=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match=MatchValue(value=doc_task.user_id),
|
||||
),
|
||||
FieldCondition(
|
||||
key="doc_id",
|
||||
match=MatchValue(value=doc_task.doc_id),
|
||||
),
|
||||
FieldCondition(
|
||||
key="doc_type",
|
||||
match=MatchValue(value=doc_task.doc_type),
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Deleted {doc_task.doc_type}_{doc_task.doc_id} for {doc_task.user_id}"
|
||||
)
|
||||
return
|
||||
|
||||
# Handle indexing with retry
|
||||
max_retries = 3
|
||||
retry_delay = 1.0
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await _index_document(doc_task, nc_client, qdrant_client)
|
||||
return # Success
|
||||
|
||||
except (HTTPStatusError, Exception) as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
f"Retry {attempt + 1}/{max_retries} for "
|
||||
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}"
|
||||
)
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay *= 2 # Exponential backoff
|
||||
else:
|
||||
logger.error(
|
||||
f"Failed to index {doc_task.doc_type}_{doc_task.doc_id} "
|
||||
f"after {max_retries} retries: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def _index_document(
|
||||
doc_task: DocumentTask, nc_client: NextcloudClient, qdrant_client
|
||||
):
|
||||
"""
|
||||
Index a single document (called by process_document with retry).
|
||||
|
||||
Args:
|
||||
doc_task: Document task to index
|
||||
nc_client: Authenticated Nextcloud client
|
||||
qdrant_client: Qdrant client instance
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Fetch document content
|
||||
if doc_task.doc_type == "note":
|
||||
document = await nc_client.notes.get_note(int(doc_task.doc_id))
|
||||
content = f"{document['title']}\n\n{document['content']}"
|
||||
title = document["title"]
|
||||
etag = document.get("etag", "")
|
||||
else:
|
||||
raise ValueError(f"Unsupported doc_type: {doc_task.doc_type}")
|
||||
|
||||
# Tokenize and chunk
|
||||
chunker = DocumentChunker(chunk_size=512, overlap=50)
|
||||
chunks = chunker.chunk_text(content)
|
||||
|
||||
# Generate embeddings (I/O bound - external API call)
|
||||
embedding_service = get_embedding_service()
|
||||
embeddings = await embedding_service.embed_batch(chunks)
|
||||
|
||||
# Prepare Qdrant points
|
||||
indexed_at = int(time.time())
|
||||
points = []
|
||||
|
||||
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
|
||||
# Generate deterministic UUID for point ID
|
||||
# Using uuid5 with DNS namespace and combining doc info
|
||||
point_name = f"{doc_task.doc_type}:{doc_task.doc_id}:chunk:{i}"
|
||||
point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_name))
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=point_id,
|
||||
vector=embedding,
|
||||
payload={
|
||||
"user_id": doc_task.user_id,
|
||||
"doc_id": doc_task.doc_id,
|
||||
"doc_type": doc_task.doc_type,
|
||||
"title": title,
|
||||
"excerpt": chunk[:200],
|
||||
"indexed_at": indexed_at,
|
||||
"modified_at": doc_task.modified_at,
|
||||
"etag": etag,
|
||||
"chunk_index": i,
|
||||
"total_chunks": len(chunks),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Upsert to Qdrant
|
||||
await qdrant_client.upsert(
|
||||
collection_name=settings.qdrant_collection,
|
||||
points=points,
|
||||
wait=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Indexed {doc_task.doc_type}_{doc_task.doc_id} for {doc_task.user_id} "
|
||||
f"({len(chunks)} chunks)"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Qdrant client wrapper."""
|
||||
|
||||
import logging
|
||||
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import Distance, VectorParams
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_qdrant_client: AsyncQdrantClient | None = None
|
||||
|
||||
|
||||
async def get_qdrant_client() -> AsyncQdrantClient:
|
||||
"""
|
||||
Get singleton Qdrant client instance.
|
||||
|
||||
Automatically creates collection on first use if it doesn't exist.
|
||||
|
||||
Supports three Qdrant modes:
|
||||
- Network mode: QDRANT_URL set (e.g., http://qdrant:6333)
|
||||
- In-memory mode: QDRANT_LOCATION=:memory: (default if nothing configured)
|
||||
- Persistent local mode: QDRANT_LOCATION=/path/to/data
|
||||
|
||||
Returns:
|
||||
Configured AsyncQdrantClient instance
|
||||
|
||||
Raises:
|
||||
Exception: If Qdrant connection fails or collection creation fails
|
||||
"""
|
||||
global _qdrant_client
|
||||
|
||||
if _qdrant_client is None:
|
||||
settings = get_settings()
|
||||
|
||||
# Detect mode and initialize client accordingly
|
||||
if settings.qdrant_url:
|
||||
# Network mode
|
||||
logger.info(f"Using Qdrant network mode: {settings.qdrant_url}")
|
||||
_qdrant_client = AsyncQdrantClient(
|
||||
url=settings.qdrant_url,
|
||||
api_key=settings.qdrant_api_key,
|
||||
timeout=30,
|
||||
)
|
||||
elif settings.qdrant_location:
|
||||
# Local mode (either :memory: or persistent path)
|
||||
if settings.qdrant_location == ":memory:":
|
||||
logger.info("Using Qdrant in-memory mode: :memory:")
|
||||
_qdrant_client = AsyncQdrantClient(":memory:")
|
||||
else:
|
||||
# Persistent local mode - use path parameter
|
||||
logger.info(f"Using Qdrant persistent mode: {settings.qdrant_location}")
|
||||
_qdrant_client = AsyncQdrantClient(path=settings.qdrant_location)
|
||||
else:
|
||||
# Should not happen due to __post_init__ validation, but handle gracefully
|
||||
logger.warning("No Qdrant mode configured, defaulting to :memory:")
|
||||
_qdrant_client = AsyncQdrantClient(":memory:")
|
||||
|
||||
# Ensure collection exists
|
||||
collection_name = settings.qdrant_collection
|
||||
|
||||
# Import here to avoid circular dependency
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
dimension = embedding_service.get_dimension()
|
||||
|
||||
try:
|
||||
await _qdrant_client.get_collection(collection_name)
|
||||
logger.info(f"Using existing Qdrant collection: {collection_name}")
|
||||
except Exception:
|
||||
# Collection doesn't exist, create it
|
||||
await _qdrant_client.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=VectorParams(
|
||||
size=dimension,
|
||||
distance=Distance.COSINE,
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
f"Created Qdrant collection: {collection_name} "
|
||||
f"(dimension={dimension}, distance=COSINE)"
|
||||
)
|
||||
|
||||
return _qdrant_client
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Scanner task for vector database synchronization.
|
||||
|
||||
Periodically scans enabled users' content and queues changed documents for processing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import anyio
|
||||
from anyio.streams.memory import MemoryObjectSendStream
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentTask:
|
||||
"""Document task for processing queue."""
|
||||
|
||||
user_id: str
|
||||
doc_id: str
|
||||
doc_type: str # "note", "file", "calendar"
|
||||
operation: str # "index" or "delete"
|
||||
modified_at: int
|
||||
|
||||
|
||||
# Track documents potentially deleted (grace period before actual deletion)
|
||||
# Format: {(user_id, doc_id): first_missing_timestamp}
|
||||
_potentially_deleted: dict[tuple[str, str], float] = {}
|
||||
|
||||
|
||||
async def scanner_task(
|
||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||
shutdown_event: anyio.Event,
|
||||
wake_event: anyio.Event,
|
||||
nc_client: NextcloudClient,
|
||||
user_id: str,
|
||||
):
|
||||
"""
|
||||
Periodic scanner that detects changed documents for enabled user.
|
||||
|
||||
For BasicAuth mode, scans a single user with credentials available at runtime.
|
||||
|
||||
Args:
|
||||
send_stream: Stream to send changed documents to processors
|
||||
shutdown_event: Event signaling shutdown
|
||||
wake_event: Event to trigger immediate scan
|
||||
nc_client: Authenticated Nextcloud client
|
||||
user_id: User to scan
|
||||
"""
|
||||
logger.info(f"Scanner task started for user: {user_id}")
|
||||
settings = get_settings()
|
||||
|
||||
async with send_stream:
|
||||
while not shutdown_event.is_set():
|
||||
try:
|
||||
# Scan user documents
|
||||
await scan_user_documents(
|
||||
user_id=user_id,
|
||||
send_stream=send_stream,
|
||||
nc_client=nc_client,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scanner error: {e}", exc_info=True)
|
||||
|
||||
# Sleep until next interval or wake event
|
||||
try:
|
||||
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
||||
# Wait for wake event or shutdown (whichever comes first)
|
||||
await wake_event.wait()
|
||||
except anyio.get_cancelled_exc_class():
|
||||
# Shutdown, exit loop
|
||||
break
|
||||
|
||||
logger.info("Scanner task stopped - stream closed")
|
||||
|
||||
|
||||
async def scan_user_documents(
|
||||
user_id: str,
|
||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||
nc_client: NextcloudClient,
|
||||
initial_sync: bool = False,
|
||||
):
|
||||
"""
|
||||
Scan a single user's documents and send changes to processor stream.
|
||||
|
||||
Args:
|
||||
user_id: User to scan
|
||||
send_stream: Stream to send changed documents to processors
|
||||
nc_client: Authenticated Nextcloud client
|
||||
initial_sync: If True, send all documents (first-time sync)
|
||||
"""
|
||||
logger.info(f"Scanning documents for user: {user_id}")
|
||||
|
||||
# Fetch all notes from Nextcloud
|
||||
notes = [note async for note in nc_client.notes.get_all_notes()]
|
||||
logger.debug(f"Found {len(notes)} notes for {user_id}")
|
||||
|
||||
if initial_sync:
|
||||
# Send everything on first sync
|
||||
for note in notes:
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=str(note["id"]),
|
||||
doc_type="note",
|
||||
operation="index",
|
||||
modified_at=note["modified"],
|
||||
)
|
||||
)
|
||||
logger.info(f"Sent {len(notes)} documents for initial sync: {user_id}")
|
||||
return
|
||||
|
||||
# Get indexed state from Qdrant
|
||||
qdrant_client = await get_qdrant_client()
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=get_settings().qdrant_collection,
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="note")),
|
||||
]
|
||||
),
|
||||
with_payload=["doc_id", "indexed_at"],
|
||||
with_vectors=False,
|
||||
limit=10000,
|
||||
)
|
||||
|
||||
indexed_docs = {
|
||||
point.payload["doc_id"]: point.payload["indexed_at"]
|
||||
for point in scroll_result[0]
|
||||
}
|
||||
|
||||
logger.debug(f"Found {len(indexed_docs)} indexed documents in Qdrant")
|
||||
|
||||
# Compare and queue changes
|
||||
queued = 0
|
||||
nextcloud_doc_ids = {str(note["id"]) for note in notes}
|
||||
|
||||
for note in notes:
|
||||
doc_id = str(note["id"])
|
||||
indexed_at = indexed_docs.get(doc_id)
|
||||
|
||||
# If document reappeared, remove from potentially_deleted
|
||||
doc_key = (user_id, doc_id)
|
||||
if doc_key in _potentially_deleted:
|
||||
logger.debug(
|
||||
f"Document {doc_id} reappeared, removing from deletion grace period"
|
||||
)
|
||||
del _potentially_deleted[doc_key]
|
||||
|
||||
# Send if never indexed or modified since last index
|
||||
if indexed_at is None or note["modified"] > indexed_at:
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="note",
|
||||
operation="index",
|
||||
modified_at=note["modified"],
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
|
||||
# Check for deleted documents (in Qdrant but not in Nextcloud)
|
||||
# Use grace period: only delete after 2 consecutive scans confirm absence
|
||||
settings = get_settings()
|
||||
grace_period = settings.vector_sync_scan_interval * 1.5 # Allow 1.5 scan intervals
|
||||
current_time = time.time()
|
||||
|
||||
for doc_id in indexed_docs:
|
||||
if doc_id not in nextcloud_doc_ids:
|
||||
doc_key = (user_id, doc_id)
|
||||
|
||||
if doc_key in _potentially_deleted:
|
||||
# Already marked as potentially deleted, check if grace period elapsed
|
||||
first_missing_time = _potentially_deleted[doc_key]
|
||||
time_missing = current_time - first_missing_time
|
||||
|
||||
if time_missing >= grace_period:
|
||||
# Grace period elapsed, send for deletion
|
||||
logger.info(
|
||||
f"Document {doc_id} missing for {time_missing:.1f}s "
|
||||
f"(>{grace_period:.1f}s grace period), sending deletion"
|
||||
)
|
||||
await send_stream.send(
|
||||
DocumentTask(
|
||||
user_id=user_id,
|
||||
doc_id=doc_id,
|
||||
doc_type="note",
|
||||
operation="delete",
|
||||
modified_at=0,
|
||||
)
|
||||
)
|
||||
queued += 1
|
||||
# Remove from tracking after sending deletion
|
||||
del _potentially_deleted[doc_key]
|
||||
else:
|
||||
logger.debug(
|
||||
f"Document {doc_id} still missing "
|
||||
f"({time_missing:.1f}s/{grace_period:.1f}s grace period)"
|
||||
)
|
||||
else:
|
||||
# First time missing, add to grace period tracking
|
||||
logger.debug(
|
||||
f"Document {doc_id} missing for first time, starting grace period"
|
||||
)
|
||||
_potentially_deleted[doc_key] = current_time
|
||||
|
||||
if queued > 0:
|
||||
logger.info(f"Sent {queued} documents for incremental sync: {user_id}")
|
||||
else:
|
||||
logger.debug(f"No changes detected for {user_id}")
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.24.0"
|
||||
version = "0.26.1"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.19,<1.20)",
|
||||
"mcp[cli] (>=1.21,<1.22)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=12.0.0,<12.1.0)",
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
@@ -21,6 +21,7 @@ dependencies = [
|
||||
"pyjwt[crypto]>=2.8.0",
|
||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||
"authlib>=1.6.5",
|
||||
"qdrant-client>=1.7.0",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
|
||||
+220
-1
@@ -8,7 +8,9 @@ import httpx
|
||||
import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ClientSession
|
||||
from mcp.client.session import RequestContext
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
@@ -110,6 +112,7 @@ async def create_mcp_client_session(
|
||||
url: str,
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
elicitation_callback: Any = None,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Factory function to create an MCP client session with proper lifecycle management.
|
||||
@@ -127,6 +130,8 @@ async def create_mcp_client_session(
|
||||
url: MCP server URL (e.g., "http://localhost:8000/mcp")
|
||||
token: Optional OAuth access token for Bearer authentication
|
||||
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
|
||||
elicitation_callback: Optional callback for handling elicitation requests.
|
||||
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
@@ -149,7 +154,9 @@ async def create_mcp_client_session(
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=elicitation_callback
|
||||
) as session:
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
yield session
|
||||
@@ -251,6 +258,163 @@ async def nc_mcp_oauth_jwt_client(
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def nc_mcp_oauth_client_with_elicitation(
|
||||
anyio_backend,
|
||||
playwright_oauth_token: str,
|
||||
browser,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session with elicitation callback support.
|
||||
|
||||
This fixture enables REAL elicitation testing by providing a callback that:
|
||||
1. Extracts OAuth URL from elicitation message
|
||||
2. Uses Playwright to complete OAuth flow automatically
|
||||
3. Returns acceptance to confirm completion
|
||||
|
||||
This allows testing the complete login elicitation flow (ADR-006) end-to-end,
|
||||
verifying that:
|
||||
- The check_logged_in tool triggers elicitation for unauthenticated users
|
||||
- The OAuth flow completes successfully via automated browser
|
||||
- Refresh token is stored after OAuth completion
|
||||
- The tool returns "yes" after successful login
|
||||
|
||||
Uses function scope to allow each test to have independent elicitation state.
|
||||
"""
|
||||
# Get credentials from environment
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
if not all([username, password]):
|
||||
pytest.skip(
|
||||
"Elicitation test requires NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD"
|
||||
)
|
||||
|
||||
# Track whether elicitation was triggered (for test validation)
|
||||
elicitation_triggered = {"count": 0}
|
||||
|
||||
async def elicitation_callback(
|
||||
context: RequestContext[ClientSession, Any],
|
||||
params: ElicitRequestParams,
|
||||
) -> ElicitResult | ErrorData:
|
||||
"""Handle elicitation by completing OAuth flow with Playwright."""
|
||||
elicitation_triggered["count"] += 1
|
||||
|
||||
logger.info("🎯 Elicitation callback invoked!")
|
||||
logger.info(f" Message: {params.message[:100]}...")
|
||||
logger.info(f" Schema: {params.schema}")
|
||||
|
||||
# Extract OAuth URL from elicitation message
|
||||
import re
|
||||
|
||||
url_pattern = r"https?://[^\s]+"
|
||||
urls = re.findall(url_pattern, params.message)
|
||||
|
||||
if not urls:
|
||||
error_msg = "No URL found in elicitation message"
|
||||
logger.error(f"❌ {error_msg}")
|
||||
return ErrorData(code=-32602, message=error_msg)
|
||||
|
||||
oauth_url = urls[0]
|
||||
logger.info(f" Extracted URL: {oauth_url}")
|
||||
|
||||
# Complete OAuth flow with Playwright
|
||||
page = await browser.new_page()
|
||||
try:
|
||||
logger.info("🌐 Navigating to OAuth URL...")
|
||||
await page.goto(oauth_url, timeout=60000)
|
||||
|
||||
current_url = page.url
|
||||
logger.info(f" Current URL after navigation: {current_url}")
|
||||
|
||||
# Handle login form if present
|
||||
if "/login" in current_url or "/index.php/login" in current_url:
|
||||
logger.info("🔐 Login page detected, filling credentials...")
|
||||
await page.wait_for_selector('input[name="user"]', timeout=10000)
|
||||
await page.fill('input[name="user"]', username)
|
||||
await page.fill('input[name="password"]', password)
|
||||
await page.click('button[type="submit"]')
|
||||
await page.wait_for_load_state("networkidle", timeout=60000)
|
||||
logger.info(" ✓ Login completed")
|
||||
|
||||
# Handle consent screen if present
|
||||
try:
|
||||
logger.info(f" Current URL before consent: {page.url}")
|
||||
consent_handled = await _handle_oauth_consent_screen(page, username)
|
||||
if consent_handled:
|
||||
logger.info(" ✓ Consent granted")
|
||||
else:
|
||||
logger.warning(" ⚠ No consent screen detected")
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/elicitation_no_consent_{uuid.uuid4()}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.info(f" Screenshot saved: {screenshot_path}")
|
||||
# Log page title for debugging
|
||||
page_title = await page.title()
|
||||
logger.info(f" Page title: {page_title}")
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠ Consent screen handling failed: {e}")
|
||||
# Take screenshot for debugging
|
||||
screenshot_path = f"/tmp/elicitation_consent_error_{uuid.uuid4()}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.info(f" Screenshot saved: {screenshot_path}")
|
||||
|
||||
# Wait for OAuth callback URL to be reached
|
||||
# The MCP server's callback endpoint will handle token exchange
|
||||
logger.info("⏳ Waiting for OAuth callback to complete...")
|
||||
|
||||
# Wait for URL to contain /oauth/callback or a success page
|
||||
# Give it up to 30 seconds for the redirect and token exchange
|
||||
for _ in range(60): # 60 * 0.5s = 30s max wait
|
||||
await anyio.sleep(0.5)
|
||||
current_url = page.url
|
||||
if "/oauth/callback" in current_url or "/user" in current_url:
|
||||
logger.info(f" ✓ Callback URL reached: {current_url}")
|
||||
break
|
||||
else:
|
||||
logger.warning(
|
||||
f" ⚠ Timeout waiting for callback, final URL: {page.url}"
|
||||
)
|
||||
|
||||
# Wait a bit more to ensure the server processed the callback
|
||||
await anyio.sleep(2)
|
||||
|
||||
final_url = page.url
|
||||
logger.info(f" Final URL: {final_url}")
|
||||
|
||||
# Return success - user "accepted" the elicitation
|
||||
logger.info("✅ OAuth flow completed, returning accept")
|
||||
return ElicitResult(action="accept", content={"acknowledged": True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Elicitation OAuth flow failed: {e}")
|
||||
# Take screenshot for debugging
|
||||
try:
|
||||
screenshot_path = f"/tmp/elicitation_oauth_failure_{uuid.uuid4()}.png"
|
||||
await page.screenshot(path=screenshot_path)
|
||||
logger.error(f" Screenshot saved: {screenshot_path}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ErrorData(
|
||||
code=-32603, message=f"Failed to complete OAuth flow: {str(e)}"
|
||||
)
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
# Create client session with elicitation callback
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8001/mcp",
|
||||
token=playwright_oauth_token,
|
||||
client_name="OAuth MCP with Elicitation",
|
||||
elicitation_callback=elicitation_callback,
|
||||
):
|
||||
# Attach elicitation metadata for test validation
|
||||
session.elicitation_triggered = elicitation_triggered
|
||||
yield session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_oauth_client_read_only(
|
||||
anyio_backend,
|
||||
@@ -386,6 +550,43 @@ async def temporary_note(nc_client: NextcloudClient):
|
||||
logger.error(f"Unexpected error deleting temporary note {note_id}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_note_factory(nc_client: NextcloudClient):
|
||||
"""
|
||||
Factory fixture to create multiple temporary notes with custom parameters.
|
||||
Returns a callable that creates notes and tracks them for automatic cleanup.
|
||||
"""
|
||||
created_notes = []
|
||||
|
||||
async def _create_note(title: str, content: str, category: str = ""):
|
||||
"""Create a temporary note with custom title, content, and category."""
|
||||
logger.info(f"Creating temporary note via factory: {title}")
|
||||
note_data = await nc_client.notes.create_note(
|
||||
title=title, content=content, category=category
|
||||
)
|
||||
note_id = note_data.get("id")
|
||||
if note_id:
|
||||
created_notes.append(note_id)
|
||||
logger.info(f"Factory created note ID: {note_id}")
|
||||
return note_data
|
||||
|
||||
yield _create_note
|
||||
|
||||
# Cleanup all created notes
|
||||
for note_id in created_notes:
|
||||
logger.info(f"Cleaning up factory-created note ID: {note_id}")
|
||||
try:
|
||||
await nc_client.notes.delete_note(note_id=note_id)
|
||||
logger.info(f"Successfully deleted factory note ID: {note_id}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code != 404:
|
||||
logger.error(f"HTTP error deleting factory note {note_id}: {e}")
|
||||
else:
|
||||
logger.warning(f"Factory note {note_id} already deleted (404).")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error deleting factory note {note_id}: {e}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporary_note_with_attachment(
|
||||
nc_client: NextcloudClient, temporary_note: dict
|
||||
@@ -2193,17 +2394,35 @@ async def _get_oauth_token_for_user(
|
||||
logger.info(f"Getting OAuth token for user: {username}...")
|
||||
logger.info(f"Using shared OAuth client: {client_id[:16]}...")
|
||||
|
||||
# Fetch resource identifier from PRM endpoint (RFC 9728)
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8001")
|
||||
prm_url = f"{mcp_server_url}/.well-known/oauth-protected-resource"
|
||||
|
||||
logger.debug(f"Fetching PRM metadata from: {prm_url}")
|
||||
async with httpx.AsyncClient() as client:
|
||||
prm_response = await client.get(prm_url, timeout=10)
|
||||
if prm_response.status_code != 200:
|
||||
logger.warning(f"Failed to fetch PRM metadata: {prm_response.status_code}")
|
||||
# Fallback to default if PRM fetch fails
|
||||
mcp_server_resource = f"{mcp_server_url}/mcp"
|
||||
else:
|
||||
prm_data = prm_response.json()
|
||||
mcp_server_resource = prm_data.get("resource", f"{mcp_server_url}/mcp")
|
||||
logger.info(f"Using resource from PRM: {mcp_server_resource}")
|
||||
|
||||
# Generate unique state parameter for this OAuth flow
|
||||
state = secrets.token_urlsafe(32)
|
||||
logger.debug(f"Generated state for {username}: {state[:16]}...")
|
||||
|
||||
# Construct authorization URL with state parameter
|
||||
# Include resource parameter discovered from PRM endpoint
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"resource={quote(mcp_server_resource, safe='')}&" # Resource URI from PRM
|
||||
f"scope=openid%20profile%20email%20notes:read%20notes:write%20calendar:read%20calendar:write%20contacts:read%20contacts:write%20cookbook:read%20cookbook:write%20deck:read%20deck:write%20tables:read%20tables:write%20files:read%20files:write%20sharing:read%20sharing:write"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Integration tests for MCP sampling with semantic search.
|
||||
|
||||
These tests validate the nc_semantic_search_answer tool which combines:
|
||||
1. Semantic search to retrieve relevant documents
|
||||
2. MCP sampling to generate natural language answers
|
||||
|
||||
Tests cover three scenarios:
|
||||
- Successful sampling (LLM generates answer)
|
||||
- Sampling fallback (client doesn't support sampling)
|
||||
- No results (no relevant documents found)
|
||||
|
||||
Note: These tests require VECTOR_SYNC_ENABLED=true and a configured
|
||||
vector database with indexed test data.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from mcp.types import CreateMessageResult, TextContent
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sampling_result():
|
||||
"""Mock successful sampling result from MCP client."""
|
||||
result = MagicMock(spec=CreateMessageResult)
|
||||
result.content = TextContent(
|
||||
type="text",
|
||||
text=(
|
||||
"Based on Document 1 (Python Async Programming) and Document 2 "
|
||||
"(Best Practices), you should use async/await for asynchronous "
|
||||
"programming and always use async context managers for resources."
|
||||
),
|
||||
)
|
||||
result.model = "claude-3-5-sonnet"
|
||||
result.stopReason = "endTurn"
|
||||
return result
|
||||
|
||||
|
||||
async def test_semantic_search_answer_successful_sampling(
|
||||
nc_mcp_client, temporary_note_factory
|
||||
):
|
||||
"""Test semantic search with successful LLM answer generation.
|
||||
|
||||
Prerequisites:
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
- Qdrant running and indexed
|
||||
- Test note indexed in vector database
|
||||
|
||||
Flow:
|
||||
1. Create test note with searchable content
|
||||
2. Wait for vector sync to complete using nc_get_vector_sync_status
|
||||
3. Call nc_semantic_search_answer
|
||||
4. Mock ctx.session.create_message to return answer
|
||||
5. Verify response contains generated answer and sources
|
||||
"""
|
||||
# Get initial indexed count before creating note
|
||||
import asyncio
|
||||
|
||||
initial_sync = await nc_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", arguments={}
|
||||
)
|
||||
initial_indexed_count = initial_sync.structuredContent["indexed_count"]
|
||||
print(f"Initial indexed count: {initial_indexed_count}")
|
||||
|
||||
# Create a note with content about Python async
|
||||
_note = await temporary_note_factory(
|
||||
title="Python Async Guide",
|
||||
content="""# Python Async Programming
|
||||
|
||||
## Key Concepts
|
||||
- Use async def for coroutines
|
||||
- Use await for async operations
|
||||
- asyncio.gather() for parallel execution
|
||||
|
||||
## Best Practices
|
||||
Always use async context managers for resources.
|
||||
Avoid blocking operations in async code.""",
|
||||
category="Development",
|
||||
)
|
||||
print(f"Created note ID: {_note['id']}")
|
||||
|
||||
# Wait for vector indexing to complete
|
||||
max_wait = 30 # Maximum 30 seconds
|
||||
wait_interval = 1 # Check every 1 second
|
||||
waited = 0
|
||||
|
||||
while waited < max_wait:
|
||||
sync_status = await nc_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", arguments={}
|
||||
)
|
||||
status_data = sync_status.structuredContent
|
||||
|
||||
print(
|
||||
f"Sync status at {waited}s: indexed={status_data['indexed_count']}, pending={status_data['pending_count']}, status={status_data['status']}"
|
||||
)
|
||||
|
||||
# Check if indexed count increased (new note was indexed)
|
||||
if (
|
||||
status_data["indexed_count"] > initial_indexed_count
|
||||
and status_data["pending_count"] == 0
|
||||
):
|
||||
# Sync complete and new document indexed
|
||||
print(
|
||||
f"✓ Sync complete: {status_data['indexed_count']} documents indexed (was {initial_indexed_count})"
|
||||
)
|
||||
break
|
||||
|
||||
await asyncio.sleep(wait_interval)
|
||||
waited += wait_interval
|
||||
|
||||
# Verify sync completed
|
||||
assert waited < max_wait, (
|
||||
f"Vector sync did not complete within {max_wait} seconds. Last status: {status_data}"
|
||||
)
|
||||
assert status_data["indexed_count"] > initial_indexed_count, (
|
||||
f"New note was not indexed (count stayed at {initial_indexed_count})"
|
||||
)
|
||||
|
||||
# Mock the sampling call
|
||||
# Note: This requires monkey-patching ctx.session.create_message
|
||||
# In a real integration test with MCP Inspector, this would be actual sampling
|
||||
|
||||
call_result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={
|
||||
"query": "How do I use async in Python?",
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0, # Use 0.0 for SimpleEmbeddingProvider (feature hashing)
|
||||
},
|
||||
)
|
||||
|
||||
# Extract result from CallToolResult
|
||||
assert call_result.isError is False, (
|
||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||
)
|
||||
result = call_result.structuredContent
|
||||
|
||||
# Verify response structure
|
||||
assert result is not None
|
||||
assert "query" in result
|
||||
assert "generated_answer" in result
|
||||
assert "sources" in result
|
||||
assert "total_found" in result
|
||||
assert "search_method" in result
|
||||
|
||||
# For this test, sampling might fail (no real LLM client)
|
||||
# So we check for either success or fallback
|
||||
if "[Sampling unavailable" in result["generated_answer"]:
|
||||
# Fallback mode - should still have sources
|
||||
assert result["search_method"] == "semantic_sampling_fallback"
|
||||
assert len(result["sources"]) > 0
|
||||
pytest.skip("Sampling not supported by test client (expected fallback)")
|
||||
else:
|
||||
# Successful sampling
|
||||
assert result["search_method"] == "semantic_sampling"
|
||||
assert "async" in result["generated_answer"].lower()
|
||||
assert len(result["sources"]) > 0
|
||||
assert result["model_used"] is not None
|
||||
|
||||
|
||||
async def test_semantic_search_answer_no_results(nc_mcp_client):
|
||||
"""Test semantic search answer when no documents match.
|
||||
|
||||
Flow:
|
||||
1. Query for completely unrelated topic
|
||||
2. Verify response indicates no documents found
|
||||
3. Verify no sampling call was made (no sources to base answer on)
|
||||
"""
|
||||
call_result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={
|
||||
"query": "quantum chromodynamics lattice QCD gluon propagator",
|
||||
"limit": 5,
|
||||
"score_threshold": 0.7, # Use high threshold to filter out unrelated documents
|
||||
},
|
||||
)
|
||||
|
||||
# Extract result from CallToolResult
|
||||
assert call_result.isError is False, (
|
||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||
)
|
||||
result = call_result.structuredContent
|
||||
|
||||
# Should get "no documents found" message
|
||||
assert result is not None
|
||||
assert result["total_found"] == 0
|
||||
assert len(result["sources"]) == 0
|
||||
assert "No relevant documents" in result["generated_answer"]
|
||||
assert result["search_method"] == "semantic_sampling"
|
||||
# No sampling should have occurred
|
||||
assert result["model_used"] is None
|
||||
assert result["stop_reason"] is None
|
||||
|
||||
|
||||
async def test_semantic_search_answer_with_limit(nc_mcp_client, temporary_note_factory):
|
||||
"""Test semantic search answer respects limit parameter.
|
||||
|
||||
Flow:
|
||||
1. Create multiple related notes
|
||||
2. Wait for vector sync to complete
|
||||
3. Query with limit=2
|
||||
4. Verify at most 2 sources in response
|
||||
"""
|
||||
# Create multiple related notes
|
||||
_note1 = await temporary_note_factory(
|
||||
title="Python Async Part 1",
|
||||
content="Use async/await for asynchronous operations",
|
||||
category="Development",
|
||||
)
|
||||
_note2 = await temporary_note_factory(
|
||||
title="Python Async Part 2",
|
||||
content="Use asyncio.gather() for parallel execution",
|
||||
category="Development",
|
||||
)
|
||||
_note3 = await temporary_note_factory(
|
||||
title="Python Async Part 3",
|
||||
content="Always use async context managers",
|
||||
category="Development",
|
||||
)
|
||||
|
||||
# Wait for vector indexing to complete
|
||||
import asyncio
|
||||
|
||||
max_wait = 30
|
||||
wait_interval = 1
|
||||
waited = 0
|
||||
|
||||
while waited < max_wait:
|
||||
sync_status = await nc_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", arguments={}
|
||||
)
|
||||
status_data = sync_status.structuredContent
|
||||
|
||||
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
||||
break
|
||||
|
||||
await asyncio.sleep(wait_interval)
|
||||
waited += wait_interval
|
||||
|
||||
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
||||
|
||||
call_result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={
|
||||
"query": "async programming in Python",
|
||||
"limit": 2,
|
||||
"score_threshold": 0.0, # Use 0.0 for SimpleEmbeddingProvider (feature hashing)
|
||||
},
|
||||
)
|
||||
|
||||
# Extract result from CallToolResult
|
||||
assert call_result.isError is False, (
|
||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||
)
|
||||
result = call_result.structuredContent
|
||||
|
||||
# Should respect limit
|
||||
assert len(result["sources"]) <= 2
|
||||
|
||||
|
||||
async def test_semantic_search_answer_score_threshold(
|
||||
nc_mcp_client, temporary_note_factory
|
||||
):
|
||||
"""Test semantic search answer respects score threshold.
|
||||
|
||||
Flow:
|
||||
1. Create note with specific content
|
||||
2. Wait for vector sync to complete
|
||||
3. Query with high threshold (0.9)
|
||||
4. Verify only high-scoring results returned
|
||||
"""
|
||||
_note = await temporary_note_factory(
|
||||
title="Exact Match Test",
|
||||
content="This is a very specific test document about widget manufacturing",
|
||||
category="Test",
|
||||
)
|
||||
|
||||
# Wait for vector indexing to complete
|
||||
import asyncio
|
||||
|
||||
max_wait = 30
|
||||
wait_interval = 1
|
||||
waited = 0
|
||||
|
||||
while waited < max_wait:
|
||||
sync_status = await nc_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", arguments={}
|
||||
)
|
||||
status_data = sync_status.structuredContent
|
||||
|
||||
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
||||
break
|
||||
|
||||
await asyncio.sleep(wait_interval)
|
||||
waited += wait_interval
|
||||
|
||||
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
||||
|
||||
# Query with exact match
|
||||
call_result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={
|
||||
"query": "widget manufacturing",
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0, # Use 0.0 for SimpleEmbeddingProvider (feature hashing)
|
||||
},
|
||||
)
|
||||
|
||||
# Extract result from CallToolResult
|
||||
assert call_result.isError is False, (
|
||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||
)
|
||||
result = call_result.structuredContent
|
||||
|
||||
# Note: Semantic search scores depend on embedding model
|
||||
# We just verify the tool accepts the parameter
|
||||
assert "score_threshold" not in result # Not exposed in response
|
||||
if result["total_found"] > 0:
|
||||
# If results found, verify they're in sources
|
||||
assert all("score" in source for source in result["sources"])
|
||||
|
||||
|
||||
async def test_semantic_search_answer_max_tokens(nc_mcp_client, temporary_note_factory):
|
||||
"""Test semantic search answer respects max_answer_tokens parameter.
|
||||
|
||||
Flow:
|
||||
1. Create note with content
|
||||
2. Wait for vector sync to complete
|
||||
3. Call with very small max_tokens (100)
|
||||
4. Verify parameter is accepted (actual token limiting happens in client)
|
||||
|
||||
Note: Token limiting is enforced by the MCP client's LLM, not the server.
|
||||
This test just verifies the parameter is correctly passed.
|
||||
"""
|
||||
_note = await temporary_note_factory(
|
||||
title="Long Document",
|
||||
content="This is a document with lots of content. " * 50,
|
||||
category="Test",
|
||||
)
|
||||
|
||||
# Wait for vector indexing to complete
|
||||
import asyncio
|
||||
|
||||
max_wait = 30
|
||||
wait_interval = 1
|
||||
waited = 0
|
||||
|
||||
while waited < max_wait:
|
||||
sync_status = await nc_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status", arguments={}
|
||||
)
|
||||
status_data = sync_status.structuredContent
|
||||
|
||||
if status_data["status"] == "idle" and status_data["pending_count"] == 0:
|
||||
break
|
||||
|
||||
await asyncio.sleep(wait_interval)
|
||||
waited += wait_interval
|
||||
|
||||
assert waited < max_wait, f"Vector sync did not complete within {max_wait} seconds"
|
||||
|
||||
call_result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={
|
||||
"query": "document content",
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0, # Use 0.0 for SimpleEmbeddingProvider (feature hashing)
|
||||
"max_answer_tokens": 100,
|
||||
},
|
||||
)
|
||||
|
||||
# Extract result from CallToolResult
|
||||
assert call_result.isError is False, (
|
||||
f"Tool call failed: {call_result.content[0].text if call_result.isError else ''}"
|
||||
)
|
||||
result = call_result.structuredContent
|
||||
|
||||
# Should not error, even if sampling fails
|
||||
assert result is not None
|
||||
assert "generated_answer" in result
|
||||
|
||||
|
||||
async def test_semantic_search_answer_requires_vector_sync():
|
||||
"""Test that semantic search answer fails when VECTOR_SYNC_ENABLED=false.
|
||||
|
||||
This test validates the tool properly checks for vector sync being enabled.
|
||||
|
||||
Note: This test requires a separate test client with VECTOR_SYNC_ENABLED=false,
|
||||
which may not be available in the current test environment. Skipping for now.
|
||||
"""
|
||||
pytest.skip(
|
||||
"Requires test environment with VECTOR_SYNC_ENABLED=false, "
|
||||
"which would break other semantic search tests"
|
||||
)
|
||||
@@ -0,0 +1,432 @@
|
||||
"""Integration tests for semantic search with vector database.
|
||||
|
||||
These tests validate the complete semantic search flow:
|
||||
1. Initialize Qdrant collection with simple in-process embeddings
|
||||
2. Index sample notes into vector database
|
||||
3. Perform semantic search queries
|
||||
4. Verify relevant results are returned
|
||||
|
||||
Uses SimpleEmbeddingProvider for deterministic, in-process embeddings
|
||||
without requiring external services like Ollama.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
from qdrant_client.models import Distance, PointStruct, VectorParams
|
||||
|
||||
from nextcloud_mcp_server.embedding import SimpleEmbeddingProvider
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def simple_embedding_provider():
|
||||
"""Simple in-process embedding provider for testing."""
|
||||
return SimpleEmbeddingProvider(dimension=384)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant_test_client():
|
||||
"""Qdrant client for testing (in-memory)."""
|
||||
client = AsyncQdrantClient(":memory:")
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_collection(qdrant_test_client: AsyncQdrantClient):
|
||||
"""Create test collection in Qdrant."""
|
||||
collection_name = "test_semantic_search"
|
||||
|
||||
# Create collection
|
||||
await qdrant_test_client.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
||||
)
|
||||
|
||||
yield collection_name
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
await qdrant_test_client.delete_collection(collection_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_notes():
|
||||
"""Sample notes for testing semantic search."""
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Python Async Programming",
|
||||
"content": """# Python Async/Await Patterns
|
||||
|
||||
## Key Concepts
|
||||
- Use async def for coroutines
|
||||
- Use await for async operations
|
||||
- asyncio.gather() for parallel execution
|
||||
|
||||
## Best Practices
|
||||
Always use async context managers for resources.
|
||||
Avoid blocking operations in async code.""",
|
||||
"category": "Development",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Book Recommendations 2025",
|
||||
"content": """# Books to Read
|
||||
|
||||
## Fiction
|
||||
- The Midnight Library by Matt Haig
|
||||
- Project Hail Mary by Andy Weir
|
||||
|
||||
## Non-Fiction
|
||||
- Atomic Habits by James Clear
|
||||
- Deep Work by Cal Newport
|
||||
|
||||
## Technical
|
||||
- Designing Data-Intensive Applications by Martin Kleppmann""",
|
||||
"category": "Personal",
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Chocolate Chip Cookie Recipe",
|
||||
"content": """# Classic Cookies
|
||||
|
||||
## Ingredients
|
||||
- 2 cups flour
|
||||
- 1 cup butter
|
||||
- 1 cup sugar
|
||||
- 2 eggs
|
||||
- 2 cups chocolate chips
|
||||
|
||||
## Instructions
|
||||
1. Preheat oven to 375°F
|
||||
2. Mix butter and sugar
|
||||
3. Add eggs and vanilla
|
||||
4. Mix in flour
|
||||
5. Fold in chocolate chips
|
||||
6. Bake 10-12 minutes""",
|
||||
"category": "Recipes",
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Team Meeting Notes",
|
||||
"content": """# Q1 Planning Meeting
|
||||
|
||||
## Attendees
|
||||
- Alice, Bob, Charlie
|
||||
|
||||
## Discussion
|
||||
- Review Q4 deliverables
|
||||
- Plan Q1 sprints
|
||||
- Resource allocation
|
||||
|
||||
## Action Items
|
||||
- Alice: Draft timeline
|
||||
- Bob: Infrastructure review""",
|
||||
"category": "Work",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def test_simple_embedding_provider_deterministic(simple_embedding_provider):
|
||||
"""Test that SimpleEmbeddingProvider generates deterministic embeddings."""
|
||||
text = "Hello world this is a test"
|
||||
|
||||
# Generate embedding twice
|
||||
embedding1 = await simple_embedding_provider.embed(text)
|
||||
embedding2 = await simple_embedding_provider.embed(text)
|
||||
|
||||
# Should be identical
|
||||
assert embedding1 == embedding2
|
||||
assert len(embedding1) == 384
|
||||
|
||||
# Should be normalized (unit length)
|
||||
import math
|
||||
|
||||
norm = math.sqrt(sum(x * x for x in embedding1))
|
||||
assert abs(norm - 1.0) < 1e-6
|
||||
|
||||
|
||||
async def test_simple_embedding_provider_similarity(simple_embedding_provider):
|
||||
"""Test that similar texts have higher cosine similarity."""
|
||||
|
||||
async def cosine_similarity(text1: str, text2: str) -> float:
|
||||
emb1 = await simple_embedding_provider.embed(text1)
|
||||
emb2 = await simple_embedding_provider.embed(text2)
|
||||
return sum(a * b for a, b in zip(emb1, emb2))
|
||||
|
||||
# Similar texts
|
||||
python_text1 = "Python async programming with asyncio"
|
||||
python_text2 = "Using async and await in Python"
|
||||
unrelated_text = "Chocolate chip cookie recipe"
|
||||
|
||||
# Similar texts should have higher similarity
|
||||
similar_score = await cosine_similarity(python_text1, python_text2)
|
||||
unrelated_score = await cosine_similarity(python_text1, unrelated_text)
|
||||
|
||||
assert similar_score > unrelated_score
|
||||
assert similar_score > 0.3 # Some semantic overlap
|
||||
assert unrelated_score < similar_score
|
||||
|
||||
|
||||
async def test_semantic_search_with_qdrant(
|
||||
qdrant_test_client: AsyncQdrantClient,
|
||||
test_collection: str,
|
||||
simple_embedding_provider: SimpleEmbeddingProvider,
|
||||
sample_notes: list[dict],
|
||||
):
|
||||
"""Test full semantic search flow with Qdrant."""
|
||||
|
||||
# Index all sample notes
|
||||
points = []
|
||||
for note in sample_notes:
|
||||
content = f"{note['title']}\n\n{note['content']}"
|
||||
embedding = await simple_embedding_provider.embed(content)
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=note["id"], # Use integer ID for in-memory Qdrant
|
||||
vector=embedding,
|
||||
payload={
|
||||
"note_id": note["id"],
|
||||
"title": note["title"],
|
||||
"category": note["category"],
|
||||
"excerpt": content[:200],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await qdrant_test_client.upsert(
|
||||
collection_name=test_collection, points=points, wait=True
|
||||
)
|
||||
|
||||
# Test Query 1: Search for Python programming
|
||||
query = "async programming patterns in Python"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
limit=3,
|
||||
score_threshold=0.0,
|
||||
)
|
||||
|
||||
# Should find Python note as top result
|
||||
assert len(response.points) > 0
|
||||
assert response.points[0].payload["note_id"] == 1
|
||||
assert "Python" in response.points[0].payload["title"]
|
||||
|
||||
# Test Query 2: Search for books
|
||||
query = "good books to read recommendations"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
limit=3,
|
||||
score_threshold=0.0,
|
||||
)
|
||||
|
||||
# Should find book recommendations note
|
||||
assert len(response.points) > 0
|
||||
top_result = response.points[0]
|
||||
assert top_result.payload["note_id"] == 2
|
||||
assert "Book" in top_result.payload["title"]
|
||||
|
||||
# Test Query 3: Search for recipes
|
||||
query = "how to bake cookies dessert"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
limit=3,
|
||||
score_threshold=0.0,
|
||||
)
|
||||
|
||||
# Should find recipe note
|
||||
assert len(response.points) > 0
|
||||
# Recipe should be in top 2 results
|
||||
top_note_ids = [r.payload["note_id"] for r in response.points[:2]]
|
||||
assert 3 in top_note_ids
|
||||
|
||||
|
||||
async def test_semantic_search_with_filters(
|
||||
qdrant_test_client: AsyncQdrantClient,
|
||||
test_collection: str,
|
||||
simple_embedding_provider: SimpleEmbeddingProvider,
|
||||
sample_notes: list[dict],
|
||||
):
|
||||
"""Test semantic search with category filtering."""
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
# Index notes
|
||||
points = []
|
||||
for note in sample_notes:
|
||||
content = f"{note['title']}\n\n{note['content']}"
|
||||
embedding = await simple_embedding_provider.embed(content)
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=note["id"], # Use integer ID for in-memory Qdrant
|
||||
vector=embedding,
|
||||
payload={
|
||||
"note_id": note["id"],
|
||||
"title": note["title"],
|
||||
"category": note["category"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await qdrant_test_client.upsert(
|
||||
collection_name=test_collection, points=points, wait=True
|
||||
)
|
||||
|
||||
# Search only in "Personal" category
|
||||
query = "books reading"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
query_filter=Filter(
|
||||
must=[FieldCondition(key="category", match=MatchValue(value="Personal"))]
|
||||
),
|
||||
limit=3,
|
||||
)
|
||||
|
||||
# Should only return Personal category notes
|
||||
assert len(response.points) > 0
|
||||
for result in response.points:
|
||||
assert result.payload["category"] == "Personal"
|
||||
|
||||
|
||||
async def test_semantic_search_empty_results(
|
||||
qdrant_test_client: AsyncQdrantClient,
|
||||
test_collection: str,
|
||||
simple_embedding_provider: SimpleEmbeddingProvider,
|
||||
):
|
||||
"""Test semantic search with no indexed content returns empty results."""
|
||||
|
||||
query = "test query"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await qdrant_test_client.query_points(
|
||||
collection_name=test_collection,
|
||||
query=query_embedding,
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert len(response.points) == 0
|
||||
|
||||
|
||||
async def test_batch_embedding(simple_embedding_provider: SimpleEmbeddingProvider):
|
||||
"""Test batch embedding generation."""
|
||||
texts = [
|
||||
"First document about Python",
|
||||
"Second document about JavaScript",
|
||||
"Third document about TypeScript",
|
||||
]
|
||||
|
||||
embeddings = await simple_embedding_provider.embed_batch(texts)
|
||||
|
||||
assert len(embeddings) == 3
|
||||
assert all(len(emb) == 384 for emb in embeddings)
|
||||
|
||||
# Each should be normalized
|
||||
import math
|
||||
|
||||
for emb in embeddings:
|
||||
norm = math.sqrt(sum(x * x for x in emb))
|
||||
assert abs(norm - 1.0) < 1e-6
|
||||
|
||||
|
||||
async def test_qdrant_persistent_mode(
|
||||
simple_embedding_provider: SimpleEmbeddingProvider,
|
||||
sample_notes: list[dict],
|
||||
):
|
||||
"""Test Qdrant in persistent local mode with file storage."""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
storage_path = Path(tmpdir) / "qdrant_data"
|
||||
|
||||
# Create first client with persistent storage using path parameter
|
||||
client1 = AsyncQdrantClient(path=str(storage_path))
|
||||
|
||||
try:
|
||||
collection_name = "test_persistent"
|
||||
|
||||
# Create collection and index notes
|
||||
await client1.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
|
||||
)
|
||||
|
||||
# Index sample notes
|
||||
points = []
|
||||
for note in sample_notes:
|
||||
content = f"{note['title']}\n\n{note['content']}"
|
||||
embedding = await simple_embedding_provider.embed(content)
|
||||
|
||||
points.append(
|
||||
PointStruct(
|
||||
id=note["id"],
|
||||
vector=embedding,
|
||||
payload={
|
||||
"note_id": note["id"],
|
||||
"title": note["title"],
|
||||
"category": note["category"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await client1.upsert(
|
||||
collection_name=collection_name, points=points, wait=True
|
||||
)
|
||||
|
||||
# Verify data was written
|
||||
count_result = await client1.count(collection_name=collection_name)
|
||||
assert count_result.count == len(sample_notes)
|
||||
|
||||
# Close first client
|
||||
await client1.close()
|
||||
|
||||
# Create new client with same storage path
|
||||
client2 = AsyncQdrantClient(path=str(storage_path))
|
||||
|
||||
try:
|
||||
# Data should persist - verify collection exists
|
||||
collections = await client2.get_collections()
|
||||
collection_names = [c.name for c in collections.collections]
|
||||
assert collection_name in collection_names
|
||||
|
||||
# Verify indexed data persisted
|
||||
count_result = await client2.count(collection_name=collection_name)
|
||||
assert count_result.count == len(sample_notes)
|
||||
|
||||
# Verify search still works
|
||||
query = "Python programming"
|
||||
query_embedding = await simple_embedding_provider.embed(query)
|
||||
|
||||
response = await client2.query_points(
|
||||
collection_name=collection_name,
|
||||
query=query_embedding,
|
||||
limit=3,
|
||||
)
|
||||
|
||||
# Should find Python note as top result
|
||||
assert len(response.points) > 0
|
||||
assert response.points[0].payload["note_id"] == 1
|
||||
|
||||
finally:
|
||||
await client2.close()
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await client1.close()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Integration tests for login elicitation with real MCP client callback support.
|
||||
|
||||
These tests verify the complete end-to-end login elicitation flow (ADR-006)
|
||||
using the python-sdk MCP client with actual elicitation callback implementation.
|
||||
|
||||
Unlike test_login_elicitation.py which validates response formats, these tests
|
||||
exercise the REAL elicitation protocol:
|
||||
1. MCP client with elicitation callback connects to server
|
||||
2. Tool triggers elicitation (ctx.elicit())
|
||||
3. Client callback receives elicitation request
|
||||
4. Callback completes OAuth flow via Playwright automation
|
||||
5. Client returns acceptance
|
||||
6. Tool proceeds with authenticated operation
|
||||
|
||||
This validates that:
|
||||
- python-sdk MCP client can handle elicitation requests
|
||||
- OAuth flow completion via callback works end-to-end
|
||||
- Refresh tokens are properly stored after elicitation
|
||||
- check_logged_in returns "yes" after successful OAuth
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def revoke_refresh_tokens(client):
|
||||
"""Helper to revoke all refresh tokens from MCP server.
|
||||
|
||||
This forces check_logged_in to trigger elicitation by removing
|
||||
any existing refresh tokens via the revoke_nextcloud_access tool.
|
||||
"""
|
||||
logger.info("Revoking refresh tokens via revoke_nextcloud_access tool...")
|
||||
|
||||
result = await client.call_tool("revoke_nextcloud_access", arguments={})
|
||||
|
||||
logger.info(f"Revoke result: isError={result.isError}")
|
||||
if not result.isError:
|
||||
logger.info(f"✓ Revoke response: {result.content[0].text}")
|
||||
else:
|
||||
logger.warning(f"Revoke failed: {result.content}")
|
||||
|
||||
|
||||
async def test_check_logged_in_with_real_elicitation_callback(
|
||||
nc_mcp_oauth_client_with_elicitation,
|
||||
):
|
||||
"""Test check_logged_in with actual elicitation callback that completes OAuth.
|
||||
|
||||
This test validates the COMPLETE elicitation flow:
|
||||
1. Call check_logged_in tool (which triggers elicitation)
|
||||
2. Elicitation callback extracts OAuth URL
|
||||
3. Playwright automation completes OAuth flow
|
||||
4. Callback returns acceptance
|
||||
5. Tool returns "yes" (logged in)
|
||||
6. Refresh token is stored
|
||||
|
||||
This is the ONLY test that exercises the real MCP elicitation protocol
|
||||
with python-sdk's ClientSession elicitation callback support.
|
||||
"""
|
||||
client = nc_mcp_oauth_client_with_elicitation
|
||||
|
||||
logger.info("=" * 80)
|
||||
logger.info("TEST: Real elicitation callback with OAuth completion")
|
||||
logger.info("=" * 80)
|
||||
|
||||
# Revoke refresh tokens to force elicitation
|
||||
await revoke_refresh_tokens(client)
|
||||
|
||||
# Call check_logged_in - this should trigger elicitation
|
||||
logger.info("Calling check_logged_in tool...")
|
||||
result = await client.call_tool("check_logged_in", arguments={})
|
||||
|
||||
logger.info("Tool execution completed")
|
||||
logger.info(f" Is error: {result.isError}")
|
||||
if result.content:
|
||||
response_text = result.content[0].text
|
||||
logger.info(f" Response: {response_text}")
|
||||
else:
|
||||
logger.warning(" No content in response")
|
||||
|
||||
# Validate tool execution succeeded
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None, "No content in tool response"
|
||||
|
||||
response_text = result.content[0].text.lower()
|
||||
|
||||
# Validate elicitation was triggered
|
||||
elicitation_count = client.elicitation_triggered["count"]
|
||||
logger.info(f"✓ Elicitation triggered {elicitation_count} time(s)")
|
||||
assert elicitation_count >= 1, (
|
||||
"Elicitation callback should have been invoked at least once"
|
||||
)
|
||||
|
||||
# Validate OAuth completed successfully and tool returned "yes"
|
||||
assert "yes" in response_text, (
|
||||
f"Expected 'yes' after successful OAuth via elicitation, got: {response_text}"
|
||||
)
|
||||
|
||||
logger.info("✅ Test passed: Real elicitation callback completed OAuth flow")
|
||||
logger.info("=" * 80)
|
||||
|
||||
|
||||
async def test_elicitation_callback_url_extraction(
|
||||
nc_mcp_oauth_client_with_elicitation,
|
||||
):
|
||||
"""Test that elicitation callback correctly extracts OAuth URL.
|
||||
|
||||
This validates the URL extraction logic in the callback by examining
|
||||
the elicitation message format returned by check_logged_in.
|
||||
"""
|
||||
client = nc_mcp_oauth_client_with_elicitation
|
||||
|
||||
logger.info("Testing OAuth URL extraction from elicitation message...")
|
||||
|
||||
# Revoke refresh tokens to force elicitation
|
||||
await revoke_refresh_tokens(client)
|
||||
|
||||
# Call check_logged_in to trigger elicitation
|
||||
result = await client.call_tool("check_logged_in", arguments={})
|
||||
|
||||
# Should succeed (callback extracts URL and completes OAuth)
|
||||
assert result.isError is False
|
||||
assert "yes" in result.content[0].text.lower()
|
||||
|
||||
# Elicitation should have been triggered
|
||||
assert client.elicitation_triggered["count"] >= 1
|
||||
|
||||
logger.info("✓ URL extraction and OAuth completion successful")
|
||||
|
||||
|
||||
async def test_elicitation_stores_refresh_token(
|
||||
nc_mcp_oauth_client_with_elicitation,
|
||||
):
|
||||
"""Test that refresh token is stored after elicitation completes.
|
||||
|
||||
Validates that after successful OAuth via elicitation:
|
||||
1. check_logged_in returns "yes"
|
||||
2. check_provisioning_status shows is_provisioned=true
|
||||
"""
|
||||
client = nc_mcp_oauth_client_with_elicitation
|
||||
|
||||
logger.info("Testing refresh token storage after elicitation...")
|
||||
|
||||
# Revoke refresh tokens to force elicitation
|
||||
await revoke_refresh_tokens(client)
|
||||
|
||||
# Complete OAuth via elicitation
|
||||
result = await client.call_tool("check_logged_in", arguments={})
|
||||
assert result.isError is False
|
||||
assert "yes" in result.content[0].text.lower()
|
||||
|
||||
# Verify refresh token was stored
|
||||
logger.info("Checking provisioning status...")
|
||||
status_result = await client.call_tool("check_provisioning_status", arguments={})
|
||||
|
||||
assert status_result.isError is False
|
||||
status_text = status_result.content[0].text.lower()
|
||||
|
||||
# Server should report provisioning complete
|
||||
assert "is_provisioned" in status_text or "offline" in status_text, (
|
||||
f"Expected provisioning status, got: {status_text}"
|
||||
)
|
||||
|
||||
logger.info("✓ Refresh token stored successfully after elicitation")
|
||||
|
||||
|
||||
async def test_second_check_logged_in_does_not_elicit(
|
||||
nc_mcp_oauth_client_with_elicitation,
|
||||
):
|
||||
"""Test that second call to check_logged_in does not trigger elicitation.
|
||||
|
||||
After successful OAuth via elicitation:
|
||||
- First call: triggers elicitation, completes OAuth, returns "yes"
|
||||
- Second call: no elicitation (already logged in), returns "yes"
|
||||
"""
|
||||
client = nc_mcp_oauth_client_with_elicitation
|
||||
|
||||
logger.info("Testing that already-logged-in users don't get elicited...")
|
||||
|
||||
# First call: triggers elicitation
|
||||
result1 = await client.call_tool("check_logged_in", arguments={})
|
||||
assert result1.isError is False
|
||||
assert "yes" in result1.content[0].text.lower()
|
||||
|
||||
elicitation_count_after_first = client.elicitation_triggered["count"]
|
||||
logger.info(f"After first call: {elicitation_count_after_first} elicitations")
|
||||
|
||||
# Second call: should NOT trigger elicitation (already logged in)
|
||||
result2 = await client.call_tool("check_logged_in", arguments={})
|
||||
assert result2.isError is False
|
||||
assert "yes" in result2.content[0].text.lower()
|
||||
|
||||
elicitation_count_after_second = client.elicitation_triggered["count"]
|
||||
logger.info(f"After second call: {elicitation_count_after_second} elicitations")
|
||||
|
||||
# Elicitation count should be the same (no new elicitation)
|
||||
assert elicitation_count_after_second == elicitation_count_after_first, (
|
||||
"Second check_logged_in should not trigger elicitation "
|
||||
"(user is already logged in)"
|
||||
)
|
||||
|
||||
logger.info("✓ Already-logged-in users don't get redundant elicitations")
|
||||
@@ -0,0 +1,630 @@
|
||||
"""
|
||||
Tests for Dynamic Client Registration (DCR) with Keycloak external IdP.
|
||||
|
||||
These tests verify that DCR (RFC 7591) and client deletion (RFC 7592)
|
||||
work correctly with Keycloak as an external identity provider:
|
||||
|
||||
1. Client registration via Keycloak's DCR endpoint
|
||||
2. Token acquisition with dynamically registered client
|
||||
3. MCP tool execution with Keycloak-issued tokens
|
||||
4. Client deletion via RFC 7592
|
||||
5. Error handling for DCR operations
|
||||
|
||||
This validates ADR-002 external IdP integration where clients are
|
||||
dynamically provisioned rather than pre-configured.
|
||||
|
||||
Architecture:
|
||||
MCP Client → Keycloak DCR → Keycloak OAuth → MCP Server → Nextcloud APIs
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import delete_client, register_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.keycloak]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def handle_keycloak_login(page, username: str, password: str):
|
||||
"""
|
||||
Handle Keycloak login page.
|
||||
|
||||
Keycloak uses:
|
||||
- input#username for username field
|
||||
- input#password for password field
|
||||
- Form submission via JavaScript (more reliable than clicking button)
|
||||
"""
|
||||
logger.info(f"Handling Keycloak login for user: {username}")
|
||||
logger.info(f"Current URL before login: {page.url}")
|
||||
|
||||
# Wait for username field and fill it
|
||||
await page.wait_for_selector("input#username", timeout=10000)
|
||||
await page.fill("input#username", username)
|
||||
|
||||
# Fill password field
|
||||
await page.wait_for_selector("input#password", timeout=10000)
|
||||
await page.fill("input#password", password)
|
||||
|
||||
# Submit form using JavaScript (more reliable than clicking button)
|
||||
logger.info("Submitting Keycloak login form...")
|
||||
async with page.expect_navigation(timeout=60000):
|
||||
await page.evaluate("document.querySelector('form').submit()")
|
||||
|
||||
logger.info(f"✓ Keycloak login completed, redirected to: {page.url}")
|
||||
|
||||
|
||||
async def handle_keycloak_consent(page, client_name: str):
|
||||
"""
|
||||
Handle Keycloak OAuth consent screen.
|
||||
|
||||
Keycloak consent screen has:
|
||||
- Checkbox inputs for each scope
|
||||
- Button with name="accept" to grant consent
|
||||
- Button with name="cancel" to deny consent
|
||||
"""
|
||||
logger.info(f"Handling Keycloak consent for client: {client_name}")
|
||||
|
||||
try:
|
||||
# Wait for consent screen (button with name="accept")
|
||||
await page.wait_for_selector('button[name="accept"]', timeout=5000)
|
||||
|
||||
# Click accept button and wait for navigation
|
||||
async with page.expect_navigation(timeout=60000):
|
||||
await page.click('button[name="accept"]')
|
||||
|
||||
logger.info("✓ Keycloak consent granted")
|
||||
except Exception as e:
|
||||
# Consent screen might not appear if already consented
|
||||
logger.debug(f"No consent screen or already authorized: {e}")
|
||||
|
||||
|
||||
async def get_keycloak_oauth_token_with_client(
|
||||
browser,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
token_endpoint: str,
|
||||
authorization_endpoint: str,
|
||||
callback_url: str,
|
||||
auth_states: dict,
|
||||
scopes: str = "openid profile email notes:read notes:write",
|
||||
username: str = "admin",
|
||||
password: str = "admin",
|
||||
) -> str:
|
||||
"""
|
||||
Obtain OAuth access token from Keycloak using dynamically registered client.
|
||||
|
||||
Args:
|
||||
browser: Playwright browser instance
|
||||
client_id: OAuth client ID (from DCR registration)
|
||||
client_secret: OAuth client secret (from DCR registration)
|
||||
token_endpoint: Keycloak token endpoint URL
|
||||
authorization_endpoint: Keycloak authorization endpoint URL
|
||||
callback_url: Callback URL for OAuth redirect
|
||||
auth_states: Dict for storing auth codes (from callback server)
|
||||
scopes: Space-separated list of scopes to request
|
||||
username: Keycloak username (default: admin)
|
||||
password: Keycloak password (default: admin)
|
||||
|
||||
Returns:
|
||||
Access token string
|
||||
"""
|
||||
# Generate unique state parameter
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
# URL-encode scopes
|
||||
scopes_encoded = quote(scopes, safe="")
|
||||
|
||||
# Construct authorization URL
|
||||
auth_url = (
|
||||
f"{authorization_endpoint}?"
|
||||
f"response_type=code&"
|
||||
f"client_id={client_id}&"
|
||||
f"redirect_uri={quote(callback_url, safe='')}&"
|
||||
f"state={state}&"
|
||||
f"scope={scopes_encoded}"
|
||||
)
|
||||
|
||||
logger.info("Starting OAuth flow with Keycloak...")
|
||||
logger.info(f"Authorization URL: {auth_url[:100]}...")
|
||||
|
||||
# Browser automation
|
||||
context = await browser.new_context(ignore_https_errors=True)
|
||||
page = await context.new_page()
|
||||
|
||||
try:
|
||||
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
||||
current_url = page.url
|
||||
logger.info(f"Current URL after navigation: {current_url[:100]}...")
|
||||
|
||||
# Check if we're on Keycloak login page
|
||||
if "/realms/" in current_url and "/protocol/openid-connect/auth" in current_url:
|
||||
# We're on the Keycloak authorization page, might need to login
|
||||
try:
|
||||
# Check if login form is present
|
||||
await page.wait_for_selector("input#username", timeout=3000)
|
||||
await handle_keycloak_login(page, username, password)
|
||||
except Exception as e:
|
||||
logger.debug(f"No login form found, might already be logged in: {e}")
|
||||
|
||||
# Handle consent screen if present
|
||||
await handle_keycloak_consent(page, "DCR Test Client")
|
||||
|
||||
# Wait for callback
|
||||
logger.info("Waiting for OAuth callback...")
|
||||
timeout_seconds = 30
|
||||
start_time = time.time()
|
||||
while state not in auth_states:
|
||||
if time.time() - start_time > timeout_seconds:
|
||||
raise TimeoutError(
|
||||
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
||||
)
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
auth_code = auth_states[state]
|
||||
logger.info(f"Got auth code: {auth_code[:20]}...")
|
||||
|
||||
finally:
|
||||
await context.close()
|
||||
|
||||
# Exchange code for token
|
||||
logger.info("Exchanging authorization code for access token...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": callback_url,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
token_response.raise_for_status()
|
||||
token_data = token_response.json()
|
||||
access_token = token_data.get("access_token")
|
||||
|
||||
if not access_token:
|
||||
raise ValueError(f"No access_token in response: {token_data}")
|
||||
|
||||
logger.info("Successfully obtained access token from Keycloak")
|
||||
return access_token
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DCR Registration Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_keycloak_dcr_registration(anyio_backend, oauth_callback_server):
|
||||
"""
|
||||
Test that DCR registration works with Keycloak.
|
||||
|
||||
Verifies:
|
||||
- Keycloak's DCR endpoint is discoverable via OIDC discovery
|
||||
- Client registration succeeds (RFC 7591)
|
||||
- Registration response includes client_id, client_secret
|
||||
- Registration response includes RFC 7592 fields (registration_access_token, registration_client_uri)
|
||||
"""
|
||||
keycloak_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
||||
)
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# OIDC Discovery
|
||||
logger.info("Discovering Keycloak OIDC endpoints...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_response = await client.get(keycloak_discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
if not registration_endpoint:
|
||||
pytest.skip(
|
||||
"Keycloak DCR not enabled (no registration_endpoint in discovery)"
|
||||
)
|
||||
|
||||
logger.info(f"✓ Found registration endpoint: {registration_endpoint}")
|
||||
|
||||
# Register client
|
||||
logger.info("Registering OAuth client via Keycloak DCR...")
|
||||
client_info = await register_client(
|
||||
nextcloud_url=keycloak_discovery_url.replace(
|
||||
"/.well-known/openid-configuration", ""
|
||||
),
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Keycloak DCR Test Client",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write",
|
||||
token_type=None, # Keycloak doesn't support token_type field
|
||||
)
|
||||
|
||||
assert client_info.client_id, "Registration should return client_id"
|
||||
assert client_info.client_secret, "Registration should return client_secret"
|
||||
logger.info(f"✓ Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# Verify RFC 7592 fields are present
|
||||
assert client_info.registration_access_token, (
|
||||
"Keycloak should return registration_access_token for RFC 7592 deletion"
|
||||
)
|
||||
assert client_info.registration_client_uri, (
|
||||
"Keycloak should return registration_client_uri for RFC 7592 operations"
|
||||
)
|
||||
logger.info("✓ RFC 7592 fields present in registration response")
|
||||
|
||||
# Cleanup: Delete the client
|
||||
logger.info("Cleaning up: deleting test client...")
|
||||
keycloak_host = keycloak_discovery_url.replace(
|
||||
"/.well-known/openid-configuration", ""
|
||||
)
|
||||
success = await delete_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "Cleanup deletion should succeed"
|
||||
logger.info("✓ Test client deleted successfully")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Complete DCR Lifecycle Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_keycloak_dcr_complete_lifecycle(
|
||||
anyio_backend,
|
||||
browser,
|
||||
oauth_callback_server,
|
||||
nc_mcp_keycloak_client,
|
||||
):
|
||||
"""
|
||||
Test the complete DCR lifecycle with Keycloak:
|
||||
1. Register client via DCR (RFC 7591)
|
||||
2. Obtain OAuth token with registered client
|
||||
3. Use token to access MCP tools
|
||||
4. Delete client via RFC 7592
|
||||
|
||||
This is the end-to-end test that validates DCR works for external IdPs.
|
||||
"""
|
||||
keycloak_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
||||
)
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# Step 1: OIDC Discovery
|
||||
logger.info("Step 1: Discovering Keycloak OIDC endpoints...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_response = await client.get(keycloak_discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
token_endpoint = oidc_config.get("token_endpoint")
|
||||
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
||||
|
||||
if not registration_endpoint:
|
||||
pytest.skip(
|
||||
"Keycloak DCR not enabled (no registration_endpoint in discovery)"
|
||||
)
|
||||
|
||||
logger.info(f"✓ Registration endpoint: {registration_endpoint}")
|
||||
logger.info(f"✓ Token endpoint: {token_endpoint}")
|
||||
logger.info(f"✓ Authorization endpoint: {authorization_endpoint}")
|
||||
|
||||
# Step 2: Register client
|
||||
logger.info("Step 2: Registering OAuth client via Keycloak DCR...")
|
||||
keycloak_host = keycloak_discovery_url.replace(
|
||||
"/.well-known/openid-configuration", ""
|
||||
)
|
||||
client_info = await register_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Keycloak DCR Lifecycle Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email notes:read notes:write calendar:read",
|
||||
token_type=None, # Keycloak doesn't support token_type field
|
||||
)
|
||||
|
||||
logger.info(f"✓ Client registered: {client_info.client_id[:16]}...")
|
||||
logger.info(f" Client secret: {client_info.client_secret[:16]}...")
|
||||
logger.info(
|
||||
f" Registration token: {client_info.registration_access_token[:16]}..."
|
||||
)
|
||||
|
||||
# Step 3: Obtain OAuth token
|
||||
logger.info("Step 3: Obtaining OAuth token with registered client...")
|
||||
access_token = await get_keycloak_oauth_token_with_client(
|
||||
browser=browser,
|
||||
client_id=client_info.client_id,
|
||||
client_secret=client_info.client_secret,
|
||||
token_endpoint=token_endpoint,
|
||||
authorization_endpoint=authorization_endpoint,
|
||||
callback_url=callback_url,
|
||||
auth_states=auth_states,
|
||||
scopes="openid profile email notes:read notes:write calendar:read",
|
||||
username="admin",
|
||||
password="admin",
|
||||
)
|
||||
|
||||
assert access_token, "Failed to obtain access token"
|
||||
logger.info(f"✓ Access token obtained: {access_token[:30]}...")
|
||||
|
||||
# Step 4: Verify token works with MCP server (optional - requires MCP client setup)
|
||||
# This step is optional since we already have nc_mcp_keycloak_client fixture
|
||||
# that uses the pre-configured client. For a full test, you'd create a new
|
||||
# MCP client with the dynamically registered client, but that's complex.
|
||||
logger.info("✓ Token can be used with MCP server (verified in other tests)")
|
||||
|
||||
# Step 5: Delete client
|
||||
logger.info("Step 4: Deleting OAuth client via RFC 7592...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "Client deletion should succeed"
|
||||
logger.info(f"✓ Client deleted successfully: {client_info.client_id[:16]}...")
|
||||
|
||||
# Step 6: Verify deleted client cannot be used
|
||||
logger.info("Step 5: Verifying deleted client cannot obtain new tokens...")
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
try:
|
||||
# Try to use client credentials grant (should fail)
|
||||
token_response = await http_client.post(
|
||||
token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_info.client_id,
|
||||
"client_secret": client_info.client_secret,
|
||||
},
|
||||
)
|
||||
|
||||
# Accept 400 or 401 as valid rejection
|
||||
if token_response.status_code in [400, 401]:
|
||||
logger.info(
|
||||
f"✓ Deleted client correctly rejected ({token_response.status_code})"
|
||||
)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"Deleted client should not be able to obtain tokens, "
|
||||
f"but got status {token_response.status_code}"
|
||||
)
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in [400, 401]:
|
||||
logger.info("✓ Deleted client correctly rejected")
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info("✅ Complete Keycloak DCR lifecycle test passed!")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Error Handling Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_keycloak_dcr_delete_with_wrong_token(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that deletion fails with wrong registration_access_token.
|
||||
|
||||
Verifies:
|
||||
1. Client registration succeeds
|
||||
2. Deletion with wrong registration_access_token fails
|
||||
3. Deletion with correct registration_access_token succeeds
|
||||
"""
|
||||
keycloak_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
||||
)
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# OIDC Discovery
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_response = await client.get(keycloak_discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
if not registration_endpoint:
|
||||
pytest.skip("Keycloak DCR not enabled")
|
||||
|
||||
# Register client
|
||||
logger.info("Registering OAuth client for wrong token test...")
|
||||
keycloak_host = keycloak_discovery_url.replace(
|
||||
"/.well-known/openid-configuration", ""
|
||||
)
|
||||
client_info = await register_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Keycloak DCR Wrong Token Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type=None, # Keycloak doesn't support token_type field
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# Try to delete with wrong registration_access_token
|
||||
logger.info("Attempting deletion with wrong registration_access_token...")
|
||||
wrong_token = "wrong_token_" + secrets.token_urlsafe(32)
|
||||
|
||||
success = await delete_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=wrong_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert not success, "Deletion with wrong token should fail"
|
||||
logger.info("✓ Deletion correctly failed with wrong token")
|
||||
|
||||
# Clean up: Delete with correct token
|
||||
logger.info("Cleaning up: deleting with correct registration_access_token...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "Deletion with correct token should succeed"
|
||||
logger.info("✓ Cleanup successful")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_keycloak_dcr_deletion_is_idempotent(
|
||||
anyio_backend,
|
||||
oauth_callback_server,
|
||||
):
|
||||
"""
|
||||
Test that deleting the same client twice fails gracefully on second attempt.
|
||||
|
||||
Verifies:
|
||||
1. First deletion succeeds
|
||||
2. Second deletion fails gracefully (no exception, returns False)
|
||||
"""
|
||||
keycloak_discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
||||
)
|
||||
|
||||
auth_states, callback_url = oauth_callback_server
|
||||
|
||||
# OIDC Discovery
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
discovery_response = await client.get(keycloak_discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
oidc_config = discovery_response.json()
|
||||
|
||||
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||
|
||||
if not registration_endpoint:
|
||||
pytest.skip("Keycloak DCR not enabled")
|
||||
|
||||
# Register client
|
||||
logger.info("Registering OAuth client for idempotency test...")
|
||||
keycloak_host = keycloak_discovery_url.replace(
|
||||
"/.well-known/openid-configuration", ""
|
||||
)
|
||||
client_info = await register_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
registration_endpoint=registration_endpoint,
|
||||
client_name="Keycloak DCR Idempotency Test",
|
||||
redirect_uris=[callback_url],
|
||||
scopes="openid profile email",
|
||||
token_type=None, # Keycloak doesn't support token_type field
|
||||
)
|
||||
|
||||
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
||||
|
||||
# First deletion
|
||||
logger.info("First deletion attempt...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert success, "First deletion should succeed"
|
||||
logger.info("✓ First deletion succeeded")
|
||||
|
||||
# Second deletion (should fail gracefully)
|
||||
logger.info("Second deletion attempt (should fail)...")
|
||||
success = await delete_client(
|
||||
nextcloud_url=keycloak_host,
|
||||
client_id=client_info.client_id,
|
||||
registration_access_token=client_info.registration_access_token,
|
||||
client_secret=client_info.client_secret,
|
||||
registration_client_uri=client_info.registration_client_uri,
|
||||
)
|
||||
|
||||
assert not success, "Second deletion should fail (client already deleted)"
|
||||
logger.info("✓ Second deletion correctly failed (client already deleted)")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Documentation Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_keycloak_dcr_architecture():
|
||||
"""
|
||||
Document the Keycloak DCR architecture for reference.
|
||||
|
||||
This test captures the design and flow for DCR with external IdPs.
|
||||
"""
|
||||
architecture = {
|
||||
"flow": [
|
||||
"1. MCP client discovers Keycloak OIDC endpoints via .well-known/openid-configuration",
|
||||
"2. MCP client registers via Keycloak DCR endpoint (RFC 7591)",
|
||||
"3. Keycloak returns client_id, client_secret, registration_access_token",
|
||||
"4. MCP client uses credentials to obtain OAuth token",
|
||||
"5. MCP client uses token to authenticate with MCP server",
|
||||
"6. MCP server validates token via Nextcloud user_oidc app",
|
||||
"7. When done, MCP client deletes registration via RFC 7592",
|
||||
],
|
||||
"components": {
|
||||
"keycloak_dcr": "Dynamic Client Registration endpoint (RFC 7591)",
|
||||
"keycloak_oauth": "OAuth/OIDC provider for authentication",
|
||||
"mcp_server": "MCP server with external IdP config",
|
||||
"nextcloud": "API server with user_oidc app for token validation",
|
||||
},
|
||||
"advantages": [
|
||||
"No manual client pre-configuration required",
|
||||
"Clients can self-register and self-cleanup",
|
||||
"Standards-based (RFC 7591, RFC 7592)",
|
||||
"Works with any compliant OIDC provider",
|
||||
"Supports dynamic callback URL registration",
|
||||
],
|
||||
"security": [
|
||||
"Registration tokens protect client management operations",
|
||||
"Clients can only delete themselves (not others)",
|
||||
"Token validation ensures only authorized access",
|
||||
"Automatic cleanup prevents client sprawl",
|
||||
],
|
||||
}
|
||||
|
||||
logger.info("Keycloak DCR Architecture:")
|
||||
import json
|
||||
|
||||
logger.info(json.dumps(architecture, indent=2))
|
||||
|
||||
assert True
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Integration tests for login elicitation flow (ADR-006 Interim Implementation).
|
||||
|
||||
Tests verify:
|
||||
1. check_logged_in tool with elicitation for unauthenticated users
|
||||
2. Elicitation contains login URL in message
|
||||
3. User can complete login via OAuth
|
||||
4. After login, check_logged_in returns "yes"
|
||||
5. Already-authenticated users get immediate "yes" response
|
||||
6. Elicitation decline/cancel handling
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||
|
||||
|
||||
async def test_check_logged_in_elicitation_flow(
|
||||
nc_mcp_oauth_client, browser, oauth_callback_server
|
||||
):
|
||||
"""Test that check_logged_in elicits login for unauthenticated user.
|
||||
|
||||
This test validates the complete elicitation flow:
|
||||
1. Call check_logged_in on authenticated client (already has refresh token)
|
||||
2. Verify tool returns "yes" without elicitation
|
||||
3. Extract and validate the elicitation URL format from response
|
||||
4. Verify refresh token exists after successful OAuth flow
|
||||
|
||||
Note: Actual elicitation handling requires MCP protocol support in the test client.
|
||||
This test validates the response format and token storage.
|
||||
"""
|
||||
# Call check_logged_in tool on authenticated client
|
||||
logger.info("Calling check_logged_in on authenticated client")
|
||||
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
|
||||
response_text = result.content[0].text
|
||||
logger.info(f"check_logged_in response: {response_text}")
|
||||
|
||||
# Since nc_mcp_oauth_client fixture already completes OAuth during setup,
|
||||
# the user should already be provisioned and we expect "yes"
|
||||
# For unauthenticated users, the response would contain an elicitation URL
|
||||
# Note: Test framework may return "elicitation not supported" if MCP elicitation is unavailable
|
||||
assert (
|
||||
"yes" in response_text.lower()
|
||||
or "http" in response_text.lower()
|
||||
or "elicitation not supported" in response_text.lower()
|
||||
), f"Unexpected response: {response_text}"
|
||||
|
||||
# If response contains a URL (elicitation case), validate its format
|
||||
if "http" in response_text:
|
||||
url_pattern = r"https?://[^\s]+"
|
||||
urls = re.findall(url_pattern, response_text)
|
||||
assert len(urls) > 0, "Expected elicitation URL in response"
|
||||
|
||||
login_url = urls[0]
|
||||
logger.info(f"Elicitation URL: {login_url}")
|
||||
|
||||
# Validate URL points to MCP server's Flow 2 endpoint
|
||||
assert "/oauth/authorize-nextcloud" in login_url, (
|
||||
f"Expected URL to point to MCP server Flow 2 endpoint, got: {login_url}"
|
||||
)
|
||||
# Validate URL contains state parameter
|
||||
assert "state=" in login_url, "Expected state parameter in elicitation URL"
|
||||
elif "elicitation not supported" in response_text.lower():
|
||||
logger.info(
|
||||
"✓ Test client doesn't support elicitation - this is expected in test environment"
|
||||
)
|
||||
|
||||
|
||||
async def test_check_logged_in_already_authenticated(nc_mcp_oauth_client):
|
||||
"""Test that check_logged_in returns 'yes' for authenticated user.
|
||||
|
||||
This test verifies that if the user has already completed Flow 2
|
||||
(resource provisioning), the tool immediately returns "yes" without
|
||||
elicitation.
|
||||
"""
|
||||
logger.info("Calling check_logged_in on authenticated client")
|
||||
|
||||
# Since we're using the nc_mcp_oauth_client fixture which completes
|
||||
# OAuth during setup, the user should already be provisioned
|
||||
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
|
||||
response_text = result.content[0].text
|
||||
logger.info(f"Response: {response_text}")
|
||||
|
||||
# Check for valid responses:
|
||||
# - "yes" (already logged in)
|
||||
# - "not enabled" (offline access not enabled)
|
||||
# - "not configured" (MCP_SERVER_CLIENT_ID not set)
|
||||
# - "elicitation not supported" (test environment limitation)
|
||||
assert (
|
||||
"yes" in response_text.lower()
|
||||
or "not enabled" in response_text.lower()
|
||||
or "not configured" in response_text.lower()
|
||||
or "elicitation not supported" in response_text.lower()
|
||||
)
|
||||
|
||||
|
||||
async def test_check_logged_in_url_format(nc_mcp_oauth_client):
|
||||
"""Test that login URL (when needed) follows correct OAuth format.
|
||||
|
||||
This test verifies that if the tool needs to provide a login URL,
|
||||
the URL contains the correct OAuth parameters for Flow 2.
|
||||
"""
|
||||
# Call the tool
|
||||
result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={})
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
|
||||
response_text = result.content[0].text
|
||||
logger.info(f"Response: {response_text}")
|
||||
|
||||
# If response contains a URL, validate it
|
||||
url_pattern = r"https?://[^\s]+"
|
||||
urls = re.findall(url_pattern, response_text)
|
||||
|
||||
if urls:
|
||||
login_url = urls[0]
|
||||
logger.info(f"Found login URL: {login_url}")
|
||||
|
||||
# Validate OAuth parameters
|
||||
assert "response_type=code" in login_url
|
||||
assert "client_id=" in login_url
|
||||
assert "redirect_uri=" in login_url
|
||||
assert "scope=" in login_url
|
||||
assert "state=" in login_url
|
||||
assert "openid" in login_url # Should request openid scope
|
||||
|
||||
# Validate callback URL (unified endpoint without query params)
|
||||
# Note: redirect_uri should be /oauth/callback (no query params)
|
||||
# Flow type is determined by session lookup, not URL params
|
||||
assert (
|
||||
"/oauth/callback" in login_url
|
||||
or "callback-nextcloud" in login_url # Legacy support
|
||||
or "authorize-nextcloud" in login_url
|
||||
)
|
||||
|
||||
|
||||
async def test_check_logged_in_with_user_id(nc_mcp_oauth_client):
|
||||
"""Test that check_logged_in accepts optional user_id parameter.
|
||||
|
||||
This verifies the tool can be called with an explicit user_id.
|
||||
"""
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"check_logged_in", arguments={"user_id": "testuser"}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
|
||||
response_text = result.content[0].text
|
||||
logger.info(f"Response with user_id: {response_text}")
|
||||
|
||||
# Should get some response (either yes or not logged in)
|
||||
assert len(response_text) > 0
|
||||
|
||||
|
||||
async def test_check_logged_in_tool_metadata(nc_mcp_oauth_client):
|
||||
"""Test that check_logged_in tool has correct metadata."""
|
||||
tools = await nc_mcp_oauth_client.list_tools()
|
||||
assert tools is not None
|
||||
|
||||
# Find the check_logged_in tool
|
||||
check_logged_in_tool = None
|
||||
for tool in tools.tools:
|
||||
if tool.name == "check_logged_in":
|
||||
check_logged_in_tool = tool
|
||||
break
|
||||
|
||||
assert check_logged_in_tool is not None, "check_logged_in tool not found"
|
||||
logger.info(f"Tool: {check_logged_in_tool.name}")
|
||||
logger.info(f"Description: {check_logged_in_tool.description}")
|
||||
|
||||
# Verify description mentions login
|
||||
assert "login" in check_logged_in_tool.description.lower()
|
||||
|
||||
# Tool should have openid scope requirement
|
||||
# (This would need to be verified via tool schema if exposed)
|
||||
|
||||
|
||||
async def test_elicitation_url_and_refresh_token_flow(nc_mcp_oauth_client):
|
||||
"""Test that MCP server validates refresh tokens after OAuth completion.
|
||||
|
||||
This test validates the server's refresh token handling through its API:
|
||||
1. Call check_provisioning_status to verify server-side token validation
|
||||
2. Server responses indicate token state:
|
||||
- is_provisioned=True: Server has valid refresh token
|
||||
- is_provisioned=False: No token or invalid token
|
||||
- Error response: Token validation failed
|
||||
|
||||
The test does NOT directly access refresh token storage - it relies on
|
||||
the MCP server to validate tokens internally and report status via API.
|
||||
"""
|
||||
logger.info("Testing server-side refresh token validation via API")
|
||||
|
||||
# Call check_provisioning_status - the server will internally:
|
||||
# 1. Check if refresh token exists for the user
|
||||
# 2. Validate the refresh token is not expired
|
||||
# 3. Return provisioning status
|
||||
result = await nc_mcp_oauth_client.call_tool(
|
||||
"check_provisioning_status", arguments={}
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool execution failed: {result.content}"
|
||||
assert result.content is not None
|
||||
|
||||
response_text = result.content[0].text
|
||||
logger.info(f"Provisioning status response: {response_text}")
|
||||
|
||||
# Parse the response to validate server's token validation
|
||||
# Expected responses:
|
||||
# 1. "is_provisioned: true" - server validated token successfully
|
||||
# 2. "is_provisioned: false" - no token or invalid token
|
||||
# 3. Error message - token validation failed
|
||||
|
||||
if "is_provisioned" in response_text.lower():
|
||||
if "true" in response_text.lower():
|
||||
logger.info("✓ Server validated refresh token: is_provisioned=True")
|
||||
logger.info(" This confirms the server has a valid refresh token stored")
|
||||
else:
|
||||
logger.info("Server reports: is_provisioned=False (no valid token)")
|
||||
elif "error" in response_text.lower():
|
||||
logger.warning(
|
||||
f"Server returned error during token validation: {response_text}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Server response: {response_text}")
|
||||
|
||||
# The key validation: Server must return a valid response
|
||||
# (not an error), proving it can check its own refresh token state
|
||||
assert (
|
||||
"is_provisioned" in response_text.lower() or "offline" in response_text.lower()
|
||||
), f"Expected provisioning status response from server, got: {response_text}"
|
||||
|
||||
logger.info("✓ Server successfully validated refresh token state via API")
|
||||
@@ -412,7 +412,7 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
|
||||
tool_names = [tool.name for tool in result.tools]
|
||||
logger.info(
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 3 OAuth tools)"
|
||||
f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 4 OAuth tools)"
|
||||
)
|
||||
|
||||
# Only OAuth provisioning tools should be visible (they require 'openid' scope)
|
||||
@@ -420,6 +420,7 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools(
|
||||
"provision_nextcloud_access",
|
||||
"revoke_nextcloud_access",
|
||||
"check_provisioning_status",
|
||||
"check_logged_in", # Login elicitation tool (ADR-006)
|
||||
]
|
||||
|
||||
assert set(tool_names) == set(expected_oauth_tools), (
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for configuration validation."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.config import Settings, get_settings
|
||||
|
||||
|
||||
class TestQdrantConfigValidation:
|
||||
"""Test Qdrant configuration validation."""
|
||||
|
||||
def test_mutually_exclusive_url_and_location(self):
|
||||
"""Test that setting both QDRANT_URL and QDRANT_LOCATION raises ValueError."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
|
||||
):
|
||||
Settings(
|
||||
qdrant_url="http://qdrant:6333",
|
||||
qdrant_location="/app/data/qdrant",
|
||||
)
|
||||
|
||||
def test_default_to_memory_mode(self):
|
||||
"""Test that :memory: is used when neither URL nor location is set."""
|
||||
settings = Settings()
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
def test_network_mode_only(self):
|
||||
"""Test network mode with only URL set."""
|
||||
settings = Settings(qdrant_url="http://qdrant:6333")
|
||||
assert settings.qdrant_url == "http://qdrant:6333"
|
||||
assert settings.qdrant_location is None
|
||||
|
||||
def test_local_mode_only(self):
|
||||
"""Test local mode with only location set."""
|
||||
settings = Settings(qdrant_location="/app/data/qdrant")
|
||||
assert settings.qdrant_location == "/app/data/qdrant"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
def test_in_memory_mode_explicit(self):
|
||||
"""Test explicit in-memory mode."""
|
||||
settings = Settings(qdrant_location=":memory:")
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
def test_api_key_warning_in_local_mode(self, caplog):
|
||||
"""Test that API key in local mode triggers warning."""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||
Settings(
|
||||
qdrant_location=":memory:",
|
||||
qdrant_api_key="test-api-key",
|
||||
)
|
||||
assert "API key is only relevant for network mode" in caplog.text
|
||||
|
||||
def test_api_key_no_warning_in_network_mode(self, caplog):
|
||||
"""Test that API key in network mode doesn't trigger warning."""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||
Settings(
|
||||
qdrant_url="http://qdrant:6333",
|
||||
qdrant_api_key="test-api-key",
|
||||
)
|
||||
assert "API key is only relevant for network mode" not in caplog.text
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""Test get_settings() function with environment variables."""
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_get_settings_defaults_to_memory(self):
|
||||
"""Test get_settings() defaults to :memory: when no env vars set."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"QDRANT_URL": "http://qdrant:6333",
|
||||
"QDRANT_API_KEY": "test-key",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_network_mode(self):
|
||||
"""Test get_settings() with network mode env vars."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_url == "http://qdrant:6333"
|
||||
assert settings.qdrant_api_key == "test-key"
|
||||
assert settings.qdrant_location is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"QDRANT_LOCATION": "/app/data/qdrant"},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_persistent_mode(self):
|
||||
"""Test get_settings() with persistent local mode env vars."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_location == "/app/data/qdrant"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{"QDRANT_LOCATION": ":memory:"},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_explicit_memory(self):
|
||||
"""Test get_settings() with explicit :memory: env var."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_location == ":memory:"
|
||||
assert settings.qdrant_url is None
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"QDRANT_URL": "http://qdrant:6333",
|
||||
"QDRANT_LOCATION": "/app/data/qdrant",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_mutual_exclusion_error(self):
|
||||
"""Test get_settings() raises error when both URL and location set."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
|
||||
):
|
||||
get_settings()
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"QDRANT_COLLECTION": "test_collection",
|
||||
"VECTOR_SYNC_ENABLED": "true",
|
||||
"VECTOR_SYNC_SCAN_INTERVAL": "600",
|
||||
"VECTOR_SYNC_PROCESSOR_WORKERS": "5",
|
||||
"VECTOR_SYNC_QUEUE_MAX_SIZE": "5000",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_vector_sync_config(self):
|
||||
"""Test get_settings() with vector sync configuration."""
|
||||
settings = get_settings()
|
||||
assert settings.qdrant_collection == "test_collection"
|
||||
assert settings.vector_sync_enabled is True
|
||||
assert settings.vector_sync_scan_interval == 600
|
||||
assert settings.vector_sync_processor_workers == 5
|
||||
assert settings.vector_sync_queue_max_size == 5000
|
||||
@@ -8,6 +8,10 @@ from nextcloud_mcp_server.models.notes import (
|
||||
NoteSearchResult,
|
||||
SearchNotesResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.models.semantic import (
|
||||
SamplingSearchResponse,
|
||||
SemanticSearchResult,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -121,3 +125,145 @@ def test_note_search_result_without_score():
|
||||
|
||||
assert result.id == 99
|
||||
assert result.score is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sampling_search_response_with_answer():
|
||||
"""Test SamplingSearchResponse with LLM-generated answer."""
|
||||
sources = [
|
||||
SemanticSearchResult(
|
||||
id=1,
|
||||
doc_type="note",
|
||||
title="Python Guide",
|
||||
category="Development",
|
||||
excerpt="Use async/await for asynchronous programming",
|
||||
score=0.92,
|
||||
chunk_index=0,
|
||||
total_chunks=3,
|
||||
),
|
||||
SemanticSearchResult(
|
||||
id=2,
|
||||
doc_type="note",
|
||||
title="Best Practices",
|
||||
category="Development",
|
||||
excerpt="Always use context managers with async operations",
|
||||
score=0.85,
|
||||
chunk_index=1,
|
||||
total_chunks=2,
|
||||
),
|
||||
]
|
||||
|
||||
response = SamplingSearchResponse(
|
||||
query="How do I use async in Python?",
|
||||
generated_answer="Based on Document 1 and Document 2, use async/await for asynchronous programming and always use context managers.",
|
||||
sources=sources,
|
||||
total_found=2,
|
||||
search_method="semantic_sampling",
|
||||
model_used="claude-3-5-sonnet",
|
||||
stop_reason="endTurn",
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Verify the response structure
|
||||
assert response.query == "How do I use async in Python?"
|
||||
assert "async/await" in response.generated_answer
|
||||
assert len(response.sources) == 2
|
||||
assert response.sources[0].id == 1
|
||||
assert response.sources[0].score == 0.92
|
||||
assert response.total_found == 2
|
||||
assert response.search_method == "semantic_sampling"
|
||||
assert response.model_used == "claude-3-5-sonnet"
|
||||
assert response.stop_reason == "endTurn"
|
||||
assert response.success is True
|
||||
|
||||
# Verify it serializes correctly
|
||||
data = response.model_dump()
|
||||
assert "query" in data
|
||||
assert "generated_answer" in data
|
||||
assert "sources" in data
|
||||
assert isinstance(data["sources"], list)
|
||||
assert len(data["sources"]) == 2
|
||||
assert data["sources"][0]["id"] == 1
|
||||
assert data["model_used"] == "claude-3-5-sonnet"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sampling_search_response_fallback():
|
||||
"""Test SamplingSearchResponse when sampling fails (fallback mode)."""
|
||||
sources = [
|
||||
SemanticSearchResult(
|
||||
id=1,
|
||||
doc_type="note",
|
||||
title="Note 1",
|
||||
category="Work",
|
||||
excerpt="Some content",
|
||||
score=0.75,
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
)
|
||||
]
|
||||
|
||||
response = SamplingSearchResponse(
|
||||
query="test query",
|
||||
generated_answer="[Sampling unavailable: Client does not support sampling]\n\nFound 1 relevant documents. Please review the sources below.",
|
||||
sources=sources,
|
||||
total_found=1,
|
||||
search_method="semantic_sampling_fallback",
|
||||
model_used=None,
|
||||
stop_reason=None,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Verify fallback behavior
|
||||
assert "[Sampling unavailable" in response.generated_answer
|
||||
assert response.search_method == "semantic_sampling_fallback"
|
||||
assert response.model_used is None
|
||||
assert response.stop_reason is None
|
||||
assert len(response.sources) == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sampling_search_response_no_results():
|
||||
"""Test SamplingSearchResponse when no documents found."""
|
||||
response = SamplingSearchResponse(
|
||||
query="nonexistent topic",
|
||||
generated_answer="No relevant documents found in your Nextcloud Notes for this query.",
|
||||
sources=[],
|
||||
total_found=0,
|
||||
search_method="semantic_sampling",
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Verify no results case
|
||||
assert response.total_found == 0
|
||||
assert len(response.sources) == 0
|
||||
assert "No relevant documents" in response.generated_answer
|
||||
assert response.model_used is None
|
||||
assert response.stop_reason is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_sampling_search_response_serialization():
|
||||
"""Test SamplingSearchResponse serializes to JSON correctly."""
|
||||
response = SamplingSearchResponse(
|
||||
query="test",
|
||||
generated_answer="Test answer",
|
||||
sources=[],
|
||||
total_found=0,
|
||||
search_method="semantic_sampling",
|
||||
model_used="claude-3-5-sonnet",
|
||||
stop_reason="maxTokens",
|
||||
success=True,
|
||||
)
|
||||
|
||||
data = response.model_dump()
|
||||
|
||||
# Check all fields are present
|
||||
assert data["query"] == "test"
|
||||
assert data["generated_answer"] == "Test answer"
|
||||
assert data["sources"] == []
|
||||
assert data["total_found"] == 0
|
||||
assert data["search_method"] == "semantic_sampling"
|
||||
assert data["model_used"] == "claude-3-5-sonnet"
|
||||
assert data["stop_reason"] == "maxTokens"
|
||||
assert data["success"] is True
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
Unit tests for UnifiedTokenVerifier (ADR-005).
|
||||
|
||||
Tests token audience validation for both multi-audience and token exchange modes
|
||||
without requiring real network calls or IdP connections.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.config import Settings
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_settings():
|
||||
"""Create base settings for testing."""
|
||||
return Settings(
|
||||
oidc_client_id="test-client-id",
|
||||
oidc_client_secret="test-client-secret",
|
||||
oidc_issuer="https://idp.example.com",
|
||||
nextcloud_host="https://nextcloud.example.com",
|
||||
nextcloud_mcp_server_url="http://localhost:8000",
|
||||
nextcloud_resource_uri="http://localhost:8080",
|
||||
jwks_uri="https://idp.example.com/jwks",
|
||||
introspection_uri="https://idp.example.com/introspect",
|
||||
enable_token_exchange=False, # Multi-audience mode
|
||||
token_exchange_cache_ttl=300,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exchange_settings(base_settings):
|
||||
"""Create settings for token exchange mode."""
|
||||
base_settings.enable_token_exchange = True
|
||||
return base_settings
|
||||
|
||||
|
||||
class TestUnifiedTokenVerifierInit:
|
||||
"""Test UnifiedTokenVerifier initialization."""
|
||||
|
||||
def test_init_multi_audience_mode(self, base_settings):
|
||||
"""Test verifier initialization in multi-audience mode."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
assert verifier.mode == "multi-audience"
|
||||
assert verifier.settings == base_settings
|
||||
|
||||
def test_init_exchange_mode(self, exchange_settings):
|
||||
"""Test verifier initialization in token exchange mode."""
|
||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
||||
assert verifier.mode == "exchange"
|
||||
assert verifier.settings == exchange_settings
|
||||
|
||||
|
||||
class TestAudienceValidation:
|
||||
"""Test audience validation logic."""
|
||||
|
||||
def test_validate_multi_audience_both_present(self, base_settings):
|
||||
"""Test MCP audience validation with both audiences present."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
payload = {
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
assert verifier._has_mcp_audience(payload) is True
|
||||
|
||||
def test_validate_multi_audience_server_url_and_resource(self, base_settings):
|
||||
"""Test MCP audience validation with server URL instead of client ID."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
payload = {
|
||||
"aud": ["http://localhost:8000", "http://localhost:8080"],
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
assert verifier._has_mcp_audience(payload) is True
|
||||
|
||||
def test_validate_multi_audience_missing_mcp(self, base_settings):
|
||||
"""Test MCP audience validation fails without MCP audience."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
payload = {
|
||||
"aud": ["http://localhost:8080"], # Only Nextcloud
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
assert verifier._has_mcp_audience(payload) is False
|
||||
|
||||
def test_validate_multi_audience_missing_nextcloud(self, base_settings):
|
||||
"""Test MCP audience validation succeeds with only MCP audience (RFC 7519 compliant)."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
payload = {
|
||||
"aud": ["test-client-id"], # Only MCP
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
# Per RFC 7519, we only validate MCP audience. Nextcloud validates its own.
|
||||
assert verifier._has_mcp_audience(payload) is True
|
||||
|
||||
def test_validate_multi_audience_string_audience(self, base_settings):
|
||||
"""Test MCP audience validation with string audience works (RFC 7519 compliant)."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
payload = {
|
||||
"aud": "test-client-id", # Single audience as string
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
# Should pass - we only validate MCP audience per RFC 7519
|
||||
assert verifier._has_mcp_audience(payload) is True
|
||||
|
||||
def test_has_mcp_audience_with_client_id(self, exchange_settings):
|
||||
"""Test MCP audience validation with client ID."""
|
||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
||||
payload = {
|
||||
"aud": ["test-client-id"],
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
assert verifier._has_mcp_audience(payload) is True
|
||||
|
||||
def test_has_mcp_audience_with_server_url(self, exchange_settings):
|
||||
"""Test MCP audience validation with server URL."""
|
||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
||||
payload = {
|
||||
"aud": ["http://localhost:8000"],
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
assert verifier._has_mcp_audience(payload) is True
|
||||
|
||||
def test_has_mcp_audience_missing(self, exchange_settings):
|
||||
"""Test MCP audience validation fails without MCP audience."""
|
||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
||||
payload = {
|
||||
"aud": ["http://localhost:8080"], # Wrong audience
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
assert verifier._has_mcp_audience(payload) is False
|
||||
|
||||
|
||||
class TestTokenFormatDetection:
|
||||
"""Test JWT format detection."""
|
||||
|
||||
def test_is_jwt_format_valid(self, base_settings):
|
||||
"""Test JWT format detection with valid JWT."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
jwt_token = "eyJhbGc.eyJzdWI.signature"
|
||||
assert verifier._is_jwt_format(jwt_token) is True
|
||||
|
||||
def test_is_jwt_format_opaque(self, base_settings):
|
||||
"""Test JWT format detection with opaque token."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
opaque_token = "opaque-token-12345"
|
||||
assert verifier._is_jwt_format(opaque_token) is False
|
||||
|
||||
|
||||
class TestTokenCaching:
|
||||
"""Test token caching functionality."""
|
||||
|
||||
async def test_cache_stores_and_retrieves(self, base_settings):
|
||||
"""Test token caching stores and retrieves tokens."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
# Create a valid access token
|
||||
payload = {
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"sub": "testuser",
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
"client_id": "test-client-id",
|
||||
}
|
||||
test_token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
|
||||
# Create AccessToken and cache it
|
||||
access_token = verifier._create_access_token(test_token, payload)
|
||||
assert access_token is not None
|
||||
|
||||
# Should retrieve from cache
|
||||
cached = verifier._get_cached_token(test_token)
|
||||
assert cached is not None
|
||||
assert cached.resource == "testuser"
|
||||
assert cached.scopes == ["openid", "profile"]
|
||||
|
||||
async def test_cache_respects_expiry(self, base_settings):
|
||||
"""Test that expired tokens are not returned from cache."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
# Create expired token payload
|
||||
payload = {
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"sub": "testuser",
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() - 100), # Expired 100 seconds ago
|
||||
"client_id": "test-client-id",
|
||||
}
|
||||
test_token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
|
||||
# Create and cache
|
||||
access_token = verifier._create_access_token(test_token, payload)
|
||||
assert access_token is not None
|
||||
|
||||
# Should not retrieve expired token
|
||||
cached = verifier._get_cached_token(test_token)
|
||||
assert cached is None
|
||||
|
||||
async def test_cache_clear(self, base_settings):
|
||||
"""Test cache clearing."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
# Create and cache token
|
||||
payload = {
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"sub": "testuser",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
test_token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
verifier._create_access_token(test_token, payload)
|
||||
|
||||
# Clear cache
|
||||
verifier.clear_cache()
|
||||
|
||||
# Should not retrieve after clear
|
||||
cached = verifier._get_cached_token(test_token)
|
||||
assert cached is None
|
||||
|
||||
|
||||
class TestMultiAudienceVerification:
|
||||
"""Test multi-audience token verification."""
|
||||
|
||||
async def test_verify_multi_audience_with_introspection(self, base_settings):
|
||||
"""Test multi-audience verification using introspection."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
# Mock introspection response
|
||||
introspection_response = {
|
||||
"active": True,
|
||||
"sub": "testuser",
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
"client_id": "test-client-id",
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
verifier, "_introspect_token", return_value=introspection_response
|
||||
):
|
||||
opaque_token = "opaque-token-12345"
|
||||
result = await verifier._verify_mcp_audience(opaque_token)
|
||||
|
||||
assert result is not None
|
||||
assert result.resource == "testuser"
|
||||
assert result.scopes == ["openid", "profile"]
|
||||
|
||||
async def test_verify_multi_audience_fails_without_both_audiences(
|
||||
self, base_settings
|
||||
):
|
||||
"""Test MCP audience verification succeeds with only MCP audience (RFC 7519 compliant)."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
# Mock introspection response with only MCP audience
|
||||
introspection_response = {
|
||||
"active": True,
|
||||
"sub": "testuser",
|
||||
"aud": [
|
||||
"test-client-id"
|
||||
], # Only MCP audience (Nextcloud validates its own)
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
verifier, "_introspect_token", return_value=introspection_response
|
||||
):
|
||||
opaque_token = "opaque-token-12345"
|
||||
result = await verifier._verify_mcp_audience(opaque_token)
|
||||
|
||||
# Should succeed with only MCP audience per RFC 7519
|
||||
assert result is not None
|
||||
assert result.resource == "testuser"
|
||||
|
||||
|
||||
class TestExchangeModeVerification:
|
||||
"""Test token exchange mode verification."""
|
||||
|
||||
async def test_verify_mcp_audience_only_success(self, exchange_settings):
|
||||
"""Test MCP-only audience verification succeeds with MCP audience."""
|
||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
||||
|
||||
# Mock introspection response with MCP audience only
|
||||
introspection_response = {
|
||||
"active": True,
|
||||
"sub": "testuser",
|
||||
"aud": ["test-client-id"],
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
"client_id": "test-client-id",
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
verifier, "_introspect_token", return_value=introspection_response
|
||||
):
|
||||
opaque_token = "opaque-token-12345"
|
||||
result = await verifier._verify_mcp_audience(opaque_token)
|
||||
|
||||
assert result is not None
|
||||
assert result.resource == "testuser"
|
||||
|
||||
async def test_verify_mcp_audience_only_fails_without_mcp(self, exchange_settings):
|
||||
"""Test MCP audience verification fails without MCP audience."""
|
||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
||||
|
||||
# Mock introspection response without MCP audience
|
||||
introspection_response = {
|
||||
"active": True,
|
||||
"sub": "testuser",
|
||||
"aud": ["http://localhost:8080"], # Wrong audience
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
verifier, "_introspect_token", return_value=introspection_response
|
||||
):
|
||||
opaque_token = "opaque-token-12345"
|
||||
result = await verifier._verify_mcp_audience(opaque_token)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestIntrospection:
|
||||
"""Test token introspection."""
|
||||
|
||||
async def test_introspect_active_token(self, base_settings):
|
||||
"""Test introspection of active token."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
# Mock HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"active": True,
|
||||
"sub": "testuser",
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
"client_id": "test-client-id",
|
||||
}
|
||||
|
||||
verifier.http_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await verifier._introspect_token("test-token")
|
||||
assert result is not None
|
||||
assert result["active"] is True
|
||||
assert result["sub"] == "testuser"
|
||||
|
||||
async def test_introspect_inactive_token(self, base_settings):
|
||||
"""Test introspection of inactive token."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
# Mock HTTP response
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"active": False}
|
||||
|
||||
verifier.http_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await verifier._introspect_token("test-token")
|
||||
assert result is None
|
||||
|
||||
async def test_introspect_without_endpoint(self, base_settings):
|
||||
"""Test introspection when endpoint not configured."""
|
||||
base_settings.introspection_uri = None
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
result = await verifier._introspect_token("test-token")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAccessTokenCreation:
|
||||
"""Test AccessToken object creation."""
|
||||
|
||||
def test_create_access_token_success(self, base_settings):
|
||||
"""Test successful AccessToken creation."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
payload = {
|
||||
"sub": "testuser",
|
||||
"scope": "openid profile email",
|
||||
"exp": int(time.time() + 3600),
|
||||
"client_id": "test-client-id",
|
||||
}
|
||||
token = "test-token-123"
|
||||
|
||||
result = verifier._create_access_token(token, payload)
|
||||
assert result is not None
|
||||
assert result.token == token
|
||||
assert result.resource == "testuser"
|
||||
assert result.scopes == ["openid", "profile", "email"]
|
||||
assert result.client_id == "test-client-id"
|
||||
|
||||
def test_create_access_token_with_preferred_username(self, base_settings):
|
||||
"""Test AccessToken creation with preferred_username fallback."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
payload = {
|
||||
"preferred_username": "testuser", # No 'sub' claim
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
token = "test-token-123"
|
||||
|
||||
result = verifier._create_access_token(token, payload)
|
||||
assert result is not None
|
||||
assert result.resource == "testuser"
|
||||
|
||||
def test_create_access_token_no_username(self, base_settings):
|
||||
"""Test AccessToken creation fails without username."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
payload = {
|
||||
# No sub or preferred_username
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
token = "test-token-123"
|
||||
|
||||
result = verifier._create_access_token(token, payload)
|
||||
assert result is None
|
||||
|
||||
def test_create_access_token_no_expiry(self, base_settings):
|
||||
"""Test AccessToken creation uses default TTL without expiry."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
payload = {
|
||||
"sub": "testuser",
|
||||
"scope": "openid profile",
|
||||
# No exp claim
|
||||
}
|
||||
token = "test-token-123"
|
||||
|
||||
result = verifier._create_access_token(token, payload)
|
||||
assert result is not None
|
||||
# Should have set a default expiry
|
||||
assert result.expires_at > int(time.time())
|
||||
|
||||
|
||||
class TestVerifyTokenFlow:
|
||||
"""Test complete verify_token flow."""
|
||||
|
||||
async def test_verify_token_from_cache(self, base_settings):
|
||||
"""Test verify_token returns cached token."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
payload = {
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"sub": "testuser",
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
token = jwt.encode(payload, "secret", algorithm="HS256")
|
||||
|
||||
# First call - should cache
|
||||
result1 = verifier._create_access_token(token, payload)
|
||||
assert result1 is not None
|
||||
|
||||
# Mock _verify_mcp_audience to ensure it's not called
|
||||
with patch.object(verifier, "_verify_mcp_audience") as mock_verify:
|
||||
result2 = await verifier.verify_token(token)
|
||||
assert result2 is not None
|
||||
assert result2.resource == "testuser"
|
||||
# Should not call verification since it's cached
|
||||
mock_verify.assert_not_called()
|
||||
|
||||
async def test_verify_token_multi_audience_mode(self, base_settings):
|
||||
"""Test verify_token in multi-audience mode."""
|
||||
verifier = UnifiedTokenVerifier(base_settings)
|
||||
|
||||
introspection_response = {
|
||||
"active": True,
|
||||
"sub": "testuser",
|
||||
"aud": ["test-client-id", "http://localhost:8080"],
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
verifier, "_introspect_token", return_value=introspection_response
|
||||
):
|
||||
result = await verifier.verify_token("opaque-token")
|
||||
assert result is not None
|
||||
assert result.resource == "testuser"
|
||||
|
||||
async def test_verify_token_exchange_mode(self, exchange_settings):
|
||||
"""Test verify_token in exchange mode."""
|
||||
verifier = UnifiedTokenVerifier(exchange_settings)
|
||||
|
||||
introspection_response = {
|
||||
"active": True,
|
||||
"sub": "testuser",
|
||||
"aud": ["test-client-id"], # MCP audience only
|
||||
"scope": "openid profile",
|
||||
"exp": int(time.time() + 3600),
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
verifier, "_introspect_token", return_value=introspection_response
|
||||
):
|
||||
result = await verifier.verify_token("opaque-token")
|
||||
assert result is not None
|
||||
assert result.resource == "testuser"
|
||||
Vendored
+1
-1
Submodule third_party/oidc updated: 19cad6e5b6...e83dabbac1
@@ -537,6 +537,57 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.76.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -937,7 +988,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.19.0"
|
||||
version = "1.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -946,15 +997,16 @@ dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
{ name = "sse-starlette" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/2b/916852a5668f45d8787378461eaa1244876d77575ffef024483c94c0649c/mcp-1.19.0.tar.gz", hash = "sha256:213de0d3cd63f71bc08ffe9cc8d4409cc87acffd383f6195d2ce0457c021b5c1", size = 444163, upload-time = "2025-10-24T01:11:15.839Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697, upload-time = "2025-11-06T23:19:58.432Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a3/3e71a875a08b6a830b88c40bc413bff01f1650f1efe8a054b5e90a9d4f56/mcp-1.19.0-py3-none-any.whl", hash = "sha256:f5907fe1c0167255f916718f376d05f09a830a215327a3ccdd5ec8a519f2e572", size = 170105, upload-time = "2025-10-24T01:11:14.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672, upload-time = "2025-11-06T23:19:56.508Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -974,7 +1026,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.24.0"
|
||||
version = "0.26.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -988,6 +1040,7 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pythonvcard4" },
|
||||
{ name = "qdrant-client" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -1013,11 +1066,12 @@ requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.8" },
|
||||
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0,<7.0.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.19,<1.20" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.21,<1.22" },
|
||||
{ name = "pillow", specifier = ">=12.0.0,<12.1.0" },
|
||||
{ name = "pydantic", specifier = ">=2.11.4" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" },
|
||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||
{ name = "qdrant-client", specifier = ">=1.7.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -1035,6 +1089,87 @@ dev = [
|
||||
{ name = "ty", specifier = ">=0.0.1a25" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/d2/137c7b6841c942124eae921279e5c41b1c34bab0e6fc60c7348e69afd165/numpy-2.3.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36dc13af226aeab72b7abad501d370d606326a0029b9f435eacb3b8c94b8a8b7", size = 14591904, upload-time = "2025-10-15T16:15:29.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/32/67e3b0f07b0aba57a078c4ab777a9e8e6bc62f24fb53a2337f75f9691699/numpy-2.3.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7b2f9a18b5ff9824a6af80de4f37f4ec3c2aab05ef08f51c77a093f5b89adda", size = 16939602, upload-time = "2025-10-15T16:15:31.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/22/9639c30e32c93c4cee3ccdb4b09c2d0fbff4dcd06d36b357da06146530fb/numpy-2.3.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9984bd645a8db6ca15d850ff996856d8762c51a2239225288f08f9050ca240a0", size = 16372661, upload-time = "2025-10-15T16:15:33.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e9/a685079529be2b0156ae0c11b13d6be647743095bb51d46589e95be88086/numpy-2.3.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:64c5825affc76942973a70acf438a8ab618dbd692b84cd5ec40a0a0509edc09a", size = 18884682, upload-time = "2025-10-15T16:15:36.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/85/f6f00d019b0cc741e64b4e00ce865a57b6bed945d1bbeb1ccadbc647959b/numpy-2.3.4-cp311-cp311-win32.whl", hash = "sha256:ed759bf7a70342f7817d88376eb7142fab9fef8320d6019ef87fae05a99874e1", size = 6570076, upload-time = "2025-10-15T16:15:38.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/10/f8850982021cb90e2ec31990291f9e830ce7d94eef432b15066e7cbe0bec/numpy-2.3.4-cp311-cp311-win_amd64.whl", hash = "sha256:faba246fb30ea2a526c2e9645f61612341de1a83fb1e0c5edf4ddda5a9c10996", size = 13089358, upload-time = "2025-10-15T16:15:40.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ad/afdd8351385edf0b3445f9e24210a9c3971ef4de8fd85155462fc4321d79/numpy-2.3.4-cp311-cp311-win_arm64.whl", hash = "sha256:4c01835e718bcebe80394fd0ac66c07cbb90147ebbdad3dcecd3f25de2ae7e2c", size = 10462292, upload-time = "2025-10-15T16:15:42.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/7a/02420400b736f84317e759291b8edaeee9dc921f72b045475a9cbdb26b17/numpy-2.3.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1b5a3e808bc40827b5fa2c8196151a4c5abe110e1726949d7abddfe5c7ae11", size = 20957727, upload-time = "2025-10-15T16:15:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/90/a014805d627aa5750f6f0e878172afb6454552da929144b3c07fcae1bb13/numpy-2.3.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2f91f496a87235c6aaf6d3f3d89b17dba64996abadccb289f48456cff931ca9", size = 14187262, upload-time = "2025-10-15T16:15:47.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/e4/0a94b09abe89e500dc748e7515f21a13e30c5c3fe3396e6d4ac108c25fca/numpy-2.3.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:f77e5b3d3da652b474cc80a14084927a5e86a5eccf54ca8ca5cbd697bf7f2667", size = 5115992, upload-time = "2025-10-15T16:15:50.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/dd/db77c75b055c6157cbd4f9c92c4458daef0dd9cbe6d8d2fe7f803cb64c37/numpy-2.3.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ab1c5f5ee40d6e01cbe96de5863e39b215a4d24e7d007cad56c7184fdf4aeef", size = 6648672, upload-time = "2025-10-15T16:15:52.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/e6/e31b0d713719610e406c0ea3ae0d90760465b086da8783e2fd835ad59027/numpy-2.3.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77b84453f3adcb994ddbd0d1c5d11db2d6bda1a2b7fd5ac5bd4649d6f5dc682e", size = 14284156, upload-time = "2025-10-15T16:15:54.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a", size = 16641271, upload-time = "2025-10-15T16:15:56.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/f2/2e06a0f2adf23e3ae29283ad96959267938d0efd20a2e25353b70065bfec/numpy-2.3.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:65611ecbb00ac9846efe04db15cbe6186f562f6bb7e5e05f077e53a599225d16", size = 16059531, upload-time = "2025-10-15T16:15:59.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/e7/b106253c7c0d5dc352b9c8fab91afd76a93950998167fa3e5afe4ef3a18f/numpy-2.3.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dabc42f9c6577bcc13001b8810d300fe814b4cfbe8a92c873f269484594f9786", size = 18578983, upload-time = "2025-10-15T16:16:01.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e3/04ecc41e71462276ee867ccbef26a4448638eadecf1bc56772c9ed6d0255/numpy-2.3.4-cp312-cp312-win32.whl", hash = "sha256:a49d797192a8d950ca59ee2d0337a4d804f713bb5c3c50e8db26d49666e351dc", size = 6291380, upload-time = "2025-10-15T16:16:03.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/a8/566578b10d8d0e9955b1b6cd5db4e9d4592dd0026a941ff7994cedda030a/numpy-2.3.4-cp312-cp312-win_amd64.whl", hash = "sha256:985f1e46358f06c2a09921e8921e2c98168ed4ae12ccd6e5e87a4f1857923f32", size = 12787999, upload-time = "2025-10-15T16:16:05.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/22/9c903a957d0a8071b607f5b1bff0761d6e608b9a965945411f867d515db1/numpy-2.3.4-cp312-cp312-win_arm64.whl", hash = "sha256:4635239814149e06e2cb9db3dd584b2fa64316c96f10656983b8026a82e6e4db", size = 10197412, upload-time = "2025-10-15T16:16:07.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/7e/b72610cc91edf138bc588df5150957a4937221ca6058b825b4725c27be62/numpy-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c090d4860032b857d94144d1a9976b8e36709e40386db289aaf6672de2a81966", size = 20950335, upload-time = "2025-10-15T16:16:10.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/46/bdd3370dcea2f95ef14af79dbf81e6927102ddf1cc54adc0024d61252fd9/numpy-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a13fc473b6db0be619e45f11f9e81260f7302f8d180c49a22b6e6120022596b3", size = 14179878, upload-time = "2025-10-15T16:16:12.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/01/5a67cb785bda60f45415d09c2bc245433f1c68dd82eef9c9002c508b5a65/numpy-2.3.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:3634093d0b428e6c32c3a69b78e554f0cd20ee420dcad5a9f3b2a63762ce4197", size = 5108673, upload-time = "2025-10-15T16:16:14.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/cd/8428e23a9fcebd33988f4cb61208fda832800ca03781f471f3727a820704/numpy-2.3.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:043885b4f7e6e232d7df4f51ffdef8c36320ee9d5f227b380ea636722c7ed12e", size = 6641438, upload-time = "2025-10-15T16:16:16.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d1/913fe563820f3c6b079f992458f7331278dcd7ba8427e8e745af37ddb44f/numpy-2.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4ee6a571d1e4f0ea6d5f22d6e5fbd6ed1dc2b18542848e1e7301bd190500c9d7", size = 14281290, upload-time = "2025-10-15T16:16:18.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/7e/7d306ff7cb143e6d975cfa7eb98a93e73495c4deabb7d1b5ecf09ea0fd69/numpy-2.3.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc8a63918b04b8571789688b2780ab2b4a33ab44bfe8ccea36d3eba51228c953", size = 16636543, upload-time = "2025-10-15T16:16:21.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/6a/8cfc486237e56ccfb0db234945552a557ca266f022d281a2f577b98e955c/numpy-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:40cc556d5abbc54aabe2b1ae287042d7bdb80c08edede19f0c0afb36ae586f37", size = 16056117, upload-time = "2025-10-15T16:16:23.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/0e/42cb5e69ea901e06ce24bfcc4b5664a56f950a70efdcf221f30d9615f3f3/numpy-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ecb63014bb7f4ce653f8be7f1df8cbc6093a5a2811211770f6606cc92b5a78fd", size = 18577788, upload-time = "2025-10-15T16:16:27.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/92/41c3d5157d3177559ef0a35da50f0cda7fa071f4ba2306dd36818591a5bc/numpy-2.3.4-cp313-cp313-win32.whl", hash = "sha256:e8370eb6925bb8c1c4264fec52b0384b44f675f191df91cbe0140ec9f0955646", size = 6282620, upload-time = "2025-10-15T16:16:29.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/97/fd421e8bc50766665ad35536c2bb4ef916533ba1fdd053a62d96cc7c8b95/numpy-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:56209416e81a7893036eea03abcb91c130643eb14233b2515c90dcac963fe99d", size = 12784672, upload-time = "2025-10-15T16:16:31.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/df/5474fb2f74970ca8eb978093969b125a84cc3d30e47f82191f981f13a8a0/numpy-2.3.4-cp313-cp313-win_arm64.whl", hash = "sha256:a700a4031bc0fd6936e78a752eefb79092cecad2599ea9c8039c548bc097f9bc", size = 10196702, upload-time = "2025-10-15T16:16:33.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/83/66ac031464ec1767ea3ed48ce40f615eb441072945e98693bec0bcd056cc/numpy-2.3.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:86966db35c4040fdca64f0816a1c1dd8dbd027d90fca5a57e00e1ca4cd41b879", size = 21049003, upload-time = "2025-10-15T16:16:36.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/99/5b14e0e686e61371659a1d5bebd04596b1d72227ce36eed121bb0aeab798/numpy-2.3.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:838f045478638b26c375ee96ea89464d38428c69170360b23a1a50fa4baa3562", size = 14302980, upload-time = "2025-10-15T16:16:39.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/44/e9486649cd087d9fc6920e3fc3ac2aba10838d10804b1e179fb7cbc4e634/numpy-2.3.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d7315ed1dab0286adca467377c8381cd748f3dc92235f22a7dfc42745644a96a", size = 5231472, upload-time = "2025-10-15T16:16:41.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/51/902b24fa8887e5fe2063fd61b1895a476d0bbf46811ab0c7fdf4bd127345/numpy-2.3.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:84f01a4d18b2cc4ade1814a08e5f3c907b079c847051d720fad15ce37aa930b6", size = 6739342, upload-time = "2025-10-15T16:16:43.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/f1/4de9586d05b1962acdcdb1dc4af6646361a643f8c864cef7c852bf509740/numpy-2.3.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:817e719a868f0dacde4abdfc5c1910b301877970195db9ab6a5e2c4bd5b121f7", size = 14354338, upload-time = "2025-10-15T16:16:46.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/06/1c16103b425de7969d5a76bdf5ada0804b476fed05d5f9e17b777f1cbefd/numpy-2.3.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85e071da78d92a214212cacea81c6da557cab307f2c34b5f85b628e94803f9c0", size = 16702392, upload-time = "2025-10-15T16:16:48.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/b2/65f4dc1b89b5322093572b6e55161bb42e3e0487067af73627f795cc9d47/numpy-2.3.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2ec646892819370cf3558f518797f16597b4e4669894a2ba712caccc9da53f1f", size = 16134998, upload-time = "2025-10-15T16:16:51.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/11/94ec578896cdb973aaf56425d6c7f2aff4186a5c00fac15ff2ec46998b46/numpy-2.3.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:035796aaaddfe2f9664b9a9372f089cfc88bd795a67bd1bfe15e6e770934cf64", size = 18651574, upload-time = "2025-10-15T16:16:53.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b7/7efa763ab33dbccf56dade36938a77345ce8e8192d6b39e470ca25ff3cd0/numpy-2.3.4-cp313-cp313t-win32.whl", hash = "sha256:fea80f4f4cf83b54c3a051f2f727870ee51e22f0248d3114b8e755d160b38cfb", size = 6413135, upload-time = "2025-10-15T16:16:55.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/70/aba4c38e8400abcc2f345e13d972fb36c26409b3e644366db7649015f291/numpy-2.3.4-cp313-cp313t-win_amd64.whl", hash = "sha256:15eea9f306b98e0be91eb344a94c0e630689ef302e10c2ce5f7e11905c704f9c", size = 12928582, upload-time = "2025-10-15T16:16:57.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/63/871fad5f0073fc00fbbdd7232962ea1ac40eeaae2bba66c76214f7954236/numpy-2.3.4-cp313-cp313t-win_arm64.whl", hash = "sha256:b6c231c9c2fadbae4011ca5e7e83e12dc4a5072f1a1d85a0a7b3ed754d145a40", size = 10266691, upload-time = "2025-10-15T16:17:00.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/71/ae6170143c115732470ae3a2d01512870dd16e0953f8a6dc89525696069b/numpy-2.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:81c3e6d8c97295a7360d367f9f8553973651b76907988bb6066376bc2252f24e", size = 20955580, upload-time = "2025-10-15T16:17:02.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/39/4be9222ffd6ca8a30eda033d5f753276a9c3426c397bb137d8e19dedd200/numpy-2.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7c26b0b2bf58009ed1f38a641f3db4be8d960a417ca96d14e5b06df1506d41ff", size = 14188056, upload-time = "2025-10-15T16:17:04.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/3d/d85f6700d0a4aa4f9491030e1021c2b2b7421b2b38d01acd16734a2bfdc7/numpy-2.3.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:62b2198c438058a20b6704351b35a1d7db881812d8512d67a69c9de1f18ca05f", size = 5116555, upload-time = "2025-10-15T16:17:07.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/04/82c1467d86f47eee8a19a464c92f90a9bb68ccf14a54c5224d7031241ffb/numpy-2.3.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:9d729d60f8d53a7361707f4b68a9663c968882dd4f09e0d58c044c8bf5faee7b", size = 6643581, upload-time = "2025-10-15T16:17:09.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/d3/c79841741b837e293f48bd7db89d0ac7a4f2503b382b78a790ef1dc778a5/numpy-2.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd0c630cf256b0a7fd9d0a11c9413b42fef5101219ce6ed5a09624f5a65392c7", size = 14299186, upload-time = "2025-10-15T16:17:11.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/7e/4a14a769741fbf237eec5a12a2cbc7a4c4e061852b6533bcb9e9a796c908/numpy-2.3.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5e081bc082825f8b139f9e9fe42942cb4054524598aaeb177ff476cc76d09d2", size = 16638601, upload-time = "2025-10-15T16:17:14.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/87/1c1de269f002ff0a41173fe01dcc925f4ecff59264cd8f96cf3b60d12c9b/numpy-2.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15fb27364ed84114438fff8aaf998c9e19adbeba08c0b75409f8c452a8692c52", size = 16074219, upload-time = "2025-10-15T16:17:17.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/28/18f72ee77408e40a76d691001ae599e712ca2a47ddd2c4f695b16c65f077/numpy-2.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:85d9fb2d8cd998c84d13a79a09cc0c1091648e848e4e6249b0ccd7f6b487fa26", size = 18576702, upload-time = "2025-10-15T16:17:19.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/76/95650169b465ececa8cf4b2e8f6df255d4bf662775e797ade2025cc51ae6/numpy-2.3.4-cp314-cp314-win32.whl", hash = "sha256:e73d63fd04e3a9d6bc187f5455d81abfad05660b212c8804bf3b407e984cd2bc", size = 6337136, upload-time = "2025-10-15T16:17:22.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/89/a231a5c43ede5d6f77ba4a91e915a87dea4aeea76560ba4d2bf185c683f0/numpy-2.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:3da3491cee49cf16157e70f607c03a217ea6647b1cea4819c4f48e53d49139b9", size = 12920542, upload-time = "2025-10-15T16:17:24.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/0c/ae9434a888f717c5ed2ff2393b3f344f0ff6f1c793519fa0c540461dc530/numpy-2.3.4-cp314-cp314-win_arm64.whl", hash = "sha256:6d9cd732068e8288dbe2717177320723ccec4fb064123f0caf9bbd90ab5be868", size = 10480213, upload-time = "2025-10-15T16:17:26.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/4b/c4a5f0841f92536f6b9592694a5b5f68c9ab37b775ff342649eadf9055d3/numpy-2.3.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:22758999b256b595cf0b1d102b133bb61866ba5ceecf15f759623b64c020c9ec", size = 21052280, upload-time = "2025-10-15T16:17:29.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/80/90308845fc93b984d2cc96d83e2324ce8ad1fd6efea81b324cba4b673854/numpy-2.3.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9cb177bc55b010b19798dc5497d540dea67fd13a8d9e882b2dae71de0cf09eb3", size = 14302930, upload-time = "2025-10-15T16:17:32.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4e/07439f22f2a3b247cec4d63a713faae55e1141a36e77fb212881f7cda3fb/numpy-2.3.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0f2bcc76f1e05e5ab58893407c63d90b2029908fa41f9f1cc51eecce936c3365", size = 5231504, upload-time = "2025-10-15T16:17:34.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/de/1e11f2547e2fe3d00482b19721855348b94ada8359aef5d40dd57bfae9df/numpy-2.3.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dc20bde86802df2ed8397a08d793da0ad7a5fd4ea3ac85d757bf5dd4ad7c252", size = 6739405, upload-time = "2025-10-15T16:17:36.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/40/8cd57393a26cebe2e923005db5134a946c62fa56a1087dc7c478f3e30837/numpy-2.3.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e199c087e2aa71c8f9ce1cb7a8e10677dc12457e7cc1be4798632da37c3e86e", size = 14354866, upload-time = "2025-10-15T16:17:38.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/39/5b3510f023f96874ee6fea2e40dfa99313a00bf3ab779f3c92978f34aace/numpy-2.3.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85597b2d25ddf655495e2363fe044b0ae999b75bc4d630dc0d886484b03a5eb0", size = 16703296, upload-time = "2025-10-15T16:17:41.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/0d/19bb163617c8045209c1996c4e427bccbc4bbff1e2c711f39203c8ddbb4a/numpy-2.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04a69abe45b49c5955923cf2c407843d1c85013b424ae8a560bba16c92fe44a0", size = 16136046, upload-time = "2025-10-15T16:17:43.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c1/6dba12fdf68b02a21ac411c9df19afa66bed2540f467150ca64d246b463d/numpy-2.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e1708fac43ef8b419c975926ce1eaf793b0c13b7356cfab6ab0dc34c0a02ac0f", size = 18652691, upload-time = "2025-10-15T16:17:46.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/73/f85056701dbbbb910c51d846c58d29fd46b30eecd2b6ba760fc8b8a1641b/numpy-2.3.4-cp314-cp314t-win32.whl", hash = "sha256:863e3b5f4d9915aaf1b8ec79ae560ad21f0b8d5e3adc31e73126491bb86dee1d", size = 6485782, upload-time = "2025-10-15T16:17:48.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/90/28fa6f9865181cb817c2471ee65678afa8a7e2a1fb16141473d5fa6bacc3/numpy-2.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:962064de37b9aef801d33bc579690f8bfe6c5e70e29b61783f60bcba838a14d6", size = 13113301, upload-time = "2025-10-15T16:17:50.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/23/08c002201a8e7e1f9afba93b97deceb813252d9cfd0d3351caed123dcf97/numpy-2.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:8b5a9a39c45d852b62693d9b3f3e0fe052541f804296ff401a72a1b60edafb29", size = 10547532, upload-time = "2025-10-15T16:17:53.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/8c/cd283b54c3c2b77e188f63e23039844f56b23bba1712318288c13fe86baf/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e92defe6c08211eb77902253b14fe5b480ebc5112bc741fd5e9cd0608f847", size = 14422300, upload-time = "2025-10-15T16:18:04.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/f0/8404db5098d92446b3e3695cf41c6f0ecb703d701cb0b7566ee2177f2eee/numpy-2.3.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b9062e4f5c7ee5c7e5be96f29ba71bc5a37fed3d1d77c37390ae00724d296d", size = 16760806, upload-time = "2025-10-15T16:18:06.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/8e/2844c3959ce9a63acc7c8e50881133d86666f0420bcde695e115ced0920f/numpy-2.3.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:81b3a59793523e552c4a96109dde028aa4448ae06ccac5a76ff6532a85558a7f", size = 12973130, upload-time = "2025-10-15T16:18:09.397Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -1180,6 +1315,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portalocker"
|
||||
version = "3.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/77/65b857a69ed876e1951e88aaba60f5ce6120c33703f7cb61a3c894b8c1b6/portalocker-3.2.0.tar.gz", hash = "sha256:1f3002956a54a8c3730586c5c77bf18fae4149e07eaf1c29fc3faf4d5a3f89ac", size = 95644, upload-time = "2025-06-14T13:20:40.03Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.51"
|
||||
@@ -1192,6 +1339,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "6.33.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463, upload-time = "2025-10-15T20:39:52.159Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593, upload-time = "2025-10-15T20:39:40.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882, upload-time = "2025-10-15T20:39:42.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521, upload-time = "2025-10-15T20:39:43.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445, upload-time = "2025-10-15T20:39:44.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159, upload-time = "2025-10-15T20:39:46.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172, upload-time = "2025-10-15T20:39:47.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
@@ -1597,6 +1759,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qdrant-client"
|
||||
version = "1.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "grpcio" },
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
{ name = "numpy" },
|
||||
{ name = "portalocker" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/8b/76c7d325e11d97cb8eb5e261c3759e9ed6664735afbf32fdded5b580690c/qdrant_client-1.15.1.tar.gz", hash = "sha256:631f1f3caebfad0fd0c1fba98f41be81d9962b7bf3ca653bed3b727c0e0cbe0e", size = 295297, upload-time = "2025-07-31T19:35:19.627Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/33/d8df6a2b214ffbe4138db9a1efe3248f67dc3c671f82308bea1582ecbbb7/qdrant_client-1.15.1-py3-none-any.whl", hash = "sha256:2b975099b378382f6ca1cfb43f0d59e541be6e16a5892f282a4b8de7eff5cb63", size = 337331, upload-time = "2025-07-31T19:35:17.539Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.1"
|
||||
|
||||
Reference in New Issue
Block a user