Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05b488bb68 | |||
| 3fa13c8bfd | |||
| 9d306b71fa | |||
| 38a936c120 | |||
| 0b2d449ffa | |||
| d881373dce | |||
| 9ade4c65f3 | |||
| 5c73b85f65 | |||
| f5764c01fc | |||
| 8c7c2a4407 | |||
| 978de5e9a4 | |||
| 4e9859117c | |||
| a134a0fc08 | |||
| 6df58af0c3 | |||
| 852606ec8b | |||
| caae6922be | |||
| fafeaf3d83 | |||
| 2ab8dad6a5 | |||
| 50216accde | |||
| bf2fdac2d0 | |||
| 626c4bf562 | |||
| a56b3f3d51 | |||
| 2896fa1dc9 | |||
| 04251401aa | |||
| e86b6e83ae | |||
| 6f5e75da15 | |||
| b2742aab80 | |||
| 208365cd3d | |||
| 26f679d86e | |||
| cf39a15db1 | |||
| 1f3c35f162 | |||
| 2bccc3dad9 | |||
| 959cb8b21a | |||
| f8a2410a0a | |||
| 03b984d5a7 | |||
| 57db18c6a3 | |||
| ea79e94842 | |||
| b0612cfa0f | |||
| 4e61d73da5 | |||
| 3b41776110 | |||
| 3e3d38696c | |||
| 7b22e5be0f | |||
| 39fba49cfe | |||
| 706a15f0bc | |||
| b8dc413b73 | |||
| 8d29ce0122 | |||
| a272e7cbab | |||
| ce55b239e2 | |||
| 432ab73741 | |||
| f93d650992 | |||
| 482ef89a73 |
@@ -0,0 +1,99 @@
|
||||
name: RAG Evaluation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
manual_path:
|
||||
description: 'Path to Nextcloud User Manual PDF in Nextcloud'
|
||||
required: false
|
||||
default: 'Nextcloud Manual.pdf'
|
||||
embedding_model:
|
||||
description: 'OpenAI embedding model'
|
||||
required: false
|
||||
default: 'openai/text-embedding-3-small'
|
||||
generation_model:
|
||||
description: 'OpenAI generation model'
|
||||
required: false
|
||||
default: 'openai/gpt-4o-mini'
|
||||
|
||||
jobs:
|
||||
rag-evaluation:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
models: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
./docker-compose.ci.yml
|
||||
up-flags: "--build"
|
||||
env:
|
||||
# Environment variables passed to docker-compose.ci.yml
|
||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
echo "Waiting for Nextcloud..."
|
||||
max_attempts=60
|
||||
attempt=0
|
||||
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info | grep -q "401"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo "Service did not become ready in time."
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Nextcloud is ready."
|
||||
|
||||
- name: Wait for MCP server to be ready
|
||||
run: |
|
||||
echo "Waiting for MCP server..."
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8000/health/live | grep -q "200"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo "MCP server did not become ready in time."
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: MCP not ready, sleeping for 2 seconds..."
|
||||
sleep 2
|
||||
done
|
||||
echo "MCP server is ready."
|
||||
|
||||
- name: Run RAG evaluation tests
|
||||
env:
|
||||
NEXTCLOUD_HOST: "http://localhost:8080"
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
RAG_MANUAL_PATH: ${{ inputs.manual_path }}
|
||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
run: |
|
||||
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: rag-evaluation-results
|
||||
path: |
|
||||
pytest-results.xml
|
||||
retention-days: 30
|
||||
@@ -1,3 +1,63 @@
|
||||
## v0.48.3 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
||||
|
||||
## v0.48.2 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
|
||||
## v0.48.1 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Move background tasks to server lifespan and deprecate SSE transport
|
||||
|
||||
## v0.48.0 (2025-11-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add tag management methods to WebDAV client
|
||||
|
||||
## v0.47.0 (2025-11-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add OpenAI provider support for embeddings and generation
|
||||
|
||||
## v0.46.2 (2025-11-22)
|
||||
|
||||
### Fix
|
||||
|
||||
- **smithery**: Enable JSON response format for scanner compatibility
|
||||
|
||||
## v0.46.1 (2025-11-22)
|
||||
|
||||
### Perf
|
||||
|
||||
- Optimize vector viz search performance
|
||||
|
||||
## v0.46.0 (2025-11-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add Smithery CLI deployment support
|
||||
- Implement ADR-016 Smithery stateless deployment mode
|
||||
|
||||
### Fix
|
||||
|
||||
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
|
||||
- **smithery**: Use container runtime pattern for config discovery
|
||||
- Add Smithery lifespan and auth mode detection
|
||||
|
||||
## v0.45.0 (2025-11-22)
|
||||
|
||||
### Feat
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:2e683fc3e18a248aa23b8022f2a3474b072b04fb851efe9b49f6b516a8944939
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Dockerfile for Smithery stateless deployment
|
||||
# ADR-016: Stateless mode for multi-user public Nextcloud instances
|
||||
#
|
||||
# This image excludes:
|
||||
# - Vector database dependencies (qdrant-client)
|
||||
# - Background sync workers
|
||||
# - Admin UI routes (/app)
|
||||
# - Semantic search tools
|
||||
#
|
||||
# Features included:
|
||||
# - Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
|
||||
git
|
||||
|
||||
# Copy project files
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev --no-editable --no-cache
|
||||
|
||||
# Set Smithery mode environment variables
|
||||
ENV SMITHERY_DEPLOYMENT=true
|
||||
ENV VECTOR_SYNC_ENABLED=false
|
||||
|
||||
# Smithery sets PORT=8081 by default
|
||||
EXPOSE 8081
|
||||
|
||||
# Health check endpoint
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD uv run python -c "import httpx; httpx.get('http://localhost:${PORT:-8081}/health/live').raise_for_status()"
|
||||
|
||||
CMD ["/app/.venv/bin/smithery-main"]
|
||||
@@ -1,9 +1,11 @@
|
||||
```markdown
|
||||
<p align="center">
|
||||
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
|
||||
</p>
|
||||
|
||||
# Nextcloud MCP Server
|
||||
|
||||
[](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
|
||||
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||
@@ -17,7 +19,20 @@ This is a **dedicated standalone MCP server** designed for external MCP clients
|
||||
|
||||
## Quick Start
|
||||
|
||||
Get up and running in 60 seconds using Docker:
|
||||
The fastest way to get started is via [Smithery](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) - no Docker or self-hosting required:
|
||||
|
||||
1. Visit the [Smithery marketplace page](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
2. Click "Deploy" and configure:
|
||||
- **Nextcloud URL**: Your Nextcloud instance (e.g., `https://cloud.example.com`)
|
||||
- **Username**: Your Nextcloud username
|
||||
- **App Password**: Generate one in Nextcloud → Settings → Security → Devices & sessions
|
||||
|
||||
> [!NOTE]
|
||||
> Smithery runs in stateless mode without semantic search. For full features, use [Docker](#docker-self-hosted) or see [ADR-016](docs/ADR-016-smithery-stateless-deployment.md).
|
||||
|
||||
## Docker (Self-Hosted)
|
||||
|
||||
For full features including semantic search, run with Docker:
|
||||
|
||||
```bash
|
||||
# 1. Create a minimal configuration
|
||||
@@ -37,12 +52,11 @@ curl http://127.0.0.1:8000/health/ready
|
||||
# 4. Connect to the endpoint
|
||||
http://127.0.0.1:8000/sse
|
||||
|
||||
# 4. Or with --transport streamable-http
|
||||
# Or with --transport streamable-http
|
||||
http://127.0.0.1:8000/mcp
|
||||
```
|
||||
|
||||
**Next Steps:**
|
||||
- Create an app password in Nextcloud: Settings → Security → Devices & sessions
|
||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
||||
|
||||
@@ -210,3 +224,4 @@ This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) fo
|
||||
- [Model Context Protocol](https://github.com/modelcontextprotocol)
|
||||
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
||||
- [Nextcloud](https://nextcloud.com/)
|
||||
```
|
||||
@@ -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.45.0
|
||||
appVersion: "0.45.0"
|
||||
version: 0.48.3
|
||||
appVersion: "0.48.3"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# CI-specific overrides for RAG evaluation pipeline
|
||||
# This file is used by the rag-evaluation.yml workflow to configure the MCP
|
||||
# container with OpenAI/GitHub Models API for vector embeddings.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
|
||||
#
|
||||
# Environment variables (set in CI workflow):
|
||||
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
|
||||
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
|
||||
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
|
||||
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
|
||||
|
||||
services:
|
||||
mcp:
|
||||
environment:
|
||||
# OpenAI provider configuration (required for CI vector sync)
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
|
||||
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
|
||||
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
|
||||
# Faster sync for CI
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
|
||||
# Enable document processing for PDF parsing
|
||||
- ENABLE_DOCUMENT_PROCESSING=true
|
||||
+21
-1
@@ -34,7 +34,7 @@ services:
|
||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
- ./third_party:/opt/apps:ro
|
||||
#- ./third_party:/opt/apps:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -224,6 +224,26 @@ services:
|
||||
- keycloak-tokens:/app/data
|
||||
- keycloak-oauth-storage:/app/.oauth
|
||||
|
||||
# Smithery stateless deployment mode (ADR-016)
|
||||
# Test with: docker compose --profile smithery up smithery
|
||||
# Then: curl http://localhost:8081/.well-known/mcp-config
|
||||
smithery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.smithery
|
||||
restart: always
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8081:8081
|
||||
environment:
|
||||
- SMITHERY_DEPLOYMENT=true
|
||||
- VECTOR_SYNC_ENABLED=false
|
||||
- PORT=8081
|
||||
profiles:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
||||
restart: always
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2025-01-22
|
||||
**Deciders:** Development Team
|
||||
**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider)
|
||||
|
||||
## Context
|
||||
|
||||
[Smithery](https://smithery.ai) is a hosting platform and marketplace for MCP servers that provides:
|
||||
|
||||
- **Discovery**: Marketplace listing for MCP servers
|
||||
- **Hosting**: Containerized deployment with auto-scaling
|
||||
- **Authentication UI**: OAuth flow presentation for users
|
||||
- **Session Configuration**: Per-user settings passed via URL parameters
|
||||
- **Observability**: Usage logs and monitoring
|
||||
|
||||
### Current Architecture Limitations
|
||||
|
||||
The current nextcloud-mcp-server architecture assumes a **self-hosted deployment** with:
|
||||
|
||||
1. **Persistent Infrastructure**
|
||||
- Qdrant vector database for semantic search
|
||||
- Background sync worker for content indexing
|
||||
- Refresh token storage for offline access
|
||||
|
||||
2. **Single-Tenant Configuration**
|
||||
- Environment variables configure one Nextcloud instance
|
||||
- `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
|
||||
- Or OAuth with a single IdP
|
||||
|
||||
3. **Stateful Operations**
|
||||
- Vector sync maintains index state across requests
|
||||
- Token storage persists between sessions
|
||||
|
||||
### Smithery Hosting Constraints
|
||||
|
||||
Smithery-hosted containers are **stateless by design**:
|
||||
|
||||
- No persistent storage between requests
|
||||
- No background workers or cron jobs
|
||||
- No databases (Qdrant, Redis, etc.)
|
||||
- Containers may be recycled at any time
|
||||
- Configuration passed per-session via URL parameters
|
||||
|
||||
### Opportunity
|
||||
|
||||
Many users have **publicly accessible Nextcloud instances** and want to:
|
||||
|
||||
1. Try the MCP server without self-hosting infrastructure
|
||||
2. Connect multiple users to different Nextcloud instances
|
||||
3. Use basic Nextcloud tools without semantic search
|
||||
4. Benefit from Smithery's discovery and OAuth UI
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a **stateless deployment mode** for Smithery that:
|
||||
|
||||
1. **Disables stateful features** (vector sync, semantic search)
|
||||
2. **Creates clients per-session** from Smithery configuration
|
||||
3. **Supports multiple Nextcloud instances** via session config
|
||||
4. **Provides a useful subset of tools** that work without infrastructure
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Smithery-Hosted Stateless Mode │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ MCP Client Smithery │
|
||||
│ (Cursor, Claude) Infrastructure │
|
||||
│ │ │ │
|
||||
│ │ 1. Connect │ │
|
||||
│ ├───────────────────────────►│ │
|
||||
│ │ │ │
|
||||
│ │ 2. Config UI │ │
|
||||
│ │◄───────────────────────────┤ User enters: │
|
||||
│ │ (Smithery presents) │ - nextcloud_url │
|
||||
│ │ │ - auth_mode (basic/oauth) │
|
||||
│ │ │ - credentials │
|
||||
│ │ 3. Tool call │ │
|
||||
│ ├───────────────────────────►│ │
|
||||
│ │ + session config │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────┴───────┐ │
|
||||
│ │ │ MCP Server │ │
|
||||
│ │ │ Container │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ 4. Create │ │
|
||||
│ │ │ client │ │
|
||||
│ │ │ from │ │
|
||||
│ │ │ config │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ │ 5. Call │ │
|
||||
│ │ │ Nextcloud │───────► User's Nextcloud │
|
||||
│ │ │ API │ Instance │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ 6. Response │ Return result │ │
|
||||
│ │◄───────────────────┤ │ │
|
||||
│ │ └───────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Session Configuration Schema
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class SmitheryConfigSchema(BaseModel):
|
||||
"""Configuration schema for Smithery session."""
|
||||
|
||||
# Required: Nextcloud instance
|
||||
nextcloud_url: str = Field(
|
||||
...,
|
||||
description="Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||
)
|
||||
|
||||
# Authentication mode
|
||||
auth_mode: str = Field(
|
||||
"app_password",
|
||||
description="Authentication method: 'app_password' or 'oauth'"
|
||||
)
|
||||
|
||||
# App Password authentication (recommended for Smithery)
|
||||
username: str | None = Field(
|
||||
None,
|
||||
description="Nextcloud username (required for app_password auth)"
|
||||
)
|
||||
app_password: str | None = Field(
|
||||
None,
|
||||
description="Nextcloud app password (Settings → Security → App passwords)"
|
||||
)
|
||||
|
||||
# OAuth authentication (advanced)
|
||||
# When auth_mode='oauth', Smithery handles the OAuth flow
|
||||
# and passes the access token automatically
|
||||
```
|
||||
|
||||
### Feature Matrix
|
||||
|
||||
| Feature | Self-Hosted | Smithery Stateless |
|
||||
|---------|-------------|-------------------|
|
||||
| **Notes** | | |
|
||||
| List/Search notes | ✓ | ✓ |
|
||||
| Get/Create/Update notes | ✓ | ✓ |
|
||||
| Semantic search | ✓ | ✗ |
|
||||
| **Calendar** | | |
|
||||
| List calendars | ✓ | ✓ |
|
||||
| Get/Create events | ✓ | ✓ |
|
||||
| **Contacts** | | |
|
||||
| List address books | ✓ | ✓ |
|
||||
| Search/Get contacts | ✓ | ✓ |
|
||||
| **Files (WebDAV)** | | |
|
||||
| List/Download files | ✓ | ✓ |
|
||||
| Upload files | ✓ | ✓ |
|
||||
| Search files | ✓ | ✓ (keyword only) |
|
||||
| **Deck** | | |
|
||||
| List boards/cards | ✓ | ✓ |
|
||||
| Create/Update cards | ✓ | ✓ |
|
||||
| **Tables** | | |
|
||||
| List/Query tables | ✓ | ✓ |
|
||||
| Create/Update rows | ✓ | ✓ |
|
||||
| **Cookbook** | | |
|
||||
| List/Get recipes | ✓ | ✓ |
|
||||
| **Semantic Search** | | |
|
||||
| Vector search | ✓ | ✗ |
|
||||
| RAG answers | ✓ | ✗ |
|
||||
| **Background Sync** | | |
|
||||
| Auto-indexing | ✓ | ✗ |
|
||||
| Webhook sync | ✓ | ✗ |
|
||||
| **Admin UI (`/app`)** | | |
|
||||
| Vector sync status | ✓ | ✗ |
|
||||
| Vector visualization | ✓ | ✗ |
|
||||
| Webhook management | ✓ | ✗ |
|
||||
| Session management | ✓ | ✗ |
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Deployment Mode Detection
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/config.py
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
SELF_HOSTED = "self_hosted" # Full features, env-based config
|
||||
SMITHERY_STATELESS = "smithery" # Stateless, session-based config
|
||||
|
||||
def get_deployment_mode() -> DeploymentMode:
|
||||
"""Detect deployment mode from environment."""
|
||||
if os.getenv("SMITHERY_DEPLOYMENT") == "true":
|
||||
return DeploymentMode.SMITHERY_STATELESS
|
||||
return DeploymentMode.SELF_HOSTED
|
||||
```
|
||||
|
||||
#### 2. Session-Based Client Factory
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/context.py
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""Get NextcloudClient - from session config or environment."""
|
||||
|
||||
mode = get_deployment_mode()
|
||||
|
||||
if mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
# Create client from Smithery session config
|
||||
config = ctx.session_config
|
||||
if not config:
|
||||
raise McpError("Session configuration required")
|
||||
|
||||
return NextcloudClient(
|
||||
base_url=config.nextcloud_url,
|
||||
username=config.username,
|
||||
password=config.app_password,
|
||||
)
|
||||
else:
|
||||
# Existing behavior: from environment or OAuth context
|
||||
return await _get_client_from_context(ctx)
|
||||
```
|
||||
|
||||
#### 3. Conditional Tool Registration
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
def create_mcp_server(mode: DeploymentMode) -> FastMCP:
|
||||
"""Create MCP server with mode-appropriate tools."""
|
||||
|
||||
mcp = FastMCP("Nextcloud MCP")
|
||||
|
||||
# Always register core tools
|
||||
configure_notes_tools(mcp)
|
||||
configure_calendar_tools(mcp)
|
||||
configure_contacts_tools(mcp)
|
||||
configure_webdav_tools(mcp)
|
||||
configure_deck_tools(mcp)
|
||||
configure_tables_tools(mcp)
|
||||
configure_cookbook_tools(mcp)
|
||||
|
||||
# Only register stateful tools in self-hosted mode
|
||||
if mode == DeploymentMode.SELF_HOSTED:
|
||||
configure_semantic_tools(mcp) # Requires Qdrant
|
||||
register_oauth_tools(mcp) # Requires token storage
|
||||
|
||||
return mcp
|
||||
```
|
||||
|
||||
#### 4. Exclude Admin UI Routes
|
||||
|
||||
The `/app` admin UI should **not be installed** in Smithery mode because:
|
||||
|
||||
- **Vector sync status** - No vector sync in stateless mode
|
||||
- **Vector visualization** - No Qdrant to visualize
|
||||
- **Webhook management** - No webhook sync without background workers
|
||||
- **Session management** - No persistent sessions to manage
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
def create_app(mode: DeploymentMode) -> Starlette:
|
||||
"""Create Starlette app with mode-appropriate routes."""
|
||||
|
||||
routes = [
|
||||
Route("/health/live", health_live, methods=["GET"]),
|
||||
Route("/health/ready", health_ready, methods=["GET"]),
|
||||
]
|
||||
|
||||
# Only mount admin UI in self-hosted mode
|
||||
if mode == DeploymentMode.SELF_HOSTED:
|
||||
browser_app = create_browser_app()
|
||||
routes.append(
|
||||
Route("/app", lambda r: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("Admin UI mounted at /app")
|
||||
else:
|
||||
logger.info("Admin UI disabled in Smithery stateless mode")
|
||||
|
||||
# Mount FastMCP at root
|
||||
mcp_app = create_mcp_server(mode).streamable_http_app()
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
|
||||
return Starlette(routes=routes, lifespan=starlette_lifespan)
|
||||
```
|
||||
|
||||
**Endpoints by Mode:**
|
||||
|
||||
| Endpoint | Self-Hosted | Smithery |
|
||||
|----------|-------------|----------|
|
||||
| `/mcp` | ✓ | ✓ |
|
||||
| `/health/live` | ✓ | ✓ |
|
||||
| `/health/ready` | ✓ | ✓ |
|
||||
| `/.well-known/mcp-config` | ✓ | ✓ |
|
||||
| `/app` | ✓ | ✗ |
|
||||
| `/app/vector-sync/status` | ✓ | ✗ |
|
||||
| `/app/vector-viz` | ✓ | ✗ |
|
||||
| `/app/webhooks` | ✓ | ✗ |
|
||||
|
||||
#### 5. Smithery Integration Files
|
||||
|
||||
**smithery.yaml:**
|
||||
```yaml
|
||||
runtime: "container"
|
||||
build:
|
||||
dockerfile: "Dockerfile.smithery"
|
||||
dockerBuildPath: "."
|
||||
startCommand:
|
||||
type: "http"
|
||||
configSchema:
|
||||
type: "object"
|
||||
required: ["nextcloud_url", "username", "app_password"]
|
||||
properties:
|
||||
nextcloud_url:
|
||||
type: "string"
|
||||
title: "Nextcloud URL"
|
||||
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||
username:
|
||||
type: "string"
|
||||
title: "Username"
|
||||
description: "Your Nextcloud username"
|
||||
app_password:
|
||||
type: "string"
|
||||
title: "App Password"
|
||||
description: "Generate at Settings → Security → App passwords"
|
||||
exampleConfig:
|
||||
nextcloud_url: "https://cloud.example.com"
|
||||
username: "alice"
|
||||
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
```
|
||||
|
||||
**Dockerfile.smithery:**
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY nextcloud_mcp_server ./nextcloud_mcp_server
|
||||
|
||||
# Install dependencies (without vector/semantic extras)
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Set Smithery mode
|
||||
ENV SMITHERY_DEPLOYMENT=true
|
||||
ENV VECTOR_SYNC_ENABLED=false
|
||||
|
||||
# Smithery sets PORT=8081
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"]
|
||||
```
|
||||
|
||||
**nextcloud_mcp_server/smithery_main.py:**
|
||||
```python
|
||||
"""Smithery-specific entrypoint for stateless deployment."""
|
||||
|
||||
import os
|
||||
import uvicorn
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from nextcloud_mcp_server.app import create_mcp_server
|
||||
from nextcloud_mcp_server.config import DeploymentMode
|
||||
|
||||
def main():
|
||||
# Force stateless mode
|
||||
os.environ["SMITHERY_DEPLOYMENT"] = "true"
|
||||
os.environ["VECTOR_SYNC_ENABLED"] = "false"
|
||||
|
||||
mcp = create_mcp_server(DeploymentMode.SMITHERY_STATELESS)
|
||||
app = mcp.streamable_http_app()
|
||||
|
||||
# Add CORS for browser-based clients
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
||||
)
|
||||
|
||||
# Smithery sets PORT environment variable
|
||||
port = int(os.environ.get("PORT", 8081))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **App Passwords over User Passwords**
|
||||
- Smithery config encourages app passwords (revocable, scoped)
|
||||
- Documentation guides users to create dedicated app passwords
|
||||
- App passwords can be revoked without changing main password
|
||||
|
||||
2. **HTTPS Required**
|
||||
- `nextcloud_url` must be HTTPS for production use
|
||||
- Validation rejects HTTP URLs in Smithery mode
|
||||
|
||||
3. **No Credential Storage**
|
||||
- Credentials exist only for request duration
|
||||
- No server-side persistence of user credentials
|
||||
- Smithery handles secure config transmission
|
||||
|
||||
4. **Scope Limitation**
|
||||
- Stateless mode cannot access offline_access
|
||||
- No background operations on user's behalf
|
||||
- Clear user expectation: tools work during session only
|
||||
|
||||
### Migration Path
|
||||
|
||||
Users can start with Smithery stateless mode and migrate to self-hosted:
|
||||
|
||||
1. **Try on Smithery** → Basic tools, no setup
|
||||
2. **Self-host for semantic search** → Add Qdrant, enable vector sync
|
||||
3. **Full deployment** → Background sync, webhooks, multi-user OAuth
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Lower barrier to entry** - Users can try without infrastructure
|
||||
2. **Multi-user support** - Each session connects to different Nextcloud
|
||||
3. **Smithery ecosystem** - Discovery, observability, OAuth UI
|
||||
4. **Clear feature tiers** - Stateless (simple) vs self-hosted (full)
|
||||
|
||||
### Negative
|
||||
|
||||
1. **No semantic search** - Key differentiator unavailable on Smithery
|
||||
2. **Per-request auth** - Credentials sent with each request
|
||||
3. **No offline access** - Cannot perform background operations
|
||||
4. **Maintenance burden** - Two deployment modes to support
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Feature subset** - May encourage users to self-host for full features
|
||||
2. **Documentation needs** - Clear guidance on mode differences required
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. External MCP Only
|
||||
|
||||
**Approach:** Only support self-hosted external MCP registration on Smithery.
|
||||
|
||||
**Rejected because:**
|
||||
- Higher barrier to entry for new users
|
||||
- Misses opportunity for Smithery marketplace visibility
|
||||
- Users want to try before committing to infrastructure
|
||||
|
||||
### 2. Embedded Vector DB (SQLite-vec)
|
||||
|
||||
**Approach:** Use SQLite with vector extensions for per-request indexing.
|
||||
|
||||
**Rejected because:**
|
||||
- No persistence between requests anyway
|
||||
- Indexing latency too high for synchronous requests
|
||||
- Complexity without benefit in stateless context
|
||||
|
||||
### 3. External Vector DB Service
|
||||
|
||||
**Approach:** Connect to Pinecone/Weaviate Cloud from Smithery container.
|
||||
|
||||
**Rejected because:**
|
||||
- Adds external dependency and cost
|
||||
- Per-user collections require complex multi-tenancy
|
||||
- Sync still impossible without background workers
|
||||
|
||||
### 4. Hybrid: Smithery + User's Qdrant
|
||||
|
||||
**Approach:** User provides their own Qdrant URL in session config.
|
||||
|
||||
**Considered for future:**
|
||||
- Could enable semantic search for advanced users
|
||||
- Adds complexity to session config
|
||||
- Sync still requires external trigger (manual or webhook)
|
||||
|
||||
## References
|
||||
|
||||
- [Smithery Documentation](https://smithery.ai/docs)
|
||||
- [Smithery Session Configuration](https://smithery.ai/docs/build/session-config)
|
||||
- [Smithery External MCPs](https://smithery.ai/docs/build/external)
|
||||
- [MCP Streamable HTTP Transport](https://modelcontextprotocol.io/docs/concepts/transports)
|
||||
- [Nextcloud App Passwords](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#app-passwords)
|
||||
+468
-321
@@ -3,6 +3,7 @@ import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
@@ -25,6 +26,8 @@ from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import ASGIApp, Receive, Send
|
||||
from starlette.types import Scope as StarletteScope
|
||||
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
@@ -36,6 +39,8 @@ from nextcloud_mcp_server.auth import (
|
||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
get_deployment_mode,
|
||||
get_document_processor_config,
|
||||
get_settings,
|
||||
)
|
||||
@@ -238,6 +243,25 @@ def validate_pkce_support(discovery: dict, discovery_url: str) -> None:
|
||||
click.echo(f"✓ PKCE support validated: {code_challenge_methods}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class VectorSyncState:
|
||||
"""
|
||||
Module-level state for vector sync background tasks.
|
||||
|
||||
This singleton bridges the Starlette server lifespan (where background tasks run)
|
||||
and FastMCP session lifespans (where MCP tools need access to the streams).
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
# Module-level singleton for vector sync state
|
||||
_vector_sync_state = VectorSyncState()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AppContext:
|
||||
"""Application context for BasicAuth mode."""
|
||||
@@ -264,17 +288,160 @@ class OAuthAppContext:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmitheryAppContext:
|
||||
"""Application context for Smithery stateless mode.
|
||||
|
||||
ADR-016: No shared client - clients created per-request from session config.
|
||||
"""
|
||||
|
||||
pass # No shared state needed - everything comes from session config
|
||||
|
||||
|
||||
# ADR-016: Smithery config schema for container runtime
|
||||
# This schema is served at /.well-known/mcp-config for Smithery discovery
|
||||
# See: https://smithery.ai/docs/build/session-config
|
||||
SMITHERY_CONFIG_SCHEMA = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://server.smithery.ai/nextcloud-mcp-server/.well-known/mcp-config",
|
||||
"title": "Nextcloud MCP Server Configuration",
|
||||
"description": "Configuration for connecting to your Nextcloud instance via app password authentication",
|
||||
"x-query-style": "flat", # Our schema has no nested objects, so flat style works
|
||||
"type": "object",
|
||||
"required": ["nextcloud_url", "username", "app_password"],
|
||||
"properties": {
|
||||
"nextcloud_url": {
|
||||
"type": "string",
|
||||
"title": "Nextcloud URL",
|
||||
"description": "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible.",
|
||||
"pattern": "^https?://.+",
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"title": "Username",
|
||||
"description": "Your Nextcloud username",
|
||||
"minLength": 1,
|
||||
},
|
||||
"app_password": {
|
||||
"type": "string",
|
||||
"title": "App Password",
|
||||
"description": "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password.",
|
||||
"minLength": 1,
|
||||
},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
# ADR-016: Context variable to hold Smithery session config per-request
|
||||
# This is set by SmitheryConfigMiddleware and accessed in context.py
|
||||
_smithery_session_config: ContextVar[dict[str, str] | None] = ContextVar(
|
||||
"smithery_session_config"
|
||||
)
|
||||
_smithery_session_config.set(None) # Set initial value
|
||||
|
||||
|
||||
def get_smithery_session_config() -> dict | None:
|
||||
"""Get the current Smithery session config from context variable.
|
||||
|
||||
Used by context.py to access config extracted from URL query parameters.
|
||||
"""
|
||||
return _smithery_session_config.get()
|
||||
|
||||
|
||||
class SmitheryConfigMiddleware:
|
||||
"""Middleware to extract Smithery config from URL query parameters.
|
||||
|
||||
ADR-016: For container runtime, Smithery passes configuration as URL query
|
||||
parameters to the /mcp endpoint. This middleware extracts those parameters
|
||||
and stores them in a context variable for access in tools.
|
||||
|
||||
Configuration parameters:
|
||||
- nextcloud_url: Nextcloud instance URL
|
||||
- username: Nextcloud username
|
||||
- app_password: Nextcloud app password
|
||||
|
||||
The extracted config is stored in a ContextVar and can be accessed via
|
||||
get_smithery_session_config() in context.py.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp):
|
||||
self.app = app
|
||||
|
||||
async def __call__(
|
||||
self, scope: StarletteScope, receive: Receive, send: Send
|
||||
) -> None:
|
||||
if scope["type"] == "http":
|
||||
# Extract config from query parameters
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
query_string = scope.get("query_string", b"").decode("utf-8")
|
||||
params = parse_qs(query_string)
|
||||
|
||||
# Build session config from query parameters
|
||||
# Smithery uses dot notation for nested objects, but our schema is flat
|
||||
session_config = {}
|
||||
for key in ["nextcloud_url", "username", "app_password"]:
|
||||
if key in params:
|
||||
# parse_qs returns lists, take first value
|
||||
session_config[key] = params[key][0]
|
||||
|
||||
# Store in context variable for access by context.py
|
||||
if session_config:
|
||||
_smithery_session_config.set(session_config)
|
||||
logger.debug(
|
||||
f"Smithery config extracted: nextcloud_url={session_config.get('nextcloud_url')}, "
|
||||
f"username={session_config.get('username')}"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
# Clear context variable after request
|
||||
_smithery_session_config.set(None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppContext]:
|
||||
"""
|
||||
Manage application lifecycle for Smithery stateless mode.
|
||||
|
||||
ADR-016: Minimal lifespan with no shared state.
|
||||
- No shared Nextcloud client (created per-request from session config)
|
||||
- No vector sync (disabled in Smithery mode)
|
||||
- No persistent storage (stateless deployment)
|
||||
- No document processors (not enabled in Smithery mode)
|
||||
"""
|
||||
logger.info("Starting MCP server in Smithery stateless mode")
|
||||
logger.info("Clients will be created per-request from session config")
|
||||
|
||||
try:
|
||||
yield SmitheryAppContext()
|
||||
finally:
|
||||
logger.info("Shutting down Smithery stateless mode")
|
||||
|
||||
|
||||
def is_oauth_mode() -> bool:
|
||||
"""
|
||||
Determine if OAuth mode should be used.
|
||||
|
||||
OAuth mode is enabled when:
|
||||
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
||||
- AND we are NOT in Smithery stateless mode
|
||||
- Or explicitly enabled via configuration
|
||||
|
||||
Returns:
|
||||
True if OAuth mode, False if BasicAuth mode
|
||||
"""
|
||||
# ADR-016: Smithery stateless mode uses per-request BasicAuth from session config
|
||||
# It's not OAuth mode even though env credentials aren't set
|
||||
deployment_mode = get_deployment_mode()
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info(
|
||||
"BasicAuth mode (Smithery stateless - credentials from session config)"
|
||||
)
|
||||
return False
|
||||
|
||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||
|
||||
@@ -407,15 +574,15 @@ async def load_oauth_client_credentials(
|
||||
@asynccontextmanager
|
||||
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
"""
|
||||
Manage application lifecycle for BasicAuth mode.
|
||||
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
|
||||
|
||||
Creates a single Nextcloud client with basic authentication
|
||||
that is shared across all requests.
|
||||
that is shared across all requests within a session.
|
||||
|
||||
If vector sync is enabled (VECTOR_SYNC_ENABLED=true), also starts
|
||||
background tasks for automatic document indexing (ADR-007).
|
||||
Note: Background tasks (scanner, processor) are started at server level
|
||||
in starlette_lifespan, not here. This lifespan runs per-session.
|
||||
"""
|
||||
logger.info("Starting MCP server in BasicAuth mode")
|
||||
logger.info("Starting MCP session in BasicAuth mode")
|
||||
logger.info("Creating Nextcloud client with BasicAuth")
|
||||
|
||||
client = NextcloudClient.from_env()
|
||||
@@ -431,91 +598,20 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
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 Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
logger.info("Qdrant collection ready")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Qdrant collection: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||
) from e
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
# Yield client context - scanner runs at server level (starlette_lifespan)
|
||||
# Include vector sync state from module singleton (set by starlette_lifespan)
|
||||
try:
|
||||
yield AppContext(
|
||||
client=client,
|
||||
storage=storage,
|
||||
document_send_stream=_vector_sync_state.document_send_stream,
|
||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||
shutdown_event=_vector_sync_state.shutdown_event,
|
||||
scanner_wake_event=_vector_sync_state.scanner_wake_event,
|
||||
)
|
||||
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
|
||||
await tg.start(
|
||||
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):
|
||||
await tg.start(
|
||||
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,
|
||||
storage=storage,
|
||||
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, storage=storage)
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth mode")
|
||||
await client.close()
|
||||
finally:
|
||||
logger.info("Shutting down BasicAuth session")
|
||||
await client.close()
|
||||
|
||||
|
||||
async def setup_oauth_config():
|
||||
@@ -831,7 +927,7 @@ async def setup_oauth_config():
|
||||
)
|
||||
|
||||
|
||||
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
|
||||
# Initialize observability (logging will be configured by uvicorn)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -858,8 +954,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
||||
)
|
||||
|
||||
# Determine authentication mode
|
||||
# Determine authentication mode and deployment mode
|
||||
oauth_enabled = is_oauth_mode()
|
||||
deployment_mode = get_deployment_mode()
|
||||
|
||||
if oauth_enabled:
|
||||
logger.info("Configuring MCP server for OAuth mode")
|
||||
@@ -920,8 +1017,17 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
auth=auth_settings,
|
||||
)
|
||||
else:
|
||||
logger.info("Configuring MCP server for BasicAuth mode")
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info("Configuring MCP server for Smithery stateless mode")
|
||||
# json_response=True returns plain JSON-RPC instead of SSE format,
|
||||
# required for Smithery scanner compatibility
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True
|
||||
)
|
||||
else:
|
||||
logger.info("Configuring MCP server for BasicAuth mode")
|
||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||
|
||||
@mcp.resource("nc://capabilities")
|
||||
async def nc_get_capabilities():
|
||||
@@ -957,8 +1063,12 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
|
||||
# Register semantic search tools (cross-app feature)
|
||||
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
||||
settings = get_settings()
|
||||
if settings.vector_sync_enabled:
|
||||
deployment_mode = get_deployment_mode()
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
||||
elif settings.vector_sync_enabled:
|
||||
logger.info("Configuring semantic search tools (vector sync enabled)")
|
||||
configure_semantic_tools(mcp)
|
||||
else:
|
||||
@@ -1035,180 +1145,177 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
|
||||
)
|
||||
|
||||
if transport == "sse":
|
||||
mcp_app = mcp.sse_app()
|
||||
starlette_lifespan = None
|
||||
elif transport in ("http", "streamable-http"):
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
mcp_app = mcp.streamable_http_app()
|
||||
|
||||
@asynccontextmanager
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# Set OAuth context for OAuth login routes (ADR-004)
|
||||
if oauth_enabled:
|
||||
# Prepare OAuth config from setup_oauth_config closure variables
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
nextcloud_resource_uri = os.getenv(
|
||||
"NEXTCLOUD_RESOURCE_URI", nextcloud_host
|
||||
)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||
@asynccontextmanager
|
||||
async def starlette_lifespan(app: Starlette):
|
||||
# Set OAuth context for OAuth login routes (ADR-004)
|
||||
if oauth_enabled:
|
||||
# Prepare OAuth config from setup_oauth_config closure variables
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
|
||||
discovery_url = os.getenv(
|
||||
"OIDC_DISCOVERY_URL",
|
||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||
|
||||
oauth_context_dict = {
|
||||
"storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
|
||||
"config": {
|
||||
"mcp_server_url": mcp_server_url,
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||
"scopes": scopes,
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"nextcloud_resource_uri": nextcloud_resource_uri,
|
||||
"oauth_provider": oauth_provider,
|
||||
},
|
||||
}
|
||||
app.state.oauth_context = oauth_context_dict
|
||||
oauth_context_dict = {
|
||||
"storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
|
||||
"config": {
|
||||
"mcp_server_url": mcp_server_url,
|
||||
"discovery_url": discovery_url,
|
||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||
"scopes": scopes,
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"nextcloud_resource_uri": nextcloud_resource_uri,
|
||||
"oauth_provider": oauth_provider,
|
||||
},
|
||||
}
|
||||
app.state.oauth_context = oauth_context_dict
|
||||
|
||||
# Also set oauth_context on browser_app for session authentication
|
||||
# browser_app is in the same function scope (defined later in create_app)
|
||||
# We need to find it in the mounted routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.oauth_context = oauth_context_dict
|
||||
logger.info(
|
||||
"OAuth context shared with browser_app for session auth"
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
else:
|
||||
# BasicAuth mode - share storage with browser_app for webhook management
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
app.state.storage = storage
|
||||
|
||||
# Also share with browser_app for webhook routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.storage = storage
|
||||
logger.info(
|
||||
"Storage shared with browser_app for webhook management"
|
||||
)
|
||||
break
|
||||
|
||||
# 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"
|
||||
# Also set oauth_context on browser_app for session authentication
|
||||
# browser_app is in the same function scope (defined later in create_app)
|
||||
# We need to find it in the mounted routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.oauth_context = oauth_context_dict
|
||||
logger.info(
|
||||
"OAuth context shared with browser_app for session auth"
|
||||
)
|
||||
break
|
||||
|
||||
# Get Nextcloud client from MCP app context
|
||||
# Create client since we're outside FastMCP lifespan
|
||||
client = NextcloudClient.from_env()
|
||||
logger.info(
|
||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||
)
|
||||
else:
|
||||
# BasicAuth mode - share storage with browser_app for webhook management
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
# Initialize Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
logger.info("Qdrant collection ready")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Qdrant collection: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||
) from e
|
||||
app.state.storage = storage
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio_module.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
# Also share with browser_app for webhook routes
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
route.app.state.storage = storage
|
||||
logger.info(
|
||||
"Storage shared with browser_app for webhook management"
|
||||
)
|
||||
break
|
||||
|
||||
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
||||
# Scanner runs at server-level (once), not per-session
|
||||
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"
|
||||
)
|
||||
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
|
||||
# Create client for vector sync (server-level, not per-session)
|
||||
client = NextcloudClient.from_env()
|
||||
|
||||
# Also share with browser_app for /app route
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
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 /app"
|
||||
)
|
||||
break
|
||||
# Initialize Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
# Start background tasks using anyio TaskGroup
|
||||
async with anyio_module.create_task_group() as tg:
|
||||
# Start scanner task
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
logger.info("Qdrant collection ready")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Qdrant collection: {e}")
|
||||
raise RuntimeError(
|
||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||
) from e
|
||||
|
||||
# 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 store in module singleton for FastMCP session lifespans
|
||||
_vector_sync_state.document_send_stream = send_stream
|
||||
_vector_sync_state.document_receive_stream = receive_stream
|
||||
_vector_sync_state.shutdown_event = shutdown_event
|
||||
_vector_sync_state.scanner_wake_event = scanner_wake_event
|
||||
logger.info("Vector sync state stored in module singleton")
|
||||
|
||||
# Also share with browser_app for /app route
|
||||
for route in app.routes:
|
||||
if isinstance(route, Mount) and route.path == "/app":
|
||||
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 /app")
|
||||
break
|
||||
|
||||
# Start background tasks using anyio TaskGroup
|
||||
async with anyio_module.create_task_group() as tg:
|
||||
# Start scanner task
|
||||
await tg.start(
|
||||
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):
|
||||
await tg.start(
|
||||
scanner_task,
|
||||
send_stream,
|
||||
processor_task,
|
||||
i,
|
||||
receive_stream.clone(),
|
||||
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):
|
||||
await tg.start(
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
# Run MCP session manager and yield
|
||||
async with AsyncExitStack() as stack:
|
||||
await stack.enter_async_context(mcp.session_manager.run())
|
||||
yield
|
||||
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):
|
||||
@@ -1361,6 +1468,26 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
||||
|
||||
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
|
||||
def smithery_mcp_config(request):
|
||||
"""Smithery MCP configuration endpoint.
|
||||
|
||||
Returns JSON Schema for Smithery's configuration UI.
|
||||
This endpoint is required for Smithery container runtime discovery.
|
||||
"""
|
||||
return JSONResponse(SMITHERY_CONFIG_SCHEMA)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/.well-known/mcp-config",
|
||||
smithery_mcp_config,
|
||||
methods=["GET"],
|
||||
)
|
||||
)
|
||||
logger.info("Smithery config endpoint enabled: /.well-known/mcp-config")
|
||||
|
||||
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
||||
|
||||
@@ -1491,85 +1618,98 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
)
|
||||
|
||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route("/", user_info_html, methods=["GET"]), # /app → user info with all tabs
|
||||
Route(
|
||||
"/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint"
|
||||
), # /app/revoke → revoke_session
|
||||
# Vector sync status fragment (htmx polling)
|
||||
Route(
|
||||
"/vector-sync/status",
|
||||
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
methods=["GET"],
|
||||
), # /app/vector-sync/status
|
||||
# Vector visualization routes
|
||||
Route(
|
||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||
), # /app/vector-viz
|
||||
Route(
|
||||
"/vector-viz/search",
|
||||
vector_visualization_search,
|
||||
methods=["GET"],
|
||||
), # /app/vector-viz/search
|
||||
Route(
|
||||
"/chunk-context",
|
||||
chunk_context_endpoint,
|
||||
methods=["GET"],
|
||||
), # /app/chunk-context
|
||||
# Webhook management routes (admin-only)
|
||||
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
|
||||
Route(
|
||||
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
|
||||
),
|
||||
Route(
|
||||
"/webhooks/disable/{preset_id:str}",
|
||||
disable_webhook_preset,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
# Add static files mount if directory exists
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
||||
if os.path.isdir(static_dir):
|
||||
browser_routes.append(
|
||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
)
|
||||
logger.info(f"Mounted static files from {static_dir}")
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
Route(
|
||||
"/", user_info_html, methods=["GET"]
|
||||
), # /app → user info with all tabs
|
||||
Route(
|
||||
"/revoke",
|
||||
revoke_session,
|
||||
methods=["POST"],
|
||||
name="revoke_session_endpoint",
|
||||
), # /app/revoke → revoke_session
|
||||
# Vector sync status fragment (htmx polling)
|
||||
Route(
|
||||
"/vector-sync/status",
|
||||
vector_sync_status_fragment,
|
||||
methods=["GET"],
|
||||
), # /app/vector-sync/status
|
||||
# Vector visualization routes
|
||||
Route(
|
||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||
), # /app/vector-viz
|
||||
Route(
|
||||
"/vector-viz/search",
|
||||
vector_visualization_search,
|
||||
methods=["GET"],
|
||||
), # /app/vector-viz/search
|
||||
Route(
|
||||
"/chunk-context",
|
||||
chunk_context_endpoint,
|
||||
methods=["GET"],
|
||||
), # /app/chunk-context
|
||||
# Webhook management routes (admin-only)
|
||||
Route(
|
||||
"/webhooks", webhook_management_pane, methods=["GET"]
|
||||
), # /app/webhooks
|
||||
Route(
|
||||
"/webhooks/enable/{preset_id:str}",
|
||||
enable_webhook_preset,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/webhooks/disable/{preset_id:str}",
|
||||
disable_webhook_preset,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
]
|
||||
|
||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||
routes.append(
|
||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
# Add static files mount if directory exists
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
||||
if os.path.isdir(static_dir):
|
||||
browser_routes.append(
|
||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
)
|
||||
logger.info(f"Mounted static files from {static_dir}")
|
||||
|
||||
# Mount browser app at /app (webapp and admin routes)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||
browser_app = Starlette(routes=browser_routes)
|
||||
browser_app.add_middleware(
|
||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||
)
|
||||
|
||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||
routes.append(
|
||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
|
||||
# Mount browser app at /app (webapp and admin routes)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||
else:
|
||||
logger.info("Admin UI (/app) disabled in Smithery stateless mode")
|
||||
|
||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
@@ -1689,4 +1829,11 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
|
||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
||||
|
||||
# ADR-016: Apply SmitheryConfigMiddleware in Smithery stateless mode
|
||||
# This must be the outermost middleware to extract config from URL query parameters
|
||||
# before any other middleware processes the request
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
app = SmitheryConfigMiddleware(app)
|
||||
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
|
||||
|
||||
return app
|
||||
|
||||
@@ -22,6 +22,7 @@ from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
@@ -139,7 +140,10 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
|
||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client: # noqa: F841
|
||||
with trace_operation("vector_viz.get_auth_client"):
|
||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||
|
||||
async with auth_client_ctx as nc_client: # noqa: F841
|
||||
# Create search algorithm (no client needed - verification removed)
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
@@ -159,24 +163,40 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
all_results = []
|
||||
if doc_types is None or len(doc_types) == 0:
|
||||
# Cross-app search - search all indexed types
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=None, # Search all types
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
else:
|
||||
# Search each document type and combine
|
||||
for doc_type in doc_types:
|
||||
with trace_operation(
|
||||
"vector_viz.search_execute",
|
||||
attributes={
|
||||
"search.algorithm": algorithm,
|
||||
"search.limit": limit * 2,
|
||||
"search.doc_type": "all",
|
||||
},
|
||||
):
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=doc_type,
|
||||
doc_type=None, # Search all types
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
else:
|
||||
# Search each document type and combine
|
||||
for doc_type in doc_types:
|
||||
with trace_operation(
|
||||
"vector_viz.search_execute",
|
||||
attributes={
|
||||
"search.algorithm": algorithm,
|
||||
"search.limit": limit * 2,
|
||||
"search.doc_type": doc_type,
|
||||
},
|
||||
):
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=doc_type,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
# Sort by score before verification
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
@@ -190,22 +210,26 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
# Store original scores and normalize for visualization
|
||||
# (best result = 1.0, worst result = 0.0 within THIS result set)
|
||||
# This makes visual encoding meaningful regardless of RRF normalization
|
||||
if search_results:
|
||||
scores = [r.score for r in search_results]
|
||||
min_score, max_score = min(scores), max(scores)
|
||||
score_range = max_score - min_score if max_score > min_score else 1.0
|
||||
with trace_operation(
|
||||
"vector_viz.score_normalize",
|
||||
attributes={"normalize.num_results": len(search_results)},
|
||||
):
|
||||
if search_results:
|
||||
scores = [r.score for r in search_results]
|
||||
min_score, max_score = min(scores), max(scores)
|
||||
score_range = max_score - min_score if max_score > min_score else 1.0
|
||||
|
||||
logger.info(
|
||||
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
|
||||
f"→ [0.0, 1.0]"
|
||||
)
|
||||
logger.info(
|
||||
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
|
||||
f"→ [0.0, 1.0]"
|
||||
)
|
||||
|
||||
# Store original score and rescale to 0-1 for visualization
|
||||
for r in search_results:
|
||||
# Store original score before normalization
|
||||
r.original_score = r.score
|
||||
# Rescale for visual encoding
|
||||
r.score = (r.score - min_score) / score_range
|
||||
# Store original score and rescale to 0-1 for visualization
|
||||
for r in search_results:
|
||||
# Store original score before normalization
|
||||
r.original_score = r.score
|
||||
# Rescale for visual encoding
|
||||
r.score = (r.score - min_score) / score_range
|
||||
|
||||
if not search_results:
|
||||
return JSONResponse(
|
||||
@@ -218,71 +242,47 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
}
|
||||
)
|
||||
|
||||
# Fetch vectors for specific matching chunks from Qdrant
|
||||
# Fetch vectors for specific matching chunks from Qdrant using batch retrieve
|
||||
vector_fetch_start = time.perf_counter()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Build filters for each specific chunk
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
with trace_operation("vector_viz.get_qdrant_client"):
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
chunk_vectors_map = {} # Map (doc_id, chunk_start, chunk_end) -> vector
|
||||
|
||||
# Fetch vectors in batches by filtering on chunk-specific fields
|
||||
for result in search_results:
|
||||
chunk_start = result.chunk_start_offset
|
||||
chunk_end = result.chunk_end_offset
|
||||
# Collect point IDs from search results for batch retrieval
|
||||
# point_id is the Qdrant internal ID returned by search algorithms
|
||||
point_ids = [r.point_id for r in search_results if r.point_id]
|
||||
|
||||
# Build filter for this specific chunk
|
||||
must_conditions = [
|
||||
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
|
||||
FieldCondition(
|
||||
key="doc_id",
|
||||
match=MatchValue(value=result.id),
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match=MatchValue(value=username),
|
||||
),
|
||||
]
|
||||
|
||||
# Add chunk position filters if available
|
||||
if chunk_start is not None:
|
||||
must_conditions.append(
|
||||
FieldCondition(
|
||||
key="chunk_start_offset",
|
||||
match=MatchValue(value=chunk_start),
|
||||
)
|
||||
)
|
||||
if chunk_end is not None:
|
||||
must_conditions.append(
|
||||
FieldCondition(
|
||||
key="chunk_end_offset",
|
||||
match=MatchValue(value=chunk_end),
|
||||
)
|
||||
if point_ids:
|
||||
# Single batch retrieve call instead of N sequential scroll calls
|
||||
# This is ~50x faster for 50 results (1 HTTP request vs 50)
|
||||
with trace_operation(
|
||||
"vector_viz.vector_retrieve",
|
||||
attributes={"retrieve.num_points": len(point_ids)},
|
||||
):
|
||||
points_response = await qdrant_client.retrieve(
|
||||
collection_name=settings.get_collection_name(),
|
||||
ids=point_ids,
|
||||
with_vectors=["dense"],
|
||||
with_payload=["doc_id", "chunk_start_offset", "chunk_end_offset"],
|
||||
)
|
||||
|
||||
# Fetch this specific chunk vector
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(must=must_conditions),
|
||||
limit=1, # Only need the first match
|
||||
with_vectors=["dense"],
|
||||
with_payload=False,
|
||||
)
|
||||
|
||||
points = points_response[0]
|
||||
if points:
|
||||
# Extract dense vector
|
||||
point = points[0]
|
||||
# Build chunk_vectors_map from batch response
|
||||
for point in points_response:
|
||||
if point.vector is not None:
|
||||
# If named vectors (dict), extract "dense"
|
||||
# Extract dense vector (handle both named and unnamed vectors)
|
||||
if isinstance(point.vector, dict):
|
||||
vector = point.vector.get("dense")
|
||||
else:
|
||||
vector = point.vector
|
||||
|
||||
chunk_key = (result.id, chunk_start, chunk_end)
|
||||
chunk_vectors_map[chunk_key] = vector
|
||||
if vector is not None and point.payload:
|
||||
doc_id = point.payload.get("doc_id")
|
||||
chunk_start = point.payload.get("chunk_start_offset")
|
||||
chunk_end = point.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, chunk_start, chunk_end)
|
||||
chunk_vectors_map[chunk_key] = vector
|
||||
|
||||
vector_fetch_duration = time.perf_counter() - vector_fetch_start
|
||||
|
||||
@@ -341,16 +341,23 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
|
||||
chunk_vectors = np.array(chunk_vectors)
|
||||
|
||||
# Generate query embedding for visualization
|
||||
# Reuse query embedding from search algorithm (avoids redundant embedding call)
|
||||
query_embed_start = time.perf_counter()
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
logger.info(
|
||||
f"Reusing query embedding from search algorithm "
|
||||
f"(dimension={len(query_embedding)})"
|
||||
)
|
||||
else:
|
||||
# Fallback: generate embedding if not available from search
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||
query_embed_duration = time.perf_counter() - query_embed_start
|
||||
|
||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||
|
||||
# Combine query vector with chunk vectors for PCA
|
||||
# Query will be the last point in the array
|
||||
all_vectors = np.vstack([chunk_vectors, np.array([query_embedding])])
|
||||
@@ -380,9 +387,26 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
# Apply PCA dimensionality reduction (768-dim → 3D) on normalized vectors
|
||||
# Run in thread pool to avoid blocking the event loop (CPU-bound)
|
||||
pca_start = time.perf_counter()
|
||||
pca = PCA(n_components=3)
|
||||
coords_3d = pca.fit_transform(all_vectors_normalized)
|
||||
|
||||
def _compute_pca(vectors: np.ndarray) -> tuple[np.ndarray, PCA]:
|
||||
pca = PCA(n_components=3)
|
||||
coords = pca.fit_transform(vectors)
|
||||
return coords, pca
|
||||
|
||||
import anyio
|
||||
|
||||
with trace_operation(
|
||||
"vector_viz.pca_compute",
|
||||
attributes={
|
||||
"pca.num_vectors": len(all_vectors_normalized),
|
||||
"pca.embedding_dim": embedding_dim,
|
||||
},
|
||||
):
|
||||
coords_3d, pca = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: _compute_pca(all_vectors_normalized)
|
||||
)
|
||||
pca_duration = time.perf_counter() - pca_start
|
||||
|
||||
# After fit, these attributes are guaranteed to be set
|
||||
|
||||
@@ -29,9 +29,9 @@ from .app import get_app
|
||||
@click.option(
|
||||
"--transport",
|
||||
"-t",
|
||||
default="sse",
|
||||
default="streamable-http",
|
||||
show_default=True,
|
||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||
type=click.Choice(["streamable-http", "http"]),
|
||||
help="MCP transport protocol",
|
||||
)
|
||||
@click.option(
|
||||
|
||||
@@ -1295,3 +1295,237 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
|
||||
logger.debug(f"Found {len(files)} files with tag ID {tag_id}")
|
||||
return files
|
||||
|
||||
async def get_file_info(self, path: str) -> dict[str, Any] | None:
|
||||
"""Get file info including file ID via WebDAV PROPFIND.
|
||||
|
||||
Args:
|
||||
path: Path to the file (relative to user's files directory)
|
||||
|
||||
Returns:
|
||||
File info dictionary with id, name, size, content_type, etc.
|
||||
Returns None if file not found.
|
||||
"""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:fileid/>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"PROPFIND",
|
||||
webdav_path,
|
||||
headers={"Depth": "0"},
|
||||
content=propfind_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"File not found: {path}")
|
||||
return None
|
||||
raise
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {
|
||||
"d": "DAV:",
|
||||
"oc": "http://owncloud.org/ns",
|
||||
}
|
||||
|
||||
response_elem = root.find("d:response", ns)
|
||||
if response_elem is None:
|
||||
return None
|
||||
|
||||
propstat = response_elem.find("d:propstat", ns)
|
||||
if propstat is None:
|
||||
return None
|
||||
|
||||
prop = propstat.find("d:prop", ns)
|
||||
if prop is None:
|
||||
return None
|
||||
|
||||
# Extract properties
|
||||
fileid_elem = prop.find("oc:fileid", ns)
|
||||
displayname_elem = prop.find("d:displayname", ns)
|
||||
contentlength_elem = prop.find("d:getcontentlength", ns)
|
||||
contenttype_elem = prop.find("d:getcontenttype", ns)
|
||||
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
||||
etag_elem = prop.find("d:getetag", ns)
|
||||
resourcetype_elem = prop.find("d:resourcetype", ns)
|
||||
|
||||
is_directory = (
|
||||
resourcetype_elem is not None
|
||||
and resourcetype_elem.find("d:collection", ns) is not None
|
||||
)
|
||||
|
||||
file_info = {
|
||||
"id": int(fileid_elem.text) if fileid_elem is not None else None,
|
||||
"path": path,
|
||||
"name": displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else path.split("/")[-1],
|
||||
"size": int(contentlength_elem.text)
|
||||
if contentlength_elem is not None and contentlength_elem.text
|
||||
else 0,
|
||||
"content_type": contenttype_elem.text
|
||||
if contenttype_elem is not None
|
||||
else "",
|
||||
"last_modified": lastmodified_elem.text
|
||||
if lastmodified_elem is not None
|
||||
else None,
|
||||
"etag": etag_elem.text.strip('"')
|
||||
if etag_elem is not None and etag_elem.text
|
||||
else None,
|
||||
"is_directory": is_directory,
|
||||
}
|
||||
|
||||
logger.debug(f"Got file info for '{path}': id={file_info['id']}")
|
||||
return file_info
|
||||
|
||||
async def create_tag(
|
||||
self,
|
||||
name: str,
|
||||
user_visible: bool = True,
|
||||
user_assignable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a system tag via WebDAV.
|
||||
|
||||
Args:
|
||||
name: Name of the tag to create
|
||||
user_visible: Whether the tag is visible to users
|
||||
user_assignable: Whether users can assign this tag
|
||||
|
||||
Returns:
|
||||
Tag dictionary with id, name, userVisible, userAssignable
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag creation fails (409 if already exists)
|
||||
"""
|
||||
# Use WebDAV POST with JSON body to create tag
|
||||
response = await self._client.post(
|
||||
"/remote.php/dav/systemtags/",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={
|
||||
"name": name,
|
||||
"userVisible": user_visible,
|
||||
"userAssignable": user_assignable,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Extract tag ID from Content-Location header (e.g., /remote.php/dav/systemtags/42)
|
||||
content_location = response.headers.get("Content-Location", "")
|
||||
tag_id = None
|
||||
if content_location:
|
||||
# Extract the numeric ID from the path
|
||||
try:
|
||||
tag_id = int(content_location.rstrip("/").split("/")[-1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
tag_info = {
|
||||
"id": tag_id,
|
||||
"name": name,
|
||||
"userVisible": user_visible,
|
||||
"userAssignable": user_assignable,
|
||||
}
|
||||
|
||||
logger.info(f"Created tag '{name}' with ID {tag_info['id']}")
|
||||
return tag_info
|
||||
|
||||
async def get_or_create_tag(
|
||||
self,
|
||||
name: str,
|
||||
user_visible: bool = True,
|
||||
user_assignable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Get a tag by name, creating it if it doesn't exist.
|
||||
|
||||
Args:
|
||||
name: Name of the tag
|
||||
user_visible: Whether the tag is visible to users (for creation)
|
||||
user_assignable: Whether users can assign this tag (for creation)
|
||||
|
||||
Returns:
|
||||
Tag dictionary with id, name, userVisible, userAssignable
|
||||
"""
|
||||
# First try to get existing tag
|
||||
existing_tag = await self.get_tag_by_name(name)
|
||||
if existing_tag:
|
||||
logger.debug(f"Tag '{name}' already exists with ID {existing_tag['id']}")
|
||||
return existing_tag
|
||||
|
||||
# Create new tag
|
||||
try:
|
||||
return await self.create_tag(name, user_visible, user_assignable)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
# Tag was created between our check and creation, fetch it
|
||||
existing_tag = await self.get_tag_by_name(name)
|
||||
if existing_tag:
|
||||
return existing_tag
|
||||
raise
|
||||
|
||||
async def assign_tag_to_file(self, file_id: int, tag_id: int) -> bool:
|
||||
"""Assign a system tag to a file.
|
||||
|
||||
Args:
|
||||
file_id: Numeric file ID
|
||||
tag_id: Numeric tag ID
|
||||
|
||||
Returns:
|
||||
True if tag was assigned successfully (or already assigned)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag assignment fails
|
||||
"""
|
||||
response = await self._client.request(
|
||||
"PUT",
|
||||
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
|
||||
headers={"Content-Length": "0"},
|
||||
content=b"",
|
||||
)
|
||||
|
||||
# 201 = Created (new assignment), 409 = Conflict (already assigned)
|
||||
if response.status_code in (201, 409):
|
||||
logger.info(f"Tagged file {file_id} with tag {tag_id}")
|
||||
return True
|
||||
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
async def remove_tag_from_file(self, file_id: int, tag_id: int) -> bool:
|
||||
"""Remove a system tag from a file.
|
||||
|
||||
Args:
|
||||
file_id: Numeric file ID
|
||||
tag_id: Numeric tag ID
|
||||
|
||||
Returns:
|
||||
True if tag was removed successfully (or wasn't assigned)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag removal fails
|
||||
"""
|
||||
response = await self._client.request(
|
||||
"DELETE",
|
||||
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
|
||||
)
|
||||
|
||||
# 204 = No Content (removed), 404 = Not Found (wasn't assigned)
|
||||
if response.status_code in (204, 404):
|
||||
logger.info(f"Removed tag {tag_id} from file {file_id}")
|
||||
return True
|
||||
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
@@ -2,8 +2,37 @@ import logging
|
||||
import logging.config
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
"""Deployment mode for the MCP server.
|
||||
|
||||
SELF_HOSTED: Full features, environment-based configuration.
|
||||
Supports vector sync, semantic search, admin UI.
|
||||
|
||||
SMITHERY_STATELESS: Stateless mode for Smithery hosting.
|
||||
Session-based configuration, no persistent storage.
|
||||
Excludes semantic search, vector sync, admin UI.
|
||||
"""
|
||||
|
||||
SELF_HOSTED = "self_hosted"
|
||||
SMITHERY_STATELESS = "smithery"
|
||||
|
||||
|
||||
def get_deployment_mode() -> DeploymentMode:
|
||||
"""Detect deployment mode from environment.
|
||||
|
||||
Returns:
|
||||
DeploymentMode.SMITHERY_STATELESS if SMITHERY_DEPLOYMENT=true,
|
||||
otherwise DeploymentMode.SELF_HOSTED (default).
|
||||
"""
|
||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||
return DeploymentMode.SMITHERY_STATELESS
|
||||
return DeploymentMode.SELF_HOSTED
|
||||
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
@@ -188,6 +217,11 @@ class Settings:
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
# OpenAI settings (for embeddings)
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
document_chunk_size: int = 2048 # Characters per chunk
|
||||
document_chunk_overlap: int = 200 # Overlapping characters between chunks
|
||||
@@ -246,6 +280,29 @@ class Settings:
|
||||
f"DOCUMENT_CHUNK_OVERLAP ({self.document_chunk_overlap}) cannot be negative."
|
||||
)
|
||||
|
||||
def get_embedding_model_name(self) -> str:
|
||||
"""
|
||||
Get the active embedding model name based on provider priority.
|
||||
|
||||
Priority order (same as ProviderRegistry):
|
||||
1. OpenAI - if OPENAI_API_KEY is set
|
||||
2. Ollama - if OLLAMA_BASE_URL is set
|
||||
3. Simple - fallback (returns "simple-384")
|
||||
|
||||
Returns:
|
||||
Active embedding model name
|
||||
"""
|
||||
# Check OpenAI first (higher priority than Ollama in registry)
|
||||
if self.openai_api_key:
|
||||
return self.openai_embedding_model
|
||||
|
||||
# Check Ollama
|
||||
if self.ollama_base_url:
|
||||
return self.ollama_embedding_model
|
||||
|
||||
# Fallback to simple provider indicator
|
||||
return "simple-384"
|
||||
|
||||
def get_collection_name(self) -> str:
|
||||
"""
|
||||
Get Qdrant collection name.
|
||||
@@ -261,8 +318,9 @@ class Settings:
|
||||
Format: {deployment-id}-{model-name}
|
||||
|
||||
Examples:
|
||||
- "my-deployment-nomic-embed-text" (OTEL_SERVICE_NAME set)
|
||||
- "mcp-container-all-minilm" (hostname fallback)
|
||||
- "my-deployment-nomic-embed-text" (Ollama)
|
||||
- "my-deployment-text-embedding-3-small" (OpenAI)
|
||||
- "mcp-container-openai-text-embedding-3-small" (hostname fallback)
|
||||
|
||||
Returns:
|
||||
Collection name string
|
||||
@@ -282,7 +340,7 @@ class Settings:
|
||||
|
||||
# Sanitize deployment ID and model name
|
||||
deployment_id = deployment_id.lower().replace(" ", "-").replace("_", "-")
|
||||
model_name = self.ollama_embedding_model.replace("/", "-").replace(":", "-")
|
||||
model_name = self.get_embedding_model_name().replace("/", "-").replace(":", "-")
|
||||
|
||||
return f"{deployment_id}-{model_name}"
|
||||
|
||||
@@ -342,6 +400,12 @@ def get_settings() -> 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",
|
||||
# OpenAI settings
|
||||
openai_api_key=os.getenv("OPENAI_API_KEY"),
|
||||
openai_base_url=os.getenv("OPENAI_BASE_URL"),
|
||||
openai_embedding_model=os.getenv(
|
||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
||||
),
|
||||
# Document chunking settings
|
||||
document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")),
|
||||
document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")),
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
"""Helper functions for accessing context in MCP tools."""
|
||||
|
||||
import logging
|
||||
|
||||
from httpx import BasicAuth
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
get_deployment_mode,
|
||||
get_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
ADR-005 compliant implementation supporting two modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
ADR-016 compliant implementation supporting three deployment modes:
|
||||
|
||||
1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
|
||||
Create client from session configuration (nextcloud_url, username, app_password)
|
||||
No persistent state - client created per-request from Smithery session config.
|
||||
|
||||
2. BasicAuth mode: Returns shared client from lifespan context
|
||||
|
||||
3. OAuth mode:
|
||||
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
|
||||
SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
|
||||
proper token audiences per MCP Security Best Practices specification.
|
||||
@@ -24,7 +40,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
by the MCP server via @require_scopes decorator, not by the IdP.
|
||||
|
||||
This function automatically detects the authentication mode by checking
|
||||
the type of the lifespan context.
|
||||
the deployment mode and type of the lifespan context.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context
|
||||
@@ -34,6 +50,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected data
|
||||
ValueError: If Smithery mode but session config is missing required fields
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -43,6 +60,12 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
return await client.capabilities()
|
||||
```
|
||||
"""
|
||||
deployment_mode = get_deployment_mode()
|
||||
|
||||
# ADR-016: Smithery stateless mode - create client from session config
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
return _get_client_from_session_config(ctx)
|
||||
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
@@ -75,3 +98,82 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
||||
f"Type: {type(lifespan_ctx)}"
|
||||
)
|
||||
|
||||
|
||||
def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Create NextcloudClient from Smithery session configuration.
|
||||
|
||||
ADR-016: In Smithery stateless mode, each request includes session config
|
||||
with the user's Nextcloud credentials. This function creates a fresh client
|
||||
for each request - no state is persisted between requests.
|
||||
|
||||
For container runtime, config is extracted from URL query parameters by
|
||||
SmitheryConfigMiddleware and stored in a context variable.
|
||||
|
||||
Expected session config fields (from Smithery configSchema):
|
||||
- nextcloud_url: str - Nextcloud instance URL (required)
|
||||
- username: str - Nextcloud username (required)
|
||||
- app_password: str - Nextcloud app password (required)
|
||||
|
||||
Args:
|
||||
ctx: MCP request context (not used directly for Smithery config)
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with session credentials
|
||||
|
||||
Raises:
|
||||
ValueError: If required session config fields are missing
|
||||
"""
|
||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
||||
|
||||
session_config = get_smithery_session_config()
|
||||
|
||||
if session_config is None:
|
||||
raise ValueError(
|
||||
"Session configuration required in Smithery mode. "
|
||||
"Ensure nextcloud_url, username, and app_password are provided as URL query parameters."
|
||||
)
|
||||
|
||||
# Extract required fields - config is always a dict from SmitheryConfigMiddleware
|
||||
nextcloud_url = session_config.get("nextcloud_url")
|
||||
username = session_config.get("username")
|
||||
app_password = session_config.get("app_password")
|
||||
|
||||
# Validate required fields
|
||||
missing_fields = []
|
||||
if not nextcloud_url:
|
||||
missing_fields.append("nextcloud_url")
|
||||
if not username:
|
||||
missing_fields.append("username")
|
||||
if not app_password:
|
||||
missing_fields.append("app_password")
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
f"Missing required session config fields: {', '.join(missing_fields)}. "
|
||||
f"Configure these in the Smithery connection settings."
|
||||
)
|
||||
|
||||
# Type assertions after validation (for type checker)
|
||||
# These are guaranteed to be str after the missing_fields check above
|
||||
assert nextcloud_url is not None
|
||||
assert username is not None
|
||||
assert app_password is not None
|
||||
|
||||
# Validate URL format
|
||||
if not nextcloud_url.startswith(("http://", "https://")):
|
||||
raise ValueError(
|
||||
f"Invalid nextcloud_url: {nextcloud_url}. "
|
||||
f"Must start with http:// or https://"
|
||||
)
|
||||
|
||||
logger.debug(f"Creating Smithery client for {nextcloud_url} as {username}")
|
||||
|
||||
# Create client with session credentials using BasicAuth
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_url,
|
||||
username=username,
|
||||
auth=BasicAuth(username, app_password),
|
||||
)
|
||||
|
||||
@@ -37,7 +37,9 @@ class BM25SparseEmbeddingProvider:
|
||||
|
||||
def encode(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate BM25 sparse embedding for a single text.
|
||||
Generate BM25 sparse embedding for a single text (synchronous).
|
||||
|
||||
Note: For async contexts, prefer encode_async() to avoid blocking the event loop.
|
||||
|
||||
Args:
|
||||
text: Input text to encode
|
||||
@@ -53,6 +55,23 @@ class BM25SparseEmbeddingProvider:
|
||||
"values": sparse_embedding.values.tolist(),
|
||||
}
|
||||
|
||||
async def encode_async(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate BM25 sparse embedding for a single text (async).
|
||||
|
||||
Runs CPU-bound BM25 encoding in thread pool to avoid blocking the event loop.
|
||||
|
||||
Args:
|
||||
text: Input text to encode
|
||||
|
||||
Returns:
|
||||
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
||||
"""
|
||||
import anyio
|
||||
|
||||
# Run CPU-bound BM25 encoding in thread pool
|
||||
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
||||
|
||||
async def encode_batch(self, texts: list[str]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate BM25 sparse embeddings for multiple texts (batched).
|
||||
|
||||
@@ -4,12 +4,14 @@ from .anthropic import AnthropicProvider
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .registry import get_provider, reset_provider
|
||||
from .simple import SimpleProvider
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
"OllamaProvider",
|
||||
"OpenAIProvider",
|
||||
"AnthropicProvider",
|
||||
"SimpleProvider",
|
||||
"BedrockProvider",
|
||||
|
||||
@@ -17,18 +17,20 @@ class AnthropicProvider(Provider):
|
||||
Note: Anthropic doesn't provide embedding models, only text generation.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
|
||||
def __init__(
|
||||
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
|
||||
):
|
||||
"""
|
||||
Initialize Anthropic provider.
|
||||
|
||||
Args:
|
||||
api_key: Anthropic API key
|
||||
model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||
"""
|
||||
self.client = AsyncAnthropic(api_key=api_key)
|
||||
self.model = model
|
||||
self.model = generation_model
|
||||
|
||||
logger.info(f"Initialized Anthropic provider (model={model})")
|
||||
logger.info(f"Initialized Anthropic provider (model={self.model})")
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Unified OpenAI provider for embeddings and text generation.
|
||||
|
||||
Supports:
|
||||
- OpenAI's standard API
|
||||
- GitHub Models API (models.github.ai)
|
||||
- Any OpenAI-compatible API via base_url override
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from .base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Well-known embedding dimensions for OpenAI models
|
||||
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
# GitHub Models API uses openai/ prefix
|
||||
"openai/text-embedding-3-small": 1536,
|
||||
"openai/text-embedding-3-large": 3072,
|
||||
}
|
||||
|
||||
|
||||
class OpenAIProvider(Provider):
|
||||
"""
|
||||
OpenAI provider supporting both embeddings and text generation.
|
||||
|
||||
Works with:
|
||||
- OpenAI's standard API (api.openai.com)
|
||||
- GitHub Models API (models.github.ai)
|
||||
- Any OpenAI-compatible API (via base_url)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
embedding_model: str | None = None,
|
||||
generation_model: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
):
|
||||
"""
|
||||
Initialize OpenAI provider.
|
||||
|
||||
Args:
|
||||
api_key: OpenAI API key (or GITHUB_TOKEN for GitHub Models)
|
||||
base_url: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
embedding_model: Model for embeddings (e.g., "text-embedding-3-small").
|
||||
None disables embeddings.
|
||||
generation_model: Model for text generation (e.g., "gpt-4o-mini").
|
||||
None disables generation.
|
||||
timeout: HTTP timeout in seconds (default: 120)
|
||||
"""
|
||||
self.embedding_model = embedding_model
|
||||
self.generation_model = generation_model
|
||||
self._dimension: int | None = None
|
||||
|
||||
# Initialize async client
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Try to get known dimension without API call
|
||||
if embedding_model and embedding_model in OPENAI_EMBEDDING_DIMENSIONS:
|
||||
self._dimension = OPENAI_EMBEDDING_DIMENSIONS[embedding_model]
|
||||
|
||||
logger.info(
|
||||
f"Initialized OpenAI provider: base_url={base_url or 'default'} "
|
||||
f"(embedding_model={embedding_model}, generation_model={generation_model}, "
|
||||
f"dimension={self._dimension})"
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
"""Whether this provider supports embedding generation."""
|
||||
return self.embedding_model is not None
|
||||
|
||||
@property
|
||||
def supports_generation(self) -> bool:
|
||||
"""Whether this provider supports text generation."""
|
||||
return self.generation_model is not None
|
||||
|
||||
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
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
response = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
|
||||
embedding = response.data[0].embedding
|
||||
|
||||
# Update dimension if not set
|
||||
if self._dimension is None:
|
||||
self._dimension = len(embedding)
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
)
|
||||
|
||||
return embedding
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts using OpenAI's batch API.
|
||||
|
||||
OpenAI supports up to 2048 inputs per request.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# OpenAI supports batches up to 2048, but use smaller batches for safety
|
||||
batch_size = 100
|
||||
all_embeddings: list[list[float]] = []
|
||||
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
|
||||
response = await self.client.embeddings.create(
|
||||
input=batch,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
|
||||
# Sort by index to maintain order
|
||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||
batch_embeddings = [item.embedding for item in sorted_data]
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
|
||||
# Update dimension if not set
|
||||
if self._dimension is None and batch_embeddings:
|
||||
self._dimension = len(batch_embeddings[0])
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension.
|
||||
|
||||
Returns:
|
||||
Vector dimension for the configured embedding model
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
RuntimeError: If dimension not detected yet (call embed first)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
if self._dimension is None:
|
||||
raise RuntimeError(
|
||||
f"Embedding dimension not detected yet for model {self.embedding_model}. "
|
||||
"Call embed() first or use a known model."
|
||||
)
|
||||
return self._dimension
|
||||
|
||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
"""
|
||||
Generate text from a prompt.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to generate from
|
||||
max_tokens: Maximum tokens to generate
|
||||
|
||||
Returns:
|
||||
Generated text
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If generation not enabled (no generation_model)
|
||||
"""
|
||||
if not self.supports_generation:
|
||||
raise NotImplementedError(
|
||||
"Text generation not supported - no generation_model configured"
|
||||
)
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.generation_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close HTTP client."""
|
||||
await self.client.close()
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .simple import SimpleProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,8 +18,9 @@ class ProviderRegistry:
|
||||
|
||||
Checks environment variables in priority order and creates appropriate provider:
|
||||
1. Bedrock (AWS_REGION + BEDROCK_*_MODEL)
|
||||
2. Ollama (OLLAMA_BASE_URL)
|
||||
3. Simple (fallback for testing/development)
|
||||
2. OpenAI (OPENAI_API_KEY)
|
||||
3. Ollama (OLLAMA_BASE_URL)
|
||||
4. Simple (fallback for testing/development)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -28,8 +30,9 @@ class ProviderRegistry:
|
||||
|
||||
Priority order:
|
||||
1. Bedrock - if AWS_REGION or BEDROCK_EMBEDDING_MODEL is set
|
||||
2. Ollama - if OLLAMA_BASE_URL is set
|
||||
3. Simple - fallback for testing/development
|
||||
2. OpenAI - if OPENAI_API_KEY is set
|
||||
3. Ollama - if OLLAMA_BASE_URL is set
|
||||
4. Simple - fallback for testing/development
|
||||
|
||||
Returns:
|
||||
Provider instance
|
||||
@@ -42,6 +45,12 @@ class ProviderRegistry:
|
||||
- BEDROCK_EMBEDDING_MODEL: Model ID for embeddings (e.g., "amazon.titan-embed-text-v2:0")
|
||||
- BEDROCK_GENERATION_MODEL: Model ID for text generation (e.g., "anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
|
||||
OpenAI:
|
||||
- OPENAI_API_KEY: OpenAI API key (or GITHUB_TOKEN for GitHub Models)
|
||||
- OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
- OPENAI_EMBEDDING_MODEL: Model for embeddings (default: "text-embedding-3-small")
|
||||
- OPENAI_GENERATION_MODEL: Model for text generation (e.g., "gpt-4o-mini")
|
||||
|
||||
Ollama:
|
||||
- OLLAMA_BASE_URL: Ollama API base URL (e.g., "http://localhost:11434")
|
||||
- OLLAMA_EMBEDDING_MODEL: Model for embeddings (default: "nomic-embed-text")
|
||||
@@ -70,7 +79,28 @@ class ProviderRegistry:
|
||||
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
)
|
||||
|
||||
# 2. Check for Ollama
|
||||
# 2. Check for OpenAI
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
if openai_api_key:
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
embedding_model = os.getenv(
|
||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
||||
)
|
||||
generation_model = os.getenv("OPENAI_GENERATION_MODEL")
|
||||
|
||||
logger.info(
|
||||
f"Using OpenAI provider: base_url={base_url or 'default'}, "
|
||||
f"embedding_model={embedding_model}, "
|
||||
f"generation_model={generation_model}"
|
||||
)
|
||||
return OpenAIProvider(
|
||||
api_key=openai_api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=generation_model,
|
||||
)
|
||||
|
||||
# 3. Check for Ollama (local LLM)
|
||||
ollama_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if ollama_url:
|
||||
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
@@ -89,12 +119,12 @@ class ProviderRegistry:
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
# 3. Fallback to Simple provider for development/testing
|
||||
# 4. Fallback to Simple provider for development/testing
|
||||
dimension = int(os.getenv("SIMPLE_EMBEDDING_DIMENSION", "384"))
|
||||
logger.warning(
|
||||
"No provider configured (AWS_REGION, OLLAMA_BASE_URL not set). "
|
||||
"No provider configured (AWS_REGION, OPENAI_API_KEY, OLLAMA_BASE_URL not set). "
|
||||
"Using SimpleProvider for testing/development. "
|
||||
"For production, configure Bedrock or Ollama."
|
||||
"For production, configure Bedrock, OpenAI, or Ollama."
|
||||
)
|
||||
return SimpleProvider(dimension=dimension)
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ class SearchResult:
|
||||
page_number: Page number for PDF documents (None for other doc types)
|
||||
chunk_index: Zero-based index of this chunk in the document
|
||||
total_chunks: Total number of chunks in the document
|
||||
point_id: Qdrant point ID for batch vector retrieval (None if not from Qdrant)
|
||||
"""
|
||||
|
||||
id: int
|
||||
@@ -153,6 +154,7 @@ class SearchResult:
|
||||
page_number: int | None = None
|
||||
chunk_index: int = 0
|
||||
total_chunks: int = 1
|
||||
point_id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate score is non-negative.
|
||||
@@ -172,8 +174,15 @@ class SearchAlgorithm(ABC):
|
||||
|
||||
All search algorithms must implement the search() method with consistent
|
||||
interface, allowing them to be used interchangeably.
|
||||
|
||||
Attributes:
|
||||
query_embedding: The query embedding generated during the last search.
|
||||
Available after search() completes for algorithms that use embeddings.
|
||||
Can be reused by callers to avoid redundant embedding generation.
|
||||
"""
|
||||
|
||||
query_embedding: list[float] | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
|
||||
@@ -9,6 +9,7 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
||||
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search.algorithms import SearchAlgorithm, SearchResult
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
@@ -99,13 +100,19 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
)
|
||||
|
||||
# Generate dense embedding for semantic search
|
||||
embedding_service = get_embedding_service()
|
||||
dense_embedding = await embedding_service.embed(query)
|
||||
with trace_operation("search.get_embedding_service"):
|
||||
embedding_service = get_embedding_service()
|
||||
with trace_operation("search.dense_embedding"):
|
||||
dense_embedding = await embedding_service.embed(query)
|
||||
# Store for reuse by callers (e.g., viz_routes PCA visualization)
|
||||
self.query_embedding = dense_embedding
|
||||
logger.debug(f"Generated dense embedding (dimension={len(dense_embedding)})")
|
||||
|
||||
# Generate sparse embedding for BM25 keyword search
|
||||
bm25_service = get_bm25_service()
|
||||
sparse_embedding = bm25_service.encode(query)
|
||||
with trace_operation("search.get_bm25_service"):
|
||||
bm25_service = get_bm25_service()
|
||||
with trace_operation("search.sparse_embedding_bm25"):
|
||||
sparse_embedding = await bm25_service.encode_async(query)
|
||||
logger.debug(
|
||||
f"Generated sparse embedding "
|
||||
f"({len(sparse_embedding['indices'])} non-zero terms)"
|
||||
@@ -132,38 +139,44 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
query_filter = Filter(must=filter_conditions)
|
||||
|
||||
# Execute hybrid search with Qdrant native RRF fusion
|
||||
qdrant_client = await get_qdrant_client()
|
||||
with trace_operation("search.get_qdrant_client"):
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
try:
|
||||
# Use prefetch to run both dense and sparse searches
|
||||
# Qdrant will automatically merge results using RRF
|
||||
search_response = await qdrant_client.query_points(
|
||||
collection_name=settings.get_collection_name(),
|
||||
prefetch=[
|
||||
# Dense semantic search
|
||||
models.Prefetch(
|
||||
query=dense_embedding,
|
||||
using="dense",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
# Sparse BM25 search
|
||||
models.Prefetch(
|
||||
query=models.SparseVector(
|
||||
indices=sparse_embedding["indices"],
|
||||
values=sparse_embedding["values"],
|
||||
with trace_operation(
|
||||
"search.qdrant_query",
|
||||
attributes={"query.limit": limit * 2, "query.fusion": self.fusion_name},
|
||||
):
|
||||
search_response = await qdrant_client.query_points(
|
||||
collection_name=settings.get_collection_name(),
|
||||
prefetch=[
|
||||
# Dense semantic search
|
||||
models.Prefetch(
|
||||
query=dense_embedding,
|
||||
using="dense",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
using="sparse",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
],
|
||||
# Fusion query (RRF or DBSF based on initialization)
|
||||
query=models.FusionQuery(fusion=self.fusion),
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
score_threshold=score_threshold,
|
||||
with_payload=True,
|
||||
with_vectors=False, # Don't return vectors to save bandwidth
|
||||
)
|
||||
# Sparse BM25 search
|
||||
models.Prefetch(
|
||||
query=models.SparseVector(
|
||||
indices=sparse_embedding["indices"],
|
||||
values=sparse_embedding["values"],
|
||||
),
|
||||
using="sparse",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
],
|
||||
# Fusion query (RRF or DBSF based on initialization)
|
||||
query=models.FusionQuery(fusion=self.fusion),
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
score_threshold=score_threshold,
|
||||
with_payload=True,
|
||||
with_vectors=False, # Don't return vectors to save bandwidth
|
||||
)
|
||||
record_qdrant_operation("search", "success")
|
||||
except Exception:
|
||||
record_qdrant_operation("search", "error")
|
||||
@@ -183,46 +196,51 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
|
||||
# Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
|
||||
# This allows multiple chunks from same doc, but removes duplicate chunks
|
||||
seen_chunks = set()
|
||||
results = []
|
||||
with trace_operation(
|
||||
"search.deduplicate",
|
||||
attributes={"dedupe.num_points": len(search_response.points)},
|
||||
):
|
||||
seen_chunks = set()
|
||||
results = []
|
||||
|
||||
for result in search_response.points:
|
||||
# doc_id can be int (notes) or str (files - file paths)
|
||||
doc_id = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
chunk_start = result.payload.get("chunk_start_offset")
|
||||
chunk_end = result.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
|
||||
for result in search_response.points:
|
||||
# doc_id can be int (notes) or str (files - file paths)
|
||||
doc_id = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
chunk_start = result.payload.get("chunk_start_offset")
|
||||
chunk_end = result.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
|
||||
|
||||
# Skip if we've already seen this exact chunk
|
||||
if chunk_key in seen_chunks:
|
||||
continue
|
||||
# Skip if we've already seen this exact chunk
|
||||
if chunk_key in seen_chunks:
|
||||
continue
|
||||
|
||||
seen_chunks.add(chunk_key)
|
||||
seen_chunks.add(chunk_key)
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=doc_id,
|
||||
doc_type=doc_type,
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score, # Fusion score (RRF or DBSF)
|
||||
metadata={
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
||||
},
|
||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||
page_number=result.payload.get("page_number"),
|
||||
chunk_index=result.payload.get("chunk_index", 0),
|
||||
total_chunks=result.payload.get("total_chunks", 1),
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=doc_id,
|
||||
doc_type=doc_type,
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score, # Fusion score (RRF or DBSF)
|
||||
metadata={
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
||||
},
|
||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||
page_number=result.payload.get("page_number"),
|
||||
chunk_index=result.payload.get("chunk_index", 0),
|
||||
total_chunks=result.payload.get("total_chunks", 1),
|
||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
logger.info(f"Returning {len(results)} unverified results after deduplication")
|
||||
if results:
|
||||
|
||||
@@ -78,6 +78,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
# Generate embedding for query
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
# Store for reuse by callers (e.g., viz_routes PCA visualization)
|
||||
self.query_embedding = query_embedding
|
||||
logger.debug(
|
||||
f"Generated embedding for query (dimension={len(query_embedding)})"
|
||||
)
|
||||
@@ -164,6 +166,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
page_number=result.payload.get("page_number"),
|
||||
chunk_index=result.payload.get("chunk_index", 0),
|
||||
total_chunks=result.payload.get("total_chunks", 1),
|
||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -335,27 +335,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
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(
|
||||
@@ -520,9 +499,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
# 6. Request LLM completion via MCP sampling with timeout
|
||||
# Note: 5 minute timeout to accommodate slower local LLMs (e.g., Ollama)
|
||||
sampling_timeout_seconds = 300
|
||||
|
||||
try:
|
||||
with anyio.fail_after(30):
|
||||
with anyio.fail_after(sampling_timeout_seconds):
|
||||
sampling_result = await ctx.session.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
@@ -569,14 +550,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Sampling request timed out after 30 seconds for query: '{query}', "
|
||||
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
|
||||
f"returning search results only"
|
||||
)
|
||||
return SamplingSearchResponse(
|
||||
query=query,
|
||||
generated_answer=(
|
||||
f"[Sampling request timed out]\n\n"
|
||||
f"The answer generation took too long (>30s). "
|
||||
f"The answer generation took too long (>{sampling_timeout_seconds}s). "
|
||||
f"Found {len(accessible_results)} relevant documents. "
|
||||
f"Please review the sources below or try a simpler query."
|
||||
),
|
||||
@@ -696,15 +677,22 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from qdrant_client.models import Filter
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
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 documents in collection, excluding placeholders
|
||||
# Placeholders are zero-vector points used to track processing state
|
||||
count_result = await qdrant_client.count(
|
||||
collection_name=settings.get_collection_name()
|
||||
collection_name=settings.get_collection_name(),
|
||||
count_filter=Filter(must=[get_placeholder_filter()]),
|
||||
)
|
||||
indexed_count = count_result.count
|
||||
|
||||
|
||||
@@ -64,20 +64,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
- Text files are decoded to UTF-8
|
||||
- Documents (PDF, DOCX, etc.) are parsed and text is extracted
|
||||
- Other binary files are base64 encoded
|
||||
|
||||
Examples:
|
||||
# Read a text file
|
||||
result = await nc_webdav_read_file("Documents/readme.txt")
|
||||
logger.info(result['content']) # Decoded text content
|
||||
|
||||
# Read a PDF document (automatically parsed)
|
||||
result = await nc_webdav_read_file("Documents/report.pdf")
|
||||
logger.info(result['content']) # Extracted text from PDF
|
||||
logger.info(result['parsing_metadata']) # Document parsing info
|
||||
|
||||
# Read a binary file
|
||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||
logger.info(result['encoding']) # 'base64'
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Smithery-specific entrypoint for stateless deployment.
|
||||
|
||||
ADR-016: This entrypoint is used when deploying on Smithery's hosting platform.
|
||||
It configures the server for stateless operation with per-session authentication.
|
||||
|
||||
Features disabled in Smithery mode:
|
||||
- Vector sync / semantic search (no persistent storage)
|
||||
- Admin UI at /app (no webhooks, no vector viz)
|
||||
- OAuth provisioning tools (no token storage)
|
||||
|
||||
Features enabled:
|
||||
- Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
|
||||
- Per-session app password authentication via Smithery configSchema
|
||||
- Health check endpoints (/health/live, /health/ready)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
from nextcloud_mcp_server.config import setup_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main():
|
||||
"""Start the MCP server in Smithery stateless mode."""
|
||||
# Setup logging first
|
||||
setup_logging()
|
||||
|
||||
# Force stateless mode environment variables
|
||||
os.environ["SMITHERY_DEPLOYMENT"] = "true"
|
||||
os.environ["VECTOR_SYNC_ENABLED"] = "false"
|
||||
|
||||
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
||||
|
||||
# Import app after setting environment variables
|
||||
from nextcloud_mcp_server.app import get_app
|
||||
|
||||
# Create the app with streamable-http transport (required for Smithery)
|
||||
app = get_app(transport="streamable-http")
|
||||
|
||||
# Smithery sets PORT environment variable
|
||||
port = int(os.environ.get("PORT", 8081))
|
||||
|
||||
logger.info(f"Listening on port {port}")
|
||||
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
log_level="info",
|
||||
# Disable access log for cleaner output
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -93,27 +93,29 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
||||
|
||||
# Validate dimension matches
|
||||
if actual_dimension != expected_dimension:
|
||||
embedding_model = settings.get_embedding_model_name()
|
||||
raise ValueError(
|
||||
f"Dimension mismatch for collection '{collection_name}':\n"
|
||||
f" Expected: {expected_dimension} (from embedding model '{settings.ollama_embedding_model}')\n"
|
||||
f" Expected: {expected_dimension} (from embedding model '{embedding_model}')\n"
|
||||
f" Found: {actual_dimension}\n"
|
||||
f"This usually means you changed the embedding model.\n"
|
||||
f"Solutions:\n"
|
||||
f" 1. Delete the old collection: Collection will be recreated with new dimensions\n"
|
||||
f" 2. Set QDRANT_COLLECTION to use a different collection name\n"
|
||||
f" 3. Revert OLLAMA_EMBEDDING_MODEL to the original model"
|
||||
f" 3. Revert to the original embedding model"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Using existing Qdrant collection: {collection_name} "
|
||||
f"(dimension={actual_dimension}, model={settings.ollama_embedding_model})"
|
||||
f"(dimension={actual_dimension}, model={settings.get_embedding_model_name()})"
|
||||
)
|
||||
|
||||
else:
|
||||
# Collection doesn't exist - create it
|
||||
embedding_model = settings.get_embedding_model_name()
|
||||
logger.info(
|
||||
f"Collection '{collection_name}' not found, creating with "
|
||||
f"dimension={expected_dimension}, model={settings.ollama_embedding_model}..."
|
||||
f"dimension={expected_dimension}, model={embedding_model}..."
|
||||
)
|
||||
await _qdrant_client.create_collection(
|
||||
collection_name=collection_name,
|
||||
@@ -134,7 +136,7 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
||||
logger.info(
|
||||
f"Created Qdrant collection: {collection_name}\n"
|
||||
f" Dense vector dimension: {expected_dimension}\n"
|
||||
f" Dense embedding model: {settings.ollama_embedding_model}\n"
|
||||
f" Dense embedding model: {embedding_model}\n"
|
||||
f" Sparse vectors: BM25 (for hybrid search)\n"
|
||||
f" Distance: COSINE\n"
|
||||
f"Background sync will index all documents with dense + sparse vectors."
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.45.0"
|
||||
version = "0.48.3"
|
||||
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"}
|
||||
@@ -39,6 +39,7 @@ dependencies = [
|
||||
"pymupdf>=1.26.6",
|
||||
"pymupdf4llm>=0.2.2",
|
||||
"pymupdf-layout>=1.26.6",
|
||||
"openai>=2.8.1",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
@@ -126,6 +127,7 @@ dev = [
|
||||
|
||||
[project.scripts]
|
||||
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
|
||||
smithery-main = "nextcloud_mcp_server.smithery_main:main"
|
||||
|
||||
[[tool.uv.index]]
|
||||
name = "testpypi"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Smithery configuration for Nextcloud MCP Server
|
||||
# See: https://smithery.ai/docs/build/configuration
|
||||
# ADR-016: Stateless deployment mode for multi-user public Nextcloud instances
|
||||
|
||||
runtime: "container"
|
||||
|
||||
build:
|
||||
dockerfile: "Dockerfile.smithery"
|
||||
dockerBuildPath: "."
|
||||
|
||||
startCommand:
|
||||
type: "http"
|
||||
configSchema:
|
||||
type: "object"
|
||||
required:
|
||||
- "nextcloud_url"
|
||||
- "username"
|
||||
- "app_password"
|
||||
properties:
|
||||
nextcloud_url:
|
||||
type: "string"
|
||||
title: "Nextcloud URL"
|
||||
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible."
|
||||
pattern: "^https?://.+"
|
||||
username:
|
||||
type: "string"
|
||||
title: "Username"
|
||||
description: "Your Nextcloud username"
|
||||
minLength: 1
|
||||
app_password:
|
||||
type: "string"
|
||||
title: "App Password"
|
||||
description: "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password."
|
||||
minLength: 1
|
||||
exampleConfig:
|
||||
nextcloud_url: "https://cloud.example.com"
|
||||
username: "alice"
|
||||
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
+8
-56
@@ -9,7 +9,6 @@ import pytest
|
||||
from httpx import HTTPStatusError
|
||||
from mcp import ClientSession
|
||||
from mcp.client.session import RequestContext
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
|
||||
|
||||
@@ -114,6 +113,7 @@ async def create_mcp_client_session(
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
elicitation_callback: Any = None,
|
||||
sampling_callback: Any = None,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Factory function to create an MCP client session with proper lifecycle management.
|
||||
@@ -133,6 +133,8 @@ async def create_mcp_client_session(
|
||||
client_name: Client name for logging (e.g., "OAuth MCP (Playwright)")
|
||||
elicitation_callback: Optional callback for handling elicitation requests.
|
||||
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
|
||||
sampling_callback: Optional callback for handling sampling (LLM generation) requests.
|
||||
Should match signature: async def callback(context: RequestContext, params: CreateMessageRequestParams) -> CreateMessageResult | ErrorData
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
@@ -156,52 +158,10 @@ async def create_mcp_client_session(
|
||||
_,
|
||||
):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=elicitation_callback
|
||||
) as session:
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
yield session
|
||||
|
||||
# Cleanup happens automatically in LIFO order - no exception suppression needed
|
||||
logger.debug(f"{client_name} client session cleaned up successfully")
|
||||
|
||||
|
||||
async def create_mcp_client_session_sse(
|
||||
url: str,
|
||||
token: str | None = None,
|
||||
client_name: str = "MCP",
|
||||
elicitation_callback: Any = None,
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Factory function to create an MCP client session using SSE transport.
|
||||
|
||||
Similar to create_mcp_client_session but uses SSE transport instead of streamable-http.
|
||||
Uses native async context managers to ensure correct LIFO cleanup order.
|
||||
|
||||
Args:
|
||||
url: MCP server URL (e.g., "http://localhost:8000/sse")
|
||||
token: Optional OAuth access token for Bearer authentication
|
||||
client_name: Client name for logging (e.g., "Basic MCP (SSE)")
|
||||
elicitation_callback: Optional callback for handling elicitation requests
|
||||
|
||||
Yields:
|
||||
Initialized MCP ClientSession
|
||||
|
||||
Note:
|
||||
SSE transport is being deprecated in favor of streamable-http.
|
||||
This function exists for compatibility testing only.
|
||||
"""
|
||||
logger.info(f"Creating SSE client for {client_name}")
|
||||
|
||||
# Prepare headers with OAuth token if provided
|
||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||
|
||||
# Use native async with - Python ensures LIFO cleanup
|
||||
# Cleanup order will be: ClientSession.__aexit__ -> sse_client.__aexit__
|
||||
# Note: sse_client yields only (read_stream, write_stream), not 3 values like streamablehttp_client
|
||||
async with sse_client(url, headers=headers) as (read_stream, write_stream):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=elicitation_callback
|
||||
read_stream,
|
||||
write_stream,
|
||||
elicitation_callback=elicitation_callback,
|
||||
sampling_callback=sampling_callback,
|
||||
) as session:
|
||||
await session.initialize()
|
||||
logger.info(f"{client_name} client session initialized successfully")
|
||||
@@ -249,18 +209,10 @@ async def nc_client(anyio_backend) -> AsyncGenerator[NextcloudClient, Any]:
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_mcp_client(anyio_backend) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""
|
||||
Fixture to create an MCP client session for integration tests using SSE transport.
|
||||
Fixture to create an MCP client session for integration tests using streamable-http.
|
||||
|
||||
Uses anyio pytest plugin for proper async fixture handling.
|
||||
|
||||
Note: SSE transport is being deprecated. This fixture uses SSE for compatibility testing.
|
||||
"""
|
||||
|
||||
# async for session in create_mcp_client_session_sse(
|
||||
# url="http://localhost:8000/sse", client_name="Basic MCP (SSE)"
|
||||
# ):
|
||||
# yield session
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8000/mcp",
|
||||
client_name="Basic MCP (HTTP)",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Pytest configuration for integration tests.
|
||||
|
||||
This conftest.py provides hooks and fixtures specific to integration tests,
|
||||
including the --provider flag for RAG tests.
|
||||
"""
|
||||
|
||||
# Valid provider names
|
||||
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
"""Add --provider command line option for RAG tests."""
|
||||
parser.addoption(
|
||||
"--provider",
|
||||
action="store",
|
||||
default=None,
|
||||
choices=VALID_PROVIDERS,
|
||||
help="LLM provider for RAG tests: openai, ollama, anthropic, bedrock",
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure custom markers."""
|
||||
config.addinivalue_line(
|
||||
"markers", "rag: mark test as RAG integration test (requires --provider flag)"
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"id": "nc-manual-001",
|
||||
"query": "What is two-factor authentication and how does it protect my Nextcloud account?",
|
||||
"ground_truth": "Two-factor authentication (2FA) protects your Nextcloud account by requiring two different proofs of identity - something you know (like a password) and something you have (like a code from your phone). The first factor is typically a password, and the second can be a text message or code generated on your phone.",
|
||||
"expected_topics": ["two-factor authentication", "2FA", "password", "security"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-002",
|
||||
"query": "How do file quotas work in Nextcloud when sharing files?",
|
||||
"ground_truth": "When you share files with other users, the shared files count against the original share owner's quota. When you share a folder and allow others to upload files, all uploaded and edited files count against your quota. Re-shared files still count against the original share owner's quota. Deleted files in trash don't count against quotas until trash exceeds 50% of quota.",
|
||||
"expected_topics": ["quota", "sharing", "files", "storage"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-003",
|
||||
"query": "How do I install the Nextcloud desktop sync client on Linux?",
|
||||
"ground_truth": "Linux users must follow instructions on the download page to add the appropriate repository for their Linux distribution, install the signing key, and use their package managers to install the desktop sync client. Linux users also need a password manager enabled, such as GNOME Keyring or KWallet, so the sync client can login automatically.",
|
||||
"expected_topics": ["Linux", "desktop client", "installation", "package manager", "GNOME Keyring", "KWallet"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-004",
|
||||
"query": "What are the system requirements for the Nextcloud desktop client on Windows?",
|
||||
"ground_truth": "The Nextcloud desktop sync client requires Windows 10 or later, 64-bits only.",
|
||||
"expected_topics": ["Windows", "system requirements", "desktop client"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "nc-manual-005",
|
||||
"query": "How do I use client applications with two-factor authentication enabled?",
|
||||
"ground_truth": "Once you have enabled 2FA, your clients will no longer be able to connect with just your password unless they also support two-factor authentication. To solve this, you should generate device-specific passwords for them. This is managed through the connected browsers and devices settings.",
|
||||
"expected_topics": ["2FA", "client applications", "device-specific passwords", "app passwords"],
|
||||
"difficulty": "medium"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Provider fixtures for integration tests.
|
||||
|
||||
This module provides pytest fixtures that configure LLM providers based on
|
||||
an explicit --provider flag. Supports OpenAI, Ollama, Anthropic, and Bedrock.
|
||||
|
||||
Usage:
|
||||
pytest tests/integration/test_rag.py --provider=openai
|
||||
pytest tests/integration/test_rag.py --provider=ollama
|
||||
pytest tests/integration/test_rag.py --provider=anthropic
|
||||
pytest tests/integration/test_rag.py --provider=bedrock
|
||||
|
||||
Environment Variables by Provider:
|
||||
|
||||
OpenAI:
|
||||
OPENAI_API_KEY: API key (required)
|
||||
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
|
||||
OPENAI_GENERATION_MODEL: Generation model (default: "gpt-4o-mini")
|
||||
|
||||
Ollama:
|
||||
OLLAMA_BASE_URL: API URL (required, e.g., "http://localhost:11434")
|
||||
OLLAMA_EMBEDDING_MODEL: Embedding model (default: "nomic-embed-text")
|
||||
OLLAMA_GENERATION_MODEL: Generation model (default: "llama3.2:1b")
|
||||
|
||||
Anthropic:
|
||||
ANTHROPIC_API_KEY: API key (required)
|
||||
ANTHROPIC_GENERATION_MODEL: Model (default: "claude-3-haiku-20240307")
|
||||
|
||||
Bedrock:
|
||||
AWS_REGION: AWS region (required)
|
||||
BEDROCK_EMBEDDING_MODEL: Embedding model ID
|
||||
BEDROCK_GENERATION_MODEL: Generation model ID
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.providers.base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid provider names (must match conftest.py)
|
||||
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
|
||||
|
||||
|
||||
async def create_generation_provider(provider_name: str) -> Provider:
|
||||
"""Create a provider configured for text generation.
|
||||
|
||||
Args:
|
||||
provider_name: One of "openai", "ollama", "anthropic", "bedrock"
|
||||
|
||||
Returns:
|
||||
Provider instance configured for generation
|
||||
|
||||
Raises:
|
||||
ValueError: If provider_name is invalid or required env vars missing
|
||||
"""
|
||||
if provider_name == "openai":
|
||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable required")
|
||||
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
generation_model = os.getenv("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
|
||||
|
||||
# GitHub Models API requires model name prefix
|
||||
if base_url and "models.github.ai" in base_url:
|
||||
if not generation_model.startswith("openai/"):
|
||||
generation_model = f"openai/{generation_model}"
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created OpenAI generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
from nextcloud_mcp_server.providers.ollama import OllamaProvider
|
||||
|
||||
base_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if not base_url:
|
||||
raise ValueError("OLLAMA_BASE_URL environment variable required")
|
||||
|
||||
generation_model = os.getenv("OLLAMA_GENERATION_MODEL", "llama3.2:1b")
|
||||
|
||||
provider = OllamaProvider(
|
||||
base_url=base_url,
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Ollama generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "anthropic":
|
||||
from nextcloud_mcp_server.providers.anthropic import AnthropicProvider
|
||||
|
||||
api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("ANTHROPIC_API_KEY environment variable required")
|
||||
|
||||
generation_model = os.getenv(
|
||||
"ANTHROPIC_GENERATION_MODEL", "claude-3-haiku-20240307"
|
||||
)
|
||||
|
||||
provider = AnthropicProvider(
|
||||
api_key=api_key,
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Anthropic generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
|
||||
|
||||
region = os.getenv("AWS_REGION")
|
||||
if not region:
|
||||
raise ValueError("AWS_REGION environment variable required")
|
||||
|
||||
generation_model = os.getenv("BEDROCK_GENERATION_MODEL")
|
||||
if not generation_model:
|
||||
raise ValueError("BEDROCK_GENERATION_MODEL environment variable required")
|
||||
|
||||
provider = BedrockProvider(
|
||||
region=region,
|
||||
embedding_model=None, # Generation only
|
||||
generation_model=generation_model,
|
||||
)
|
||||
logger.info(f"Created Bedrock generation provider: model={generation_model}")
|
||||
return provider
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
|
||||
|
||||
|
||||
async def create_embedding_provider(provider_name: str) -> Provider:
|
||||
"""Create a provider configured for embeddings.
|
||||
|
||||
Args:
|
||||
provider_name: One of "openai", "ollama", "bedrock"
|
||||
(Anthropic does not support embeddings)
|
||||
|
||||
Returns:
|
||||
Provider instance configured for embeddings
|
||||
|
||||
Raises:
|
||||
ValueError: If provider_name is invalid, doesn't support embeddings,
|
||||
or required env vars missing
|
||||
"""
|
||||
if provider_name == "anthropic":
|
||||
raise ValueError("Anthropic does not support embeddings")
|
||||
|
||||
if provider_name == "openai":
|
||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
||||
|
||||
api_key = os.getenv("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("OPENAI_API_KEY environment variable required")
|
||||
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
|
||||
|
||||
# GitHub Models API requires model name prefix
|
||||
if base_url and "models.github.ai" in base_url:
|
||||
if not embedding_model.startswith("openai/"):
|
||||
embedding_model = f"openai/{embedding_model}"
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created OpenAI embedding provider: model={embedding_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "ollama":
|
||||
from nextcloud_mcp_server.providers.ollama import OllamaProvider
|
||||
|
||||
base_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if not base_url:
|
||||
raise ValueError("OLLAMA_BASE_URL environment variable required")
|
||||
|
||||
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
|
||||
provider = OllamaProvider(
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Ollama embedding provider: model={embedding_model}")
|
||||
return provider
|
||||
|
||||
elif provider_name == "bedrock":
|
||||
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
|
||||
|
||||
region = os.getenv("AWS_REGION")
|
||||
if not region:
|
||||
raise ValueError("AWS_REGION environment variable required")
|
||||
|
||||
embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL")
|
||||
if not embedding_model:
|
||||
raise ValueError("BEDROCK_EMBEDDING_MODEL environment variable required")
|
||||
|
||||
provider = BedrockProvider(
|
||||
region=region,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=None, # Embeddings only
|
||||
)
|
||||
logger.info(f"Created Bedrock embedding provider: model={embedding_model}")
|
||||
return provider
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pytest Fixtures
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def provider_name(request) -> str:
|
||||
"""Get the provider name from --provider flag.
|
||||
|
||||
Raises pytest.skip if --provider not specified.
|
||||
"""
|
||||
name = request.config.getoption("--provider")
|
||||
if not name:
|
||||
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
|
||||
return name
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||
"""Fixture providing a generation-capable provider.
|
||||
|
||||
Requires --provider flag to be set.
|
||||
"""
|
||||
provider = await create_generation_provider(provider_name)
|
||||
yield provider
|
||||
await provider.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def embedding_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||
"""Fixture providing an embedding-capable provider.
|
||||
|
||||
Requires --provider flag to be set.
|
||||
Note: Anthropic does not support embeddings - test will fail if used.
|
||||
"""
|
||||
if provider_name == "anthropic":
|
||||
pytest.skip("Anthropic does not support embeddings")
|
||||
|
||||
provider = await create_embedding_provider(provider_name)
|
||||
yield provider
|
||||
await provider.close()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""MCP sampling support for integration tests.
|
||||
|
||||
This module provides utilities to enable real LLM-based sampling in integration tests
|
||||
using any provider that supports text generation (OpenAI, Ollama, Anthropic, Bedrock).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp import types
|
||||
from mcp.client.session import ClientSession, RequestContext
|
||||
|
||||
from nextcloud_mcp_server.providers.base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_sampling_callback(provider: Provider):
|
||||
"""Factory to create a sampling callback using any generation-capable provider.
|
||||
|
||||
The callback conforms to MCP's SamplingFnT protocol and can be passed
|
||||
to ClientSession for handling sampling requests from the server.
|
||||
|
||||
Args:
|
||||
provider: Any Provider instance that supports generation
|
||||
(supports_generation=True)
|
||||
|
||||
Returns:
|
||||
Async callback function for MCP sampling
|
||||
|
||||
Raises:
|
||||
ValueError: If provider doesn't support generation
|
||||
|
||||
Example:
|
||||
```python
|
||||
from nextcloud_mcp_server.providers import get_provider
|
||||
|
||||
provider = get_provider() # Auto-detect from environment
|
||||
if provider.supports_generation:
|
||||
callback = create_sampling_callback(provider)
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8000/mcp",
|
||||
sampling_callback=callback,
|
||||
):
|
||||
# Session now supports sampling
|
||||
pass
|
||||
```
|
||||
"""
|
||||
if not provider.supports_generation:
|
||||
raise ValueError(
|
||||
f"Provider {provider.__class__.__name__} does not support generation"
|
||||
)
|
||||
|
||||
# Get model name for logging (provider-specific attribute)
|
||||
model_name = (
|
||||
getattr(provider, "generation_model", None) or provider.__class__.__name__
|
||||
)
|
||||
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession, Any],
|
||||
params: types.CreateMessageRequestParams,
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
"""Handle sampling requests using the configured provider."""
|
||||
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
||||
|
||||
# Extract messages and build prompt
|
||||
messages_text = []
|
||||
for msg in params.messages:
|
||||
if hasattr(msg.content, "text"):
|
||||
role_prefix = "User" if msg.role == "user" else "Assistant"
|
||||
messages_text.append(f"{role_prefix}: {msg.content.text}")
|
||||
|
||||
prompt = "\n\n".join(messages_text)
|
||||
|
||||
# Add system prompt if provided
|
||||
if params.systemPrompt:
|
||||
prompt = f"System: {params.systemPrompt}\n\n{prompt}"
|
||||
|
||||
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
||||
|
||||
try:
|
||||
# Generate response using provider
|
||||
# Note: temperature is typically hardcoded in providers at 0.7
|
||||
response = await provider.generate(
|
||||
prompt=prompt,
|
||||
max_tokens=params.maxTokens,
|
||||
)
|
||||
|
||||
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
||||
|
||||
return types.CreateMessageResult(
|
||||
role="assistant",
|
||||
content=types.TextContent(type="text", text=response),
|
||||
model=model_name,
|
||||
stopReason="endTurn",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
|
||||
return types.ErrorData(
|
||||
code=types.INTERNAL_ERROR,
|
||||
message=f"Generation failed: {e!s}",
|
||||
)
|
||||
|
||||
return sampling_callback
|
||||
|
||||
|
||||
def create_openai_sampling_callback(provider: "Provider"):
|
||||
"""Factory to create a sampling callback using OpenAI provider.
|
||||
|
||||
This is a backward-compatible wrapper around create_sampling_callback().
|
||||
Prefer using create_sampling_callback() directly for new code.
|
||||
|
||||
Args:
|
||||
provider: OpenAIProvider instance configured with a generation model
|
||||
|
||||
Returns:
|
||||
Async callback function for MCP sampling
|
||||
"""
|
||||
return create_sampling_callback(provider)
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Integration tests for RAG pipeline with multiple LLM providers.
|
||||
|
||||
These tests validate the complete semantic search and MCP sampling flow using:
|
||||
1. MCP server's built-in semantic search (embeddings handled server-side)
|
||||
2. MCP sampling for answer generation (any generation-capable provider)
|
||||
3. Pre-indexed Nextcloud User Manual as the knowledge base
|
||||
|
||||
Usage:
|
||||
# Run with OpenAI (including GitHub Models API)
|
||||
OPENAI_API_KEY=... pytest tests/integration/test_rag.py --provider=openai -v
|
||||
|
||||
# Run with Ollama
|
||||
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_GENERATION_MODEL=llama3.2:1b \\
|
||||
pytest tests/integration/test_rag.py --provider=ollama -v
|
||||
|
||||
# Run with Anthropic
|
||||
ANTHROPIC_API_KEY=... pytest tests/integration/test_rag.py --provider=anthropic -v
|
||||
|
||||
# Run with AWS Bedrock
|
||||
AWS_REGION=us-east-1 BEDROCK_GENERATION_MODEL=... \\
|
||||
pytest tests/integration/test_rag.py --provider=bedrock -v
|
||||
|
||||
Environment Variables:
|
||||
See tests/integration/provider_fixtures.py for provider-specific configuration.
|
||||
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud Manual.pdf")
|
||||
|
||||
Prerequisites:
|
||||
- Nextcloud User Manual PDF uploaded to Nextcloud
|
||||
- VECTOR_SYNC_ENABLED=true on the MCP server
|
||||
- Provider-specific environment variables set
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.providers.base import Provider
|
||||
from tests.conftest import create_mcp_client_session
|
||||
from tests.integration.provider_fixtures import create_generation_provider
|
||||
from tests.integration.sampling_support import create_sampling_callback
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default path to the Nextcloud User Manual PDF
|
||||
DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
|
||||
|
||||
|
||||
async def llm_judge(
|
||||
provider: Provider,
|
||||
ground_truth: str,
|
||||
system_output: str,
|
||||
) -> bool:
|
||||
"""Use LLM to judge if system output aligns with ground truth.
|
||||
|
||||
Args:
|
||||
provider: Any provider with generation capability
|
||||
ground_truth: The expected/reference answer
|
||||
system_output: The system's actual output to evaluate
|
||||
|
||||
Returns:
|
||||
True if output aligns with ground truth, False otherwise
|
||||
"""
|
||||
prompt = f"""GROUND TRUTH: {ground_truth}
|
||||
|
||||
SYSTEM OUTPUT: {system_output}
|
||||
|
||||
Does the system output contain the key facts from the ground truth?
|
||||
|
||||
Answer: TRUE or FALSE"""
|
||||
|
||||
logger.info("Received ground truth: %s", ground_truth)
|
||||
logger.info("Received system output: %s", system_output)
|
||||
|
||||
response = await provider.generate(prompt, max_tokens=10)
|
||||
logger.info("LLM Judge response: %s", response)
|
||||
return "TRUE" in response.upper()
|
||||
|
||||
|
||||
# Mark all tests as integration tests
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.rag,
|
||||
]
|
||||
|
||||
# Ground truth fixture path
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
GROUND_TRUTH_FILE = FIXTURES_DIR / "nextcloud_manual_ground_truth.json"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ground_truth_qa():
|
||||
"""Load ground truth Q&A pairs for the Nextcloud manual."""
|
||||
if not GROUND_TRUTH_FILE.exists():
|
||||
pytest.skip(f"Ground truth file not found: {GROUND_TRUTH_FILE}")
|
||||
|
||||
with open(GROUND_TRUTH_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
||||
"""Ensure the Nextcloud User Manual PDF is tagged and indexed for vector search.
|
||||
|
||||
This fixture:
|
||||
1. Gets file info for the manual PDF
|
||||
2. Creates/gets the 'vector-index' tag
|
||||
3. Assigns the tag to the file
|
||||
4. Waits for vector sync to complete indexing
|
||||
|
||||
Environment Variables:
|
||||
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf)
|
||||
"""
|
||||
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
|
||||
|
||||
logger.info(f"Setting up indexed manual PDF: {manual_path}")
|
||||
|
||||
# Get file info to verify file exists and get file ID
|
||||
file_info = await nc_client.webdav.get_file_info(manual_path)
|
||||
if not file_info:
|
||||
pytest.skip(f"Manual PDF not found at '{manual_path}'")
|
||||
|
||||
file_id = file_info["id"]
|
||||
logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
|
||||
|
||||
# Create or get the vector-index tag
|
||||
tag = await nc_client.webdav.get_or_create_tag("vector-index")
|
||||
tag_id = tag["id"]
|
||||
logger.info(f"Using tag 'vector-index' (tag_id={tag_id})")
|
||||
|
||||
# Assign tag to file
|
||||
await nc_client.webdav.assign_tag_to_file(file_id, tag_id)
|
||||
logger.info(f"Tagged file {file_id} with vector-index tag")
|
||||
|
||||
# Wait for vector sync to complete indexing
|
||||
max_attempts = 60
|
||||
poll_interval = 10
|
||||
|
||||
logger.info("Waiting for vector sync to index the manual...")
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
# Call the MCP tool via the existing client session
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_get_vector_sync_status",
|
||||
arguments={},
|
||||
)
|
||||
|
||||
if not result.isError:
|
||||
content = result.structuredContent or {}
|
||||
indexed = content.get("indexed_count", 0)
|
||||
pending = content.get("pending_count", 1)
|
||||
|
||||
logger.info(
|
||||
f"Attempt {attempt}/{max_attempts}: "
|
||||
f"indexed={indexed}, pending={pending}"
|
||||
)
|
||||
|
||||
if indexed > 0 and pending == 0:
|
||||
logger.info(
|
||||
f"Vector indexing complete: {indexed} documents indexed"
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Attempt {attempt}: Error checking status: {e}")
|
||||
|
||||
if attempt < max_attempts:
|
||||
await anyio.sleep(poll_interval)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Vector indexing may not be complete after {max_attempts} attempts"
|
||||
)
|
||||
|
||||
yield {
|
||||
"path": manual_path,
|
||||
"file_id": file_id,
|
||||
"tag_id": tag_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def provider_name(request) -> str:
|
||||
"""Get the provider name from --provider flag.
|
||||
|
||||
Raises pytest.skip if --provider not specified.
|
||||
"""
|
||||
name = request.config.getoption("--provider")
|
||||
if not name:
|
||||
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
|
||||
return name
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||
"""Provider configured for text generation.
|
||||
|
||||
Requires --provider flag to be set.
|
||||
"""
|
||||
provider = await create_generation_provider(provider_name)
|
||||
yield provider
|
||||
await provider.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
async def nc_mcp_client_with_sampling(
|
||||
anyio_backend, generation_provider, provider_name
|
||||
) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""MCP client with sampling support using the specified provider.
|
||||
|
||||
This fixture creates an MCP client that can handle sampling requests
|
||||
from the server using the configured generation provider.
|
||||
"""
|
||||
sampling_callback = create_sampling_callback(generation_provider)
|
||||
|
||||
async for session in create_mcp_client_session(
|
||||
url="http://localhost:8000/mcp",
|
||||
client_name=f"Sampling MCP ({provider_name})",
|
||||
sampling_callback=sampling_callback,
|
||||
):
|
||||
yield session
|
||||
|
||||
|
||||
async def test_semantic_search_retrieval(
|
||||
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, generation_provider
|
||||
):
|
||||
"""Test that semantic search retrieves relevant documents from the manual.
|
||||
|
||||
This tests the retrieval component of RAG - ensuring that queries
|
||||
return relevant chunks from the indexed Nextcloud User Manual.
|
||||
"""
|
||||
# Use first query from ground truth
|
||||
test_case = ground_truth_qa[0] # 2FA question
|
||||
query = test_case["query"]
|
||||
|
||||
# Perform semantic search via MCP tool
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
arguments={
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool call failed: {result}"
|
||||
data = result.structuredContent
|
||||
|
||||
# Verify we got results
|
||||
assert data["success"] is True
|
||||
assert data["total_found"] > 0, f"No results for query: {query}"
|
||||
assert len(data["results"]) > 0
|
||||
|
||||
# Use LLM judge to evaluate if excerpts are relevant to ground truth
|
||||
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
|
||||
is_relevant = await llm_judge(
|
||||
generation_provider,
|
||||
test_case["ground_truth"],
|
||||
all_excerpts,
|
||||
)
|
||||
assert is_relevant, f"LLM judge: excerpts not relevant to query: {query}"
|
||||
|
||||
|
||||
async def test_semantic_search_answer_with_sampling(
|
||||
nc_mcp_client_with_sampling,
|
||||
ground_truth_qa,
|
||||
indexed_manual_pdf,
|
||||
generation_provider,
|
||||
):
|
||||
"""Test semantic search with MCP sampling for answer generation.
|
||||
|
||||
This tests the full RAG pipeline:
|
||||
1. Semantic search retrieves relevant documents
|
||||
2. MCP sampling generates an answer from the retrieved context
|
||||
3. Provider generates the answer via the sampling callback
|
||||
|
||||
Uses nc_mcp_client_with_sampling which has sampling enabled.
|
||||
"""
|
||||
# Use the 2FA question - has clear expected answer
|
||||
test_case = ground_truth_qa[0]
|
||||
query = test_case["query"]
|
||||
|
||||
result = await nc_mcp_client_with_sampling.call_tool(
|
||||
"nc_semantic_search_answer",
|
||||
arguments={
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0,
|
||||
"max_answer_tokens": 300,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False, f"Tool call failed: {result}"
|
||||
data = result.structuredContent
|
||||
|
||||
# Verify response structure
|
||||
assert data["success"] is True
|
||||
assert "query" in data
|
||||
assert "generated_answer" in data
|
||||
assert "sources" in data
|
||||
assert "search_method" in data
|
||||
|
||||
# Check for either successful sampling or graceful fallback
|
||||
fallback_methods = {
|
||||
"semantic_sampling_unsupported",
|
||||
"semantic_sampling_user_declined",
|
||||
"semantic_sampling_timeout",
|
||||
"semantic_sampling_mcp_error",
|
||||
"semantic_sampling_fallback",
|
||||
}
|
||||
|
||||
if data["search_method"] in fallback_methods:
|
||||
# Fallback mode - verify sources still returned
|
||||
assert len(data["sources"]) > 0, "Expected sources even in fallback mode"
|
||||
pytest.skip(
|
||||
f"MCP sampling not available (method: {data['search_method']}), "
|
||||
f"but retrieval succeeded with {len(data['sources'])} sources"
|
||||
)
|
||||
else:
|
||||
# Successful sampling - verify answer quality
|
||||
assert data["search_method"] == "semantic_sampling"
|
||||
assert data["generated_answer"] is not None
|
||||
assert len(data["generated_answer"]) > 50 # Non-trivial answer
|
||||
|
||||
# Use LLM judge to evaluate answer relevance
|
||||
is_relevant = await llm_judge(
|
||||
generation_provider,
|
||||
test_case["ground_truth"],
|
||||
data["generated_answer"],
|
||||
)
|
||||
assert is_relevant, f"LLM judge: answer not relevant to query: {query}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"qa_index,min_expected_results",
|
||||
[
|
||||
(0, 1), # 2FA question
|
||||
(1, 1), # File quotas question
|
||||
(2, 1), # Linux installation question
|
||||
(3, 1), # Windows requirements question
|
||||
(4, 1), # Client apps with 2FA question
|
||||
],
|
||||
)
|
||||
async def test_retrieval_quality_all_queries(
|
||||
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, qa_index, min_expected_results
|
||||
):
|
||||
"""Test retrieval quality for all ground truth queries.
|
||||
|
||||
Validates that each query returns at least the minimum expected
|
||||
number of relevant results from the Nextcloud manual.
|
||||
"""
|
||||
if qa_index >= len(ground_truth_qa):
|
||||
pytest.skip(f"Ground truth index {qa_index} not available")
|
||||
|
||||
test_case = ground_truth_qa[qa_index]
|
||||
query = test_case["query"]
|
||||
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
arguments={
|
||||
"query": query,
|
||||
"limit": 5,
|
||||
"score_threshold": 0.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
data = result.structuredContent
|
||||
|
||||
assert data["total_found"] >= min_expected_results, (
|
||||
f"Query '{query}' returned {data['total_found']} results, "
|
||||
f"expected at least {min_expected_results}"
|
||||
)
|
||||
|
||||
|
||||
async def test_no_results_for_unrelated_query(nc_mcp_client, indexed_manual_pdf):
|
||||
"""Test that completely unrelated queries return low/no scores.
|
||||
|
||||
The Nextcloud manual shouldn't have relevant content for
|
||||
quantum physics queries.
|
||||
"""
|
||||
result = await nc_mcp_client.call_tool(
|
||||
"nc_semantic_search",
|
||||
arguments={
|
||||
"query": "quantum entanglement hadron collider particle physics",
|
||||
"limit": 5,
|
||||
"score_threshold": 0.5, # Higher threshold to filter irrelevant
|
||||
},
|
||||
)
|
||||
|
||||
assert result.isError is False
|
||||
data = result.structuredContent
|
||||
|
||||
# Should have few or no high-scoring results
|
||||
# Low score threshold means we might get some results, but they should be low quality
|
||||
if data["total_found"] > 0:
|
||||
# If results exist, they should have low scores
|
||||
max_score = max(r["score"] for r in data["results"])
|
||||
assert max_score < 0.8, f"Unexpected high score {max_score} for unrelated query"
|
||||
@@ -3,8 +3,8 @@
|
||||
DEPRECATED: This module is maintained for backward compatibility with RAG evaluation tests.
|
||||
New code should use nextcloud_mcp_server.providers directly.
|
||||
|
||||
Supports Ollama (local), Anthropic (cloud), and Bedrock (AWS) providers for both ground truth
|
||||
generation and evaluation.
|
||||
Supports Ollama (local), Anthropic (cloud), Bedrock (AWS), and OpenAI (cloud) providers
|
||||
for both ground truth generation and evaluation.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -13,6 +13,7 @@ from nextcloud_mcp_server.providers import (
|
||||
AnthropicProvider,
|
||||
BedrockProvider,
|
||||
OllamaProvider,
|
||||
OpenAIProvider,
|
||||
Provider,
|
||||
)
|
||||
|
||||
@@ -25,11 +26,14 @@ def create_llm_provider(
|
||||
anthropic_model: str | None = None,
|
||||
bedrock_region: str | None = None,
|
||||
bedrock_model: str | None = None,
|
||||
openai_api_key: str | None = None,
|
||||
openai_base_url: str | None = None,
|
||||
openai_model: str | None = None,
|
||||
) -> Provider:
|
||||
"""Create an LLM provider from environment variables or arguments.
|
||||
|
||||
Args:
|
||||
provider: Provider type ('ollama', 'anthropic', or 'bedrock').
|
||||
provider: Provider type ('ollama', 'anthropic', 'bedrock', or 'openai').
|
||||
Defaults to RAG_EVAL_PROVIDER env var or 'ollama'
|
||||
ollama_base_url: Ollama base URL. Defaults to RAG_EVAL_OLLAMA_BASE_URL or 'http://localhost:11434'
|
||||
ollama_model: Ollama model. Defaults to RAG_EVAL_OLLAMA_MODEL or 'llama3.2:1b'
|
||||
@@ -38,6 +42,9 @@ def create_llm_provider(
|
||||
bedrock_region: AWS region. Defaults to RAG_EVAL_BEDROCK_REGION or AWS_REGION env var
|
||||
bedrock_model: Bedrock model ID. Defaults to RAG_EVAL_BEDROCK_MODEL or
|
||||
'anthropic.claude-3-sonnet-20240229-v1:0'
|
||||
openai_api_key: OpenAI API key. Defaults to OPENAI_API_KEY env var
|
||||
openai_base_url: OpenAI base URL. Defaults to OPENAI_BASE_URL (for GitHub Models API)
|
||||
openai_model: OpenAI model. Defaults to OPENAI_GENERATION_MODEL or 'gpt-4o-mini'
|
||||
|
||||
Returns:
|
||||
Provider instance
|
||||
@@ -83,7 +90,22 @@ def create_llm_provider(
|
||||
region_name=region, embedding_model=None, generation_model=model
|
||||
)
|
||||
|
||||
elif provider == "openai":
|
||||
api_key = openai_api_key or os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"OpenAI API key required. Set OPENAI_API_KEY environment variable."
|
||||
)
|
||||
base_url = openai_base_url or os.environ.get("OPENAI_BASE_URL")
|
||||
model = openai_model or os.environ.get("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
|
||||
return OpenAIProvider(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=None,
|
||||
generation_model=model,
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid provider: {provider}. Must be 'ollama', 'anthropic', or 'bedrock'."
|
||||
f"Invalid provider: {provider}. Must be 'ollama', 'anthropic', 'bedrock', or 'openai'."
|
||||
)
|
||||
|
||||
@@ -117,3 +117,244 @@ def test_parse_search_response_with_empty_tags(mocker):
|
||||
assert len(results) == 1
|
||||
assert "tags" in results[0]
|
||||
assert results[0]["tags"] == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_file_info_returns_file_details(mocker):
|
||||
"""Test that get_file_info returns file info including file ID."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock PROPFIND response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 207
|
||||
mock_response.content = b"""<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/testuser/Documents/test.pdf</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<oc:fileid>12345</oc:fileid>
|
||||
<d:displayname>test.pdf</d:displayname>
|
||||
<d:getcontentlength>1024</d:getcontentlength>
|
||||
<d:getcontenttype>application/pdf</d:getcontenttype>
|
||||
<d:getlastmodified>Sat, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
|
||||
<d:getetag>"abc123"</d:getetag>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"""
|
||||
mock_response.raise_for_status = mocker.Mock()
|
||||
|
||||
mock_http_client.request = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call get_file_info
|
||||
result = await client.get_file_info("Documents/test.pdf")
|
||||
|
||||
# Verify result
|
||||
assert result is not None
|
||||
assert result["id"] == 12345
|
||||
assert result["name"] == "test.pdf"
|
||||
assert result["path"] == "Documents/test.pdf"
|
||||
assert result["content_type"] == "application/pdf"
|
||||
assert result["size"] == 1024
|
||||
assert result["etag"] == "abc123"
|
||||
assert result["is_directory"] is False
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_file_info_returns_none_for_missing_file(mocker):
|
||||
"""Test that get_file_info returns None for missing files."""
|
||||
from httpx import HTTPStatusError, Response
|
||||
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock 404 response
|
||||
mock_response = mocker.Mock(spec=Response)
|
||||
mock_response.status_code = 404
|
||||
mock_http_client.request = AsyncMock(
|
||||
side_effect=HTTPStatusError(
|
||||
"Not Found", request=mocker.Mock(), response=mock_response
|
||||
)
|
||||
)
|
||||
|
||||
# Call get_file_info
|
||||
result = await client.get_file_info("nonexistent.pdf")
|
||||
|
||||
# Verify result is None
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_create_tag_creates_system_tag(mocker):
|
||||
"""Test that create_tag creates a system tag via OCS API."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock OCS response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json = mocker.Mock(
|
||||
return_value={
|
||||
"ocs": {
|
||||
"data": {
|
||||
"id": 42,
|
||||
"name": "vector-index",
|
||||
"userVisible": True,
|
||||
"userAssignable": True,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
mock_response.raise_for_status = mocker.Mock()
|
||||
|
||||
mock_http_client.post = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call create_tag
|
||||
result = await client.create_tag("vector-index")
|
||||
|
||||
# Verify result
|
||||
assert result["id"] == 42
|
||||
assert result["name"] == "vector-index"
|
||||
assert result["userVisible"] is True
|
||||
assert result["userAssignable"] is True
|
||||
|
||||
# Verify API call
|
||||
mock_http_client.post.assert_called_once()
|
||||
call_args = mock_http_client.post.call_args
|
||||
assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
|
||||
assert call_args[1]["json"]["name"] == "vector-index"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_or_create_tag_returns_existing_tag(mocker):
|
||||
"""Test that get_or_create_tag returns existing tag without creating."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock existing tag
|
||||
mocker.patch.object(
|
||||
client,
|
||||
"get_tag_by_name",
|
||||
return_value={"id": 42, "name": "vector-index", "userVisible": True},
|
||||
)
|
||||
mock_create = mocker.patch.object(client, "create_tag")
|
||||
|
||||
# Call get_or_create_tag
|
||||
result = await client.get_or_create_tag("vector-index")
|
||||
|
||||
# Verify existing tag returned without creating
|
||||
assert result["id"] == 42
|
||||
mock_create.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_or_create_tag_creates_new_tag(mocker):
|
||||
"""Test that get_or_create_tag creates tag when not found."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock no existing tag
|
||||
mocker.patch.object(client, "get_tag_by_name", return_value=None)
|
||||
mocker.patch.object(
|
||||
client,
|
||||
"create_tag",
|
||||
return_value={"id": 42, "name": "vector-index", "userVisible": True},
|
||||
)
|
||||
|
||||
# Call get_or_create_tag
|
||||
result = await client.get_or_create_tag("vector-index")
|
||||
|
||||
# Verify tag was created
|
||||
assert result["id"] == 42
|
||||
client.create_tag.assert_called_once_with("vector-index", True, True)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_assign_tag_to_file_success(mocker):
|
||||
"""Test that assign_tag_to_file assigns tag via WebDAV."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock 201 Created response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 201
|
||||
|
||||
mock_http_client.request = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call assign_tag_to_file
|
||||
result = await client.assign_tag_to_file(12345, 42)
|
||||
|
||||
# Verify result
|
||||
assert result is True
|
||||
|
||||
# Verify API call
|
||||
mock_http_client.request.assert_called_once()
|
||||
call_args = mock_http_client.request.call_args
|
||||
assert call_args[0][0] == "PUT"
|
||||
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_assign_tag_to_file_already_assigned(mocker):
|
||||
"""Test that assign_tag_to_file handles already assigned (409) gracefully."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock 409 Conflict response (already assigned)
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 409
|
||||
|
||||
mock_http_client.request = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call assign_tag_to_file
|
||||
result = await client.assign_tag_to_file(12345, 42)
|
||||
|
||||
# Verify result (should succeed even with 409)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_remove_tag_from_file_success(mocker):
|
||||
"""Test that remove_tag_from_file removes tag via WebDAV."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock 204 No Content response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 204
|
||||
|
||||
mock_http_client.request = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call remove_tag_from_file
|
||||
result = await client.remove_tag_from_file(12345, 42)
|
||||
|
||||
# Verify result
|
||||
assert result is True
|
||||
|
||||
# Verify API call
|
||||
mock_http_client.request.assert_called_once()
|
||||
call_args = mock_http_client.request.call_args
|
||||
assert call_args[0][0] == "DELETE"
|
||||
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_remove_tag_from_file_not_assigned(mocker):
|
||||
"""Test that remove_tag_from_file handles not assigned (404) gracefully."""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Mock 404 Not Found response (tag wasn't assigned)
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
mock_http_client.request = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Call remove_tag_from_file
|
||||
result = await client.remove_tag_from_file(12345, 42)
|
||||
|
||||
# Verify result (should succeed even with 404)
|
||||
assert result is True
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Unit tests for OpenAI provider."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.providers.openai import (
|
||||
OPENAI_EMBEDDING_DIMENSIONS,
|
||||
OpenAIProvider,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_openai_client(mocker):
|
||||
"""Mock OpenAI AsyncClient."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.embeddings = MagicMock()
|
||||
mock_client.chat = MagicMock()
|
||||
mock_client.chat.completions = MagicMock()
|
||||
mock_client.close = AsyncMock()
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.providers.openai.AsyncOpenAI", return_value=mock_client
|
||||
)
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_embedding(mock_openai_client):
|
||||
"""Test OpenAI embedding with text-embedding-3-small."""
|
||||
# Mock response
|
||||
mock_embedding_data = MagicMock()
|
||||
mock_embedding_data.embedding = [0.1, 0.2, 0.3]
|
||||
mock_embedding_data.index = 0
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [mock_embedding_data]
|
||||
|
||||
mock_openai_client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create provider
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
generation_model=None,
|
||||
)
|
||||
|
||||
# Test embedding
|
||||
embedding = await provider.embed("test text")
|
||||
|
||||
assert embedding == [0.1, 0.2, 0.3]
|
||||
mock_openai_client.embeddings.create.assert_called_once_with(
|
||||
input="test text",
|
||||
model="text-embedding-3-small",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_embedding_batch(mock_openai_client):
|
||||
"""Test OpenAI batch embedding."""
|
||||
# Mock response
|
||||
mock_embedding_data_1 = MagicMock()
|
||||
mock_embedding_data_1.embedding = [0.1, 0.2, 0.3]
|
||||
mock_embedding_data_1.index = 0
|
||||
|
||||
mock_embedding_data_2 = MagicMock()
|
||||
mock_embedding_data_2.embedding = [0.4, 0.5, 0.6]
|
||||
mock_embedding_data_2.index = 1
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [mock_embedding_data_1, mock_embedding_data_2]
|
||||
|
||||
mock_openai_client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create provider
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
generation_model=None,
|
||||
)
|
||||
|
||||
# Test batch embedding
|
||||
embeddings = await provider.embed_batch(["text1", "text2"])
|
||||
|
||||
assert len(embeddings) == 2
|
||||
assert embeddings[0] == [0.1, 0.2, 0.3]
|
||||
assert embeddings[1] == [0.4, 0.5, 0.6]
|
||||
mock_openai_client.embeddings.create.assert_called_once_with(
|
||||
input=["text1", "text2"],
|
||||
model="text-embedding-3-small",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_generation(mock_openai_client):
|
||||
"""Test OpenAI text generation."""
|
||||
# Mock response
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "Generated response"
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
mock_openai_client.chat.completions.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create provider
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model=None,
|
||||
generation_model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Test generation
|
||||
text = await provider.generate("test prompt", max_tokens=100)
|
||||
|
||||
assert text == "Generated response"
|
||||
mock_openai_client.chat.completions.create.assert_called_once_with(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "test prompt"}],
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_both_capabilities(mock_openai_client):
|
||||
"""Test OpenAI with both embedding and generation models."""
|
||||
# Mock embedding response
|
||||
mock_embedding_data = MagicMock()
|
||||
mock_embedding_data.embedding = [0.1, 0.2]
|
||||
mock_embedding_data.index = 0
|
||||
|
||||
mock_embed_response = MagicMock()
|
||||
mock_embed_response.data = [mock_embedding_data]
|
||||
mock_openai_client.embeddings.create = AsyncMock(return_value=mock_embed_response)
|
||||
|
||||
# Mock generation response
|
||||
mock_choice = MagicMock()
|
||||
mock_choice.message.content = "Response"
|
||||
|
||||
mock_gen_response = MagicMock()
|
||||
mock_gen_response.choices = [mock_choice]
|
||||
mock_openai_client.chat.completions.create = AsyncMock(
|
||||
return_value=mock_gen_response
|
||||
)
|
||||
|
||||
# Create provider with both models
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
generation_model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
assert provider.supports_embeddings is True
|
||||
assert provider.supports_generation is True
|
||||
|
||||
# Test both capabilities
|
||||
embedding = await provider.embed("test")
|
||||
assert embedding == [0.1, 0.2]
|
||||
|
||||
text = await provider.generate("test")
|
||||
assert text == "Response"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_no_embeddings():
|
||||
"""Test OpenAI provider with no embedding model raises error."""
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model=None,
|
||||
generation_model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
assert provider.supports_embeddings is False
|
||||
|
||||
with pytest.raises(NotImplementedError, match="no embedding_model configured"):
|
||||
await provider.embed("test")
|
||||
|
||||
with pytest.raises(NotImplementedError, match="no embedding_model configured"):
|
||||
await provider.embed_batch(["test"])
|
||||
|
||||
with pytest.raises(NotImplementedError, match="no embedding_model configured"):
|
||||
provider.get_dimension()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_no_generation():
|
||||
"""Test OpenAI provider with no generation model raises error."""
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
generation_model=None,
|
||||
)
|
||||
|
||||
assert provider.supports_generation is False
|
||||
|
||||
with pytest.raises(NotImplementedError, match="no generation_model configured"):
|
||||
await provider.generate("test")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_known_dimension():
|
||||
"""Test dimension detection for known OpenAI models."""
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
)
|
||||
|
||||
# Known model should have dimension set from lookup table
|
||||
assert provider.get_dimension() == 1536
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_unknown_dimension_detected(mock_openai_client):
|
||||
"""Test dimension detection for unknown model via API call."""
|
||||
# Mock response with specific dimension
|
||||
mock_embedding_data = MagicMock()
|
||||
mock_embedding_data.embedding = [0.1] * 768
|
||||
mock_embedding_data.index = 0
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [mock_embedding_data]
|
||||
mock_openai_client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="custom-embedding-model",
|
||||
)
|
||||
|
||||
# Dimension not known yet for custom model
|
||||
with pytest.raises(RuntimeError, match="not detected yet"):
|
||||
provider.get_dimension()
|
||||
|
||||
# Detect dimension via embed call
|
||||
await provider.embed("test")
|
||||
|
||||
# Now dimension should be available
|
||||
assert provider.get_dimension() == 768
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_github_models_api(mock_openai_client):
|
||||
"""Test OpenAI provider with GitHub Models API configuration."""
|
||||
# Mock response
|
||||
mock_embedding_data = MagicMock()
|
||||
mock_embedding_data.embedding = [0.1, 0.2, 0.3]
|
||||
mock_embedding_data.index = 0
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [mock_embedding_data]
|
||||
mock_openai_client.embeddings.create = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Create provider with GitHub Models configuration
|
||||
provider = OpenAIProvider(
|
||||
api_key="ghp_test_token",
|
||||
base_url="https://models.github.ai/inference",
|
||||
embedding_model="openai/text-embedding-3-small",
|
||||
generation_model=None,
|
||||
)
|
||||
|
||||
# Known dimension for GitHub Models prefixed model
|
||||
assert (
|
||||
provider.get_dimension()
|
||||
== OPENAI_EMBEDDING_DIMENSIONS["openai/text-embedding-3-small"]
|
||||
)
|
||||
|
||||
# Test embedding
|
||||
embedding = await provider.embed("test text")
|
||||
assert embedding == [0.1, 0.2, 0.3]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_empty_batch():
|
||||
"""Test OpenAI batch embedding with empty list."""
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
)
|
||||
|
||||
embeddings = await provider.embed_batch([])
|
||||
assert embeddings == []
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_openai_close(mock_openai_client):
|
||||
"""Test OpenAI client close."""
|
||||
provider = OpenAIProvider(
|
||||
api_key="test-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
)
|
||||
|
||||
await provider.close()
|
||||
mock_openai_client.close.assert_called_once()
|
||||
@@ -259,3 +259,89 @@ class TestChunkConfigValidation:
|
||||
match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE",
|
||||
):
|
||||
get_settings()
|
||||
|
||||
|
||||
class TestEmbeddingModelName:
|
||||
"""Test get_embedding_model_name() method."""
|
||||
|
||||
def test_openai_takes_priority(self):
|
||||
"""Test that OpenAI model is returned when OPENAI_API_KEY is set."""
|
||||
settings = Settings(
|
||||
openai_api_key="test-key",
|
||||
openai_embedding_model="text-embedding-3-large",
|
||||
ollama_base_url="http://ollama:11434",
|
||||
ollama_embedding_model="nomic-embed-text",
|
||||
)
|
||||
assert settings.get_embedding_model_name() == "text-embedding-3-large"
|
||||
|
||||
def test_ollama_used_when_no_openai(self):
|
||||
"""Test that Ollama model is returned when no OpenAI configured."""
|
||||
settings = Settings(
|
||||
ollama_base_url="http://ollama:11434",
|
||||
ollama_embedding_model="all-minilm",
|
||||
)
|
||||
assert settings.get_embedding_model_name() == "all-minilm"
|
||||
|
||||
def test_simple_fallback(self):
|
||||
"""Test fallback to simple provider when nothing configured."""
|
||||
settings = Settings()
|
||||
assert settings.get_embedding_model_name() == "simple-384"
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPENAI_API_KEY": "test-openai-key",
|
||||
"OPENAI_EMBEDDING_MODEL": "openai/text-embedding-3-small",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_get_settings_openai_model(self):
|
||||
"""Test get_settings() loads OpenAI embedding model."""
|
||||
settings = get_settings()
|
||||
assert settings.openai_api_key == "test-openai-key"
|
||||
assert settings.openai_embedding_model == "openai/text-embedding-3-small"
|
||||
assert settings.get_embedding_model_name() == "openai/text-embedding-3-small"
|
||||
|
||||
|
||||
class TestCollectionNameWithProviders:
|
||||
"""Test get_collection_name() with different providers."""
|
||||
|
||||
def test_collection_name_with_openai(self):
|
||||
"""Test collection name uses OpenAI model when configured."""
|
||||
settings = Settings(
|
||||
openai_api_key="test-key",
|
||||
openai_embedding_model="text-embedding-3-small",
|
||||
otel_service_name="my-deployment",
|
||||
)
|
||||
assert settings.get_collection_name() == "my-deployment-text-embedding-3-small"
|
||||
|
||||
def test_collection_name_with_github_models(self):
|
||||
"""Test collection name sanitizes GitHub Models prefix."""
|
||||
settings = Settings(
|
||||
openai_api_key="ghp_test",
|
||||
openai_embedding_model="openai/text-embedding-3-small",
|
||||
otel_service_name="my-deployment",
|
||||
)
|
||||
# Slashes should be replaced with dashes
|
||||
assert (
|
||||
settings.get_collection_name()
|
||||
== "my-deployment-openai-text-embedding-3-small"
|
||||
)
|
||||
|
||||
def test_collection_name_with_ollama(self):
|
||||
"""Test collection name uses Ollama model when no OpenAI."""
|
||||
settings = Settings(
|
||||
ollama_base_url="http://ollama:11434",
|
||||
ollama_embedding_model="nomic-embed-text",
|
||||
otel_service_name="my-deployment",
|
||||
)
|
||||
assert settings.get_collection_name() == "my-deployment-nomic-embed-text"
|
||||
|
||||
def test_collection_name_explicit_override(self):
|
||||
"""Test explicit QDRANT_COLLECTION overrides auto-generation."""
|
||||
settings = Settings(
|
||||
qdrant_collection="custom-collection",
|
||||
openai_api_key="test-key",
|
||||
openai_embedding_model="text-embedding-3-large",
|
||||
)
|
||||
assert settings.get_collection_name() == "custom-collection"
|
||||
|
||||
@@ -1936,7 +1936,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.45.0"
|
||||
version = "0.48.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
@@ -1951,6 +1951,7 @@ dependencies = [
|
||||
{ name = "jinja2" },
|
||||
{ name = "langchain-text-splitters" },
|
||||
{ name = "mcp", extra = ["cli"] },
|
||||
{ name = "openai" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
|
||||
{ name = "opentelemetry-instrumentation-asgi" },
|
||||
@@ -1999,6 +2000,7 @@ requires-dist = [
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.22,<1.23" },
|
||||
{ name = "openai", specifier = ">=2.8.1" },
|
||||
{ name = "opentelemetry-api", specifier = ">=1.28.2" },
|
||||
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },
|
||||
{ name = "opentelemetry-instrumentation-asgi", specifier = ">=0.49b2" },
|
||||
@@ -2146,6 +2148,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "distro" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jiter" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/e4/42591e356f1d53c568418dc7e30dcda7be31dd5a4d570bca22acb0525862/openai-2.8.1.tar.gz", hash = "sha256:cb1b79eef6e809f6da326a7ef6038719e35aa944c42d081807bfa1be8060f15f", size = 602490, upload-time = "2025-11-17T22:39:59.549Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4f/dbc0c124c40cb390508a82770fb9f6e3ed162560181a85089191a851c59a/openai-2.8.1-py3-none-any.whl", hash = "sha256:c6c3b5a04994734386e8dad3c00a393f56d3b68a27cd2e8acae91a59e4122463", size = 1022688, upload-time = "2025-11-17T22:39:57.675Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.38.0"
|
||||
|
||||
Reference in New Issue
Block a user