Compare commits

...

23 Commits

Author SHA1 Message Date
Chris Coutinho 167e49788e feat(helm): add Qdrant local mode support with three deployment options [skip ci]
Add support for three Qdrant deployment modes in Helm chart:
1. In-memory mode (:memory:) - Default, zero-config, ephemeral storage
2. Persistent local mode (path-based) - File-based storage with PVC
3. Network mode (URL-based) - Dedicated Qdrant service or external instance

Changes:
- Restructured qdrant configuration in values.yaml with mode selector
- Added conditional environment variable logic in deployment.yaml
- Created PVC template for persistent local mode with optional existingClaim
- Added qdrantPvcName helper template in _helpers.tpl
- Updated README.md with Helm registry URL (https://cbcoutinho.github.io/nextcloud-mcp-server)

Breaking change: Default changed from requiring qdrant.enabled to using
in-memory mode (:memory:) when no Qdrant configuration is provided.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 07:14:19 +01:00
Chris Coutinho 857d8f2152 feat: add Qdrant local mode support with in-memory and persistent storage
Adds flexible Qdrant deployment modes to reduce infrastructure requirements
for local development and smaller deployments:

**Configuration Changes:**
- Add QDRANT_LOCATION environment variable (mutually exclusive with QDRANT_URL)
- Three modes: network (URL), in-memory (:memory:, default), persistent (file path)
- Settings dataclass validation via __post_init__ ensures mutual exclusivity
- API key warning when set in local mode (ignored, only for network mode)

**Client Initialization:**
- Auto-detect mode: network (url + api_key) vs local (:memory: or path=)
- In-memory: AsyncQdrantClient(":memory:") - zero config default
- Persistent: AsyncQdrantClient(path="/app/data/qdrant") - file storage
- Network: AsyncQdrantClient(url, api_key) - production mode

**Docker Compose Updates:**
- Qdrant service moved to optional profile (--profile qdrant)
- MCP service uses QDRANT_LOCATION=:memory: by default
- Added mcp-data volume for persistent storage (/app/data)
- No hard dependency on qdrant service

**Documentation:**
- Comprehensive configuration guide in docs/configuration.md
- All three modes documented with pros/cons
- Docker Compose examples for each mode
- Environment variable reference table

**Tests:**
- 13 new config validation tests (mutual exclusivity, defaults, warnings)
- Persistent mode integration test (create, close, reopen, verify persistence)
- All 82 unit tests + 5 smoke tests pass

**Breaking Change:**
- Default changed from QDRANT_URL=http://qdrant:6333 to QDRANT_LOCATION=:memory:
- Simplifies local development (no external service needed)
- Production deployments: explicitly set QDRANT_URL or QDRANT_LOCATION

Related: ADR-007 background vector sync implementation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 07:07:07 +01:00
Chris Coutinho 72232f937a refactor: migrate vector sync from asyncio.Queue to anyio memory object streams
Replace asyncio.Queue with anyio.create_memory_object_stream() throughout
the vector sync system for better library consistency and improved shutdown
semantics.

## Changes Made

**scanner.py**:
- Changed parameter type from `asyncio.Queue` to `MemoryObjectSendStream[DocumentTask]`
- Replaced all `await document_queue.put()` calls with `await send_stream.send()`
- Wrapped scanner loop in `async with send_stream:` context manager for automatic cleanup
- Updated log messages: "Queued" → "Sent"
- Removed `import asyncio` (no longer needed)

**processor.py**:
- Changed parameter type from `asyncio.Queue` to `MemoryObjectReceiveStream[DocumentTask]`
- Replaced `asyncio.wait_for(document_queue.get(), timeout=1.0)` with `anyio.fail_after(1.0)` + `await receive_stream.receive()`
- Removed all `document_queue.task_done()` calls (not needed with streams)
- Added `anyio.EndOfStream` exception handling for graceful shutdown when scanner closes
- Removed `import asyncio` (no longer needed)

**app.py**:
- Removed `import asyncio` from top-level imports
- Added `from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream`
- Updated AppContext dataclass:
  - Replaced `document_queue: Optional[asyncio.Queue]` with:
    - `document_send_stream: Optional[MemoryObjectSendStream]`
    - `document_receive_stream: Optional[MemoryObjectReceiveStream]`
- Updated `app_lifespan_basic()`:
  - Replaced `asyncio.Queue(maxsize=...)` with `anyio.create_memory_object_stream(max_buffer_size=...)`
  - Pass `send_stream` to scanner_task
  - Pass `receive_stream.clone()` to each processor_task (enables multiple consumers)
  - Updated AppContext yield to include both streams
- Updated `starlette_lifespan()`:
  - Same changes as app_lifespan_basic for streamable-http transport
  - Removed `import asyncio as asyncio_module` (no longer needed)
  - Updated app.state storage to use send_stream and receive_stream

**semantic.py**:
- Updated `nc_get_vector_sync_status()` tool:
  - Access `document_receive_stream` instead of `document_queue` from lifespan context
  - Use `stream_stats.current_buffer_used` instead of `queue.qsize()` for pending count
  - More reliable metrics (qsize() was not guaranteed accurate)

## Benefits

1. **Library Consistency**: Pure anyio throughout codebase (was mixing asyncio.Queue with anyio.Event and anyio.create_task_group)
2. **Graceful Shutdown**: `async with send_stream:` automatically closes stream on exit, signaling EndOfStream to all processors
3. **Better Timeout Handling**: `anyio.fail_after()` is more idiomatic than `asyncio.wait_for()`
4. **Stream Cloning**: Easy to add multiple consumers via `receive_stream.clone()`
5. **Better Statistics**: `.statistics()` provides accurate buffer metrics (qsize() was unreliable)
6. **Type Safety**: Separate send/receive types prevent accidental misuse
7. **No task_done() tracking**: Streams handle completion automatically

## Testing

-  All 69 unit tests passing
-  All 5 smoke tests passing
-  No regressions in functionality
-  Graceful shutdown behavior improved

## References

- https://anyio.readthedocs.io/en/stable/why.html#queue-fix
- https://anyio.readthedocs.io/en/stable/streams.html#memory-object-streams

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 06:43:44 +01:00
Chris Coutinho 4b026e9aa0 feat: implement ADR-009 - refactor semantic search to use generic semantic:read scope
This implements ADR-009, which documents the decision to use a generic
`semantic:read` OAuth scope instead of requiring all app-specific scopes
for semantic search functionality.

Changes:
- Created new `nextcloud_mcp_server/models/semantic.py` with semantic search models
  - SemanticSearchResult (with new doc_type field for multi-app support)
  - SemanticSearchResponse
  - SamplingSearchResponse
  - VectorSyncStatusResponse

- Created new `nextcloud_mcp_server/server/semantic.py` with semantic search tools
  - nc_semantic_search (renamed from nc_notes_semantic_search)
  - nc_semantic_search_answer (renamed from nc_notes_semantic_search_answer)
  - nc_get_vector_sync_status (renamed from nc_notes_get_vector_sync_status)
  - All tools now use @require_scopes("semantic:read") instead of "notes:read"

- Updated `nextcloud_mcp_server/server/notes.py`
  - Removed semantic search tools (moved to semantic.py)
  - Removed semantic search model imports
  - Removed unused MCP imports (ModelHint, ModelPreferences, etc.)

- Updated `nextcloud_mcp_server/models/notes.py`
  - Removed semantic search models (moved to semantic.py)

- Updated `nextcloud_mcp_server/app.py`
  - Import configure_semantic_tools
  - Register semantic tools when VECTOR_SYNC_ENABLED=true

- Updated `nextcloud_mcp_server/server/__init__.py`
  - Export configure_semantic_tools

- Updated tests
  - tests/integration/test_sampling.py: Use new tool names
  - tests/unit/test_response_models.py: Import from semantic.py, add doc_type field

Architecture:
- Semantic search is now a cross-app feature, not tied to Notes
- Uses dual-phase authorization: semantic:read scope + per-document verification
- Supports future multi-app indexing (notes, calendar, deck, files, contacts)

Test results:
- All 69 unit tests passing
- All 5 smoke tests passing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 05:53:53 +01:00
Chris Coutinho 31799ffd9a docs: remove VECTOR_SYNC_ENABLED_APPS env var, use per-user database settings
Replace static VECTOR_SYNC_ENABLED_APPS environment variable with per-user
database storage for which apps to index. This allows each user to control
their own indexing preferences (e.g., enable notes and calendar but not
deck or files).

Rationale:
- Nextcloud doesn't support granular OAuth scopes at the app level
- Per-user settings provide flexibility for multi-user deployments
- Users control app enablement via nc_enable_vector_sync MCP tool
- Aligns with OAuth architecture where users manage their own settings

Changes:
- ADR-007: Remove VECTOR_SYNC_ENABLED_APPS from configuration section
- ADR-007: Update scanner implementation to read from database
- ADR-007: Add explanation of per-user app enablement mechanism
- ADR-007: Clarify that nc_enable_vector_sync tool manages this setting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 05:11:56 +01:00
Chris Coutinho 5cc598e1b1 docs: refactor semantic search from notes-specific to multi-app architecture
Update ADRs to reflect that vector database and semantic search support
multiple Nextcloud apps (notes, calendar, deck, files, contacts) rather
than being notes-specific. Introduce semantic:read/write OAuth scopes
to replace app-specific scope requirements for cross-app search.

Changes:
- ADR-007: Add plugin architecture (DocumentScanner, DocumentProcessor,
  DocumentVerifier) for multi-app vector sync
- ADR-008: Rename tools from nc_notes_semantic_* to nc_semantic_*, update
  scope from notes:read to semantic:read
- ADR-009: NEW - Document decision to use generic semantic:read scope
  with dual-phase authorization instead of requiring all app scopes
- oauth-architecture.md: Add semantic:read/write scope documentation
- README.md: Move semantic search to dedicated section separate from Notes

This is a breaking change that correctly positions semantic search as a
cross-app capability before broader adoption. Existing deployments will
need to re-authenticate with the new semantic:read scope.

Relates to user request to decouple vector database from notes-only model
and establish proper OAuth scope boundaries for multi-app semantic search.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 04:47:20 +01:00
Chris Coutinho a6c76c5cc1 chore: Add openid scope to nc_notes_get_vector_sync_status 2025-11-09 03:27:17 +01:00
Chris Coutinho a854656d3c fix: implement deletion grace period and vector sync status tool
This commit addresses issues with vector database synchronization that
were causing test failures:

1. **Deletion Grace Period** (scanner.py)
   - Fixed premature deletion of documents due to pagination cursor
     inconsistencies in Notes API
   - Implemented 2-scan verification with 1.5x scan interval grace period
     (15 seconds default)
   - Documents must be missing for 2 consecutive scans before deletion
   - Documents that reappear are removed from deletion tracking
   - Prevents false deletions during concurrent note creation/indexing

2. **Vector Sync Status Tool** (server/notes.py, models/notes.py)
   - Added nc_notes_get_vector_sync_status MCP tool
   - Returns indexed_count, pending_count, status, and enabled fields
   - Enables tests and clients to wait for vector sync completion
   - Uses lifespan context to access document queue and Qdrant client

3. **Test Improvements** (test_sampling.py, conftest.py)
   - Added temporary_note_factory fixture for creating multiple test notes
   - Updated all sampling tests to wait for vector sync completion
   - Adjusted score_threshold to 0.0 for SimpleEmbeddingProvider
     (feature hashing produces low-quality embeddings)
   - Fixed CallToolResult extraction (removed ["result"] key access)
   - Removed invalid @pytest.mark.asyncio markers (anyio mode)

All integration tests now pass successfully.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 03:11:39 +01:00
Chris Coutinho bb5d4f464f feat: implement MCP sampling for semantic search RAG (ADR-008)
Add nc_notes_semantic_search_answer tool that combines semantic search
with MCP sampling to generate natural language answers from retrieved
Nextcloud Notes. This enables Retrieval-Augmented Generation (RAG)
patterns without requiring a server-side LLM.

Key features:
- Client-side LLM generation via ctx.session.create_message()
- Graceful fallback when sampling unavailable
- Proper source citations in generated answers
- No results optimization (skips sampling when no docs found)
- Comprehensive unit and integration tests

Implementation details:
- SamplingSearchResponse model with generated_answer and sources
- Fixed prompt template with document context and citation instructions
- Model preferences hint Claude Sonnet for balanced performance
- Falls back to returning documents without answer on sampling failure

Updates:
- Add ADR-008 documenting sampling architecture decision
- Add MCP sampling pattern guidance to CLAUDE.md
- Update README.md and docs/notes.md (7 → 9 tools)
- Add 4 unit tests and 6 integration tests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 01:00:18 +01:00
Chris Coutinho e32c8f4aec feat: add optional vector database and semantic search to helm chart
Add support for deploying Qdrant vector database and Ollama embedding
service as optional helm chart dependencies. Enables semantic search
capabilities for Nextcloud content with flexible deployment options.

Chart Dependencies:
- Add Qdrant v0.9.0 from qdrant/qdrant-helm (conditional)
- Add Ollama v1.33.0 from otwld/ollama-helm (conditional)
- Both dependencies only deploy when enabled

Configuration (values.yaml):
- vectorSync: Background sync settings (interval, workers, queue size)
- qdrant: Subchart config with persistence, resources, clustering
- ollama: Subchart config with model pull, persistence, resources
  - Support for external Ollama via ollama.url (no subchart deployment)
- openai: Alternative embedding provider (OpenAI or compatible API)

Environment Variables (deployment.yaml):
- VECTOR_SYNC_* variables when vectorSync.enabled
- QDRANT_URL, QDRANT_COLLECTION when qdrant.enabled
- OLLAMA_BASE_URL, OLLAMA_EMBEDDING_MODEL when ollama enabled or URL set
- OPENAI_API_KEY when openai.enabled

Documentation:
- README: New "Vector Search & Semantic Capabilities" section
- README: Example 5 showing three deployment patterns
- NOTES.txt: Conditional guidance when vector features enabled
- Secret template for OpenAI API key management

All features disabled by default for backward compatibility.
Tested with helm template and helm lint.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 00:03:51 +01:00
Chris Coutinho ee183e1c1c feat: add vector sync processing status to /user/page endpoint
Add real-time processing status display to the browser UI at /user/page
showing indexed document count, pending queue size, and sync status.
Implements the status display described in ADR-007 lines 280-298.

Changes:
- Store document_queue and related state in app.state for route access
- Add _get_processing_status() helper to query Qdrant and check queue
- Display status section in user_info_html() with indexed/pending counts
- Show color-coded status badge (green "Idle" or orange "Syncing")
- Only displays when VECTOR_SYNC_ENABLED=true

Status appears in both BasicAuth and OAuth modes, positioned after
session info but before logout buttons. Numbers are formatted with
commas for readability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 23:59:18 +01:00
Chris Coutinho 1a57f97d3a refactor: update to Qdrant query_points API and fix Playwright Keycloak login
- Replace deprecated qdrant_client.search() with query_points() API
- Update semantic search implementation in notes.py
- Update all integration tests to use query_points()
- Fix Keycloak login in test_keycloak_dcr.py to use form.submit() instead of button click
- Remove unnecessary popup handler code
- Simplify consent screen logging
2025-11-08 22:41:14 +01:00
Chris Coutinho e96c02e4d4 fix: remove unnecessary urllib3<2.0 constraint
The urllib3<2.0 constraint was added unnecessarily during troubleshooting.
urllib3 2.x works perfectly fine with qdrant-client. The import path for
urllib3.util.Url and parse_url remains the same across 1.x and 2.x versions.

Changes:
- Remove urllib3<2.0 constraint from pyproject.toml
- Upgrade to urllib3 2.5.0 (latest)
- All integration tests pass with urllib3 2.x

Verified:
- from urllib3.util import Url, parse_url works in 2.5.0
- All 6 semantic search integration tests pass
- qdrant-client 1.15.1 works correctly with urllib3 2.5.0

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 22:18:31 +01:00
Chris Coutinho 7b8c3f93a8 test: add integration tests for semantic search with in-process embeddings
Adds comprehensive integration tests for vector database semantic search that
work without external dependencies (Ollama), making them suitable for CI/CD.

Changes:
- Add SimpleEmbeddingProvider: in-process TF-IDF-like embeddings using feature hashing
- Make Ollama optional: embedding service now falls back to SimpleEmbeddingProvider
- Add 6 integration tests covering semantic search, filtering, and batch operations
- Downgrade urllib3 to 1.26.x for qdrant-client compatibility
- Update docker-compose.yml to comment out Ollama configuration (optional)

The SimpleEmbeddingProvider generates deterministic, normalized embeddings
suitable for testing semantic similarity without requiring external services.
Tests validate that similar texts have higher cosine similarity and that
semantic search correctly ranks results by relevance.

Test coverage:
- Deterministic embedding generation
- Semantic similarity between texts
- Full search flow with Qdrant (in-memory)
- Category filtering
- Empty result handling
- Batch embedding generation

All tests pass and can run in GitHub CI without Ollama infrastructure.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 22:13:33 +01:00
Chris Coutinho fdd82f59e2 feat: implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
Completes the ADR-007 implementation by adding user-facing semantic search
functionality. Previous phases implemented scanner and processor for background
indexing; this adds the query interface.

Changes:
- Add nc_notes_semantic_search MCP tool for natural language queries
- Fix Qdrant point IDs to use UUIDs instead of strings (was causing 400 errors)
- Reduce scan interval default from 1 hour to 5 minutes for faster updates
- Add SemanticSearchResult and SemanticSearchNotesResponse models
- Implement dual-phase authorization (Qdrant filter + Nextcloud API verification)

The semantic search enables finding notes by meaning rather than exact keywords,
using vector embeddings to understand query intent. Point ID fix resolves
critical bug where all document indexing failed with "invalid point ID" errors.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 21:51:12 +01:00
Chris Coutinho 4dbb2eb468 fix: integrate vector sync tasks with Starlette lifespan for streamable-http
Fixes background task startup for streamable-http transport by integrating
vector sync initialization into the Starlette lifespan context manager.

Starlette Lifespan Integration:
- Moved background task startup from FastMCP lifespan to Starlette lifespan
- FastMCP lifespan only triggers on MCP session establishment
- Starlette lifespan runs on server startup (correct timing)
- Fixed module scoping issues with local imports (anyio_module, asyncio_module)
- Added conditional startup based on oauth_enabled flag

Scanner Fixes:
- Fixed NotesClient method: list_notes() → get_all_notes()
- Properly handle AsyncIterator with list comprehension
- Collects all notes before processing

Verified Working:
- Background tasks start successfully on server startup
- Scanner fetches notes from Nextcloud API
- Processor pool (3 workers) ready for document processing
- Health endpoint reports Qdrant status
- No startup errors

Phase 3 Complete:
- BasicAuth mode with vector sync fully functional
- Background tasks integrate cleanly with streamable-http transport
- Graceful shutdown with coordinated task cancellation

Related: ADR-007 Background Vector Database Synchronization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 21:20:26 +01:00
Chris Coutinho 8f45e996e8 feat: implement vector sync scanner and processor (ADR-007 Phase 2)
Implements background vector database synchronization using anyio
TaskGroups for BasicAuth mode with single-user credentials.

Scanner Implementation:
- Periodic document discovery (hourly, configurable)
- Timestamp-based change detection (Nextcloud vs Qdrant)
- Wake event for immediate scanning on-demand
- Supports both initial sync (all docs) and incremental sync (changes only)
- Detects deleted documents and queues for removal

Processor Implementation:
- Concurrent document processing pool (3 workers default)
- I/O-bound embedding generation via Ollama API
- Retry logic with exponential backoff (3 retries)
- Document chunking (512 words, 50-word overlap)
- Handles both index and delete operations
- Upserts vectors to Qdrant with rich metadata

App Lifespan Integration:
- Extended AppContext with background task state
- Modified app_lifespan_basic() to start tasks via anyio TaskGroups
- Graceful shutdown with coordinated task cancellation
- Only activates when VECTOR_SYNC_ENABLED=true

Embedding Service:
- OllamaEmbeddingProvider with TLS support
- Singleton pattern for shared client instances
- Batch embedding support for efficiency
- Auto-detects embedding dimension (768 for nomic-embed-text)

Qdrant Client:
- Async client wrapper with singleton pattern
- Auto-creates collection on first use
- COSINE distance metric for semantic similarity
- Integrates with embedding service for dimension detection

Health Check Enhancement:
- Added Qdrant status check to /health/ready endpoint
- Only checks when VECTOR_SYNC_ENABLED=true
- 2-second timeout for health probe
- Reports connection errors with details

Configuration:
- VECTOR_SYNC_ENABLED: Enable background sync
- VECTOR_SYNC_SCAN_INTERVAL: Scanner frequency (3600s default)
- VECTOR_SYNC_PROCESSOR_WORKERS: Concurrent processors (3 default)
- QDRANT_URL, QDRANT_API_KEY, QDRANT_COLLECTION: Vector DB config
- OLLAMA_BASE_URL, OLLAMA_EMBEDDING_MODEL: Embedding service config

Dependencies Added:
- qdrant-client>=1.7.0: Vector database client

Docker Compose:
- Added Qdrant service with health check
- Exposed ports 6333 (REST) and 6334 (gRPC)
- Configured MCP service with vector sync environment
- Added qdrant-data volume for persistence

Known Issue:
- FastMCP lifespan not triggering for streamable-http transport
- Background tasks will start once lifespan integration is complete
- Lifespan triggers on MCP session establishment, not server startup

Related: ADR-007 Background Vector Database Synchronization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 21:14:38 +01:00
Chris Coutinho dc93da2ea0 docs: add ADR-007 for background vector database synchronization
Add comprehensive ADR-007 documenting background vector database
synchronization architecture using anyio TaskGroups for in-process
concurrency. This supersedes ADR-003's conceptual background worker.

Key decisions:
- In-process architecture using anyio TaskGroups (not Celery)
- Scanner task runs hourly, detects changes via timestamp comparison
- In-memory asyncio.Queue for pending documents
- Pool of 3 concurrent processor tasks for I/O-bound embedding workloads
- Qdrant metadata as single source of truth for indexing state
- Simple user controls: enable/disable with status visibility

Benefits:
- Single container deployment (was 3: mcp, celery-worker, celery-beat)
- No distributed task queue infrastructure
- Shared process state (no volume coordination)
- Sufficient throughput for I/O-bound embedding APIs
- Simpler debugging and deployment

Update ADR-003 status to "Superseded by ADR-007" with reference link.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 20:32:49 +01:00
Chris Coutinho 31ff8a71bf Merge pull request #270 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to 54282d3
2025-11-08 11:24:14 +01:00
renovate-bot-cbcoutinho[bot] bd012831cf chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to 54282d3 2025-11-08 05:06:25 +00:00
github-actions[bot] 4ceaf45ffd bump: version 0.26.0 → 0.26.1 2025-11-08 03:59:28 +00:00
Chris Coutinho 21b878a2e7 Merge pull request #265 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.21,<1.22
2025-11-08 04:59:05 +01:00
renovate-bot-cbcoutinho[bot] c1e135c4a2 fix(deps): update dependency mcp to >=1.21,<1.22 2025-11-07 05:06:10 +00:00
48 changed files with 6579 additions and 32 deletions
+6
View File
@@ -1,3 +1,9 @@
## v0.26.1 (2025-11-08)
### Fix
- **deps**: update dependency mcp to >=1.21,<1.22
## v0.26.0 (2025-11-08)
### Feat
+76
View File
@@ -224,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
+1
View File
@@ -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"]
+4 -2
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
charts/
+9
View File
@@ -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"
+11 -2
View File
@@ -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.26.0
appVersion: "0.26.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
+157 -4
View File
@@ -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 }}
+134
View File
@@ -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: ""
+42 -1
View File
@@ -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"]
@@ -183,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:
@@ -190,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,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
+268
View File
@@ -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
+152
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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:
+199 -9
View File
@@ -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
@@ -32,6 +34,7 @@ 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
@@ -42,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__)
@@ -206,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
@@ -369,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")
@@ -379,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():
@@ -798,6 +876,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}"
)
# 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 = (
@@ -924,9 +1010,95 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
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):
@@ -946,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
@@ -976,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(
{
@@ -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
@@ -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.
@@ -224,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
@@ -371,6 +440,45 @@ async def user_info_html(request: Request) -> HTMLResponse:
</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>
"""
# Build IdP profile HTML
idp_profile_html = ""
if "idp_profile" in user_context:
@@ -507,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 ""}
+61
View File
@@ -1,3 +1,4 @@
import logging
import logging.config
import os
from dataclasses import dataclass
@@ -156,6 +157,46 @@ class 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.
@@ -192,4 +233,24 @@ def get_settings() -> Settings:
# 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",
)
@@ -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"]
+43
View File
@@ -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()
+111
View File
@@ -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
+109
View File
@@ -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",
]
+2
View File
@@ -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",
+441
View File
@@ -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)}",
)
)
+16
View File
@@ -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
+220
View File
@@ -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
+219
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.26.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.20,<1.21)",
"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",
+37
View File
@@ -550,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
View File
+396
View File
@@ -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"
)
+432
View File
@@ -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()
+630
View File
@@ -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
+153
View File
@@ -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
+146
View File
@@ -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
Generated
+184 -5
View File
@@ -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.20.0"
version = "1.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -953,9 +1004,9 @@ dependencies = [
{ name = "starlette" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/22/fae38092e6c2995c03232635028510d77e7decff31b4ae79dfa0ba99c635/mcp-1.20.0.tar.gz", hash = "sha256:9ccc09eaadbfbcbbdab1c9723cfe2e0d1d9e324d7d3ce7e332ef90b09ed35177", size = 451377, upload-time = "2025-10-30T22:14:53.421Z" }
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/df/00/76fc92f4892d47fecb37131d0e95ea69259f077d84c68f6793a0d96cfe80/mcp-1.20.0-py3-none-any.whl", hash = "sha256:d0dc06f93653f7432ff89f694721c87f79876b6f93741bf628ad1e48f7ac5e5d", size = 173136, upload-time = "2025-10-30T22:14:51.078Z" },
{ 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]
@@ -975,7 +1026,7 @@ wheels = [
[[package]]
name = "nextcloud-mcp-server"
version = "0.26.0"
version = "0.26.1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -989,6 +1040,7 @@ dependencies = [
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pythonvcard4" },
{ name = "qdrant-client" },
]
[package.dev-dependencies]
@@ -1014,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.20,<1.21" },
{ 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]
@@ -1036,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"
@@ -1181,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"
@@ -1193,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"
@@ -1598,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"