Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01535f82e0 |
@@ -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:2e683fc3e18a248aa23b8022f2a3474b072b04fb851efe9b49f6b516a8944939
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.10@sha256:29bd45092ea8902c0bbb7f0a338f0494a382b1f4b18355df5be270ade679ff1d /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"]
|
|
||||||
@@ -19,20 +19,7 @@ This is a **dedicated standalone MCP server** designed for external MCP clients
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
The fastest way to get started is via [Smithery](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) - no Docker or self-hosting required:
|
Get up and running in 60 seconds using Docker:
|
||||||
|
|
||||||
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
|
```bash
|
||||||
# 1. Create a minimal configuration
|
# 1. Create a minimal configuration
|
||||||
@@ -52,11 +39,12 @@ curl http://127.0.0.1:8000/health/ready
|
|||||||
# 4. Connect to the endpoint
|
# 4. Connect to the endpoint
|
||||||
http://127.0.0.1:8000/sse
|
http://127.0.0.1:8000/sse
|
||||||
|
|
||||||
# Or with --transport streamable-http
|
# 4. Or with --transport streamable-http
|
||||||
http://127.0.0.1:8000/mcp
|
http://127.0.0.1:8000/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
**Next Steps:**
|
**Next Steps:**
|
||||||
|
- Create an app password in Nextcloud: Settings → Security → Devices & sessions
|
||||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||||
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
||||||
|
|
||||||
|
|||||||
@@ -224,26 +224,6 @@ services:
|
|||||||
- keycloak-tokens:/app/data
|
- keycloak-tokens:/app/data
|
||||||
- keycloak-oauth-storage:/app/.oauth
|
- 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:
|
qdrant:
|
||||||
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1,492 +0,0 @@
|
|||||||
# 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)
|
|
||||||
+76
-274
@@ -3,7 +3,6 @@ import os
|
|||||||
import time
|
import time
|
||||||
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 TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
@@ -26,8 +25,6 @@ from starlette.middleware.cors import CORSMiddleware
|
|||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
from starlette.routing import Mount, Route
|
from starlette.routing import Mount, Route
|
||||||
from starlette.staticfiles import StaticFiles
|
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 (
|
from nextcloud_mcp_server.auth import (
|
||||||
InsufficientScopeError,
|
InsufficientScopeError,
|
||||||
@@ -39,8 +36,6 @@ from nextcloud_mcp_server.auth import (
|
|||||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||||
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,
|
|
||||||
get_deployment_mode,
|
|
||||||
get_document_processor_config,
|
get_document_processor_config,
|
||||||
get_settings,
|
get_settings,
|
||||||
)
|
)
|
||||||
@@ -269,160 +264,17 @@ 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:
|
def is_oauth_mode() -> bool:
|
||||||
"""
|
"""
|
||||||
Determine if OAuth mode should be used.
|
Determine if OAuth mode should be used.
|
||||||
|
|
||||||
OAuth mode is enabled when:
|
OAuth mode is enabled when:
|
||||||
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
||||||
- AND we are NOT in Smithery stateless mode
|
|
||||||
- Or explicitly enabled via configuration
|
- Or explicitly enabled via configuration
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if OAuth mode, False if BasicAuth mode
|
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")
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
|
||||||
@@ -1006,9 +858,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine authentication mode and deployment mode
|
# Determine authentication mode
|
||||||
oauth_enabled = is_oauth_mode()
|
oauth_enabled = is_oauth_mode()
|
||||||
deployment_mode = get_deployment_mode()
|
|
||||||
|
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
logger.info("Configuring MCP server for OAuth mode")
|
logger.info("Configuring MCP server for OAuth mode")
|
||||||
@@ -1069,13 +920,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
auth=auth_settings,
|
auth=auth_settings,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
logger.info("Configuring MCP server for BasicAuth mode")
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||||
logger.info("Configuring MCP server for Smithery stateless mode")
|
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_smithery)
|
|
||||||
else:
|
|
||||||
logger.info("Configuring MCP server for BasicAuth mode")
|
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
|
||||||
|
|
||||||
@mcp.resource("nc://capabilities")
|
@mcp.resource("nc://capabilities")
|
||||||
async def nc_get_capabilities():
|
async def nc_get_capabilities():
|
||||||
@@ -1111,12 +957,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = 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)
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
deployment_mode = get_deployment_mode()
|
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:
|
||||||
@@ -1519,26 +1361,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
)
|
)
|
||||||
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
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.
|
# 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)
|
||||||
|
|
||||||
@@ -1669,98 +1491,85 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = 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)
|
# These require session authentication, so we wrap them in a separate app
|
||||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||||
# These require session authentication, so we wrap them in a separate app
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
revoke_session,
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
user_info_html,
|
||||||
revoke_session,
|
vector_sync_status_fragment,
|
||||||
user_info_html,
|
)
|
||||||
|
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",
|
||||||
vector_sync_status_fragment,
|
vector_sync_status_fragment,
|
||||||
)
|
methods=["GET"],
|
||||||
from nextcloud_mcp_server.auth.viz_routes import (
|
), # /app/vector-sync/status
|
||||||
chunk_context_endpoint,
|
# Vector visualization routes
|
||||||
vector_visualization_html,
|
Route(
|
||||||
|
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||||
|
), # /app/vector-viz
|
||||||
|
Route(
|
||||||
|
"/vector-viz/search",
|
||||||
vector_visualization_search,
|
vector_visualization_search,
|
||||||
)
|
methods=["GET"],
|
||||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
), # /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,
|
disable_webhook_preset,
|
||||||
enable_webhook_preset,
|
methods=["DELETE"],
|
||||||
webhook_management_pane,
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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}")
|
||||||
|
|
||||||
# Create a separate Starlette app for browser routes that need session auth
|
browser_app = Starlette(routes=browser_routes)
|
||||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
browser_app.add_middleware(
|
||||||
browser_routes = [
|
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||||
Route(
|
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||||
"/", 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 static files mount if directory exists
|
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
routes.append(
|
||||||
if os.path.isdir(static_dir):
|
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||||
browser_routes.append(
|
)
|
||||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
||||||
)
|
|
||||||
logger.info(f"Mounted static files from {static_dir}")
|
|
||||||
|
|
||||||
browser_app = Starlette(routes=browser_routes)
|
# Mount browser app at /app (webapp and admin routes)
|
||||||
browser_app.add_middleware(
|
routes.append(Mount("/app", app=browser_app))
|
||||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||||
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)
|
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||||
routes.append(Mount("/", app=mcp_app))
|
routes.append(Mount("/", app=mcp_app))
|
||||||
@@ -1880,11 +1689,4 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = 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")
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -2,37 +2,8 @@ import logging
|
|||||||
import logging.config
|
import logging.config
|
||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, Optional
|
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 = {
|
LOGGING_CONFIG = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
|
|||||||
@@ -1,37 +1,21 @@
|
|||||||
"""Helper functions for accessing context in MCP tools."""
|
"""Helper functions for accessing context in MCP tools."""
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from httpx import BasicAuth
|
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import get_settings
|
||||||
DeploymentMode,
|
|
||||||
get_deployment_mode,
|
|
||||||
get_settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_client(ctx: Context) -> NextcloudClient:
|
async def get_client(ctx: Context) -> NextcloudClient:
|
||||||
"""
|
"""
|
||||||
Get the appropriate Nextcloud client based on authentication mode.
|
Get the appropriate Nextcloud client based on authentication mode.
|
||||||
|
|
||||||
ADR-016 compliant implementation supporting three deployment modes:
|
ADR-005 compliant implementation supporting two modes:
|
||||||
|
1. BasicAuth mode: Returns shared client from lifespan context
|
||||||
1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
|
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||||
Create client from session configuration (nextcloud_url, username, app_password)
|
Token already contains both MCP and Nextcloud audiences - use directly
|
||||||
No persistent state - client created per-request from Smithery session config.
|
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||||
|
Exchange MCP token for Nextcloud token via RFC 8693
|
||||||
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
|
SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
|
||||||
proper token audiences per MCP Security Best Practices specification.
|
proper token audiences per MCP Security Best Practices specification.
|
||||||
@@ -40,7 +24,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
|
||||||
@@ -50,7 +34,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
|
||||||
@@ -60,12 +43,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()
|
||||||
lifespan_ctx = ctx.request_context.lifespan_context
|
lifespan_ctx = ctx.request_context.lifespan_context
|
||||||
|
|
||||||
@@ -98,82 +75,3 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
||||||
f"Type: {type(lifespan_ctx)}"
|
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),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
@@ -126,7 +126,6 @@ dev = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
|
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
|
||||||
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"
|
|
||||||
Reference in New Issue
Block a user