Merge pull request #640 from cbcoutinho/refactor/remove-smithery-update-qdrant
Remove Smithery deployment mode, update qdrant-client for PyPI publishing
This commit is contained in:
@@ -1,44 +0,0 @@
|
|||||||
# 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:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /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"]
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
# Nextcloud MCP Server
|
# Nextcloud MCP Server
|
||||||
|
|
||||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||||
[](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
|
||||||
|
|
||||||
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||||
|
|
||||||
@@ -16,20 +15,7 @@ This is a **dedicated standalone MCP server** designed for external MCP clients
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Looking for AI features inside Nextcloud?** Nextcloud also provides [Context Agent](https://github.com/nextcloud/context_agent), which powers the Assistant app and runs as an ExApp inside Nextcloud. See [docs/comparison-context-agent.md](docs/comparison-context-agent.md) for a detailed comparison of use cases.
|
> **Looking for AI features inside Nextcloud?** Nextcloud also provides [Context Agent](https://github.com/nextcloud/context_agent), which powers the Assistant app and runs as an ExApp inside Nextcloud. See [docs/comparison-context-agent.md](docs/comparison-context-agent.md) for a detailed comparison of use cases.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start (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:
|
For full features including semantic search, run with Docker:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances
|
# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances
|
||||||
|
|
||||||
**Status:** Proposed
|
**Status:** Deprecated (removed in v0.66.0)
|
||||||
**Date:** 2025-01-22
|
**Date:** 2025-01-22
|
||||||
|
**Deprecated:** 2026-03-22 — Smithery deployment mode has been removed. Smithery sunsetted its free tier and third-party hosting conflicts with the project's privacy-first design. See ADR-022 for rationale.
|
||||||
**Deciders:** Development Team
|
**Deciders:** Development Team
|
||||||
**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider)
|
**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider)
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity:
|
|||||||
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
# Valid values: single_user_basic, multi_user_basic,
|
# Valid values: single_user_basic, multi_user_basic,
|
||||||
# oauth_single_audience, oauth_token_exchange, smithery
|
# oauth_single_audience, oauth_token_exchange
|
||||||
```
|
```
|
||||||
|
|
||||||
**Detection logic**:
|
**Detection logic**:
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ The Nextcloud MCP Server currently supports five distinct deployment modes (ADR-
|
|||||||
2. **Multi-User BasicAuth** - HTTP header credential pass-through
|
2. **Multi-User BasicAuth** - HTTP header credential pass-through
|
||||||
3. **OAuth Single-Audience** - Multi-audience token validation
|
3. **OAuth Single-Audience** - Multi-audience token validation
|
||||||
4. **OAuth Token Exchange** - RFC 8693 delegation
|
4. **OAuth Token Exchange** - RFC 8693 delegation
|
||||||
5. **Smithery Stateless** - Session URL parameters (free tier sunsetting March 2026)
|
|
||||||
|
|
||||||
This complexity creates several problems:
|
This complexity creates several problems:
|
||||||
|
|
||||||
### Maintenance Burden
|
### Maintenance Burden
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ This document provides a unified reference for authentication flows across all d
|
|||||||
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
|
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
|
||||||
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
|
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
|
||||||
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
|
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
|
||||||
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
|
|
||||||
|
|
||||||
## Communication Patterns
|
## Communication Patterns
|
||||||
|
|
||||||
@@ -303,59 +302,6 @@ Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-ser
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Smithery Stateless
|
|
||||||
|
|
||||||
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
|
|
||||||
|
|
||||||
Enabled by `SMITHERY_DEPLOYMENT=true`.
|
|
||||||
|
|
||||||
#### MCP Client → MCP Server → Nextcloud
|
|
||||||
|
|
||||||
```
|
|
||||||
MCP Client MCP Server Nextcloud
|
|
||||||
│ │ │
|
|
||||||
│── SSE Connect ─────────────▶│ │
|
|
||||||
│ ?nextcloud_url=... │ │
|
|
||||||
│ &username=... │ │
|
|
||||||
│ &app_password=... │ │
|
|
||||||
│ │── SmitheryConfigMiddleware │
|
|
||||||
│ │ Extract URL params │
|
|
||||||
│ │ │
|
|
||||||
│── MCP Request ─────────────▶│ │
|
|
||||||
│ (no Authorization header) │ │
|
|
||||||
│ │── Create per-request ─────▶│
|
|
||||||
│ │ NextcloudClient │
|
|
||||||
│ │ │
|
|
||||||
│ │── HTTP + BasicAuth ───────▶│
|
|
||||||
│ │ (from session params) │
|
|
||||||
│ │◀── API Response ───────────│
|
|
||||||
│◀── Tool Result ─────────────│ │
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key characteristics:**
|
|
||||||
- Configuration passed via URL query parameters (Smithery `configSchema`)
|
|
||||||
- No persistent state - client created fresh per request
|
|
||||||
- No OAuth infrastructure
|
|
||||||
- No background sync support (stateless)
|
|
||||||
- No admin UI available
|
|
||||||
|
|
||||||
**Required session parameters:**
|
|
||||||
- `nextcloud_url`: Nextcloud instance URL
|
|
||||||
- `username`: Nextcloud username
|
|
||||||
- `app_password`: Nextcloud app password
|
|
||||||
|
|
||||||
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
|
|
||||||
|
|
||||||
#### Background Sync
|
|
||||||
|
|
||||||
Not supported. Smithery mode is fully stateless with no credential storage.
|
|
||||||
|
|
||||||
#### Astrolabe Integration
|
|
||||||
|
|
||||||
Not applicable. Smithery deployments don't integrate with Astrolabe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration Quick Reference
|
## Configuration Quick Reference
|
||||||
|
|
||||||
### Single-User BasicAuth
|
### Single-User BasicAuth
|
||||||
@@ -404,12 +350,6 @@ TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
|||||||
TOKEN_STORAGE_DB=/data/tokens.db
|
TOKEN_STORAGE_DB=/data/tokens.db
|
||||||
```
|
```
|
||||||
|
|
||||||
### Smithery Stateless
|
|
||||||
```bash
|
|
||||||
SMITHERY_DEPLOYMENT=true
|
|
||||||
# All other config comes from session URL parameters
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related Documentation
|
## Related Documentation
|
||||||
|
|||||||
@@ -313,7 +313,6 @@ if ENABLE_SEMANTIC_SEARCH == true:
|
|||||||
| Multi-User BasicAuth | ✅ | ✅ Yes |
|
| Multi-User BasicAuth | ✅ | ✅ Yes |
|
||||||
| OAuth Single-Audience | ✅ | ✅ Yes |
|
| OAuth Single-Audience | ✅ | ✅ Yes |
|
||||||
| OAuth Token Exchange | ✅ | ✅ Yes |
|
| OAuth Token Exchange | ✅ | ✅ Yes |
|
||||||
| Smithery Stateless | N/A (not supported) | N/A |
|
|
||||||
|
|
||||||
### When to Explicitly Set ENABLE_BACKGROUND_OPERATIONS
|
### When to Explicitly Set ENABLE_BACKGROUND_OPERATIONS
|
||||||
|
|
||||||
@@ -366,7 +365,6 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
|
|||||||
| `multi_user_basic` | Multi-user with BasicAuth pass-through |
|
| `multi_user_basic` | Multi-user with BasicAuth pass-through |
|
||||||
| `oauth_single_audience` | Multi-user OAuth (recommended) |
|
| `oauth_single_audience` | Multi-user OAuth (recommended) |
|
||||||
| `oauth_token_exchange` | Multi-user OAuth with token exchange |
|
| `oauth_token_exchange` | Multi-user OAuth with token exchange |
|
||||||
| `smithery` | Smithery platform deployment |
|
|
||||||
|
|
||||||
### Mode Detection Priority
|
### Mode Detection Priority
|
||||||
|
|
||||||
@@ -377,7 +375,7 @@ When `MCP_DEPLOYMENT_MODE` is set:
|
|||||||
|
|
||||||
When `MCP_DEPLOYMENT_MODE` is NOT set:
|
When `MCP_DEPLOYMENT_MODE` is NOT set:
|
||||||
1. ✅ Auto-detection runs (existing behavior)
|
1. ✅ Auto-detection runs (existing behavior)
|
||||||
2. ✅ Priority: Smithery → Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience
|
2. ✅ Priority: Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ MCP_DEPLOYMENT_MODE=oauth_single_audience
|
|||||||
- `multi_user_basic` - Multi-user with BasicAuth pass-through
|
- `multi_user_basic` - Multi-user with BasicAuth pass-through
|
||||||
- `oauth_single_audience` - Multi-user OAuth (recommended)
|
- `oauth_single_audience` - Multi-user OAuth (recommended)
|
||||||
- `oauth_token_exchange` - Multi-user OAuth with token exchange
|
- `oauth_token_exchange` - Multi-user OAuth with token exchange
|
||||||
- `smithery` - Smithery platform deployment
|
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
- ✅ Clear which mode is active
|
- ✅ Clear which mode is active
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ ENABLE_BACKGROUND_OPERATIONS=true
|
|||||||
|
|
||||||
**Symptom:**
|
**Symptom:**
|
||||||
```
|
```
|
||||||
ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery
|
ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange
|
||||||
```
|
```
|
||||||
|
|
||||||
**Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`.
|
**Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`.
|
||||||
@@ -82,7 +82,6 @@ MCP_DEPLOYMENT_MODE=single_user_basic # Single-user with username/passw
|
|||||||
MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth
|
MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth
|
||||||
MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended)
|
MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended)
|
||||||
MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange
|
MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange
|
||||||
MCP_DEPLOYMENT_MODE=smithery # Smithery deployment
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection.
|
Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection.
|
||||||
|
|||||||
+1
-11
@@ -4,7 +4,7 @@
|
|||||||
# Optional: Explicitly declare deployment mode (ADR-021)
|
# Optional: Explicitly declare deployment mode (ADR-021)
|
||||||
# If not set, mode is auto-detected from other settings
|
# If not set, mode is auto-detected from other settings
|
||||||
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
||||||
# oauth_token_exchange, smithery
|
# oauth_token_exchange
|
||||||
#
|
#
|
||||||
# Recommendation: Set this for clarity and to catch configuration errors early
|
# Recommendation: Set this for clarity and to catch configuration errors early
|
||||||
#MCP_DEPLOYMENT_MODE=oauth_single_audience
|
#MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
@@ -119,16 +119,6 @@ NEXTCLOUD_PASSWORD=
|
|||||||
# Optional features (semantic search, document processing):
|
# Optional features (semantic search, document processing):
|
||||||
# See "Optional Features" section below
|
# See "Optional Features" section below
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# SMITHERY STATELESS MODE
|
|
||||||
# ============================================
|
|
||||||
# Stateless multi-tenant deployment for Smithery platform
|
|
||||||
# Configuration comes from session URL parameters
|
|
||||||
# No persistent storage, no OAuth, no vector sync
|
|
||||||
#
|
|
||||||
# Required: None (all config from session URL)
|
|
||||||
# This mode is activated automatically when deployed to Smithery
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# OPTIONAL FEATURES (All Deployment Modes)
|
# OPTIONAL FEATURES (All Deployment Modes)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -218,8 +218,6 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
auth_mode = "multi_user_basic"
|
auth_mode = "multi_user_basic"
|
||||||
elif mode == AuthMode.SINGLE_USER_BASIC:
|
elif mode == AuthMode.SINGLE_USER_BASIC:
|
||||||
auth_mode = "basic"
|
auth_mode = "basic"
|
||||||
elif mode == AuthMode.SMITHERY_STATELESS:
|
|
||||||
auth_mode = "smithery"
|
|
||||||
else:
|
else:
|
||||||
auth_mode = "unknown"
|
auth_mode = "unknown"
|
||||||
|
|
||||||
|
|||||||
+75
-270
@@ -8,10 +8,9 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from contextvars import ContextVar
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, cast
|
from typing import Optional, cast
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import click
|
import click
|
||||||
@@ -95,7 +94,6 @@ from nextcloud_mcp_server.auth.webhook_routes import (
|
|||||||
)
|
)
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
|
||||||
Settings,
|
Settings,
|
||||||
get_document_processor_config,
|
get_document_processor_config,
|
||||||
get_settings,
|
get_settings,
|
||||||
@@ -357,67 +355,6 @@ 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 BasicAuthMiddleware:
|
class BasicAuthMiddleware:
|
||||||
"""Middleware to extract BasicAuth credentials from Authorization header.
|
"""Middleware to extract BasicAuth credentials from Authorization header.
|
||||||
|
|
||||||
@@ -462,76 +399,6 @@ class BasicAuthMiddleware:
|
|||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
async def load_oauth_client_credentials(
|
async def load_oauth_client_credentials(
|
||||||
nextcloud_host: str, registration_endpoint: str | None
|
nextcloud_host: str, registration_endpoint: str | None
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
@@ -1169,12 +1036,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||||
)
|
)
|
||||||
deployment_mode = (
|
|
||||||
DeploymentMode.SMITHERY_STATELESS
|
|
||||||
if mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
else DeploymentMode.SELF_HOSTED
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log hybrid authentication status for multi-user BasicAuth with offline access
|
# Log hybrid authentication status for multi-user BasicAuth with offline access
|
||||||
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
|
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1386,20 +1247,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
enable_dns_rebinding_protection=False
|
enable_dns_rebinding_protection=False
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
elif mode == AuthMode.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,
|
|
||||||
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
|
||||||
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
|
||||||
transport_security=TransportSecuritySettings(
|
|
||||||
enable_dns_rebinding_protection=False
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# BasicAuth modes (single-user or multi-user)
|
# BasicAuth modes (single-user or multi-user)
|
||||||
logger.info(f"Configuring MCP server for {mode.value} mode")
|
logger.info(f"Configuring MCP server for {mode.value} mode")
|
||||||
@@ -1448,10 +1295,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Register semantic search tools (cross-app feature)
|
# Register semantic search tools (cross-app feature)
|
||||||
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
if settings.vector_sync_enabled:
|
||||||
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)")
|
logger.info("Configuring semantic search tools (vector sync enabled)")
|
||||||
configure_semantic_tools(mcp)
|
configure_semantic_tools(mcp)
|
||||||
else:
|
else:
|
||||||
@@ -2103,9 +1947,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
checks["auth_mode"] = "basic"
|
checks["auth_mode"] = "basic"
|
||||||
checks["auth_configured"] = "error: credentials not set"
|
checks["auth_configured"] = "error: credentials not set"
|
||||||
is_ready = False
|
is_ready = False
|
||||||
elif mode == AuthMode.SMITHERY_STATELESS:
|
|
||||||
checks["auth_mode"] = "smithery"
|
|
||||||
checks["auth_configured"] = "ok"
|
|
||||||
|
|
||||||
# Check Qdrant status if using network mode (external Qdrant service)
|
# Check Qdrant status if using network mode (external Qdrant service)
|
||||||
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
||||||
@@ -2187,20 +2028,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
||||||
|
|
||||||
# Add management API endpoints for Nextcloud PHP app
|
# Add management API endpoints for Nextcloud PHP app
|
||||||
# Tier 1: Public endpoints (no auth required) - available in all non-Smithery modes
|
# Tier 1: Public endpoints (no auth required)
|
||||||
# These let Astrolabe show basic server status even in single-user BasicAuth mode
|
# These let Astrolabe show basic server status even in single-user BasicAuth mode
|
||||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
|
||||||
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
|
routes.append(
|
||||||
routes.append(
|
Route(
|
||||||
Route(
|
"/api/v1/vector-sync/status",
|
||||||
"/api/v1/vector-sync/status",
|
get_vector_sync_status,
|
||||||
get_vector_sync_status,
|
methods=["GET"],
|
||||||
methods=["GET"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"Public management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status"
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Public management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status"
|
||||||
|
)
|
||||||
|
|
||||||
# Tier 2+: Authenticated management endpoints (OAuth required)
|
# Tier 2+: Authenticated management endpoints (OAuth required)
|
||||||
# Available in: OAuth modes OR multi-user BasicAuth with offline access
|
# Available in: OAuth modes OR multi-user BasicAuth with offline access
|
||||||
@@ -2286,26 +2126,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"/api/v1/webhooks, /api/v1/pdf-preview"
|
"/api/v1/webhooks, /api/v1/pdf-preview"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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.
|
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
|
||||||
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
|
||||||
|
|
||||||
@@ -2453,80 +2273,72 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||||
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
# Create a separate Starlette app for browser routes that need session auth
|
||||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||||
# Create a separate Starlette app for browser routes that need session auth
|
browser_routes = [
|
||||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
Route("/", user_info_html, methods=["GET"]), # /app → user info with all tabs
|
||||||
browser_routes = [
|
Route(
|
||||||
Route(
|
"/revoke",
|
||||||
"/", user_info_html, methods=["GET"]
|
revoke_session,
|
||||||
), # /app → user info with all tabs
|
methods=["POST"],
|
||||||
Route(
|
name="revoke_session_endpoint",
|
||||||
"/revoke",
|
), # /app/revoke → revoke_session
|
||||||
revoke_session,
|
# Vector sync status fragment (htmx polling)
|
||||||
methods=["POST"],
|
Route(
|
||||||
name="revoke_session_endpoint",
|
"/vector-sync/status",
|
||||||
), # /app/revoke → revoke_session
|
vector_sync_status_fragment,
|
||||||
# Vector sync status fragment (htmx polling)
|
methods=["GET"],
|
||||||
Route(
|
), # /app/vector-sync/status
|
||||||
"/vector-sync/status",
|
# Vector visualization routes
|
||||||
vector_sync_status_fragment,
|
Route(
|
||||||
methods=["GET"],
|
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||||
), # /app/vector-sync/status
|
), # /app/vector-viz
|
||||||
# Vector visualization routes
|
Route(
|
||||||
Route(
|
"/vector-viz/search",
|
||||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
vector_visualization_search,
|
||||||
), # /app/vector-viz
|
methods=["GET"],
|
||||||
Route(
|
), # /app/vector-viz/search
|
||||||
"/vector-viz/search",
|
Route(
|
||||||
vector_visualization_search,
|
"/chunk-context",
|
||||||
methods=["GET"],
|
chunk_context_endpoint,
|
||||||
), # /app/vector-viz/search
|
methods=["GET"],
|
||||||
Route(
|
), # /app/chunk-context
|
||||||
"/chunk-context",
|
# Webhook management routes (admin-only)
|
||||||
chunk_context_endpoint,
|
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
|
||||||
methods=["GET"],
|
Route(
|
||||||
), # /app/chunk-context
|
"/webhooks/enable/{preset_id:str}",
|
||||||
# Webhook management routes (admin-only)
|
enable_webhook_preset,
|
||||||
Route(
|
methods=["POST"],
|
||||||
"/webhooks", webhook_management_pane, methods=["GET"]
|
),
|
||||||
), # /app/webhooks
|
Route(
|
||||||
Route(
|
"/webhooks/disable/{preset_id:str}",
|
||||||
"/webhooks/enable/{preset_id:str}",
|
disable_webhook_preset,
|
||||||
enable_webhook_preset,
|
methods=["DELETE"],
|
||||||
methods=["POST"],
|
),
|
||||||
),
|
]
|
||||||
Route(
|
|
||||||
"/webhooks/disable/{preset_id:str}",
|
|
||||||
disable_webhook_preset,
|
|
||||||
methods=["DELETE"],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add static files mount if directory exists
|
# Add static files mount if directory exists
|
||||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
||||||
if os.path.isdir(static_dir):
|
if os.path.isdir(static_dir):
|
||||||
browser_routes.append(
|
browser_routes.append(
|
||||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
)
|
|
||||||
logger.info(f"Mounted static files from {static_dir}")
|
|
||||||
|
|
||||||
browser_app = Starlette(routes=browser_routes)
|
|
||||||
browser_app.add_middleware(
|
|
||||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
|
||||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
|
||||||
)
|
)
|
||||||
|
logger.info(f"Mounted static files from {static_dir}")
|
||||||
|
|
||||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
browser_app = Starlette(routes=browser_routes)
|
||||||
routes.append(
|
browser_app.add_middleware(
|
||||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||||
)
|
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||||
|
)
|
||||||
|
|
||||||
# Mount browser app at /app (webapp and admin routes)
|
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||||
routes.append(Mount("/app", app=browser_app))
|
routes.append(
|
||||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||||
else:
|
)
|
||||||
logger.info("Admin UI (/app) disabled in Smithery stateless mode")
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||||
routes.append(Mount("/", app=mcp_app))
|
routes.append(Mount("/", app=mcp_app))
|
||||||
@@ -2648,13 +2460,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
logger.info("WWW-Authenticate scope challenge handler enabled")
|
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")
|
|
||||||
|
|
||||||
# Apply BasicAuthMiddleware for multi-user BasicAuth pass-through mode
|
# Apply BasicAuthMiddleware for multi-user BasicAuth pass-through mode
|
||||||
if settings.enable_multi_user_basic_auth:
|
if settings.enable_multi_user_basic_auth:
|
||||||
app = BasicAuthMiddleware(app)
|
app = BasicAuthMiddleware(app)
|
||||||
|
|||||||
@@ -4,37 +4,8 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
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 = {
|
LOGGING_CONFIG = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
@@ -168,7 +139,7 @@ class Settings:
|
|||||||
# Deployment mode (ADR-021: explicit mode selection)
|
# Deployment mode (ADR-021: explicit mode selection)
|
||||||
# Optional: If not set, mode is auto-detected from other settings
|
# Optional: If not set, mode is auto-detected from other settings
|
||||||
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
||||||
# oauth_token_exchange, smithery
|
# oauth_token_exchange
|
||||||
deployment_mode: str | None = None
|
deployment_mode: str | None = None
|
||||||
|
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
@@ -434,15 +405,10 @@ def _is_multi_user_mode() -> bool:
|
|||||||
|
|
||||||
Single-user modes are:
|
Single-user modes are:
|
||||||
- Single-user BasicAuth (username and password both set)
|
- Single-user BasicAuth (username and password both set)
|
||||||
- Smithery Stateless (SMITHERY_DEPLOYMENT=true)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if multi-user mode detected
|
True if multi-user mode detected
|
||||||
"""
|
"""
|
||||||
# Smithery is always single-user (stateless)
|
|
||||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Multi-user BasicAuth explicitly enabled
|
# Multi-user BasicAuth explicitly enabled
|
||||||
if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true":
|
if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true":
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ See ADR-020 for detailed architecture and deployment mode documentation.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
@@ -28,7 +27,6 @@ class AuthMode(Enum):
|
|||||||
MULTI_USER_BASIC = "multi_user_basic"
|
MULTI_USER_BASIC = "multi_user_basic"
|
||||||
OAUTH_SINGLE_AUDIENCE = "oauth_single"
|
OAUTH_SINGLE_AUDIENCE = "oauth_single"
|
||||||
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
|
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
|
||||||
SMITHERY_STATELESS = "smithery"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -199,25 +197,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
|||||||
"MCP tokens are separate from Nextcloud tokens. "
|
"MCP tokens are separate from Nextcloud tokens. "
|
||||||
"Server exchanges MCP token for Nextcloud token on each request.",
|
"Server exchanges MCP token for Nextcloud token on each request.",
|
||||||
),
|
),
|
||||||
AuthMode.SMITHERY_STATELESS: ModeRequirements(
|
|
||||||
required=[], # All config from session URL params
|
|
||||||
optional=[],
|
|
||||||
forbidden=[
|
|
||||||
"nextcloud_host",
|
|
||||||
"nextcloud_username",
|
|
||||||
"nextcloud_password",
|
|
||||||
"enable_multi_user_basic_auth",
|
|
||||||
"enable_token_exchange",
|
|
||||||
"enable_offline_access",
|
|
||||||
"vector_sync_enabled",
|
|
||||||
"oidc_client_id",
|
|
||||||
"oidc_client_secret",
|
|
||||||
],
|
|
||||||
conditional={},
|
|
||||||
description="Stateless multi-tenant deployment for Smithery platform. "
|
|
||||||
"Configuration comes from session URL parameters. "
|
|
||||||
"No persistent storage, no OAuth, no vector sync.",
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -226,11 +205,10 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
|||||||
|
|
||||||
Mode detection priority (ADR-021):
|
Mode detection priority (ADR-021):
|
||||||
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
|
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
|
||||||
1. Smithery (explicit flag)
|
1. Token exchange (most specific OAuth mode)
|
||||||
2. Token exchange (most specific OAuth mode)
|
2. Multi-user BasicAuth
|
||||||
3. Multi-user BasicAuth
|
3. Single-user BasicAuth
|
||||||
4. Single-user BasicAuth
|
4. OAuth single-audience (default OAuth mode)
|
||||||
5. OAuth single-audience (default OAuth mode)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings: Application settings
|
settings: Application settings
|
||||||
@@ -254,7 +232,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
|||||||
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
|
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
|
||||||
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
|
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
|
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||||
"smithery": AuthMode.SMITHERY_STATELESS,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if mode_str not in mode_map:
|
if mode_str not in mode_map:
|
||||||
@@ -269,12 +246,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
|||||||
return explicit_mode
|
return explicit_mode
|
||||||
|
|
||||||
# Auto-detection (existing behavior)
|
# Auto-detection (existing behavior)
|
||||||
# Check for Smithery mode (explicit environment variable)
|
|
||||||
# Note: This checks the environment directly, not settings
|
|
||||||
# because Smithery mode has no settings-based config
|
|
||||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
|
||||||
return AuthMode.SMITHERY_STATELESS
|
|
||||||
|
|
||||||
# Check for token exchange (most specific OAuth mode)
|
# Check for token exchange (most specific OAuth mode)
|
||||||
if settings.enable_token_exchange:
|
if settings.enable_token_exchange:
|
||||||
return AuthMode.OAUTH_TOKEN_EXCHANGE
|
return AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ from nextcloud_mcp_server.auth.context_helper import (
|
|||||||
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
||||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import get_settings
|
||||||
DeploymentMode,
|
|
||||||
get_deployment_mode,
|
|
||||||
get_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -25,15 +21,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
"""
|
"""
|
||||||
Get the appropriate Nextcloud client based on authentication mode.
|
Get the appropriate Nextcloud client based on authentication mode.
|
||||||
|
|
||||||
ADR-016 compliant implementation supporting three deployment modes:
|
Supports the following deployment modes:
|
||||||
|
|
||||||
1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
|
1. BasicAuth mode: Returns shared client from lifespan context
|
||||||
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
|
2. OAuth mode:
|
||||||
|
|
||||||
3. OAuth mode:
|
|
||||||
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||||
Token already contains both MCP and Nextcloud audiences - use directly
|
Token already contains both MCP and Nextcloud audiences - use directly
|
||||||
b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||||
@@ -46,7 +38,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
by the MCP server via @require_scopes decorator, not by the IdP.
|
by the MCP server via @require_scopes decorator, not by the IdP.
|
||||||
|
|
||||||
This function automatically detects the authentication mode by checking
|
This function automatically detects the authentication mode by checking
|
||||||
the deployment mode and type of the lifespan context.
|
the type of the lifespan context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: MCP request context
|
ctx: MCP request context
|
||||||
@@ -56,7 +48,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
AttributeError: If context doesn't contain expected data
|
AttributeError: If context doesn't contain expected data
|
||||||
ValueError: If Smithery mode but session config is missing required fields
|
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@@ -66,12 +57,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
return await client.capabilities()
|
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()
|
settings = get_settings()
|
||||||
|
|
||||||
# Multi-user BasicAuth pass-through mode - extract credentials from request
|
# Multi-user BasicAuth pass-through mode - extract credentials from request
|
||||||
@@ -111,85 +96,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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 # noqa: PLC0415
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
||||||
"""
|
"""
|
||||||
Create NextcloudClient from BasicAuth credentials in request headers.
|
Create NextcloudClient from BasicAuth credentials in request headers.
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""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 # noqa: PLC0415
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
+1
-5
@@ -22,7 +22,7 @@ dependencies = [
|
|||||||
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
|
||||||
"alembic>=1.14.0", # Database migrations
|
"alembic>=1.14.0", # Database migrations
|
||||||
"authlib>=1.6.5",
|
"authlib>=1.6.5",
|
||||||
"qdrant-client>=1.7.0",
|
"qdrant-client>=1.17.0",
|
||||||
"fastembed>=0.7.3", # BM25 sparse vector embeddings for hybrid search
|
"fastembed>=0.7.3", # BM25 sparse vector embeddings for hybrid search
|
||||||
"anthropic>=0.42.0", # For RAG evaluation with Anthropic LLMs
|
"anthropic>=0.42.0", # For RAG evaluation with Anthropic LLMs
|
||||||
"boto3>=1.35.0", # For Amazon Bedrock provider (optional)
|
"boto3>=1.35.0", # For Amazon Bedrock provider (optional)
|
||||||
@@ -113,9 +113,6 @@ extend-select = ["I", "PLC0415"]
|
|||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"tests/**" = ["PLC0415"]
|
"tests/**" = ["PLC0415"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
|
||||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.10.0,<0.11.0"]
|
requires = ["uv_build>=0.10.0,<0.11.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
@@ -142,7 +139,6 @@ dev = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli"
|
nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli"
|
||||||
smithery-main = "nextcloud_mcp_server.smithery_main:main"
|
|
||||||
|
|
||||||
[[tool.uv.index]]
|
[[tool.uv.index]]
|
||||||
name = "testpypi"
|
name = "testpypi"
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# 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"
|
|
||||||
@@ -22,14 +22,6 @@ from nextcloud_mcp_server.config_validators import (
|
|||||||
class TestModeDetection:
|
class TestModeDetection:
|
||||||
"""Test auth mode detection from configuration."""
|
"""Test auth mode detection from configuration."""
|
||||||
|
|
||||||
def test_smithery_mode_detection(self):
|
|
||||||
"""Test Smithery mode is detected from environment variable."""
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
||||||
mode = detect_auth_mode(settings)
|
|
||||||
assert mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
|
|
||||||
def test_token_exchange_mode_detection(self):
|
def test_token_exchange_mode_detection(self):
|
||||||
"""Test token exchange mode is detected."""
|
"""Test token exchange mode is detected."""
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
@@ -70,20 +62,6 @@ class TestModeDetection:
|
|||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
|
||||||
def test_mode_priority_smithery_over_all(self):
|
|
||||||
"""Test Smithery mode has highest priority."""
|
|
||||||
settings = Settings(
|
|
||||||
nextcloud_host="http://localhost",
|
|
||||||
nextcloud_username="admin",
|
|
||||||
nextcloud_password="password",
|
|
||||||
enable_token_exchange=True,
|
|
||||||
enable_multi_user_basic_auth=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
||||||
mode = detect_auth_mode(settings)
|
|
||||||
assert mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
|
|
||||||
def test_mode_priority_token_exchange_over_basic(self):
|
def test_mode_priority_token_exchange_over_basic(self):
|
||||||
"""Test token exchange has priority over BasicAuth."""
|
"""Test token exchange has priority over BasicAuth."""
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
@@ -486,57 +464,6 @@ class TestOAuthTokenExchangeValidation:
|
|||||||
assert any("nextcloud_password" in err.lower() for err in errors)
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
|
||||||
class TestSmitheryValidation:
|
|
||||||
"""Test validation for Smithery stateless mode."""
|
|
||||||
|
|
||||||
def test_valid_empty_config(self):
|
|
||||||
"""Test valid empty config for Smithery mode."""
|
|
||||||
settings = Settings()
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
||||||
mode, errors = validate_configuration(settings)
|
|
||||||
|
|
||||||
assert mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
assert len(errors) == 0
|
|
||||||
|
|
||||||
def test_forbidden_nextcloud_host(self):
|
|
||||||
"""Test error when NEXTCLOUD_HOST is set."""
|
|
||||||
settings = Settings(
|
|
||||||
nextcloud_host="http://localhost",
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
||||||
mode, errors = validate_configuration(settings)
|
|
||||||
|
|
||||||
assert mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
assert any("nextcloud_host" in err.lower() for err in errors)
|
|
||||||
|
|
||||||
def test_forbidden_credentials(self):
|
|
||||||
"""Test error when credentials are set."""
|
|
||||||
settings = Settings(
|
|
||||||
nextcloud_username="admin",
|
|
||||||
nextcloud_password="password",
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
||||||
mode, errors = validate_configuration(settings)
|
|
||||||
|
|
||||||
assert mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
assert any("nextcloud_username" in err.lower() for err in errors)
|
|
||||||
|
|
||||||
def test_forbidden_vector_sync(self):
|
|
||||||
"""Test error when vector sync is enabled."""
|
|
||||||
settings = Settings(
|
|
||||||
vector_sync_enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
|
||||||
mode, errors = validate_configuration(settings)
|
|
||||||
|
|
||||||
assert mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
assert any("vector_sync_enabled" in err.lower() for err in errors)
|
|
||||||
|
|
||||||
|
|
||||||
class TestModeSummary:
|
class TestModeSummary:
|
||||||
"""Test mode summary generation."""
|
"""Test mode summary generation."""
|
||||||
|
|
||||||
@@ -550,14 +477,6 @@ class TestModeSummary:
|
|||||||
assert "NEXTCLOUD_PASSWORD" in summary
|
assert "NEXTCLOUD_PASSWORD" in summary
|
||||||
assert "VECTOR_SYNC_ENABLED" in summary
|
assert "VECTOR_SYNC_ENABLED" in summary
|
||||||
|
|
||||||
def test_smithery_summary(self):
|
|
||||||
"""Test summary for Smithery mode."""
|
|
||||||
summary = get_mode_summary(AuthMode.SMITHERY_STATELESS)
|
|
||||||
|
|
||||||
assert "smithery" in summary
|
|
||||||
assert "session" in summary.lower()
|
|
||||||
assert "(none" in summary # No required config
|
|
||||||
|
|
||||||
def test_oauth_token_exchange_summary(self):
|
def test_oauth_token_exchange_summary(self):
|
||||||
"""Test summary for OAuth token exchange mode."""
|
"""Test summary for OAuth token exchange mode."""
|
||||||
summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE)
|
summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE)
|
||||||
@@ -898,22 +817,6 @@ class TestExplicitModeSelection:
|
|||||||
|
|
||||||
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
|
||||||
def test_explicit_smithery_mode(self):
|
|
||||||
"""Test explicit smithery mode selection."""
|
|
||||||
with patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"MCP_DEPLOYMENT_MODE": "smithery",
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
):
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
mode = detect_auth_mode(settings)
|
|
||||||
|
|
||||||
assert mode == AuthMode.SMITHERY_STATELESS
|
|
||||||
|
|
||||||
def test_invalid_deployment_mode_raises_error(self):
|
def test_invalid_deployment_mode_raises_error(self):
|
||||||
"""Test invalid MCP_DEPLOYMENT_MODE raises ValueError."""
|
"""Test invalid MCP_DEPLOYMENT_MODE raises ValueError."""
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
|
|||||||
@@ -2170,7 +2170,7 @@ requires-dist = [
|
|||||||
{ name = "pymupdf4llm", specifier = ">=0.2.2" },
|
{ name = "pymupdf4llm", specifier = ">=0.2.2" },
|
||||||
{ name = "python-json-logger", specifier = ">=3.2.0" },
|
{ name = "python-json-logger", specifier = ">=3.2.0" },
|
||||||
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
{ name = "pythonvcard4", specifier = ">=0.2.0" },
|
||||||
{ name = "qdrant-client", git = "https://github.com/cbcoutinho/qdrant-client?branch=fix%2Ffusion-score-threshold" },
|
{ name = "qdrant-client", specifier = ">=1.17.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
@@ -3469,8 +3469,8 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qdrant-client"
|
name = "qdrant-client"
|
||||||
version = "1.16.2"
|
version = "1.17.1"
|
||||||
source = { git = "https://github.com/cbcoutinho/qdrant-client?branch=fix%2Ffusion-score-threshold#a62ec3098bca86af799147695a0e2b6fb759b3aa" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "grpcio" },
|
{ name = "grpcio" },
|
||||||
{ name = "httpx", extra = ["http2"] },
|
{ name = "httpx", extra = ["http2"] },
|
||||||
@@ -3480,6 +3480,10 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/30/dd/f8a8261b83946af3cd65943c93c4f83e044f01184e8525404989d22a81a5/qdrant_client-1.17.1.tar.gz", hash = "sha256:22f990bbd63485ed97ba551a4c498181fcb723f71dcab5d6e4e43fe1050a2bc0", size = 344979, upload-time = "2026-03-13T17:13:44.678Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qh3"
|
name = "qh3"
|
||||||
|
|||||||
Reference in New Issue
Block a user