Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9f458a8b7 | |||
| 3b41776110 | |||
| 3e3d38696c | |||
| 7b22e5be0f | |||
| 39fba49cfe | |||
| 706a15f0bc | |||
| b8dc413b73 | |||
| 8d29ce0122 | |||
| a272e7cbab | |||
| ce55b239e2 | |||
| 432ab73741 | |||
| f93d650992 | |||
| 482ef89a73 |
@@ -0,0 +1,44 @@
|
|||||||
|
# Dockerfile for Smithery stateless deployment
|
||||||
|
# ADR-016: Stateless mode for multi-user public Nextcloud instances
|
||||||
|
#
|
||||||
|
# This image excludes:
|
||||||
|
# - Vector database dependencies (qdrant-client)
|
||||||
|
# - Background sync workers
|
||||||
|
# - Admin UI routes (/app)
|
||||||
|
# - Semantic search tools
|
||||||
|
#
|
||||||
|
# Features included:
|
||||||
|
# - Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
|
||||||
|
# - Per-session app password authentication
|
||||||
|
# - Multi-user support via Smithery session config
|
||||||
|
|
||||||
|
FROM docker.io/library/python:3.12-slim-trixie@sha256: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"]
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
```markdown
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
|
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
# Nextcloud MCP Server
|
# Nextcloud MCP Server
|
||||||
|
|
||||||
|
[](https://smithery.ai/server/@cbcoutinho/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)
|
||||||
|
|
||||||
**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.**
|
||||||
@@ -17,7 +19,20 @@ This is a **dedicated standalone MCP server** designed for external MCP clients
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Get up and running in 60 seconds using Docker:
|
The fastest way to get started is via [Smithery](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) - no Docker or self-hosting required:
|
||||||
|
|
||||||
|
1. Visit the [Smithery marketplace page](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||||
|
2. Click "Deploy" and configure:
|
||||||
|
- **Nextcloud URL**: Your Nextcloud instance (e.g., `https://cloud.example.com`)
|
||||||
|
- **Username**: Your Nextcloud username
|
||||||
|
- **App Password**: Generate one in Nextcloud → Settings → Security → Devices & sessions
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Smithery runs in stateless mode without semantic search. For full features, use [Docker](#docker-self-hosted) or see [ADR-016](docs/ADR-016-smithery-stateless-deployment.md).
|
||||||
|
|
||||||
|
## Docker (Self-Hosted)
|
||||||
|
|
||||||
|
For full features including semantic search, run with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Create a minimal configuration
|
# 1. Create a minimal configuration
|
||||||
@@ -37,12 +52,11 @@ 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
|
||||||
|
|
||||||
# 4. Or with --transport streamable-http
|
# 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)
|
||||||
|
|
||||||
@@ -210,3 +224,4 @@ This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) fo
|
|||||||
- [Model Context Protocol](https://github.com/modelcontextprotocol)
|
- [Model Context Protocol](https://github.com/modelcontextprotocol)
|
||||||
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
|
||||||
- [Nextcloud](https://nextcloud.com/)
|
- [Nextcloud](https://nextcloud.com/)
|
||||||
|
```
|
||||||
@@ -224,6 +224,26 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,492 @@
|
|||||||
|
# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances
|
||||||
|
|
||||||
|
**Status:** Proposed
|
||||||
|
**Date:** 2025-01-22
|
||||||
|
**Deciders:** Development Team
|
||||||
|
**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[Smithery](https://smithery.ai) is a hosting platform and marketplace for MCP servers that provides:
|
||||||
|
|
||||||
|
- **Discovery**: Marketplace listing for MCP servers
|
||||||
|
- **Hosting**: Containerized deployment with auto-scaling
|
||||||
|
- **Authentication UI**: OAuth flow presentation for users
|
||||||
|
- **Session Configuration**: Per-user settings passed via URL parameters
|
||||||
|
- **Observability**: Usage logs and monitoring
|
||||||
|
|
||||||
|
### Current Architecture Limitations
|
||||||
|
|
||||||
|
The current nextcloud-mcp-server architecture assumes a **self-hosted deployment** with:
|
||||||
|
|
||||||
|
1. **Persistent Infrastructure**
|
||||||
|
- Qdrant vector database for semantic search
|
||||||
|
- Background sync worker for content indexing
|
||||||
|
- Refresh token storage for offline access
|
||||||
|
|
||||||
|
2. **Single-Tenant Configuration**
|
||||||
|
- Environment variables configure one Nextcloud instance
|
||||||
|
- `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
|
||||||
|
- Or OAuth with a single IdP
|
||||||
|
|
||||||
|
3. **Stateful Operations**
|
||||||
|
- Vector sync maintains index state across requests
|
||||||
|
- Token storage persists between sessions
|
||||||
|
|
||||||
|
### Smithery Hosting Constraints
|
||||||
|
|
||||||
|
Smithery-hosted containers are **stateless by design**:
|
||||||
|
|
||||||
|
- No persistent storage between requests
|
||||||
|
- No background workers or cron jobs
|
||||||
|
- No databases (Qdrant, Redis, etc.)
|
||||||
|
- Containers may be recycled at any time
|
||||||
|
- Configuration passed per-session via URL parameters
|
||||||
|
|
||||||
|
### Opportunity
|
||||||
|
|
||||||
|
Many users have **publicly accessible Nextcloud instances** and want to:
|
||||||
|
|
||||||
|
1. Try the MCP server without self-hosting infrastructure
|
||||||
|
2. Connect multiple users to different Nextcloud instances
|
||||||
|
3. Use basic Nextcloud tools without semantic search
|
||||||
|
4. Benefit from Smithery's discovery and OAuth UI
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Implement a **stateless deployment mode** for Smithery that:
|
||||||
|
|
||||||
|
1. **Disables stateful features** (vector sync, semantic search)
|
||||||
|
2. **Creates clients per-session** from Smithery configuration
|
||||||
|
3. **Supports multiple Nextcloud instances** via session config
|
||||||
|
4. **Provides a useful subset of tools** that work without infrastructure
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Smithery-Hosted Stateless Mode │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ MCP Client Smithery │
|
||||||
|
│ (Cursor, Claude) Infrastructure │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 1. Connect │ │
|
||||||
|
│ ├───────────────────────────►│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 2. Config UI │ │
|
||||||
|
│ │◄───────────────────────────┤ User enters: │
|
||||||
|
│ │ (Smithery presents) │ - nextcloud_url │
|
||||||
|
│ │ │ - auth_mode (basic/oauth) │
|
||||||
|
│ │ │ - credentials │
|
||||||
|
│ │ 3. Tool call │ │
|
||||||
|
│ ├───────────────────────────►│ │
|
||||||
|
│ │ + session config │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌───────┴───────┐ │
|
||||||
|
│ │ │ MCP Server │ │
|
||||||
|
│ │ │ Container │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ 4. Create │ │
|
||||||
|
│ │ │ client │ │
|
||||||
|
│ │ │ from │ │
|
||||||
|
│ │ │ config │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ ▼ │ │
|
||||||
|
│ │ │ 5. Call │ │
|
||||||
|
│ │ │ Nextcloud │───────► User's Nextcloud │
|
||||||
|
│ │ │ API │ Instance │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ ▼ │ │
|
||||||
|
│ │ 6. Response │ Return result │ │
|
||||||
|
│ │◄───────────────────┤ │ │
|
||||||
|
│ │ └───────────────┘ │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Configuration Schema
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class SmitheryConfigSchema(BaseModel):
|
||||||
|
"""Configuration schema for Smithery session."""
|
||||||
|
|
||||||
|
# Required: Nextcloud instance
|
||||||
|
nextcloud_url: str = Field(
|
||||||
|
...,
|
||||||
|
description="Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authentication mode
|
||||||
|
auth_mode: str = Field(
|
||||||
|
"app_password",
|
||||||
|
description="Authentication method: 'app_password' or 'oauth'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# App Password authentication (recommended for Smithery)
|
||||||
|
username: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Nextcloud username (required for app_password auth)"
|
||||||
|
)
|
||||||
|
app_password: str | None = Field(
|
||||||
|
None,
|
||||||
|
description="Nextcloud app password (Settings → Security → App passwords)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth authentication (advanced)
|
||||||
|
# When auth_mode='oauth', Smithery handles the OAuth flow
|
||||||
|
# and passes the access token automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Matrix
|
||||||
|
|
||||||
|
| Feature | Self-Hosted | Smithery Stateless |
|
||||||
|
|---------|-------------|-------------------|
|
||||||
|
| **Notes** | | |
|
||||||
|
| List/Search notes | ✓ | ✓ |
|
||||||
|
| Get/Create/Update notes | ✓ | ✓ |
|
||||||
|
| Semantic search | ✓ | ✗ |
|
||||||
|
| **Calendar** | | |
|
||||||
|
| List calendars | ✓ | ✓ |
|
||||||
|
| Get/Create events | ✓ | ✓ |
|
||||||
|
| **Contacts** | | |
|
||||||
|
| List address books | ✓ | ✓ |
|
||||||
|
| Search/Get contacts | ✓ | ✓ |
|
||||||
|
| **Files (WebDAV)** | | |
|
||||||
|
| List/Download files | ✓ | ✓ |
|
||||||
|
| Upload files | ✓ | ✓ |
|
||||||
|
| Search files | ✓ | ✓ (keyword only) |
|
||||||
|
| **Deck** | | |
|
||||||
|
| List boards/cards | ✓ | ✓ |
|
||||||
|
| Create/Update cards | ✓ | ✓ |
|
||||||
|
| **Tables** | | |
|
||||||
|
| List/Query tables | ✓ | ✓ |
|
||||||
|
| Create/Update rows | ✓ | ✓ |
|
||||||
|
| **Cookbook** | | |
|
||||||
|
| List/Get recipes | ✓ | ✓ |
|
||||||
|
| **Semantic Search** | | |
|
||||||
|
| Vector search | ✓ | ✗ |
|
||||||
|
| RAG answers | ✓ | ✗ |
|
||||||
|
| **Background Sync** | | |
|
||||||
|
| Auto-indexing | ✓ | ✗ |
|
||||||
|
| Webhook sync | ✓ | ✗ |
|
||||||
|
| **Admin UI (`/app`)** | | |
|
||||||
|
| Vector sync status | ✓ | ✗ |
|
||||||
|
| Vector visualization | ✓ | ✗ |
|
||||||
|
| Webhook management | ✓ | ✗ |
|
||||||
|
| Session management | ✓ | ✗ |
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
#### 1. Deployment Mode Detection
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nextcloud_mcp_server/config.py
|
||||||
|
|
||||||
|
class DeploymentMode(Enum):
|
||||||
|
SELF_HOSTED = "self_hosted" # Full features, env-based config
|
||||||
|
SMITHERY_STATELESS = "smithery" # Stateless, session-based config
|
||||||
|
|
||||||
|
def get_deployment_mode() -> DeploymentMode:
|
||||||
|
"""Detect deployment mode from environment."""
|
||||||
|
if os.getenv("SMITHERY_DEPLOYMENT") == "true":
|
||||||
|
return DeploymentMode.SMITHERY_STATELESS
|
||||||
|
return DeploymentMode.SELF_HOSTED
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Session-Based Client Factory
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nextcloud_mcp_server/context.py
|
||||||
|
|
||||||
|
async def get_client(ctx: Context) -> NextcloudClient:
|
||||||
|
"""Get NextcloudClient - from session config or environment."""
|
||||||
|
|
||||||
|
mode = get_deployment_mode()
|
||||||
|
|
||||||
|
if mode == DeploymentMode.SMITHERY_STATELESS:
|
||||||
|
# Create client from Smithery session config
|
||||||
|
config = ctx.session_config
|
||||||
|
if not config:
|
||||||
|
raise McpError("Session configuration required")
|
||||||
|
|
||||||
|
return NextcloudClient(
|
||||||
|
base_url=config.nextcloud_url,
|
||||||
|
username=config.username,
|
||||||
|
password=config.app_password,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Existing behavior: from environment or OAuth context
|
||||||
|
return await _get_client_from_context(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Conditional Tool Registration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nextcloud_mcp_server/app.py
|
||||||
|
|
||||||
|
def create_mcp_server(mode: DeploymentMode) -> FastMCP:
|
||||||
|
"""Create MCP server with mode-appropriate tools."""
|
||||||
|
|
||||||
|
mcp = FastMCP("Nextcloud MCP")
|
||||||
|
|
||||||
|
# Always register core tools
|
||||||
|
configure_notes_tools(mcp)
|
||||||
|
configure_calendar_tools(mcp)
|
||||||
|
configure_contacts_tools(mcp)
|
||||||
|
configure_webdav_tools(mcp)
|
||||||
|
configure_deck_tools(mcp)
|
||||||
|
configure_tables_tools(mcp)
|
||||||
|
configure_cookbook_tools(mcp)
|
||||||
|
|
||||||
|
# Only register stateful tools in self-hosted mode
|
||||||
|
if mode == DeploymentMode.SELF_HOSTED:
|
||||||
|
configure_semantic_tools(mcp) # Requires Qdrant
|
||||||
|
register_oauth_tools(mcp) # Requires token storage
|
||||||
|
|
||||||
|
return mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Exclude Admin UI Routes
|
||||||
|
|
||||||
|
The `/app` admin UI should **not be installed** in Smithery mode because:
|
||||||
|
|
||||||
|
- **Vector sync status** - No vector sync in stateless mode
|
||||||
|
- **Vector visualization** - No Qdrant to visualize
|
||||||
|
- **Webhook management** - No webhook sync without background workers
|
||||||
|
- **Session management** - No persistent sessions to manage
|
||||||
|
|
||||||
|
```python
|
||||||
|
# nextcloud_mcp_server/app.py
|
||||||
|
|
||||||
|
def create_app(mode: DeploymentMode) -> Starlette:
|
||||||
|
"""Create Starlette app with mode-appropriate routes."""
|
||||||
|
|
||||||
|
routes = [
|
||||||
|
Route("/health/live", health_live, methods=["GET"]),
|
||||||
|
Route("/health/ready", health_ready, methods=["GET"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Only mount admin UI in self-hosted mode
|
||||||
|
if mode == DeploymentMode.SELF_HOSTED:
|
||||||
|
browser_app = create_browser_app()
|
||||||
|
routes.append(
|
||||||
|
Route("/app", lambda r: RedirectResponse("/app/", status_code=307))
|
||||||
|
)
|
||||||
|
routes.append(Mount("/app", app=browser_app))
|
||||||
|
logger.info("Admin UI mounted at /app")
|
||||||
|
else:
|
||||||
|
logger.info("Admin UI disabled in Smithery stateless mode")
|
||||||
|
|
||||||
|
# Mount FastMCP at root
|
||||||
|
mcp_app = create_mcp_server(mode).streamable_http_app()
|
||||||
|
routes.append(Mount("/", app=mcp_app))
|
||||||
|
|
||||||
|
return Starlette(routes=routes, lifespan=starlette_lifespan)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endpoints by Mode:**
|
||||||
|
|
||||||
|
| Endpoint | Self-Hosted | Smithery |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| `/mcp` | ✓ | ✓ |
|
||||||
|
| `/health/live` | ✓ | ✓ |
|
||||||
|
| `/health/ready` | ✓ | ✓ |
|
||||||
|
| `/.well-known/mcp-config` | ✓ | ✓ |
|
||||||
|
| `/app` | ✓ | ✗ |
|
||||||
|
| `/app/vector-sync/status` | ✓ | ✗ |
|
||||||
|
| `/app/vector-viz` | ✓ | ✗ |
|
||||||
|
| `/app/webhooks` | ✓ | ✗ |
|
||||||
|
|
||||||
|
#### 5. Smithery Integration Files
|
||||||
|
|
||||||
|
**smithery.yaml:**
|
||||||
|
```yaml
|
||||||
|
runtime: "container"
|
||||||
|
build:
|
||||||
|
dockerfile: "Dockerfile.smithery"
|
||||||
|
dockerBuildPath: "."
|
||||||
|
startCommand:
|
||||||
|
type: "http"
|
||||||
|
configSchema:
|
||||||
|
type: "object"
|
||||||
|
required: ["nextcloud_url", "username", "app_password"]
|
||||||
|
properties:
|
||||||
|
nextcloud_url:
|
||||||
|
type: "string"
|
||||||
|
title: "Nextcloud URL"
|
||||||
|
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||||
|
username:
|
||||||
|
type: "string"
|
||||||
|
title: "Username"
|
||||||
|
description: "Your Nextcloud username"
|
||||||
|
app_password:
|
||||||
|
type: "string"
|
||||||
|
title: "App Password"
|
||||||
|
description: "Generate at Settings → Security → App passwords"
|
||||||
|
exampleConfig:
|
||||||
|
nextcloud_url: "https://cloud.example.com"
|
||||||
|
username: "alice"
|
||||||
|
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dockerfile.smithery:**
|
||||||
|
```dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install uv
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
COPY nextcloud_mcp_server ./nextcloud_mcp_server
|
||||||
|
|
||||||
|
# Install dependencies (without vector/semantic extras)
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
# Set Smithery mode
|
||||||
|
ENV SMITHERY_DEPLOYMENT=true
|
||||||
|
ENV VECTOR_SYNC_ENABLED=false
|
||||||
|
|
||||||
|
# Smithery sets PORT=8081
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**nextcloud_mcp_server/smithery_main.py:**
|
||||||
|
```python
|
||||||
|
"""Smithery-specific entrypoint for stateless deployment."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uvicorn
|
||||||
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.app import create_mcp_server
|
||||||
|
from nextcloud_mcp_server.config import DeploymentMode
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Force stateless mode
|
||||||
|
os.environ["SMITHERY_DEPLOYMENT"] = "true"
|
||||||
|
os.environ["VECTOR_SYNC_ENABLED"] = "false"
|
||||||
|
|
||||||
|
mcp = create_mcp_server(DeploymentMode.SMITHERY_STATELESS)
|
||||||
|
app = mcp.streamable_http_app()
|
||||||
|
|
||||||
|
# Add CORS for browser-based clients
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "OPTIONS"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Smithery sets PORT environment variable
|
||||||
|
port = int(os.environ.get("PORT", 8081))
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
1. **App Passwords over User Passwords**
|
||||||
|
- Smithery config encourages app passwords (revocable, scoped)
|
||||||
|
- Documentation guides users to create dedicated app passwords
|
||||||
|
- App passwords can be revoked without changing main password
|
||||||
|
|
||||||
|
2. **HTTPS Required**
|
||||||
|
- `nextcloud_url` must be HTTPS for production use
|
||||||
|
- Validation rejects HTTP URLs in Smithery mode
|
||||||
|
|
||||||
|
3. **No Credential Storage**
|
||||||
|
- Credentials exist only for request duration
|
||||||
|
- No server-side persistence of user credentials
|
||||||
|
- Smithery handles secure config transmission
|
||||||
|
|
||||||
|
4. **Scope Limitation**
|
||||||
|
- Stateless mode cannot access offline_access
|
||||||
|
- No background operations on user's behalf
|
||||||
|
- Clear user expectation: tools work during session only
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
Users can start with Smithery stateless mode and migrate to self-hosted:
|
||||||
|
|
||||||
|
1. **Try on Smithery** → Basic tools, no setup
|
||||||
|
2. **Self-host for semantic search** → Add Qdrant, enable vector sync
|
||||||
|
3. **Full deployment** → Background sync, webhooks, multi-user OAuth
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
1. **Lower barrier to entry** - Users can try without infrastructure
|
||||||
|
2. **Multi-user support** - Each session connects to different Nextcloud
|
||||||
|
3. **Smithery ecosystem** - Discovery, observability, OAuth UI
|
||||||
|
4. **Clear feature tiers** - Stateless (simple) vs self-hosted (full)
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
1. **No semantic search** - Key differentiator unavailable on Smithery
|
||||||
|
2. **Per-request auth** - Credentials sent with each request
|
||||||
|
3. **No offline access** - Cannot perform background operations
|
||||||
|
4. **Maintenance burden** - Two deployment modes to support
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
1. **Feature subset** - May encourage users to self-host for full features
|
||||||
|
2. **Documentation needs** - Clear guidance on mode differences required
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### 1. External MCP Only
|
||||||
|
|
||||||
|
**Approach:** Only support self-hosted external MCP registration on Smithery.
|
||||||
|
|
||||||
|
**Rejected because:**
|
||||||
|
- Higher barrier to entry for new users
|
||||||
|
- Misses opportunity for Smithery marketplace visibility
|
||||||
|
- Users want to try before committing to infrastructure
|
||||||
|
|
||||||
|
### 2. Embedded Vector DB (SQLite-vec)
|
||||||
|
|
||||||
|
**Approach:** Use SQLite with vector extensions for per-request indexing.
|
||||||
|
|
||||||
|
**Rejected because:**
|
||||||
|
- No persistence between requests anyway
|
||||||
|
- Indexing latency too high for synchronous requests
|
||||||
|
- Complexity without benefit in stateless context
|
||||||
|
|
||||||
|
### 3. External Vector DB Service
|
||||||
|
|
||||||
|
**Approach:** Connect to Pinecone/Weaviate Cloud from Smithery container.
|
||||||
|
|
||||||
|
**Rejected because:**
|
||||||
|
- Adds external dependency and cost
|
||||||
|
- Per-user collections require complex multi-tenancy
|
||||||
|
- Sync still impossible without background workers
|
||||||
|
|
||||||
|
### 4. Hybrid: Smithery + User's Qdrant
|
||||||
|
|
||||||
|
**Approach:** User provides their own Qdrant URL in session config.
|
||||||
|
|
||||||
|
**Considered for future:**
|
||||||
|
- Could enable semantic search for advanced users
|
||||||
|
- Adds complexity to session config
|
||||||
|
- Sync still requires external trigger (manual or webhook)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Smithery Documentation](https://smithery.ai/docs)
|
||||||
|
- [Smithery Session Configuration](https://smithery.ai/docs/build/session-config)
|
||||||
|
- [Smithery External MCPs](https://smithery.ai/docs/build/external)
|
||||||
|
- [MCP Streamable HTTP Transport](https://modelcontextprotocol.io/docs/concepts/transports)
|
||||||
|
- [Nextcloud App Passwords](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#app-passwords)
|
||||||
+276
-78
@@ -3,6 +3,7 @@ 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
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ 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,
|
||||||
@@ -36,6 +39,8 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -264,17 +269,160 @@ class OAuthAppContext:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmitheryAppContext:
|
||||||
|
"""Application context for Smithery stateless mode.
|
||||||
|
|
||||||
|
ADR-016: No shared client - clients created per-request from session config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass # No shared state needed - everything comes from session config
|
||||||
|
|
||||||
|
|
||||||
|
# ADR-016: Smithery config schema for container runtime
|
||||||
|
# This schema is served at /.well-known/mcp-config for Smithery discovery
|
||||||
|
# See: https://smithery.ai/docs/build/session-config
|
||||||
|
SMITHERY_CONFIG_SCHEMA = {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "https://server.smithery.ai/nextcloud-mcp-server/.well-known/mcp-config",
|
||||||
|
"title": "Nextcloud MCP Server Configuration",
|
||||||
|
"description": "Configuration for connecting to your Nextcloud instance via app password authentication",
|
||||||
|
"x-query-style": "flat", # Our schema has no nested objects, so flat style works
|
||||||
|
"type": "object",
|
||||||
|
"required": ["nextcloud_url", "username", "app_password"],
|
||||||
|
"properties": {
|
||||||
|
"nextcloud_url": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Nextcloud URL",
|
||||||
|
"description": "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible.",
|
||||||
|
"pattern": "^https?://.+",
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Username",
|
||||||
|
"description": "Your Nextcloud username",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
"app_password": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "App Password",
|
||||||
|
"description": "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password.",
|
||||||
|
"minLength": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ADR-016: Context variable to hold Smithery session config per-request
|
||||||
|
# This is set by SmitheryConfigMiddleware and accessed in context.py
|
||||||
|
_smithery_session_config: ContextVar[dict[str, str] | None] = ContextVar(
|
||||||
|
"smithery_session_config"
|
||||||
|
)
|
||||||
|
_smithery_session_config.set(None) # Set initial value
|
||||||
|
|
||||||
|
|
||||||
|
def get_smithery_session_config() -> dict | None:
|
||||||
|
"""Get the current Smithery session config from context variable.
|
||||||
|
|
||||||
|
Used by context.py to access config extracted from URL query parameters.
|
||||||
|
"""
|
||||||
|
return _smithery_session_config.get()
|
||||||
|
|
||||||
|
|
||||||
|
class SmitheryConfigMiddleware:
|
||||||
|
"""Middleware to extract Smithery config from URL query parameters.
|
||||||
|
|
||||||
|
ADR-016: For container runtime, Smithery passes configuration as URL query
|
||||||
|
parameters to the /mcp endpoint. This middleware extracts those parameters
|
||||||
|
and stores them in a context variable for access in tools.
|
||||||
|
|
||||||
|
Configuration parameters:
|
||||||
|
- nextcloud_url: Nextcloud instance URL
|
||||||
|
- username: Nextcloud username
|
||||||
|
- app_password: Nextcloud app password
|
||||||
|
|
||||||
|
The extracted config is stored in a ContextVar and can be accessed via
|
||||||
|
get_smithery_session_config() in context.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self, scope: StarletteScope, receive: Receive, send: Send
|
||||||
|
) -> None:
|
||||||
|
if scope["type"] == "http":
|
||||||
|
# Extract config from query parameters
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
query_string = scope.get("query_string", b"").decode("utf-8")
|
||||||
|
params = parse_qs(query_string)
|
||||||
|
|
||||||
|
# Build session config from query parameters
|
||||||
|
# Smithery uses dot notation for nested objects, but our schema is flat
|
||||||
|
session_config = {}
|
||||||
|
for key in ["nextcloud_url", "username", "app_password"]:
|
||||||
|
if key in params:
|
||||||
|
# parse_qs returns lists, take first value
|
||||||
|
session_config[key] = params[key][0]
|
||||||
|
|
||||||
|
# Store in context variable for access by context.py
|
||||||
|
if session_config:
|
||||||
|
_smithery_session_config.set(session_config)
|
||||||
|
logger.debug(
|
||||||
|
f"Smithery config extracted: nextcloud_url={session_config.get('nextcloud_url')}, "
|
||||||
|
f"username={session_config.get('username')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
finally:
|
||||||
|
# Clear context variable after request
|
||||||
|
_smithery_session_config.set(None)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppContext]:
|
||||||
|
"""
|
||||||
|
Manage application lifecycle for Smithery stateless mode.
|
||||||
|
|
||||||
|
ADR-016: Minimal lifespan with no shared state.
|
||||||
|
- No shared Nextcloud client (created per-request from session config)
|
||||||
|
- No vector sync (disabled in Smithery mode)
|
||||||
|
- No persistent storage (stateless deployment)
|
||||||
|
- No document processors (not enabled in Smithery mode)
|
||||||
|
"""
|
||||||
|
logger.info("Starting MCP server in Smithery stateless mode")
|
||||||
|
logger.info("Clients will be created per-request from session config")
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield SmitheryAppContext()
|
||||||
|
finally:
|
||||||
|
logger.info("Shutting down Smithery stateless mode")
|
||||||
|
|
||||||
|
|
||||||
def is_oauth_mode() -> bool:
|
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")
|
||||||
|
|
||||||
@@ -858,8 +1006,9 @@ 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
|
# Determine authentication mode and deployment 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")
|
||||||
@@ -920,8 +1069,13 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
auth=auth_settings,
|
auth=auth_settings,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Configuring MCP server for BasicAuth mode")
|
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||||
|
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():
|
||||||
@@ -957,8 +1111,12 @@ 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()
|
||||||
if settings.vector_sync_enabled:
|
deployment_mode = get_deployment_mode()
|
||||||
|
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||||
|
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
||||||
|
elif settings.vector_sync_enabled:
|
||||||
logger.info("Configuring semantic search tools (vector sync enabled)")
|
logger.info("Configuring semantic search tools (vector sync enabled)")
|
||||||
configure_semantic_tools(mcp)
|
configure_semantic_tools(mcp)
|
||||||
else:
|
else:
|
||||||
@@ -1361,6 +1519,26 @@ 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)
|
||||||
|
|
||||||
@@ -1491,85 +1669,98 @@ 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)
|
||||||
# These require session authentication, so we wrap them in a separate app
|
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
||||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
# These require session authentication, so we wrap them in a separate app
|
||||||
revoke_session,
|
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||||
user_info_html,
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
vector_sync_status_fragment,
|
revoke_session,
|
||||||
)
|
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"],
|
|
||||||
), # /app/vector-sync/status
|
|
||||||
# Vector visualization routes
|
|
||||||
Route(
|
|
||||||
"/vector-viz", vector_visualization_html, methods=["GET"]
|
|
||||||
), # /app/vector-viz
|
|
||||||
Route(
|
|
||||||
"/vector-viz/search",
|
|
||||||
vector_visualization_search,
|
|
||||||
methods=["GET"],
|
|
||||||
), # /app/vector-viz/search
|
|
||||||
Route(
|
|
||||||
"/chunk-context",
|
|
||||||
chunk_context_endpoint,
|
|
||||||
methods=["GET"],
|
|
||||||
), # /app/chunk-context
|
|
||||||
# Webhook management routes (admin-only)
|
|
||||||
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
|
|
||||||
Route(
|
|
||||||
"/webhooks/enable/{preset_id:str}", enable_webhook_preset, methods=["POST"]
|
|
||||||
),
|
|
||||||
Route(
|
|
||||||
"/webhooks/disable/{preset_id:str}",
|
|
||||||
disable_webhook_preset,
|
|
||||||
methods=["DELETE"],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Add static files mount if directory exists
|
|
||||||
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
|
||||||
if os.path.isdir(static_dir):
|
|
||||||
browser_routes.append(
|
|
||||||
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
||||||
)
|
)
|
||||||
logger.info(f"Mounted static files from {static_dir}")
|
from nextcloud_mcp_server.auth.viz_routes import (
|
||||||
|
chunk_context_endpoint,
|
||||||
|
vector_visualization_html,
|
||||||
|
vector_visualization_search,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||||
|
disable_webhook_preset,
|
||||||
|
enable_webhook_preset,
|
||||||
|
webhook_management_pane,
|
||||||
|
)
|
||||||
|
|
||||||
browser_app = Starlette(routes=browser_routes)
|
# Create a separate Starlette app for browser routes that need session auth
|
||||||
browser_app.add_middleware(
|
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||||
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
browser_routes = [
|
||||||
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
Route(
|
||||||
)
|
"/", user_info_html, methods=["GET"]
|
||||||
|
), # /app → user info with all tabs
|
||||||
|
Route(
|
||||||
|
"/revoke",
|
||||||
|
revoke_session,
|
||||||
|
methods=["POST"],
|
||||||
|
name="revoke_session_endpoint",
|
||||||
|
), # /app/revoke → revoke_session
|
||||||
|
# Vector sync status fragment (htmx polling)
|
||||||
|
Route(
|
||||||
|
"/vector-sync/status",
|
||||||
|
vector_sync_status_fragment,
|
||||||
|
methods=["GET"],
|
||||||
|
), # /app/vector-sync/status
|
||||||
|
# Vector visualization routes
|
||||||
|
Route(
|
||||||
|
"/vector-viz", vector_visualization_html, methods=["GET"]
|
||||||
|
), # /app/vector-viz
|
||||||
|
Route(
|
||||||
|
"/vector-viz/search",
|
||||||
|
vector_visualization_search,
|
||||||
|
methods=["GET"],
|
||||||
|
), # /app/vector-viz/search
|
||||||
|
Route(
|
||||||
|
"/chunk-context",
|
||||||
|
chunk_context_endpoint,
|
||||||
|
methods=["GET"],
|
||||||
|
), # /app/chunk-context
|
||||||
|
# Webhook management routes (admin-only)
|
||||||
|
Route(
|
||||||
|
"/webhooks", webhook_management_pane, methods=["GET"]
|
||||||
|
), # /app/webhooks
|
||||||
|
Route(
|
||||||
|
"/webhooks/enable/{preset_id:str}",
|
||||||
|
enable_webhook_preset,
|
||||||
|
methods=["POST"],
|
||||||
|
),
|
||||||
|
Route(
|
||||||
|
"/webhooks/disable/{preset_id:str}",
|
||||||
|
disable_webhook_preset,
|
||||||
|
methods=["DELETE"],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
# Add static files mount if directory exists
|
||||||
routes.append(
|
static_dir = os.path.join(os.path.dirname(__file__), "auth", "static")
|
||||||
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
if os.path.isdir(static_dir):
|
||||||
)
|
browser_routes.append(
|
||||||
|
Mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
)
|
||||||
|
logger.info(f"Mounted static files from {static_dir}")
|
||||||
|
|
||||||
# Mount browser app at /app (webapp and admin routes)
|
browser_app = Starlette(routes=browser_routes)
|
||||||
routes.append(Mount("/app", app=browser_app))
|
browser_app.add_middleware(
|
||||||
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
AuthenticationMiddleware, # type: ignore[invalid-argument-type]
|
||||||
|
backend=SessionAuthBackend(oauth_enabled=oauth_enabled),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add redirect from /app to /app/ (Starlette requires trailing slash for mounted apps)
|
||||||
|
routes.append(
|
||||||
|
Route("/app", lambda request: RedirectResponse("/app/", status_code=307))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount browser app at /app (webapp and admin routes)
|
||||||
|
routes.append(Mount("/app", app=browser_app))
|
||||||
|
logger.info("App routes with session auth: /app, /app/webhooks, /app/revoke")
|
||||||
|
else:
|
||||||
|
logger.info("Admin UI (/app) disabled in Smithery stateless mode")
|
||||||
|
|
||||||
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
# Mount FastMCP at root last (catch-all, handles OAuth via token_verifier)
|
||||||
routes.append(Mount("/", app=mcp_app))
|
routes.append(Mount("/", app=mcp_app))
|
||||||
@@ -1689,4 +1880,11 @@ 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,8 +2,37 @@ 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,21 +1,37 @@
|
|||||||
"""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 get_settings
|
from nextcloud_mcp_server.config import (
|
||||||
|
DeploymentMode,
|
||||||
|
get_deployment_mode,
|
||||||
|
get_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def get_client(ctx: Context) -> NextcloudClient:
|
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-005 compliant implementation supporting two modes:
|
ADR-016 compliant implementation supporting three deployment modes:
|
||||||
1. BasicAuth mode: Returns shared client from lifespan context
|
|
||||||
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
|
||||||
Token already contains both MCP and Nextcloud audiences - use directly
|
Create client from session configuration (nextcloud_url, username, app_password)
|
||||||
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
No persistent state - client created per-request from Smithery session config.
|
||||||
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.
|
||||||
@@ -24,7 +40,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 type of the lifespan context.
|
the deployment mode and type of the lifespan context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: MCP request context
|
ctx: MCP request context
|
||||||
@@ -34,6 +50,7 @@ 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
|
||||||
@@ -43,6 +60,12 @@ 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
|
||||||
|
|
||||||
@@ -75,3 +98,82 @@ 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),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Smithery-specific entrypoint for stateless deployment.
|
||||||
|
|
||||||
|
ADR-016: This entrypoint is used when deploying on Smithery's hosting platform.
|
||||||
|
It configures the server for stateless operation with per-session authentication.
|
||||||
|
|
||||||
|
Features disabled in Smithery mode:
|
||||||
|
- Vector sync / semantic search (no persistent storage)
|
||||||
|
- Admin UI at /app (no webhooks, no vector viz)
|
||||||
|
- OAuth provisioning tools (no token storage)
|
||||||
|
|
||||||
|
Features enabled:
|
||||||
|
- Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
|
||||||
|
- Per-session app password authentication via Smithery configSchema
|
||||||
|
- Health check endpoints (/health/live, /health/ready)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import setup_logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Start the MCP server in Smithery stateless mode."""
|
||||||
|
# Setup logging first
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
# Force stateless mode environment variables
|
||||||
|
os.environ["SMITHERY_DEPLOYMENT"] = "true"
|
||||||
|
os.environ["VECTOR_SYNC_ENABLED"] = "false"
|
||||||
|
|
||||||
|
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
||||||
|
|
||||||
|
# Import app after setting environment variables
|
||||||
|
from nextcloud_mcp_server.app import get_app
|
||||||
|
|
||||||
|
# Create the app with streamable-http transport (required for Smithery)
|
||||||
|
app = get_app(transport="streamable-http")
|
||||||
|
|
||||||
|
# Smithery sets PORT environment variable
|
||||||
|
port = int(os.environ.get("PORT", 8081))
|
||||||
|
|
||||||
|
logger.info(f"Listening on port {port}")
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=port,
|
||||||
|
log_level="info",
|
||||||
|
# Disable access log for cleaner output
|
||||||
|
access_log=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -126,6 +126,7 @@ 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"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Smithery configuration for Nextcloud MCP Server
|
||||||
|
# See: https://smithery.ai/docs/build/configuration
|
||||||
|
# ADR-016: Stateless deployment mode for multi-user public Nextcloud instances
|
||||||
|
|
||||||
|
runtime: "container"
|
||||||
|
|
||||||
|
build:
|
||||||
|
dockerfile: "Dockerfile.smithery"
|
||||||
|
dockerBuildPath: "."
|
||||||
|
|
||||||
|
startCommand:
|
||||||
|
type: "http"
|
||||||
|
configSchema:
|
||||||
|
type: "object"
|
||||||
|
required:
|
||||||
|
- "nextcloud_url"
|
||||||
|
- "username"
|
||||||
|
- "app_password"
|
||||||
|
properties:
|
||||||
|
nextcloud_url:
|
||||||
|
type: "string"
|
||||||
|
title: "Nextcloud URL"
|
||||||
|
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com). Must be publicly accessible."
|
||||||
|
pattern: "^https?://.+"
|
||||||
|
username:
|
||||||
|
type: "string"
|
||||||
|
title: "Username"
|
||||||
|
description: "Your Nextcloud username"
|
||||||
|
minLength: 1
|
||||||
|
app_password:
|
||||||
|
type: "string"
|
||||||
|
title: "App Password"
|
||||||
|
description: "Nextcloud app password. Generate at Settings > Security > App passwords. Do NOT use your main password."
|
||||||
|
minLength: 1
|
||||||
|
exampleConfig:
|
||||||
|
nextcloud_url: "https://cloud.example.com"
|
||||||
|
username: "alice"
|
||||||
|
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||||
Reference in New Issue
Block a user