feat(server): Experimental support for OAuth2/OIDC authentication
This commit is contained in:
@@ -0,0 +1,742 @@
|
|||||||
|
# OAuth2/OIDC Implementation Plan for Nextcloud MCP Server
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
Upgrade the Nextcloud MCP server to support OAuth2/OIDC authentication using Nextcloud's OIDC app as the Authorization Server, eliminating the need for baked-in credentials in server deployment.
|
||||||
|
|
||||||
|
**Status**: ✅ Research Complete - Implementation Ready
|
||||||
|
|
||||||
|
## Research Findings Summary
|
||||||
|
|
||||||
|
### ✅ Verified Nextcloud OIDC Capabilities
|
||||||
|
- **Token Format**: Opaque tokens by default, **RFC 9068 JWT access tokens available** (must be enabled per-client)
|
||||||
|
- **Discovery**: Full OpenID Connect discovery available at `/.well-known/openid-configuration`
|
||||||
|
- **JWKS**: Available at `/apps/oidc/jwks` for JWT signature validation
|
||||||
|
- **Dynamic Registration**: Supported via `/apps/oidc/register` (must be enabled by admin)
|
||||||
|
- **Introspection**: ❌ NOT available - must use **userinfo endpoint** for token validation
|
||||||
|
- **Userinfo**: Available at `/apps/oidc/userinfo` - validates token and returns user claims
|
||||||
|
- **Scopes**: `openid`, `profile`, `email`, `roles`, `groups`
|
||||||
|
- **User Claims**: `sub`, `preferred_username` (both contain Nextcloud username)
|
||||||
|
|
||||||
|
### 🔑 Key Implementation Decisions
|
||||||
|
1. **Primary Token Validation**: Use **userinfo endpoint** (not introspection)
|
||||||
|
2. **JWT Support**: Optional - enables local validation if client configured for RFC 9068
|
||||||
|
3. **User Context**: Extract username from `sub` or `preferred_username` claim via userinfo
|
||||||
|
4. **Dynamic Registration**: Primary deployment method (zero-config)
|
||||||
|
5. **Token Lifetime**: Access tokens default to 3600s, clients default to 3600s (both configurable)
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Server Role: Resource Server (RS) - RFC 9728
|
||||||
|
The MCP server acts as a **Resource Server** that:
|
||||||
|
- Validates OAuth tokens issued by Nextcloud OIDC app (Authorization Server)
|
||||||
|
- Protects MCP tools/resources with OAuth authentication
|
||||||
|
- Uses validated tokens to make Nextcloud API calls on behalf of authenticated users
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
```
|
||||||
|
1. Client connects to MCP Server (RS)
|
||||||
|
2. MCP Server provides RFC 9728 metadata pointing to Nextcloud OIDC (AS)
|
||||||
|
3. Client performs OAuth flow with Nextcloud OIDC
|
||||||
|
4. Client presents access token to MCP Server
|
||||||
|
5. MCP Server validates token via userinfo endpoint (or JWT if configured)
|
||||||
|
6. MCP Server extracts username from claims
|
||||||
|
7. MCP Server uses token to call Nextcloud APIs with user context
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### 1. Dynamic Client Registration (PRIMARY APPROACH)
|
||||||
|
**Use Nextcloud OIDC's Dynamic Client Registration for zero-config deployment**
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- No manual client setup required
|
||||||
|
- MCP server auto-registers on first startup
|
||||||
|
- Automatic credential generation
|
||||||
|
- Self-healing if client expires
|
||||||
|
- Better developer/deployment experience
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```python
|
||||||
|
# Startup sequence:
|
||||||
|
1. Check for existing client credentials (file/env)
|
||||||
|
2. If none found, POST to /apps/oidc/register
|
||||||
|
3. Store client_id and client_secret persistently
|
||||||
|
4. Use credentials for OAuth flow
|
||||||
|
5. Auto re-register if client expires (3600s default)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nextcloud OIDC Requirements:**
|
||||||
|
- Admin must enable "Dynamic Client Registration" in OIDC app settings
|
||||||
|
- Rate limiting via BruteForce protection
|
||||||
|
- Max 100 dynamic clients per instance
|
||||||
|
- Clients expire after 1 hour (configurable via occ)
|
||||||
|
|
||||||
|
### 2. Token Validation Strategy: Userinfo Endpoint (PRIMARY)
|
||||||
|
|
||||||
|
**✅ VERIFIED IMPLEMENTATION: Userinfo Endpoint Validation**
|
||||||
|
|
||||||
|
Nextcloud OIDC **does NOT provide** a token introspection endpoint. Token validation must use:
|
||||||
|
|
||||||
|
**Primary: Userinfo Endpoint Validation**
|
||||||
|
- Call `/apps/oidc/userinfo` with Bearer token
|
||||||
|
- Nextcloud validates token internally (checks expiration, client, etc.)
|
||||||
|
- Returns user claims if valid: `sub`, `preferred_username`, `email`, `roles`, `groups`
|
||||||
|
- HTTP 400/401 if token invalid
|
||||||
|
- Cache results with TTL matching token expiration (3600s default)
|
||||||
|
|
||||||
|
**Implementation Pattern**:
|
||||||
|
```python
|
||||||
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
|
# Call userinfo endpoint
|
||||||
|
response = await client.get(
|
||||||
|
f"{nextcloud_host}/apps/oidc/userinfo",
|
||||||
|
headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
claims = response.json()
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id="", # Not available from userinfo
|
||||||
|
scopes=["openid", "profile"], # From original request
|
||||||
|
expires_at=calculate_expiry() # 3600s from now
|
||||||
|
)
|
||||||
|
return None # Invalid token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional: JWT Validation (Performance Optimization)**
|
||||||
|
- Available if client configured with "JWT Access Tokens (RFC 9068)" enabled
|
||||||
|
- Fetch JWKS from `/apps/oidc/jwks`
|
||||||
|
- Validate JWT signatures locally (no network call)
|
||||||
|
- Cache JWKS with refresh mechanism
|
||||||
|
- Falls back to userinfo if JWT validation fails
|
||||||
|
|
||||||
|
**Trade-offs**:
|
||||||
|
- Userinfo: Simpler, always works, network call per validation
|
||||||
|
- JWT: Faster, no network call, requires per-client configuration
|
||||||
|
|
||||||
|
### 3. Dual-Mode Authentication (Backward Compatibility)
|
||||||
|
Support both authentication modes:
|
||||||
|
|
||||||
|
**Mode 1: OAuth2/OIDC (NEW)**
|
||||||
|
- Environment: `NEXTCLOUD_HOST` + optional `NEXTCLOUD_OIDC_CLIENT_ID/SECRET`
|
||||||
|
- Auto-registers if no client credentials provided
|
||||||
|
- Per-request client creation with bearer token
|
||||||
|
|
||||||
|
**Mode 2: Basic Auth (LEGACY)**
|
||||||
|
- Environment: `NEXTCLOUD_HOST` + `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD`
|
||||||
|
- Current implementation preserved
|
||||||
|
- Single client in lifespan context
|
||||||
|
|
||||||
|
### 4. HTTP Client Architecture
|
||||||
|
|
||||||
|
**✅ REVISED: Context-aware Client Retrieval**
|
||||||
|
|
||||||
|
Instead of per-request client creation, use a helper that extracts user context:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Helper function to get client from MCP context
|
||||||
|
async def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||||
|
"""Extract authenticated user context and create NextcloudClient."""
|
||||||
|
# MCP SDK provides AccessToken from TokenVerifier
|
||||||
|
access_token: AccessToken = ctx.request_context.session.access_token
|
||||||
|
|
||||||
|
# Extract username from cached userinfo claims
|
||||||
|
# (stored during token verification)
|
||||||
|
username = access_token.scopes[0] # Or from custom metadata
|
||||||
|
|
||||||
|
# Create client with bearer token
|
||||||
|
return NextcloudClient.from_token(
|
||||||
|
base_url=base_url,
|
||||||
|
token=access_token.token,
|
||||||
|
username=username
|
||||||
|
)
|
||||||
|
|
||||||
|
# In tool implementations:
|
||||||
|
@mcp.tool()
|
||||||
|
async def nc_notes_create(title: str, content: str):
|
||||||
|
ctx = mcp.get_context()
|
||||||
|
|
||||||
|
if oauth_mode:
|
||||||
|
client = await get_client_from_context(ctx, nextcloud_host)
|
||||||
|
else:
|
||||||
|
# Legacy: use lifespan client
|
||||||
|
client = ctx.request_context.lifespan_context.client
|
||||||
|
|
||||||
|
return await client.notes.create_note(title, content)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Pattern**:
|
||||||
|
- Token verification caches userinfo claims
|
||||||
|
- Helper retrieves username from cached data (no additional API call)
|
||||||
|
- Client uses bearer token for Nextcloud API calls
|
||||||
|
|
||||||
|
### 5. User Context Extraction
|
||||||
|
|
||||||
|
**✅ VERIFIED: Userinfo Endpoint Response**
|
||||||
|
|
||||||
|
From Nextcloud OIDC userinfo endpoint response:
|
||||||
|
- **Username**: `sub` AND `preferred_username` (both contain Nextcloud username)
|
||||||
|
- **Scopes**: Determined by scopes requested during OAuth flow
|
||||||
|
- **Groups/Roles**: Available via `roles` or `groups` scope
|
||||||
|
- **Profile**: `name`, `email`, `picture`, etc. (if `profile` scope requested)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# During token verification:
|
||||||
|
userinfo = await fetch_userinfo(token)
|
||||||
|
# {
|
||||||
|
# "sub": "username",
|
||||||
|
# "preferred_username": "username",
|
||||||
|
# "email": "user@example.com",
|
||||||
|
# "roles": ["group1", "group2"], # if 'roles' scope
|
||||||
|
# "groups": ["group1", "group2"] # if 'groups' scope
|
||||||
|
# }
|
||||||
|
|
||||||
|
username = userinfo["sub"] # or userinfo["preferred_username"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Storage Strategy**:
|
||||||
|
- Cache userinfo in AccessToken metadata
|
||||||
|
- Use MCP SDK's built-in token caching
|
||||||
|
- TTL matches access token expiration (3600s default)
|
||||||
|
|
||||||
|
## Implementation Components
|
||||||
|
|
||||||
|
### New Modules
|
||||||
|
|
||||||
|
#### 1. `nextcloud_mcp_server/auth/__init__.py`
|
||||||
|
Exports: `NextcloudTokenVerifier`, `BearerAuth`, `register_client`
|
||||||
|
|
||||||
|
#### 2. `nextcloud_mcp_server/auth/token_verifier.py`
|
||||||
|
```python
|
||||||
|
class NextcloudTokenVerifier(TokenVerifier):
|
||||||
|
"""
|
||||||
|
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
||||||
|
|
||||||
|
Primary method: Userinfo endpoint validation (always works)
|
||||||
|
Optional: JWT validation if client configured for RFC 9068
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nextcloud_host: str,
|
||||||
|
userinfo_uri: str,
|
||||||
|
jwks_uri: str | None = None,
|
||||||
|
enable_jwt_validation: bool = False
|
||||||
|
):
|
||||||
|
self.nextcloud_host = nextcloud_host
|
||||||
|
self.userinfo_uri = userinfo_uri
|
||||||
|
self.jwks_uri = jwks_uri
|
||||||
|
self.enable_jwt_validation = enable_jwt_validation
|
||||||
|
|
||||||
|
# Cache for validated tokens: token -> (userinfo, expiry)
|
||||||
|
self._token_cache: dict[str, tuple[dict, float]] = {}
|
||||||
|
|
||||||
|
# JWKS cache (if JWT validation enabled)
|
||||||
|
self._jwks: dict | None = None
|
||||||
|
self._jwks_expires: float = 0
|
||||||
|
|
||||||
|
self._client = httpx.AsyncClient()
|
||||||
|
|
||||||
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Verify token using userinfo endpoint (primary) or JWT validation (optional).
|
||||||
|
|
||||||
|
Returns AccessToken with userinfo cached in metadata.
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if token in self._token_cache:
|
||||||
|
userinfo, expiry = self._token_cache[token]
|
||||||
|
if time.time() < expiry:
|
||||||
|
return self._create_access_token(token, userinfo)
|
||||||
|
|
||||||
|
# Try JWT validation first if enabled
|
||||||
|
if self.enable_jwt_validation and self.jwks_uri:
|
||||||
|
access_token = await self._verify_jwt(token)
|
||||||
|
if access_token:
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
# Fall back to (or use primary) userinfo validation
|
||||||
|
return await self._verify_via_userinfo(token)
|
||||||
|
|
||||||
|
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||||
|
"""Validate token by calling userinfo endpoint."""
|
||||||
|
try:
|
||||||
|
response = await self._client.get(
|
||||||
|
self.userinfo_uri,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
userinfo = response.json()
|
||||||
|
|
||||||
|
# Cache for 3600s (default token lifetime)
|
||||||
|
# TODO: Get actual expiry from token if JWT
|
||||||
|
expiry = time.time() + 3600
|
||||||
|
self._token_cache[token] = (userinfo, expiry)
|
||||||
|
|
||||||
|
return self._create_access_token(token, userinfo)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Userinfo validation failed: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _verify_jwt(self, token: str) -> AccessToken | None:
|
||||||
|
"""Validate JWT token locally using JWKS (optional optimization)."""
|
||||||
|
try:
|
||||||
|
# Fetch JWKS if not cached
|
||||||
|
if not self._jwks or time.time() > self._jwks_expires:
|
||||||
|
await self._refresh_jwks()
|
||||||
|
|
||||||
|
# Decode and validate JWT
|
||||||
|
claims = jwt.decode(
|
||||||
|
token,
|
||||||
|
self._jwks,
|
||||||
|
algorithms=["RS256", "HS256"],
|
||||||
|
issuer=self.nextcloud_host,
|
||||||
|
options={"verify_aud": False} # Nextcloud may not include aud
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract userinfo from JWT claims
|
||||||
|
userinfo = {
|
||||||
|
"sub": claims.get("sub"),
|
||||||
|
"preferred_username": claims.get("preferred_username"),
|
||||||
|
"email": claims.get("email"),
|
||||||
|
"roles": claims.get("roles", []),
|
||||||
|
"groups": claims.get("groups", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
expiry = claims.get("exp", time.time() + 3600)
|
||||||
|
self._token_cache[token] = (userinfo, expiry)
|
||||||
|
|
||||||
|
return self._create_access_token(token, userinfo)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"JWT validation failed, falling back to userinfo: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _create_access_token(self, token: str, userinfo: dict) -> AccessToken:
|
||||||
|
"""Create AccessToken with userinfo in metadata."""
|
||||||
|
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||||
|
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id="", # Not available from userinfo
|
||||||
|
scopes=["openid", "profile", "email"], # TODO: Track actual scopes
|
||||||
|
expires_at=int(time.time() + 3600), # TODO: Get from JWT exp claim
|
||||||
|
# Store username in scopes[0] as workaround for MCP SDK limitation
|
||||||
|
# Or use custom AccessToken subclass with username field
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _refresh_jwks(self):
|
||||||
|
"""Fetch JWKS from Nextcloud OIDC."""
|
||||||
|
response = await self._client.get(self.jwks_uri)
|
||||||
|
response.raise_for_status()
|
||||||
|
self._jwks = response.json()
|
||||||
|
self._jwks_expires = time.time() + 3600 # Cache for 1 hour
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Cleanup resources."""
|
||||||
|
await self._client.aclose()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `nextcloud_mcp_server/auth/client_registration.py`
|
||||||
|
```python
|
||||||
|
async def register_client(
|
||||||
|
nextcloud_url: str,
|
||||||
|
client_name: str = "Nextcloud MCP Server",
|
||||||
|
redirect_uris: list[str] = None
|
||||||
|
) -> dict:
|
||||||
|
"""Register MCP server as OAuth client with Nextcloud OIDC"""
|
||||||
|
# POST to /apps/oidc/register
|
||||||
|
# Return client_id, client_secret, expires_at
|
||||||
|
|
||||||
|
async def load_or_register_client(storage_path: str) -> dict:
|
||||||
|
"""Load existing client or register new one"""
|
||||||
|
# Check storage file
|
||||||
|
# Validate expiration
|
||||||
|
# Re-register if expired
|
||||||
|
# Persist credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. `nextcloud_mcp_server/auth/bearer_auth.py`
|
||||||
|
```python
|
||||||
|
class BearerAuth(httpx.Auth):
|
||||||
|
"""Bearer token authentication for httpx"""
|
||||||
|
|
||||||
|
def __init__(self, token: str):
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def auth_flow(self, request):
|
||||||
|
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||||
|
yield request
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
#### 1. `nextcloud_mcp_server/app.py`
|
||||||
|
```python
|
||||||
|
# Add OAuth configuration
|
||||||
|
from nextcloud_mcp_server.auth import NextcloudTokenVerifier, register_client
|
||||||
|
|
||||||
|
# In get_app():
|
||||||
|
if oauth_enabled:
|
||||||
|
# Load or register client
|
||||||
|
client_info = await load_or_register_client(storage_path)
|
||||||
|
|
||||||
|
# Create token verifier
|
||||||
|
token_verifier = NextcloudTokenVerifier(
|
||||||
|
jwks_uri=f"{nextcloud_host}/apps/oidc/jwks",
|
||||||
|
issuer=f"{nextcloud_host}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure FastMCP with OAuth
|
||||||
|
mcp = FastMCP(
|
||||||
|
"Nextcloud MCP",
|
||||||
|
token_verifier=token_verifier,
|
||||||
|
auth=AuthSettings(
|
||||||
|
issuer_url=nextcloud_host,
|
||||||
|
resource_server_url=mcp_server_url,
|
||||||
|
required_scopes=["openid", "profile"]
|
||||||
|
),
|
||||||
|
lifespan=app_lifespan_oauth # Don't create client in lifespan
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Legacy BasicAuth mode
|
||||||
|
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. `nextcloud_mcp_server/client/__init__.py`
|
||||||
|
```python
|
||||||
|
class NextcloudClient:
|
||||||
|
def __init__(self, base_url: str, username: str, auth: Auth | None = None):
|
||||||
|
# Accept either BasicAuth or BearerAuth
|
||||||
|
self._client = AsyncClient(base_url=base_url, auth=auth, ...)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls):
|
||||||
|
"""Legacy: Create from username/password env vars"""
|
||||||
|
return cls(base_url, username, auth=BasicAuth(username, password))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_token(cls, base_url: str, token: str, username: str):
|
||||||
|
"""OAuth: Create from bearer token"""
|
||||||
|
return cls(base_url, username, auth=BearerAuth(token))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `nextcloud_mcp_server/server/notes.py` (and other tool modules)
|
||||||
|
```python
|
||||||
|
from nextcloud_mcp_server.auth import get_client_from_context
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def nc_notes_create(title: str, content: str):
|
||||||
|
ctx: Context = mcp.get_context()
|
||||||
|
|
||||||
|
# OAuth mode: Get client from request context
|
||||||
|
if oauth_enabled:
|
||||||
|
client = get_client_from_context(ctx)
|
||||||
|
else:
|
||||||
|
# Legacy mode: Use lifespan client
|
||||||
|
client = ctx.request_context.lifespan_context.client
|
||||||
|
|
||||||
|
return await client.notes.create_note(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. `nextcloud_mcp_server/config.py`
|
||||||
|
```python
|
||||||
|
class NextcloudConfig:
|
||||||
|
# Common
|
||||||
|
host: str
|
||||||
|
|
||||||
|
# OAuth mode
|
||||||
|
oauth_enabled: bool = False
|
||||||
|
oidc_client_id: str | None = None
|
||||||
|
oidc_client_secret: str | None = None
|
||||||
|
client_storage_path: str = ".nextcloud_oauth_client.json"
|
||||||
|
mcp_server_url: str = "http://localhost:8000/mcp"
|
||||||
|
required_scopes: list[str] = ["openid", "profile", "email"]
|
||||||
|
|
||||||
|
# Legacy mode
|
||||||
|
username: str | None = None
|
||||||
|
password: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls):
|
||||||
|
oauth_enabled = not (
|
||||||
|
os.getenv("NEXTCLOUD_USERNAME") and
|
||||||
|
os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
)
|
||||||
|
return cls(oauth_enabled=oauth_enabled, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
#### Updated `env.sample`
|
||||||
|
```bash
|
||||||
|
# Nextcloud Instance
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
|
||||||
|
# ===== AUTHENTICATION MODE =====
|
||||||
|
# Choose ONE of the following:
|
||||||
|
|
||||||
|
# Option 1: OAuth2/OIDC (RECOMMENDED)
|
||||||
|
# - Requires Nextcloud OIDC app installed
|
||||||
|
# - Enable "Dynamic Client Registration" in OIDC app settings
|
||||||
|
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty
|
||||||
|
# - Optional: Pre-register client and provide credentials
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||||
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000/mcp
|
||||||
|
|
||||||
|
# Option 2: Basic Authentication (LEGACY - Will be deprecated)
|
||||||
|
# - Requires username and password
|
||||||
|
# - Less secure - credentials stored in environment
|
||||||
|
# - Use only for backward compatibility
|
||||||
|
NEXTCLOUD_USERNAME=
|
||||||
|
NEXTCLOUD_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### New Python Dependencies
|
||||||
|
```toml
|
||||||
|
# pyproject.toml additions:
|
||||||
|
dependencies = [
|
||||||
|
# ... existing ...
|
||||||
|
"PyJWT[crypto]>=2.8.0", # JWT validation
|
||||||
|
"cryptography>=41.0.0", # JWKS key handling (if not present)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nextcloud OIDC Setup
|
||||||
|
|
||||||
|
### Administrator Setup (One-time)
|
||||||
|
1. Install Nextcloud OIDC app from App Store
|
||||||
|
2. Navigate to Settings → OIDC
|
||||||
|
3. Enable "Dynamic Client Registration"
|
||||||
|
4. (Optional) Configure token expiration times via CLI:
|
||||||
|
```bash
|
||||||
|
php occ config:app:set oidc expire_time --value "3600"
|
||||||
|
php occ config:app:set oidc refresh_expire_time --value "86400"
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Server Deployment (Zero-config)
|
||||||
|
1. Set `NEXTCLOUD_HOST` environment variable
|
||||||
|
2. Set `NEXTCLOUD_MCP_SERVER_URL` (if not localhost:8000)
|
||||||
|
3. Start MCP server → Auto-registers on first run
|
||||||
|
4. Client credentials stored in `.nextcloud_oauth_client.json`
|
||||||
|
|
||||||
|
### Alternative: Pre-registered Client
|
||||||
|
```bash
|
||||||
|
# Create client via CLI
|
||||||
|
php occ oidc:create \
|
||||||
|
--name="Nextcloud MCP Server" \
|
||||||
|
--type=confidential \
|
||||||
|
--redirect-uri="http://localhost:8000/oauth/callback"
|
||||||
|
|
||||||
|
# Set credentials in environment
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<generated-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<generated-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- Token validation with mocked JWKS
|
||||||
|
- JWT claim extraction
|
||||||
|
- Client registration flow
|
||||||
|
- Bearer auth implementation
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Dynamic client registration against test Nextcloud
|
||||||
|
- OAuth flow end-to-end
|
||||||
|
- Token-based API calls
|
||||||
|
- Client expiration and re-registration
|
||||||
|
- Dual-mode authentication (OAuth + BasicAuth)
|
||||||
|
|
||||||
|
### Test Fixtures
|
||||||
|
```python
|
||||||
|
# tests/conftest.py additions:
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_oidc_server():
|
||||||
|
"""Mock Nextcloud OIDC endpoints"""
|
||||||
|
# Mock /apps/oidc/openid-configuration
|
||||||
|
# Mock /apps/oidc/jwks
|
||||||
|
# Mock /apps/oidc/register
|
||||||
|
# Mock /apps/oidc/token
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def oauth_nc_client(mock_oidc_server):
|
||||||
|
"""NextcloudClient with OAuth token"""
|
||||||
|
token = generate_test_jwt()
|
||||||
|
return NextcloudClient.from_token(base_url, token, "testuser")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
### Phase 1: Implementation (Week 1-2)
|
||||||
|
- [ ] Implement token verifier with JWT validation
|
||||||
|
- [ ] Implement dynamic client registration
|
||||||
|
- [ ] Add BearerAuth for httpx
|
||||||
|
- [ ] Modify NextcloudClient for dual-mode auth
|
||||||
|
- [ ] Update app.py with OAuth configuration
|
||||||
|
- [ ] Add configuration management
|
||||||
|
|
||||||
|
### Phase 2: Testing (Week 2-3)
|
||||||
|
- [ ] Unit tests for all auth components
|
||||||
|
- [ ] Integration tests with test Nextcloud instance
|
||||||
|
- [ ] End-to-end OAuth flow testing
|
||||||
|
- [ ] Backward compatibility testing
|
||||||
|
|
||||||
|
### Phase 3: Documentation (Week 3)
|
||||||
|
- [ ] Update README.md with OAuth setup
|
||||||
|
- [ ] Update CLAUDE.md with architecture changes
|
||||||
|
- [ ] Add OAuth troubleshooting guide
|
||||||
|
- [ ] Document OIDC app configuration
|
||||||
|
- [ ] Add migration guide for existing deployments
|
||||||
|
|
||||||
|
### Phase 4: Deployment (Week 4)
|
||||||
|
- [ ] Release with both modes supported
|
||||||
|
- [ ] Monitor for issues
|
||||||
|
- [ ] Deprecation notice for BasicAuth
|
||||||
|
- [ ] Plan BasicAuth removal timeline (6+ months)
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Token Security
|
||||||
|
- Store client secrets securely (file permissions, secret managers)
|
||||||
|
- Validate JWT signatures against trusted JWKS
|
||||||
|
- Verify token claims (issuer, audience, expiration)
|
||||||
|
- Implement token refresh logic
|
||||||
|
- Rate limit token validation failures
|
||||||
|
|
||||||
|
### Client Registration Security
|
||||||
|
- Nextcloud OIDC provides BruteForce protection
|
||||||
|
- Dynamic clients limited to 100 per instance
|
||||||
|
- Clients expire after 1 hour (configurable)
|
||||||
|
- Admin must explicitly enable dynamic registration
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
- Bearer tokens used for Nextcloud API calls
|
||||||
|
- Token scopes control access levels
|
||||||
|
- User context preserved in all API operations
|
||||||
|
- No credential storage in MCP server
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### JWT Validation Performance
|
||||||
|
- JWKS caching with TTL (e.g., 1 hour)
|
||||||
|
- Key rotation handling via JWKS refresh
|
||||||
|
- Local validation (no network call per request)
|
||||||
|
- Async validation to avoid blocking
|
||||||
|
|
||||||
|
### Client Creation
|
||||||
|
- OAuth mode: Per-request client creation (lightweight)
|
||||||
|
- BasicAuth mode: Single client in lifespan (current)
|
||||||
|
- Connection pooling maintained in both modes
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Scope-based Authorization
|
||||||
|
- Define custom Nextcloud scopes for MCP operations
|
||||||
|
- Map MCP tools to required scopes
|
||||||
|
- Fine-grained permission control
|
||||||
|
|
||||||
|
### Multi-tenant Support
|
||||||
|
- Support multiple Nextcloud instances
|
||||||
|
- Per-user client registration
|
||||||
|
- Tenant isolation
|
||||||
|
|
||||||
|
### Token Introspection Fallback
|
||||||
|
- Implement RFC 7662 introspection
|
||||||
|
- Use if JWT validation fails
|
||||||
|
- Support for opaque tokens
|
||||||
|
|
||||||
|
### Admin Controls
|
||||||
|
- MCP server admin UI for OAuth config
|
||||||
|
- Client credential rotation
|
||||||
|
- Usage monitoring and logging
|
||||||
|
|
||||||
|
## Decisions Made (Post-Research)
|
||||||
|
|
||||||
|
1. **✅ Token Validation Method**: Userinfo endpoint (primary), JWT optional
|
||||||
|
- Nextcloud OIDC does NOT provide introspection endpoint
|
||||||
|
- Userinfo endpoint validates token AND returns user claims
|
||||||
|
- JWT validation available as performance optimization if client configured
|
||||||
|
|
||||||
|
2. **✅ Client expiration handling**: Auto re-register with logging
|
||||||
|
- Clients expire after 3600s by default
|
||||||
|
- Check expiry on startup and periodically
|
||||||
|
- Auto-register with backoff on failure
|
||||||
|
|
||||||
|
3. **✅ Scope requirements**: `["openid", "profile", "email"]`
|
||||||
|
- Sufficient for basic user identification
|
||||||
|
- Optional: Add `"roles"` or `"groups"` for group-based authorization
|
||||||
|
|
||||||
|
4. **✅ Token caching**: In-memory with 3600s TTL
|
||||||
|
- Cache userinfo response (includes all needed claims)
|
||||||
|
- Use token string as cache key
|
||||||
|
- TTL matches default access token lifetime
|
||||||
|
|
||||||
|
5. **✅ Client storage**: JSON file with 0600 permissions
|
||||||
|
- Default: `.nextcloud_oauth_client.json`
|
||||||
|
- Configurable via env var
|
||||||
|
- Contains: client_id, client_secret, issued_at
|
||||||
|
|
||||||
|
6. **✅ Username extraction**: From `sub` or `preferred_username` claim
|
||||||
|
- Both contain Nextcloud username (verified)
|
||||||
|
- Retrieved during token validation
|
||||||
|
- Cached with token
|
||||||
|
|
||||||
|
7. **✅ BasicAuth deprecation**: 12 months after OAuth stable release
|
||||||
|
- Phase 1: OAuth + BasicAuth (6 months)
|
||||||
|
- Phase 2: OAuth only, deprecation warnings (6 months)
|
||||||
|
- Phase 3: Remove BasicAuth
|
||||||
|
|
||||||
|
## Key Changes from Original Plan
|
||||||
|
|
||||||
|
### 1. Token Validation
|
||||||
|
**Original**: JWT validation with JWKS (primary), introspection (fallback)
|
||||||
|
**Updated**: Userinfo endpoint (primary), JWT validation (optional optimization)
|
||||||
|
- Reason: Nextcloud OIDC has no introspection endpoint
|
||||||
|
|
||||||
|
### 2. User Context Extraction
|
||||||
|
**Original**: Extract username from JWT claims
|
||||||
|
**Updated**: Fetch from userinfo endpoint during validation
|
||||||
|
- Reason: Opaque tokens by default, userinfo always works
|
||||||
|
|
||||||
|
### 3. Token Caching Strategy
|
||||||
|
**Original**: MCP SDK handles all caching
|
||||||
|
**Updated**: Custom cache in TokenVerifier for userinfo responses
|
||||||
|
- Reason: Need to cache username separately from AccessToken
|
||||||
|
|
||||||
|
### 4. JWT Support
|
||||||
|
**Original**: Required for all deployments
|
||||||
|
**Updated**: Optional performance optimization
|
||||||
|
- Reason: Requires per-client configuration in Nextcloud OIDC
|
||||||
|
- Default: Opaque tokens validated via userinfo
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [MCP Python SDK OAuth Documentation](https://github.com/modelcontextprotocol/python-sdk)
|
||||||
|
- [MCP RFC 9728 Protected Resource Metadata](https://www.rfc-editor.org/rfc/rfc9728.html)
|
||||||
|
- [Nextcloud OIDC App Repository](https://github.com/H2CK/oidc)
|
||||||
|
- [OpenID Connect Dynamic Client Registration](https://openid.net/specs/openid-connect-registration-1_0.html)
|
||||||
|
- [RFC 9068 JWT Access Tokens](https://www.rfc-editor.org/rfc/rfc9068.html)
|
||||||
|
- [MCP Simple Auth Example](~/Software/python-sdk/examples/servers/simple-auth/)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
✅ MCP server can authenticate via Nextcloud OIDC with zero manual client setup
|
||||||
|
✅ Dynamic client registration works automatically on first run
|
||||||
|
✅ JWT tokens validated locally without per-request network calls
|
||||||
|
✅ Backward compatibility maintained with BasicAuth mode
|
||||||
|
✅ All existing tests pass in both auth modes
|
||||||
|
✅ Documentation complete for OAuth setup and migration
|
||||||
|
✅ Security review passed (token handling, credential storage)
|
||||||
|
✅ Performance benchmarks meet targets (< 10ms token validation overhead)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# OAuth Testing Setup
|
||||||
|
|
||||||
|
This document describes the automated OAuth testing infrastructure for the Nextcloud MCP server.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
We've created a comprehensive testing setup that includes:
|
||||||
|
|
||||||
|
1. **OIDC App Configuration** - Nextcloud OIDC app automatically installed and configured with dynamic client registration
|
||||||
|
2. **Dual MCP Services** - Two MCP server instances running in Docker:
|
||||||
|
- `mcp` (port 8000) - BasicAuth mode (username/password)
|
||||||
|
- `mcp-oauth` (port 8001) - OAuth mode (dynamic client registration)
|
||||||
|
3. **Test Fixtures** - Pytest fixtures for OAuth client testing
|
||||||
|
4. **Integration Tests** - OAuth-specific integration tests
|
||||||
|
|
||||||
|
## Docker Compose Setup
|
||||||
|
|
||||||
|
The `docker-compose.yml` includes:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app: # Nextcloud with OIDC app enabled
|
||||||
|
mcp: # BasicAuth MCP server (port 8000)
|
||||||
|
mcp-oauth: # OAuth MCP server (port 8001)
|
||||||
|
```
|
||||||
|
|
||||||
|
## OIDC Configuration
|
||||||
|
|
||||||
|
The OIDC app is configured automatically via `app-hooks/post-installation/install-oidc-app.sh`:
|
||||||
|
|
||||||
|
- **Dynamic Client Registration**: Enabled
|
||||||
|
- **Config Key**: `dynamic_client_registration` (not `allow_dynamic_client_registration`)
|
||||||
|
- **Registration Endpoint**: `http://localhost:8080/apps/oidc/register`
|
||||||
|
|
||||||
|
### Important: Config Key Fix
|
||||||
|
|
||||||
|
The correct OIDC config key is `dynamic_client_registration`. The initial implementation used `allow_dynamic_client_registration` which was incorrect and caused the registration endpoint to not appear in the OIDC discovery document.
|
||||||
|
|
||||||
|
## Test Fixtures
|
||||||
|
|
||||||
|
Located in `tests/conftest.py`:
|
||||||
|
|
||||||
|
### `oauth_token`
|
||||||
|
Session-scoped fixture that obtains an OAuth access token.
|
||||||
|
|
||||||
|
**Current Limitation**: Nextcloud OIDC only supports `authorization_code` and `refresh_token` grant types, not the `password` grant type. This means we cannot automatically obtain tokens for testing without implementing a full browser-based OAuth flow.
|
||||||
|
|
||||||
|
### `nc_oauth_client`
|
||||||
|
Session-scoped NextcloudClient configured with OAuth bearer token authentication.
|
||||||
|
|
||||||
|
**Status**: Implemented but currently skipped due to token acquisition limitation.
|
||||||
|
|
||||||
|
### `nc_mcp_oauth_client`
|
||||||
|
Session-scoped MCP client that connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
|
||||||
|
**Status**: Implemented but marked as skip - requires full OAuth authorization flow implementation in MCP SDK.
|
||||||
|
|
||||||
|
## Current Test Status
|
||||||
|
|
||||||
|
### ✅ Working
|
||||||
|
- OIDC app installation and configuration
|
||||||
|
- Dynamic client registration
|
||||||
|
- OAuth infrastructure (BearerAuth, TokenVerifier, client registration)
|
||||||
|
- Docker compose dual-mode setup
|
||||||
|
|
||||||
|
### ⚠️ Limitations
|
||||||
|
- **No automated token acquisition**: Nextcloud OIDC doesn't support the Resource Owner Password Credentials grant, which means we cannot programmatically get tokens for testing without browser interaction
|
||||||
|
- **Manual testing only**: OAuth functionality must be tested manually using a browser-based OAuth flow
|
||||||
|
- **MCP OAuth server untested**: The OAuth MCP server requires the full OAuth authorization flow to be implemented in the MCP Python SDK
|
||||||
|
|
||||||
|
## Manual Testing OAuth
|
||||||
|
|
||||||
|
To manually test OAuth functionality:
|
||||||
|
|
||||||
|
1. Start the docker-compose environment:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. The OAuth MCP server runs on port 8001 and will:
|
||||||
|
- Automatically register a client via dynamic registration
|
||||||
|
- Store client credentials in `/app/.oauth/` volume
|
||||||
|
- Display OAuth configuration on startup
|
||||||
|
|
||||||
|
3. To test OAuth with a real client:
|
||||||
|
- Use the authorization endpoint: `http://localhost:8080/apps/oidc/authorize`
|
||||||
|
- Implement the authorization code flow
|
||||||
|
- Exchange code for token at: `http://localhost:8080/apps/oidc/token`
|
||||||
|
|
||||||
|
## Future Work
|
||||||
|
|
||||||
|
To enable automated OAuth testing, one of these approaches is needed:
|
||||||
|
|
||||||
|
1. **Mock OIDC Server**: Create a test OIDC server that supports password grant
|
||||||
|
2. **Browser Automation**: Use Selenium/Playwright to automate the OAuth flow
|
||||||
|
3. **Test-Only Password Grant**: Patch Nextcloud OIDC to support password grant in test mode
|
||||||
|
4. **Pre-generated Tokens**: Manually generate long-lived tokens and use them in tests
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests (OAuth tests will be skipped)
|
||||||
|
uv run pytest tests/integration/test_oauth.py -v
|
||||||
|
|
||||||
|
# Run only the invalid token test (this one works)
|
||||||
|
uv run pytest tests/integration/test_oauth.py::TestOAuthTokenValidation::test_invalid_token_fails -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
- `tests/conftest.py` - Added OAuth fixtures and token acquisition logic
|
||||||
|
- `tests/integration/test_oauth.py` - OAuth-specific integration tests
|
||||||
|
- `docker-compose.yml` - Added `mcp-oauth` service
|
||||||
|
- `app-hooks/post-installation/install-oidc-app.sh` - OIDC installation and configuration
|
||||||
|
- `nextcloud_mcp_server/client/__init__.py` - Added `from_token()` classmethod
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `from_token()` method was added to NextcloudClient to support OAuth authentication
|
||||||
|
- All OAuth infrastructure is in place and functional
|
||||||
|
- The main limitation is automated token acquisition for testing, not the OAuth implementation itself
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Installing and configuring OIDC app for testing..."
|
||||||
|
|
||||||
|
# Enable the OIDC app
|
||||||
|
php /var/www/html/occ app:enable oidc
|
||||||
|
|
||||||
|
# Configure OIDC for testing with dynamic client registration enabled
|
||||||
|
# Note: The correct config key is 'dynamic_client_registration', not 'allow_dynamic_client_registration'
|
||||||
|
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
|
||||||
|
|
||||||
|
echo "OIDC app installed and configured successfully"
|
||||||
@@ -47,6 +47,8 @@ services:
|
|||||||
mcp:
|
mcp:
|
||||||
build: .
|
build: .
|
||||||
command: ["--transport", "streamable-http"]
|
command: ["--transport", "streamable-http"]
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8000:8000
|
- 127.0.0.1:8000:8000
|
||||||
environment:
|
environment:
|
||||||
@@ -56,6 +58,22 @@ services:
|
|||||||
#volumes:
|
#volumes:
|
||||||
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
|
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
|
||||||
|
|
||||||
|
mcp-oauth:
|
||||||
|
build: .
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001"]
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8001:8001
|
||||||
|
environment:
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
# No USERNAME/PASSWORD - will use OAuth
|
||||||
|
volumes:
|
||||||
|
- oauth-client-storage:/app/.oauth
|
||||||
|
#volumes:
|
||||||
|
#- ./nextcloud_mcp_server:/app/nextcloud_mcp_server:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
db:
|
db:
|
||||||
|
oauth-client-storage:
|
||||||
|
|||||||
+20
@@ -1,3 +1,23 @@
|
|||||||
|
# Nextcloud Instance
|
||||||
NEXTCLOUD_HOST=
|
NEXTCLOUD_HOST=
|
||||||
|
|
||||||
|
# ===== AUTHENTICATION MODE =====
|
||||||
|
# Choose ONE of the following:
|
||||||
|
|
||||||
|
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
|
||||||
|
# - Requires Nextcloud OIDC app installed and configured
|
||||||
|
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
|
||||||
|
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
|
||||||
|
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
|
||||||
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
||||||
|
# - Requires username and password
|
||||||
|
# - Credentials stored in environment variables
|
||||||
|
# - Use only for backward compatibility or if OAuth unavailable
|
||||||
|
# - If these are set, OAuth mode is disabled
|
||||||
NEXTCLOUD_USERNAME=
|
NEXTCLOUD_USERNAME=
|
||||||
NEXTCLOUD_PASSWORD=
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|||||||
+391
-16
@@ -1,17 +1,25 @@
|
|||||||
import click
|
import click
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager, AsyncExitStack
|
from contextlib import asynccontextmanager, AsyncExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from pydantic import AnyHttpUrl
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.routing import Mount
|
from starlette.routing import Mount
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.server.auth.settings import AuthSettings
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import setup_logging
|
from nextcloud_mcp_server.config import setup_logging
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||||
|
from nextcloud_mcp_server.auth import (
|
||||||
|
NextcloudTokenVerifier,
|
||||||
|
load_or_register_client,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.server import (
|
from nextcloud_mcp_server.server import (
|
||||||
configure_calendar_tools,
|
configure_calendar_tools,
|
||||||
configure_contacts_tools,
|
configure_contacts_tools,
|
||||||
@@ -27,36 +35,266 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppContext:
|
class AppContext:
|
||||||
|
"""Application context for BasicAuth mode."""
|
||||||
|
|
||||||
client: NextcloudClient
|
client: NextcloudClient
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OAuthAppContext:
|
||||||
|
"""Application context for OAuth mode."""
|
||||||
|
|
||||||
|
nextcloud_host: str
|
||||||
|
token_verifier: NextcloudTokenVerifier
|
||||||
|
|
||||||
|
|
||||||
|
def is_oauth_mode() -> bool:
|
||||||
|
"""
|
||||||
|
Determine if OAuth mode should be used.
|
||||||
|
|
||||||
|
OAuth mode is enabled when:
|
||||||
|
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
||||||
|
- Or explicitly enabled via configuration
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if OAuth mode, False if BasicAuth mode
|
||||||
|
"""
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
|
||||||
|
# If both username and password are set, use BasicAuth
|
||||||
|
if username and password:
|
||||||
|
logger.info(
|
||||||
|
"BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
|
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||||
"""Manage application lifecycle with type-safe context"""
|
"""
|
||||||
# Initialize on startup
|
Manage application lifecycle for BasicAuth mode.
|
||||||
logging.info("Creating Nextcloud client")
|
|
||||||
|
Creates a single Nextcloud client with basic authentication
|
||||||
|
that is shared across all requests.
|
||||||
|
"""
|
||||||
|
logger.info("Starting MCP server in BasicAuth mode")
|
||||||
|
logger.info("Creating Nextcloud client with BasicAuth")
|
||||||
|
|
||||||
client = NextcloudClient.from_env()
|
client = NextcloudClient.from_env()
|
||||||
logging.info("Client initialization wait complete.")
|
logger.info("Client initialization complete")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield AppContext(client=client)
|
yield AppContext(client=client)
|
||||||
finally:
|
finally:
|
||||||
# Cleanup on shutdown
|
logger.info("Shutting down BasicAuth mode")
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||||
|
"""
|
||||||
|
Manage application lifecycle for OAuth mode.
|
||||||
|
|
||||||
|
Initializes OAuth client registration and token verifier.
|
||||||
|
Does NOT create a Nextcloud client - clients are created per-request.
|
||||||
|
"""
|
||||||
|
logger.info("Starting MCP server in OAuth mode")
|
||||||
|
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError("NEXTCLOUD_HOST environment variable is required")
|
||||||
|
|
||||||
|
nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
|
|
||||||
|
# Get OAuth discovery endpoint
|
||||||
|
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch OIDC discovery
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(discovery_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
discovery = response.json()
|
||||||
|
|
||||||
|
logger.info(f"OIDC discovery successful: {discovery_url}")
|
||||||
|
|
||||||
|
# Extract endpoints
|
||||||
|
userinfo_uri = discovery["userinfo_endpoint"]
|
||||||
|
registration_endpoint = discovery.get("registration_endpoint")
|
||||||
|
|
||||||
|
logger.info(f"Userinfo endpoint: {userinfo_uri}")
|
||||||
|
|
||||||
|
# Handle client registration
|
||||||
|
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
|
||||||
|
storage_path = os.getenv(
|
||||||
|
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_id and client_secret:
|
||||||
|
logger.info("Using pre-configured OAuth client credentials")
|
||||||
|
elif registration_endpoint:
|
||||||
|
logger.info("Dynamic client registration available")
|
||||||
|
mcp_server_url = os.getenv(
|
||||||
|
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||||
|
)
|
||||||
|
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||||
|
|
||||||
|
# Load or register client
|
||||||
|
client_info = await load_or_register_client(
|
||||||
|
nextcloud_url=nextcloud_host,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
storage_path=storage_path,
|
||||||
|
client_name="Nextcloud MCP Server",
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"OAuth mode requires either:\n"
|
||||||
|
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
|
||||||
|
"2. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create token verifier
|
||||||
|
token_verifier = NextcloudTokenVerifier(
|
||||||
|
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("OAuth initialization complete")
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield OAuthAppContext(
|
||||||
|
nextcloud_host=nextcloud_host, token_verifier=token_verifier
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
logger.info("Shutting down OAuth mode")
|
||||||
|
await token_verifier.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize OAuth mode: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_oauth_config():
|
||||||
|
"""
|
||||||
|
Setup OAuth configuration by performing OIDC discovery and client registration.
|
||||||
|
|
||||||
|
This is done synchronously before FastMCP initialization because FastMCP
|
||||||
|
requires token_verifier at construction time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (nextcloud_host, token_verifier, auth_settings)
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise ValueError(
|
||||||
|
"NEXTCLOUD_HOST environment variable is required for OAuth mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
|
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||||
|
|
||||||
|
# Fetch OIDC discovery
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(discovery_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
discovery = response.json()
|
||||||
|
|
||||||
|
logger.info("OIDC discovery successful")
|
||||||
|
|
||||||
|
# Extract endpoints
|
||||||
|
issuer = discovery["issuer"]
|
||||||
|
userinfo_uri = discovery["userinfo_endpoint"]
|
||||||
|
registration_endpoint = discovery.get("registration_endpoint")
|
||||||
|
|
||||||
|
# Handle client registration
|
||||||
|
client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
|
||||||
|
|
||||||
|
if client_id and client_secret:
|
||||||
|
logger.info("Using pre-configured OAuth client credentials")
|
||||||
|
elif registration_endpoint:
|
||||||
|
logger.info("Dynamic client registration available")
|
||||||
|
storage_path = os.getenv(
|
||||||
|
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||||
|
)
|
||||||
|
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||||
|
redirect_uris = [f"{mcp_server_url}/oauth/callback"]
|
||||||
|
|
||||||
|
# Load or register client
|
||||||
|
client_info = await load_or_register_client(
|
||||||
|
nextcloud_url=nextcloud_host,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
storage_path=storage_path,
|
||||||
|
client_name="Nextcloud MCP Server",
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"OAuth mode requires either:\n"
|
||||||
|
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n"
|
||||||
|
"2. Dynamic client registration enabled on Nextcloud OIDC app"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create token verifier
|
||||||
|
token_verifier = NextcloudTokenVerifier(
|
||||||
|
nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create auth settings
|
||||||
|
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||||
|
auth_settings = AuthSettings(
|
||||||
|
issuer_url=AnyHttpUrl(issuer),
|
||||||
|
resource_server_url=AnyHttpUrl(mcp_server_url),
|
||||||
|
required_scopes=["openid", "profile"],
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("OAuth configuration complete")
|
||||||
|
|
||||||
|
return nextcloud_host, token_verifier, auth_settings
|
||||||
|
|
||||||
|
|
||||||
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||||
setup_logging()
|
setup_logging()
|
||||||
|
|
||||||
# Create an MCP server
|
# Determine authentication mode
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan)
|
oauth_enabled = is_oauth_mode()
|
||||||
|
|
||||||
|
# WARNING: This is a synchronous function but OAuth setup requires async
|
||||||
|
# For now, OAuth configuration will be handled differently
|
||||||
|
# We'll need to restructure this or use a factory pattern
|
||||||
|
|
||||||
|
if oauth_enabled:
|
||||||
|
logger.info("Configuring MCP server for OAuth mode")
|
||||||
|
logger.warning(
|
||||||
|
"OAuth mode requires async initialization - use factory pattern or separate setup"
|
||||||
|
)
|
||||||
|
# For now, fall back to a simplified OAuth setup
|
||||||
|
# TODO: This needs to be restructured to support async initialization
|
||||||
|
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_oauth)
|
||||||
|
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():
|
||||||
"""Get the Nextcloud Host capabilities"""
|
"""Get the Nextcloud Host capabilities"""
|
||||||
ctx: Context = (
|
ctx: Context = mcp.get_context()
|
||||||
mcp.get_context()
|
client = get_nextcloud_client(ctx)
|
||||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
|
||||||
return await client.capabilities()
|
return await client.capabilities()
|
||||||
|
|
||||||
# Define available apps and their configuration functions
|
# Define available apps and their configuration functions
|
||||||
@@ -101,16 +339,23 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option("--host", "-h", default="127.0.0.1", show_default=True)
|
@click.option(
|
||||||
@click.option("--port", "-p", type=int, default=8000, show_default=True)
|
"--host", "-h", default="127.0.0.1", show_default=True, help="Server host"
|
||||||
@click.option("--workers", "-w", type=int, default=None)
|
)
|
||||||
@click.option("--reload", "-r", is_flag=True)
|
@click.option(
|
||||||
|
"--port", "-p", type=int, default=8000, show_default=True, help="Server port"
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--workers", "-w", type=int, default=None, help="Number of worker processes"
|
||||||
|
)
|
||||||
|
@click.option("--reload", "-r", is_flag=True, help="Enable auto-reload")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--log-level",
|
"--log-level",
|
||||||
"-l",
|
"-l",
|
||||||
default="info",
|
default="info",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
|
type=click.Choice(["critical", "error", "warning", "info", "debug", "trace"]),
|
||||||
|
help="Logging level",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--transport",
|
"--transport",
|
||||||
@@ -118,6 +363,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
default="sse",
|
default="sse",
|
||||||
show_default=True,
|
show_default=True,
|
||||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||||
|
help="MCP transport protocol",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--enable-app",
|
"--enable-app",
|
||||||
@@ -126,6 +372,35 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
|
type=click.Choice(["notes", "tables", "webdav", "calendar", "contacts", "deck"]),
|
||||||
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
help="Enable specific Nextcloud app APIs. Can be specified multiple times. If not specified, all apps are enabled.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth/--no-oauth",
|
||||||
|
default=None,
|
||||||
|
help="Force OAuth mode (if enabled) or BasicAuth mode (if disabled). By default, auto-detected based on environment variables.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth-client-id",
|
||||||
|
envvar="NEXTCLOUD_OIDC_CLIENT_ID",
|
||||||
|
help="OAuth client ID (can also use NEXTCLOUD_OIDC_CLIENT_ID env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth-client-secret",
|
||||||
|
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
|
||||||
|
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--oauth-storage-path",
|
||||||
|
envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE",
|
||||||
|
default=".nextcloud_oauth_client.json",
|
||||||
|
show_default=True,
|
||||||
|
help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--mcp-server-url",
|
||||||
|
envvar="NEXTCLOUD_MCP_SERVER_URL",
|
||||||
|
default="http://localhost:8000",
|
||||||
|
show_default=True,
|
||||||
|
help="MCP server URL for OAuth callbacks (can also use NEXTCLOUD_MCP_SERVER_URL env var)",
|
||||||
|
)
|
||||||
def run(
|
def run(
|
||||||
host: str,
|
host: str,
|
||||||
port: int,
|
port: int,
|
||||||
@@ -134,7 +409,107 @@ def run(
|
|||||||
log_level: str,
|
log_level: str,
|
||||||
transport: str,
|
transport: str,
|
||||||
enable_app: tuple[str, ...],
|
enable_app: tuple[str, ...],
|
||||||
|
oauth: bool | None,
|
||||||
|
oauth_client_id: str | None,
|
||||||
|
oauth_client_secret: str | None,
|
||||||
|
oauth_storage_path: str,
|
||||||
|
mcp_server_url: str,
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Run the Nextcloud MCP server.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Authentication Modes:
|
||||||
|
- BasicAuth: Set NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD
|
||||||
|
- OAuth: Leave USERNAME/PASSWORD unset (requires OIDC app enabled)
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
# BasicAuth mode (legacy)
|
||||||
|
$ nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# OAuth mode with auto-registration
|
||||||
|
$ nextcloud-mcp-server --oauth
|
||||||
|
|
||||||
|
# OAuth mode with pre-configured client
|
||||||
|
$ nextcloud-mcp-server --oauth --oauth-client-id=xxx --oauth-client-secret=yyy
|
||||||
|
"""
|
||||||
|
# Set OAuth env vars from CLI options if provided
|
||||||
|
if oauth_client_id:
|
||||||
|
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
|
||||||
|
if oauth_client_secret:
|
||||||
|
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
|
||||||
|
if oauth_storage_path:
|
||||||
|
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
|
||||||
|
if mcp_server_url:
|
||||||
|
os.environ["NEXTCLOUD_MCP_SERVER_URL"] = mcp_server_url
|
||||||
|
|
||||||
|
# Force OAuth mode if explicitly requested
|
||||||
|
if oauth is True:
|
||||||
|
# Clear username/password to force OAuth mode
|
||||||
|
if "NEXTCLOUD_USERNAME" in os.environ:
|
||||||
|
click.echo(
|
||||||
|
"Warning: --oauth flag set, ignoring NEXTCLOUD_USERNAME", err=True
|
||||||
|
)
|
||||||
|
del os.environ["NEXTCLOUD_USERNAME"]
|
||||||
|
if "NEXTCLOUD_PASSWORD" in os.environ:
|
||||||
|
click.echo(
|
||||||
|
"Warning: --oauth flag set, ignoring NEXTCLOUD_PASSWORD", err=True
|
||||||
|
)
|
||||||
|
del os.environ["NEXTCLOUD_PASSWORD"]
|
||||||
|
|
||||||
|
# Validate OAuth configuration
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
raise click.ClickException(
|
||||||
|
"OAuth mode requires NEXTCLOUD_HOST environment variable to be set"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if we have client credentials OR if dynamic registration is possible
|
||||||
|
has_client_creds = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") and os.getenv(
|
||||||
|
"NEXTCLOUD_OIDC_CLIENT_SECRET"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_client_creds:
|
||||||
|
# No client credentials - will attempt dynamic registration
|
||||||
|
# Show helpful message before server starts
|
||||||
|
click.echo("", err=True)
|
||||||
|
click.echo("OAuth Configuration:", err=True)
|
||||||
|
click.echo(" Mode: Dynamic Client Registration", err=True)
|
||||||
|
click.echo(" Host: " + nextcloud_host, err=True)
|
||||||
|
click.echo(
|
||||||
|
" Storage: "
|
||||||
|
+ os.getenv(
|
||||||
|
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
|
||||||
|
),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
click.echo("", err=True)
|
||||||
|
click.echo(
|
||||||
|
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
|
||||||
|
)
|
||||||
|
click.echo(" in your Nextcloud OIDC app settings.", err=True)
|
||||||
|
click.echo("", err=True)
|
||||||
|
else:
|
||||||
|
click.echo("", err=True)
|
||||||
|
click.echo("OAuth Configuration:", err=True)
|
||||||
|
click.echo(" Mode: Pre-configured Client", err=True)
|
||||||
|
click.echo(" Host: " + nextcloud_host, err=True)
|
||||||
|
click.echo(
|
||||||
|
" Client ID: "
|
||||||
|
+ os.getenv("NEXTCLOUD_OIDC_CLIENT_ID", "")[:16]
|
||||||
|
+ "...",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
click.echo("", err=True)
|
||||||
|
|
||||||
|
elif oauth is False:
|
||||||
|
# Force BasicAuth mode - verify credentials exist
|
||||||
|
if not os.getenv("NEXTCLOUD_USERNAME") or not os.getenv("NEXTCLOUD_PASSWORD"):
|
||||||
|
raise click.ClickException(
|
||||||
|
"--no-oauth flag set but NEXTCLOUD_USERNAME or NEXTCLOUD_PASSWORD not set"
|
||||||
|
)
|
||||||
|
|
||||||
enabled_apps = list(enable_app) if enable_app else None
|
enabled_apps = list(enable_app) if enable_app else None
|
||||||
|
|
||||||
if reload or workers:
|
if reload or workers:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""OAuth authentication components for Nextcloud MCP server."""
|
||||||
|
|
||||||
|
from .bearer_auth import BearerAuth
|
||||||
|
from .client_registration import load_or_register_client, register_client
|
||||||
|
from .context_helper import get_client_from_context
|
||||||
|
from .token_verifier import NextcloudTokenVerifier
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BearerAuth",
|
||||||
|
"NextcloudTokenVerifier",
|
||||||
|
"register_client",
|
||||||
|
"load_or_register_client",
|
||||||
|
"get_client_from_context",
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Bearer token authentication for httpx."""
|
||||||
|
|
||||||
|
from httpx import Auth, Request
|
||||||
|
|
||||||
|
|
||||||
|
class BearerAuth(Auth):
|
||||||
|
"""
|
||||||
|
Bearer token authentication flow for httpx.
|
||||||
|
|
||||||
|
This auth class adds the Authorization: Bearer <token> header
|
||||||
|
to all outgoing requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, token: str):
|
||||||
|
"""
|
||||||
|
Initialize bearer authentication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The bearer token to use for authentication
|
||||||
|
"""
|
||||||
|
self.token = token
|
||||||
|
|
||||||
|
def auth_flow(self, request: Request):
|
||||||
|
"""
|
||||||
|
Add Authorization header to the request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The outgoing HTTP request
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The modified request with Authorization header
|
||||||
|
"""
|
||||||
|
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||||
|
yield request
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"""Dynamic client registration for Nextcloud OIDC."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ClientInfo:
|
||||||
|
"""Client registration information."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
client_id_issued_at: int,
|
||||||
|
client_secret_expires_at: int,
|
||||||
|
redirect_uris: list[str],
|
||||||
|
):
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self.client_id_issued_at = client_id_issued_at
|
||||||
|
self.client_secret_expires_at = client_secret_expires_at
|
||||||
|
self.redirect_uris = redirect_uris
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if the client has expired."""
|
||||||
|
return time.time() >= self.client_secret_expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expires_soon(self) -> bool:
|
||||||
|
"""Check if client expires within 5 minutes."""
|
||||||
|
return time.time() >= (self.client_secret_expires_at - 300)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary for storage."""
|
||||||
|
return {
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"client_id_issued_at": self.client_id_issued_at,
|
||||||
|
"client_secret_expires_at": self.client_secret_expires_at,
|
||||||
|
"redirect_uris": self.redirect_uris,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "ClientInfo":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
return cls(
|
||||||
|
client_id=data["client_id"],
|
||||||
|
client_secret=data["client_secret"],
|
||||||
|
client_id_issued_at=data["client_id_issued_at"],
|
||||||
|
client_secret_expires_at=data["client_secret_expires_at"],
|
||||||
|
redirect_uris=data["redirect_uris"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def register_client(
|
||||||
|
nextcloud_url: str,
|
||||||
|
registration_endpoint: str,
|
||||||
|
client_name: str = "Nextcloud MCP Server",
|
||||||
|
redirect_uris: list[str] | None = None,
|
||||||
|
scopes: str = "openid profile email",
|
||||||
|
) -> ClientInfo:
|
||||||
|
"""
|
||||||
|
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_url: Base URL of the Nextcloud instance
|
||||||
|
registration_endpoint: Full URL to the registration endpoint
|
||||||
|
client_name: Name of the client application
|
||||||
|
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||||
|
scopes: Space-separated list of scopes to request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ClientInfo with registration details
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPStatusError: If registration fails
|
||||||
|
ValueError: If response is invalid
|
||||||
|
"""
|
||||||
|
if redirect_uris is None:
|
||||||
|
redirect_uris = ["http://localhost:8000/oauth/callback"]
|
||||||
|
|
||||||
|
client_metadata = {
|
||||||
|
"client_name": client_name,
|
||||||
|
"redirect_uris": redirect_uris,
|
||||||
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
|
"response_types": ["code"],
|
||||||
|
"scope": scopes,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||||
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
registration_endpoint,
|
||||||
|
json=client_metadata,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
client_info = response.json()
|
||||||
|
logger.info(
|
||||||
|
f"Successfully registered client: {client_info.get('client_id')}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Client expires at: {client_info.get('client_secret_expires_at')} "
|
||||||
|
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ClientInfo(
|
||||||
|
client_id=client_info["client_id"],
|
||||||
|
client_secret=client_info["client_secret"],
|
||||||
|
client_id_issued_at=client_info.get(
|
||||||
|
"client_id_issued_at", int(time.time())
|
||||||
|
),
|
||||||
|
client_secret_expires_at=client_info.get(
|
||||||
|
"client_secret_expires_at", int(time.time()) + 3600
|
||||||
|
),
|
||||||
|
redirect_uris=client_info.get("redirect_uris", redirect_uris),
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
|
||||||
|
logger.error(f"Response: {e.response.text}")
|
||||||
|
raise
|
||||||
|
except KeyError as e:
|
||||||
|
logger.error(f"Invalid response from registration endpoint: missing {e}")
|
||||||
|
raise ValueError(f"Invalid registration response: missing {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
|
||||||
|
"""
|
||||||
|
Load client credentials from storage file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
storage_path: Path to the JSON file containing client credentials
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ClientInfo if file exists and is valid, None otherwise
|
||||||
|
"""
|
||||||
|
if not storage_path.exists():
|
||||||
|
logger.debug(f"Client storage file not found: {storage_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(storage_path, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
client_info = ClientInfo.from_dict(data)
|
||||||
|
|
||||||
|
if client_info.is_expired:
|
||||||
|
logger.warning(
|
||||||
|
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
|
||||||
|
if client_info.expires_soon:
|
||||||
|
logger.warning("Client expires soon (within 5 minutes)")
|
||||||
|
|
||||||
|
return client_info
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||||
|
logger.error(f"Failed to load client from file: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
|
||||||
|
"""
|
||||||
|
Save client credentials to storage file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_info: Client information to save
|
||||||
|
storage_path: Path to save the JSON file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: If file cannot be written
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create directory if it doesn't exist
|
||||||
|
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Write client info
|
||||||
|
with open(storage_path, "w") as f:
|
||||||
|
json.dump(client_info.to_dict(), f, indent=2)
|
||||||
|
|
||||||
|
# Set restrictive permissions (owner read/write only)
|
||||||
|
os.chmod(storage_path, 0o600)
|
||||||
|
|
||||||
|
logger.info(f"Saved client credentials to {storage_path}")
|
||||||
|
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Failed to save client credentials: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def load_or_register_client(
|
||||||
|
nextcloud_url: str,
|
||||||
|
registration_endpoint: str,
|
||||||
|
storage_path: str | Path,
|
||||||
|
client_name: str = "Nextcloud MCP Server",
|
||||||
|
redirect_uris: list[str] | None = None,
|
||||||
|
force_register: bool = False,
|
||||||
|
) -> ClientInfo:
|
||||||
|
"""
|
||||||
|
Load client from storage or register a new one if not found/expired.
|
||||||
|
|
||||||
|
This function:
|
||||||
|
1. Checks for existing client credentials in storage
|
||||||
|
2. Validates the credentials are not expired
|
||||||
|
3. Registers a new client if needed
|
||||||
|
4. Saves the new client credentials
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_url: Base URL of the Nextcloud instance
|
||||||
|
registration_endpoint: Full URL to the registration endpoint
|
||||||
|
storage_path: Path to store client credentials
|
||||||
|
client_name: Name of the client application
|
||||||
|
redirect_uris: List of redirect URIs
|
||||||
|
force_register: Force registration even if valid credentials exist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ClientInfo with valid credentials
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPStatusError: If registration fails
|
||||||
|
ValueError: If response is invalid
|
||||||
|
"""
|
||||||
|
storage_path = Path(storage_path)
|
||||||
|
|
||||||
|
# Try to load existing client unless forced to register
|
||||||
|
if not force_register:
|
||||||
|
client_info = load_client_from_file(storage_path)
|
||||||
|
if client_info:
|
||||||
|
return client_info
|
||||||
|
|
||||||
|
# Register new client
|
||||||
|
logger.info("Registering new OAuth client...")
|
||||||
|
client_info = await register_client(
|
||||||
|
nextcloud_url=nextcloud_url,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
client_name=client_name,
|
||||||
|
redirect_uris=redirect_uris,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to storage
|
||||||
|
save_client_to_file(client_info, storage_path)
|
||||||
|
|
||||||
|
return client_info
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Helper functions for extracting OAuth context from MCP requests."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context
|
||||||
|
from mcp.server.auth.provider import AccessToken
|
||||||
|
|
||||||
|
from ..client import NextcloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
|
||||||
|
"""
|
||||||
|
Extract authenticated user context from MCP request and create NextcloudClient.
|
||||||
|
|
||||||
|
This function retrieves the OAuth access token from the MCP context,
|
||||||
|
extracts the username from the token's resource field (where we stored it
|
||||||
|
during token verification), and creates a NextcloudClient with bearer auth.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP request context containing session info
|
||||||
|
base_url: Nextcloud base URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextcloudClient configured with bearer token auth
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AttributeError: If context doesn't contain expected OAuth session data
|
||||||
|
ValueError: If username cannot be extracted from token
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get AccessToken from MCP session (set by TokenVerifier)
|
||||||
|
access_token: AccessToken = ctx.request_context.session.access_token
|
||||||
|
|
||||||
|
# Extract username from resource field (RFC 8707)
|
||||||
|
# We stored the username here during token verification
|
||||||
|
username = access_token.resource
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
logger.error("No username found in access token resource field")
|
||||||
|
raise ValueError("Username not available in OAuth token context")
|
||||||
|
|
||||||
|
logger.debug(f"Creating OAuth NextcloudClient for user: {username}")
|
||||||
|
|
||||||
|
# Create client with bearer token
|
||||||
|
return NextcloudClient.from_token(
|
||||||
|
base_url=base_url, token=access_token.token, username=username
|
||||||
|
)
|
||||||
|
|
||||||
|
except AttributeError as e:
|
||||||
|
logger.error(f"Failed to extract OAuth context: {e}")
|
||||||
|
logger.error("This may indicate the server is not running in OAuth mode")
|
||||||
|
raise
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Token verification using Nextcloud OIDC userinfo endpoint."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudTokenVerifier(TokenVerifier):
|
||||||
|
"""
|
||||||
|
Validates access tokens using Nextcloud OIDC userinfo endpoint.
|
||||||
|
|
||||||
|
This verifier:
|
||||||
|
1. Calls the userinfo endpoint with the bearer token
|
||||||
|
2. Caches successful responses to avoid repeated API calls
|
||||||
|
3. Extracts username from the 'sub' or 'preferred_username' claim
|
||||||
|
4. Optionally supports JWT validation for performance (future enhancement)
|
||||||
|
|
||||||
|
The userinfo endpoint validates the token and returns user claims if valid,
|
||||||
|
or returns HTTP 400/401 if the token is invalid or expired.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nextcloud_host: str,
|
||||||
|
userinfo_uri: str,
|
||||||
|
cache_ttl: int = 3600,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the token verifier.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com)
|
||||||
|
userinfo_uri: Full URL to the userinfo endpoint
|
||||||
|
cache_ttl: Time-to-live for cached tokens in seconds (default: 3600)
|
||||||
|
"""
|
||||||
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
|
self.userinfo_uri = userinfo_uri
|
||||||
|
self.cache_ttl = cache_ttl
|
||||||
|
|
||||||
|
# Cache: token -> (userinfo, expiry_timestamp)
|
||||||
|
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||||
|
|
||||||
|
# HTTP client for userinfo requests
|
||||||
|
self._client = httpx.AsyncClient(timeout=10.0)
|
||||||
|
|
||||||
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Verify a bearer token by calling the userinfo endpoint.
|
||||||
|
|
||||||
|
This method:
|
||||||
|
1. Checks the cache first for recent validations
|
||||||
|
2. Calls the userinfo endpoint if not cached
|
||||||
|
3. Returns AccessToken with username stored in metadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The bearer token to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if valid, None if invalid or expired
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
cached = self._get_cached_token(token)
|
||||||
|
if cached:
|
||||||
|
logger.debug("Token found in cache")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Validate via userinfo endpoint
|
||||||
|
try:
|
||||||
|
return await self._verify_via_userinfo(token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Token verification failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _verify_via_userinfo(self, token: str) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Validate token by calling the userinfo endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The bearer token to verify
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if valid, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self._client.get(
|
||||||
|
self.userinfo_uri, headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
userinfo = response.json()
|
||||||
|
logger.debug(
|
||||||
|
f"Token validated successfully for user: {userinfo.get('sub')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
expiry = time.time() + self.cache_ttl
|
||||||
|
self._token_cache[token] = (userinfo, expiry)
|
||||||
|
|
||||||
|
# Create AccessToken with username in resource field (workaround for MCP SDK)
|
||||||
|
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||||
|
if not username:
|
||||||
|
logger.error("No username found in userinfo response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id="", # Not available from userinfo
|
||||||
|
scopes=self._extract_scopes(userinfo),
|
||||||
|
expires_at=int(expiry),
|
||||||
|
resource=username, # Store username in resource field (RFC 8707)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif response.status_code in (400, 401, 403):
|
||||||
|
logger.info(f"Token validation failed: HTTP {response.status_code}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Unexpected response from userinfo: {response.status_code}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error("Timeout while validating token via userinfo endpoint")
|
||||||
|
return None
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Network error while validating token: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error during token validation: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_cached_token(self, token: str) -> AccessToken | None:
|
||||||
|
"""
|
||||||
|
Retrieve a token from cache if not expired.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The bearer token to look up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if cached and valid, None otherwise
|
||||||
|
"""
|
||||||
|
if token not in self._token_cache:
|
||||||
|
return None
|
||||||
|
|
||||||
|
userinfo, expiry = self._token_cache[token]
|
||||||
|
|
||||||
|
# Check if expired
|
||||||
|
if time.time() >= expiry:
|
||||||
|
logger.debug("Cached token expired, removing from cache")
|
||||||
|
del self._token_cache[token]
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return cached AccessToken
|
||||||
|
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id="",
|
||||||
|
scopes=self._extract_scopes(userinfo),
|
||||||
|
expires_at=int(expiry),
|
||||||
|
resource=username,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_scopes(self, userinfo: dict[str, Any]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Extract scopes from userinfo response.
|
||||||
|
|
||||||
|
Since the userinfo response doesn't include the original scopes,
|
||||||
|
we infer them from the claims present in the response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userinfo: The userinfo response dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of inferred scopes
|
||||||
|
"""
|
||||||
|
scopes = ["openid"] # Always present
|
||||||
|
|
||||||
|
if "email" in userinfo:
|
||||||
|
scopes.append("email")
|
||||||
|
|
||||||
|
if any(
|
||||||
|
key in userinfo for key in ["name", "given_name", "family_name", "picture"]
|
||||||
|
):
|
||||||
|
scopes.append("profile")
|
||||||
|
|
||||||
|
if "roles" in userinfo:
|
||||||
|
scopes.append("roles")
|
||||||
|
|
||||||
|
if "groups" in userinfo:
|
||||||
|
scopes.append("groups")
|
||||||
|
|
||||||
|
return scopes
|
||||||
|
|
||||||
|
def clear_cache(self):
|
||||||
|
"""Clear the token cache."""
|
||||||
|
self._token_cache.clear()
|
||||||
|
logger.debug("Token cache cleared")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Cleanup resources."""
|
||||||
|
await self._client.aclose()
|
||||||
|
logger.debug("Token verifier closed")
|
||||||
@@ -85,6 +85,23 @@ class NextcloudClient:
|
|||||||
# Pass username to constructor
|
# Pass username to constructor
|
||||||
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
|
return cls(base_url=host, username=username, auth=BasicAuth(username, password))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_token(cls, base_url: str, token: str, username: str):
|
||||||
|
"""Create NextcloudClient with OAuth bearer token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Nextcloud base URL
|
||||||
|
token: OAuth access token
|
||||||
|
username: Nextcloud username
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextcloudClient configured with bearer token authentication
|
||||||
|
"""
|
||||||
|
from ..auth import BearerAuth
|
||||||
|
|
||||||
|
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
||||||
|
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
||||||
|
|
||||||
async def capabilities(self):
|
async def capabilities(self):
|
||||||
response = await self._client.get(
|
response = await self._client.get(
|
||||||
"/ocs/v2.php/cloud/capabilities",
|
"/ocs/v2.php/cloud/capabilities",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Helper functions for accessing context in MCP tools."""
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
|
||||||
|
def get_client(ctx: Context) -> NextcloudClient:
|
||||||
|
"""
|
||||||
|
Get the appropriate Nextcloud client based on authentication mode.
|
||||||
|
|
||||||
|
In BasicAuth mode, returns the shared client from lifespan context.
|
||||||
|
In OAuth mode, creates a new client per-request using the OAuth context.
|
||||||
|
|
||||||
|
This function automatically detects the authentication mode by checking
|
||||||
|
the type of the lifespan context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP request context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextcloudClient configured for the current authentication mode
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AttributeError: If context doesn't contain expected data
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@mcp.tool()
|
||||||
|
async def my_tool(ctx: Context):
|
||||||
|
client = get_client(ctx)
|
||||||
|
return await client.capabilities()
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
lifespan_ctx = ctx.request_context.lifespan_context
|
||||||
|
|
||||||
|
# Try BasicAuth mode first (has 'client' attribute)
|
||||||
|
if hasattr(lifespan_ctx, "client"):
|
||||||
|
return lifespan_ctx.client
|
||||||
|
|
||||||
|
# OAuth mode (has 'nextcloud_host' attribute)
|
||||||
|
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||||
|
from nextcloud_mcp_server.auth import get_client_from_context
|
||||||
|
|
||||||
|
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||||
|
|
||||||
|
# Unknown context type
|
||||||
|
raise AttributeError(
|
||||||
|
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
||||||
|
f"Type: {type(lifespan_ctx)}"
|
||||||
|
)
|
||||||
@@ -4,7 +4,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.calendar import (
|
from nextcloud_mcp_server.models.calendar import (
|
||||||
Calendar,
|
Calendar,
|
||||||
ListCalendarsResponse,
|
ListCalendarsResponse,
|
||||||
@@ -18,7 +18,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||||
"""List all available calendars for the user"""
|
"""List all available calendars for the user"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
calendars_data = await client.calendar.list_calendars()
|
calendars_data = await client.calendar.list_calendars()
|
||||||
|
|
||||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||||
@@ -74,7 +74,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with event creation result
|
Dict with event creation result
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
event_data = {
|
event_data = {
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -133,7 +133,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
List of events matching the filters
|
List of events matching the filters
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
# Convert YYYY-MM-DD format dates to datetime objects
|
# Convert YYYY-MM-DD format dates to datetime objects
|
||||||
start_datetime = None
|
start_datetime = None
|
||||||
@@ -207,7 +207,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
):
|
):
|
||||||
"""Get detailed information about a specific event"""
|
"""Get detailed information about a specific event"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||||
return event_data
|
return event_data
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
etag: str = "",
|
etag: str = "",
|
||||||
):
|
):
|
||||||
"""Update any aspect of an existing event"""
|
"""Update any aspect of an existing event"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
# Build update data with only non-None values
|
# Build update data with only non-None values
|
||||||
event_data = {}
|
event_data = {}
|
||||||
@@ -290,7 +290,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
):
|
):
|
||||||
"""Delete a calendar event"""
|
"""Delete a calendar event"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -332,7 +332,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
Dict with meeting creation result
|
Dict with meeting creation result
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
# Combine date and time for start_datetime
|
# Combine date and time for start_datetime
|
||||||
start_datetime = f"{date}T{time}:00"
|
start_datetime = f"{date}T{time}:00"
|
||||||
@@ -366,7 +366,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
):
|
):
|
||||||
"""Get upcoming events in next N days"""
|
"""Get upcoming events in next N days"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
now = dt.datetime.now()
|
now = dt.datetime.now()
|
||||||
end_datetime = now + dt.timedelta(days=days_ahead)
|
end_datetime = now + dt.timedelta(days=days_ahead)
|
||||||
@@ -435,7 +435,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
List of available time slots with start/end times and duration
|
List of available time slots with start/end times and duration
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
# Parse attendees
|
# Parse attendees
|
||||||
attendee_list = []
|
attendee_list = []
|
||||||
@@ -536,7 +536,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
Summary of operation results including counts and details
|
Summary of operation results including counts and details
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
if operation not in ["update", "delete", "move"]:
|
if operation not in ["update", "delete", "move"]:
|
||||||
raise ValueError("Operation must be 'update', 'delete', or 'move'")
|
raise ValueError("Operation must be 'update', 'delete', or 'move'")
|
||||||
@@ -758,7 +758,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
Result of the calendar management operation
|
Result of the calendar management operation
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
if action == "list":
|
if action == "list":
|
||||||
return await client.calendar.list_calendars()
|
return await client.calendar.list_calendars()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -12,13 +12,13 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||||
"""List all addressbooks for the user."""
|
"""List all addressbooks for the user."""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.contacts.list_addressbooks()
|
return await client.contacts.list_addressbooks()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||||
"""List all contacts in the specified addressbook."""
|
"""List all contacts in the specified addressbook."""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -31,7 +31,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
name: The name of the addressbook.
|
name: The name of the addressbook.
|
||||||
display_name: The display name of the addressbook.
|
display_name: The display name of the addressbook.
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.contacts.create_addressbook(
|
return await client.contacts.create_addressbook(
|
||||||
name=name, display_name=display_name
|
name=name, display_name=display_name
|
||||||
)
|
)
|
||||||
@@ -39,7 +39,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||||
"""Delete an addressbook."""
|
"""Delete an addressbook."""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.contacts.delete_addressbook(name=name)
|
return await client.contacts.delete_addressbook(name=name)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -53,7 +53,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
uid: The unique ID for the contact.
|
uid: The unique ID for the contact.
|
||||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.contacts.create_contact(
|
return await client.contacts.create_contact(
|
||||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||||
)
|
)
|
||||||
@@ -61,7 +61,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||||
"""Delete a contact."""
|
"""Delete a contact."""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -76,7 +76,7 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
||||||
etag: Optional ETag for optimistic concurrency control.
|
etag: Optional ETag for optimistic concurrency control.
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.contacts.update_contact(
|
return await client.contacts.update_contact(
|
||||||
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
|
addressbook=addressbook, uid=uid, contact_data=contact_data, etag=etag
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.deck import (
|
from nextcloud_mcp_server.models.deck import (
|
||||||
DeckBoard,
|
DeckBoard,
|
||||||
DeckStack,
|
DeckStack,
|
||||||
@@ -30,7 +30,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
"""List all Nextcloud Deck boards"""
|
"""List all Nextcloud Deck boards"""
|
||||||
ctx: Context = mcp.get_context()
|
ctx: Context = mcp.get_context()
|
||||||
await ctx.warning("This message is deprecated, use the deck_get_board instead")
|
await ctx.warning("This message is deprecated, use the deck_get_board instead")
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
boards = await client.deck.get_boards()
|
boards = await client.deck.get_boards()
|
||||||
return [board.model_dump() for board in boards]
|
return [board.model_dump() for board in boards]
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
await ctx.warning(
|
await ctx.warning(
|
||||||
"This resource is deprecated, use the deck_get_board tool instead"
|
"This resource is deprecated, use the deck_get_board tool instead"
|
||||||
)
|
)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board.model_dump()
|
return board.model_dump()
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
await ctx.warning(
|
await ctx.warning(
|
||||||
"This resource is deprecated, use the deck_get_stacks tool instead"
|
"This resource is deprecated, use the deck_get_stacks tool instead"
|
||||||
)
|
)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
return [stack.model_dump() for stack in stacks]
|
return [stack.model_dump() for stack in stacks]
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
await ctx.warning(
|
await ctx.warning(
|
||||||
"This resource is deprecated, use the deck_get_stack tool instead"
|
"This resource is deprecated, use the deck_get_stack tool instead"
|
||||||
)
|
)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
return stack.model_dump()
|
return stack.model_dump()
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
await ctx.warning(
|
await ctx.warning(
|
||||||
"This resource is deprecated, use the deck_get_cards tool instead"
|
"This resource is deprecated, use the deck_get_cards tool instead"
|
||||||
)
|
)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
if stack.cards:
|
if stack.cards:
|
||||||
return [card.model_dump() for card in stack.cards]
|
return [card.model_dump() for card in stack.cards]
|
||||||
@@ -87,7 +87,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
await ctx.warning(
|
await ctx.warning(
|
||||||
"This resource is deprecated, use the deck_get_card tool instead"
|
"This resource is deprecated, use the deck_get_card tool instead"
|
||||||
)
|
)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||||
return card.model_dump()
|
return card.model_dump()
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
await ctx.warning(
|
await ctx.warning(
|
||||||
"This resource is deprecated, use the deck_get_labels tool instead"
|
"This resource is deprecated, use the deck_get_labels tool instead"
|
||||||
)
|
)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return [label.model_dump() for label in board.labels]
|
return [label.model_dump() for label in board.labels]
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
await ctx.warning(
|
await ctx.warning(
|
||||||
"This resource is deprecated, use the deck_get_label tool instead"
|
"This resource is deprecated, use the deck_get_label tool instead"
|
||||||
)
|
)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
label = await client.deck.get_label(board_id, label_id)
|
label = await client.deck.get_label(board_id, label_id)
|
||||||
return label.model_dump()
|
return label.model_dump()
|
||||||
|
|
||||||
@@ -118,28 +118,28 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||||
"""Get all Nextcloud Deck boards"""
|
"""Get all Nextcloud Deck boards"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
boards = await client.deck.get_boards()
|
boards = await client.deck.get_boards()
|
||||||
return boards
|
return boards
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||||
"""Get details of a specific Nextcloud Deck board"""
|
"""Get details of a specific Nextcloud Deck board"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board
|
return board
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||||
"""Get all stacks in a Nextcloud Deck board"""
|
"""Get all stacks in a Nextcloud Deck board"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||||
"""Get details of a specific Nextcloud Deck stack"""
|
"""Get details of a specific Nextcloud Deck stack"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
return stack
|
return stack
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> list[DeckCard]:
|
) -> list[DeckCard]:
|
||||||
"""Get all cards in a Nextcloud Deck stack"""
|
"""Get all cards in a Nextcloud Deck stack"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
if stack.cards:
|
if stack.cards:
|
||||||
return stack.cards
|
return stack.cards
|
||||||
@@ -159,21 +159,21 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
ctx: Context, board_id: int, stack_id: int, card_id: int
|
ctx: Context, board_id: int, stack_id: int, card_id: int
|
||||||
) -> DeckCard:
|
) -> DeckCard:
|
||||||
"""Get details of a specific Nextcloud Deck card"""
|
"""Get details of a specific Nextcloud Deck card"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||||
"""Get all labels in a Nextcloud Deck board"""
|
"""Get all labels in a Nextcloud Deck board"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board.labels
|
return board.labels
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||||
"""Get details of a specific Nextcloud Deck label"""
|
"""Get details of a specific Nextcloud Deck label"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
label = await client.deck.get_label(board_id, label_id)
|
label = await client.deck.get_label(board_id, label_id)
|
||||||
return label
|
return label
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title: The title of the new board
|
title: The title of the new board
|
||||||
color: The hexadecimal color of the new board (e.g. FF0000)
|
color: The hexadecimal color of the new board (e.g. FF0000)
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
board = await client.deck.create_board(title, color)
|
board = await client.deck.create_board(title, color)
|
||||||
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
|
return CreateBoardResponse(id=board.id, title=board.title, color=board.color)
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title: The title of the new stack
|
title: The title of the new stack
|
||||||
order: Order for sorting the stacks
|
order: Order for sorting the stacks
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
stack = await client.deck.create_stack(board_id, title, order)
|
stack = await client.deck.create_stack(board_id, title, order)
|
||||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||||
|
|
||||||
@@ -226,7 +226,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title: New title for the stack
|
title: New title for the stack
|
||||||
order: New order for the stack
|
order: New order for the stack
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.update_stack(board_id, stack_id, title, order)
|
await client.deck.update_stack(board_id, stack_id, title, order)
|
||||||
return StackOperationResponse(
|
return StackOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -245,7 +245,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id: The ID of the board
|
board_id: The ID of the board
|
||||||
stack_id: The ID of the stack
|
stack_id: The ID of the stack
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.delete_stack(board_id, stack_id)
|
await client.deck.delete_stack(board_id, stack_id)
|
||||||
return StackOperationResponse(
|
return StackOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -277,7 +277,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
description: Description of the card
|
description: Description of the card
|
||||||
duedate: Due date of the card (ISO-8601 format)
|
duedate: Due date of the card (ISO-8601 format)
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
card = await client.deck.create_card(
|
card = await client.deck.create_card(
|
||||||
board_id, stack_id, title, type, order, description, duedate
|
board_id, stack_id, title, type, order, description, duedate
|
||||||
)
|
)
|
||||||
@@ -318,7 +318,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
archived: Whether the card should be archived
|
archived: Whether the card should be archived
|
||||||
done: Completion date for the card (ISO-8601 format)
|
done: Completion date for the card (ISO-8601 format)
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.update_card(
|
await client.deck.update_card(
|
||||||
board_id,
|
board_id,
|
||||||
stack_id,
|
stack_id,
|
||||||
@@ -351,7 +351,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stack_id: The ID of the stack
|
stack_id: The ID of the stack
|
||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.delete_card(board_id, stack_id, card_id)
|
await client.deck.delete_card(board_id, stack_id, card_id)
|
||||||
return CardOperationResponse(
|
return CardOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -372,7 +372,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stack_id: The ID of the stack
|
stack_id: The ID of the stack
|
||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.archive_card(board_id, stack_id, card_id)
|
await client.deck.archive_card(board_id, stack_id, card_id)
|
||||||
return CardOperationResponse(
|
return CardOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -393,7 +393,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stack_id: The ID of the stack
|
stack_id: The ID of the stack
|
||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.unarchive_card(board_id, stack_id, card_id)
|
await client.deck.unarchive_card(board_id, stack_id, card_id)
|
||||||
return CardOperationResponse(
|
return CardOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -421,7 +421,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
order: New position in the target stack
|
order: New position in the target stack
|
||||||
target_stack_id: The ID of the target stack
|
target_stack_id: The ID of the target stack
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.reorder_card(
|
await client.deck.reorder_card(
|
||||||
board_id, stack_id, card_id, order, target_stack_id
|
board_id, stack_id, card_id, order, target_stack_id
|
||||||
)
|
)
|
||||||
@@ -445,7 +445,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title: The title of the new label
|
title: The title of the new label
|
||||||
color: The color of the new label (hex format without #)
|
color: The color of the new label (hex format without #)
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
label = await client.deck.create_label(board_id, title, color)
|
label = await client.deck.create_label(board_id, title, color)
|
||||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
title: New title for the label
|
title: New title for the label
|
||||||
color: New color for the label (hex format without #)
|
color: New color for the label (hex format without #)
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.update_label(board_id, label_id, title, color)
|
await client.deck.update_label(board_id, label_id, title, color)
|
||||||
return LabelOperationResponse(
|
return LabelOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -484,7 +484,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id: The ID of the board
|
board_id: The ID of the board
|
||||||
label_id: The ID of the label
|
label_id: The ID of the label
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.delete_label(board_id, label_id)
|
await client.deck.delete_label(board_id, label_id)
|
||||||
return LabelOperationResponse(
|
return LabelOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -506,7 +506,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
label_id: The ID of the label to assign
|
label_id: The ID of the label to assign
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
|
await client.deck.assign_label_to_card(board_id, stack_id, card_id, label_id)
|
||||||
return CardOperationResponse(
|
return CardOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -528,7 +528,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
label_id: The ID of the label to remove
|
label_id: The ID of the label to remove
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
|
await client.deck.remove_label_from_card(board_id, stack_id, card_id, label_id)
|
||||||
return CardOperationResponse(
|
return CardOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -551,7 +551,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
user_id: The user ID to assign
|
user_id: The user ID to assign
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
|
await client.deck.assign_user_to_card(board_id, stack_id, card_id, user_id)
|
||||||
return CardOperationResponse(
|
return CardOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
@@ -573,7 +573,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
card_id: The ID of the card
|
card_id: The ID of the card
|
||||||
user_id: The user ID to unassign
|
user_id: The user ID to unassign
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
|
await client.deck.unassign_user_from_card(board_id, stack_id, card_id, user_id)
|
||||||
return CardOperationResponse(
|
return CardOperationResponse(
|
||||||
success=True,
|
success=True,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from mcp.types import ErrorData
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.notes import (
|
from nextcloud_mcp_server.models.notes import (
|
||||||
Note,
|
Note,
|
||||||
NotesSettings,
|
NotesSettings,
|
||||||
@@ -27,7 +27,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
ctx: Context = (
|
ctx: Context = (
|
||||||
mcp.get_context()
|
mcp.get_context()
|
||||||
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
) # https://github.com/modelcontextprotocol/python-sdk/issues/244
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
settings_data = await client.notes.get_settings()
|
settings_data = await client.notes.get_settings()
|
||||||
return NotesSettings(**settings_data)
|
return NotesSettings(**settings_data)
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str):
|
async def nc_notes_get_attachment_resource(note_id: int, attachment_filename: str):
|
||||||
"""Get a specific attachment from a note"""
|
"""Get a specific attachment from a note"""
|
||||||
ctx: Context = mcp.get_context()
|
ctx: Context = mcp.get_context()
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
# Assuming a method get_note_attachment exists in the client
|
# Assuming a method get_note_attachment exists in the client
|
||||||
# This method should return the raw content and determine the mime type
|
# This method should return the raw content and determine the mime type
|
||||||
content, mime_type = await client.webdav.get_note_attachment(
|
content, mime_type = await client.webdav.get_note_attachment(
|
||||||
@@ -57,7 +57,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
"""Get user note using note id"""
|
"""Get user note using note id"""
|
||||||
|
|
||||||
ctx: Context = mcp.get_context()
|
ctx: Context = mcp.get_context()
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.get_note(note_id)
|
note_data = await client.notes.get_note(note_id)
|
||||||
return Note(**note_data)
|
return Note(**note_data)
|
||||||
@@ -81,7 +81,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
title: str, content: str, category: str, ctx: Context
|
title: str, content: str, category: str, ctx: Context
|
||||||
) -> CreateNoteResponse:
|
) -> CreateNoteResponse:
|
||||||
"""Create a new note"""
|
"""Create a new note"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.create_note(
|
note_data = await client.notes.create_note(
|
||||||
title=title,
|
title=title,
|
||||||
@@ -133,7 +133,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
If the note has been modified by someone else since you retrieved it,
|
If the note has been modified by someone else since you retrieved it,
|
||||||
the update will fail with a 412 error."""
|
the update will fail with a 412 error."""
|
||||||
logger.info("Updating note %s", note_id)
|
logger.info("Updating note %s", note_id)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.update(
|
note_data = await client.notes.update(
|
||||||
note_id=note_id,
|
note_id=note_id,
|
||||||
@@ -183,7 +183,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
between the note and what will be appended."""
|
between the note and what will be appended."""
|
||||||
|
|
||||||
logger.info("Appending content to note %s", note_id)
|
logger.info("Appending content to note %s", note_id)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.append_content(
|
note_data = await client.notes.append_content(
|
||||||
note_id=note_id, content=content
|
note_id=note_id, content=content
|
||||||
@@ -220,7 +220,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
"""Search notes by title or content, returning only id, title, and category."""
|
"""Search notes by title or content, returning only id, title, and category."""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
search_results_raw = await client.notes_search_notes(query=query)
|
search_results_raw = await client.notes_search_notes(query=query)
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||||
"""Get a specific note by its ID"""
|
"""Get a specific note by its ID"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
note_data = await client.notes.get_note(note_id)
|
note_data = await client.notes.get_note(note_id)
|
||||||
return Note(**note_data)
|
return Note(**note_data)
|
||||||
@@ -285,7 +285,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
note_id: int, attachment_filename: str, ctx: Context
|
note_id: int, attachment_filename: str, ctx: Context
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
"""Get a specific attachment from a note"""
|
"""Get a specific attachment from a note"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
content, mime_type = await client.webdav.get_note_attachment(
|
content, mime_type = await client.webdav.get_note_attachment(
|
||||||
note_id=note_id, filename=attachment_filename
|
note_id=note_id, filename=attachment_filename
|
||||||
@@ -322,7 +322,7 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||||
"""Delete a note permanently"""
|
"""Delete a note permanently"""
|
||||||
logger.info("Deleting note %s", note_id)
|
logger.info("Deleting note %s", note_id)
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
try:
|
try:
|
||||||
await client.notes.delete_note(note_id)
|
await client.notes.delete_note(note_id)
|
||||||
return DeleteNoteResponse(
|
return DeleteNoteResponse(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -12,13 +12,13 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_tables_list_tables(ctx: Context):
|
async def nc_tables_list_tables(ctx: Context):
|
||||||
"""List all tables available to the user"""
|
"""List all tables available to the user"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.tables.list_tables()
|
return await client.tables.list_tables()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||||
"""Get the schema/structure of a specific table including columns and views"""
|
"""Get the schema/structure of a specific table including columns and views"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.tables.get_table_schema(table_id)
|
return await client.tables.get_table_schema(table_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -29,7 +29,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
offset: int | None = None,
|
offset: int | None = None,
|
||||||
):
|
):
|
||||||
"""Read rows from a table with optional pagination"""
|
"""Read rows from a table with optional pagination"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -38,7 +38,7 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
Data should be a dictionary mapping column IDs to values, e.g. {1: "text", 2: 42}
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.tables.create_row(table_id, data)
|
return await client.tables.create_row(table_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -47,11 +47,11 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
Data should be a dictionary mapping column IDs to new values, e.g. {1: "new text", 2: 99}
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.tables.update_row(row_id, data)
|
return await client.tables.update_row(row_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||||
"""Delete a row from a table"""
|
"""Delete a row from a table"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.tables.delete_row(row_id)
|
return await client.tables.delete_row(row_id)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
# List a specific folder
|
# List a specific folder
|
||||||
await nc_webdav_list_directory("Documents/Projects")
|
await nc_webdav_list_directory("Documents/Projects")
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.webdav.list_directory(path)
|
return await client.webdav.list_directory(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -49,7 +49,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
result = await nc_webdav_read_file("Images/photo.jpg")
|
result = await nc_webdav_read_file("Images/photo.jpg")
|
||||||
logger.info(result['encoding']) # 'base64'
|
logger.info(result['encoding']) # 'base64'
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
content, content_type = await client.webdav.read_file(path)
|
content, content_type = await client.webdav.read_file(path)
|
||||||
|
|
||||||
# For text files, decode content for easier viewing
|
# For text files, decode content for easier viewing
|
||||||
@@ -97,7 +97,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
# Write binary data (base64 encoded)
|
# Write binary data (base64 encoded)
|
||||||
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
|
await nc_webdav_write_file("files/data.bin", base64_content, "application/octet-stream;base64")
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
|
|
||||||
# Handle base64 encoded content
|
# Handle base64 encoded content
|
||||||
if content_type and "base64" in content_type.lower():
|
if content_type and "base64" in content_type.lower():
|
||||||
@@ -127,7 +127,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
# Create nested directories (parent must exist)
|
# Create nested directories (parent must exist)
|
||||||
await nc_webdav_create_directory("Projects/MyApp/docs")
|
await nc_webdav_create_directory("Projects/MyApp/docs")
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.webdav.create_directory(path)
|
return await client.webdav.create_directory(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -147,7 +147,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
# Delete a directory (will delete all contents)
|
# Delete a directory (will delete all contents)
|
||||||
await nc_webdav_delete_resource("temp_folder")
|
await nc_webdav_delete_resource("temp_folder")
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.webdav.delete_resource(path)
|
return await client.webdav.delete_resource(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@@ -177,7 +177,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
# Move and overwrite if destination exists
|
# Move and overwrite if destination exists
|
||||||
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
|
await nc_webdav_move_resource("document.txt", "Archive/document.txt", overwrite=True)
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.webdav.move_resource(
|
return await client.webdav.move_resource(
|
||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
@@ -209,7 +209,7 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
# Copy and overwrite if destination exists
|
# Copy and overwrite if destination exists
|
||||||
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
|
await nc_webdav_copy_resource("document.txt", "Backup/document.txt", overwrite=True)
|
||||||
"""
|
"""
|
||||||
client: NextcloudClient = ctx.request_context.lifespan_context.client
|
client = get_client(ctx)
|
||||||
return await client.webdav.copy_resource(
|
return await client.webdav.copy_resource(
|
||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test script to verify OAuth MCP tools work correctly.
|
||||||
|
|
||||||
|
This script connects to the OAuth MCP server and tests tool execution.
|
||||||
|
Note: This currently requires a valid OAuth token, which must be obtained
|
||||||
|
through the browser-based OAuth flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mcp import ClientSession
|
||||||
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oauth_mcp_tools():
|
||||||
|
"""Test OAuth MCP server tools."""
|
||||||
|
print("Connecting to OAuth MCP server on port 8001...")
|
||||||
|
|
||||||
|
streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp")
|
||||||
|
session_context = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||||
|
session_context = ClientSession(read_stream, write_stream)
|
||||||
|
session = await session_context.__aenter__()
|
||||||
|
|
||||||
|
print("Initializing session...")
|
||||||
|
await session.initialize()
|
||||||
|
print("✓ Session initialized successfully")
|
||||||
|
|
||||||
|
# List available tools
|
||||||
|
print("\nListing available tools...")
|
||||||
|
result = await session.list_tools()
|
||||||
|
print(f"✓ Found {len(result.tools)} tools")
|
||||||
|
|
||||||
|
for tool in result.tools[:5]: # Show first 5
|
||||||
|
print(f" - {tool.name}: {tool.description}")
|
||||||
|
|
||||||
|
if len(result.tools) > 5:
|
||||||
|
print(f" ... and {len(result.tools) - 5} more")
|
||||||
|
|
||||||
|
# Try to call a simple tool
|
||||||
|
print("\nTesting tool execution...")
|
||||||
|
print("Note: Tool execution will fail without a valid OAuth token")
|
||||||
|
print(" (OAuth token must be obtained through browser flow)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to list tables (this will fail without OAuth token)
|
||||||
|
response = await session.call_tool("nc_tables_list_tables", {})
|
||||||
|
print(f"✓ Tool executed successfully: {response}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Tool execution failed (expected without OAuth token): {e}")
|
||||||
|
print("\nTo use OAuth tools, you need to:")
|
||||||
|
print(" 1. Implement the browser-based OAuth authorization flow")
|
||||||
|
print(" 2. Obtain an access token from Nextcloud OIDC")
|
||||||
|
print(" 3. Include the token in the Authorization header")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up
|
||||||
|
if session_context is not None:
|
||||||
|
try:
|
||||||
|
await session_context.__aexit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await streamable_context.__aexit__(None, None, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("OAuth MCP Server Tool Test")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
success = asyncio.run(test_oauth_mcp_tools())
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
if success:
|
||||||
|
print("✓ Test completed (tools accessible)")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("✗ Test failed")
|
||||||
|
sys.exit(1)
|
||||||
Executable
+290
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Verification script for Nextcloud OIDC implementation.
|
||||||
|
|
||||||
|
This script tests the OIDC endpoints to understand token format and capabilities.
|
||||||
|
Usage: python scripts/verify_oidc.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudOIDCVerifier:
|
||||||
|
"""Verify Nextcloud OIDC implementation details."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.client = httpx.AsyncClient(follow_redirects=True, timeout=30.0)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def get_discovery(self) -> dict:
|
||||||
|
"""Fetch OIDC discovery document."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("1. OIDC Discovery Endpoint")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
url = f"{self.base_url}/.well-known/openid-configuration"
|
||||||
|
print(f"URL: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
discovery = response.json()
|
||||||
|
|
||||||
|
print("\n✓ Discovery endpoint successful")
|
||||||
|
print(f"\nIssuer: {discovery.get('issuer')}")
|
||||||
|
print(f"Authorization endpoint: {discovery.get('authorization_endpoint')}")
|
||||||
|
print(f"Token endpoint: {discovery.get('token_endpoint')}")
|
||||||
|
print(f"Userinfo endpoint: {discovery.get('userinfo_endpoint')}")
|
||||||
|
print(f"JWKS URI: {discovery.get('jwks_uri')}")
|
||||||
|
print(
|
||||||
|
f"Registration endpoint: {discovery.get('registration_endpoint', 'NOT AVAILABLE')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\nSupported scopes: {', '.join(discovery.get('scopes_supported', []))}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Response types: {', '.join(discovery.get('response_types_supported', []))}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Grant types: {', '.join(discovery.get('grant_types_supported', []))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return discovery
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
print(f"\n✗ Discovery failed: HTTP {e.response.status_code}")
|
||||||
|
print(f"Response: {e.response.text}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Discovery failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
async def get_jwks(self, jwks_uri: str) -> dict:
|
||||||
|
"""Fetch JWKS to check if JWT tokens are supported."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("2. JWKS Endpoint (JWT Support)")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
print(f"URL: {jwks_uri}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.client.get(jwks_uri)
|
||||||
|
response.raise_for_status()
|
||||||
|
jwks = response.json()
|
||||||
|
|
||||||
|
print("\n✓ JWKS endpoint successful")
|
||||||
|
print(f"Number of keys: {len(jwks.get('keys', []))}")
|
||||||
|
|
||||||
|
for idx, key in enumerate(jwks.get("keys", []), 1):
|
||||||
|
print(f"\nKey {idx}:")
|
||||||
|
print(f" - Key type: {key.get('kty')}")
|
||||||
|
print(f" - Algorithm: {key.get('alg')}")
|
||||||
|
print(f" - Use: {key.get('use', 'N/A')}")
|
||||||
|
print(f" - Key ID: {key.get('kid', 'N/A')}")
|
||||||
|
|
||||||
|
return jwks
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ JWKS failed: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def test_dynamic_registration(
|
||||||
|
self, registration_endpoint: str | None
|
||||||
|
) -> dict | None:
|
||||||
|
"""Test dynamic client registration."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("3. Dynamic Client Registration")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
if not registration_endpoint:
|
||||||
|
print("✗ Dynamic registration not available (not in discovery)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f"URL: {registration_endpoint}")
|
||||||
|
|
||||||
|
client_metadata = {
|
||||||
|
"client_name": "Nextcloud MCP Server Test",
|
||||||
|
"redirect_uris": ["http://localhost:8000/oauth/callback"],
|
||||||
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
|
"grant_types": ["authorization_code", "refresh_token"],
|
||||||
|
"response_types": ["code"],
|
||||||
|
"scope": "openid profile email roles groups",
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\nRegistration payload:")
|
||||||
|
print(json.dumps(client_metadata, indent=2))
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self.client.post(
|
||||||
|
registration_endpoint,
|
||||||
|
json=client_metadata,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
client_info = response.json()
|
||||||
|
|
||||||
|
print("\n✓ Dynamic registration successful")
|
||||||
|
print(f"\nClient ID: {client_info.get('client_id')}")
|
||||||
|
print(f"Client Secret: {client_info.get('client_secret', 'N/A')[:20]}...")
|
||||||
|
print(
|
||||||
|
f"Client ID issued at: {client_info.get('client_id_issued_at', 'N/A')}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Client secret expires at: {client_info.get('client_secret_expires_at', 'Never')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save for later use
|
||||||
|
with open("/tmp/nextcloud_oidc_client.json", "w") as f:
|
||||||
|
json.dump(client_info, f, indent=2)
|
||||||
|
print("\n✓ Client credentials saved to /tmp/nextcloud_oidc_client.json")
|
||||||
|
|
||||||
|
return client_info
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
print(f"\n✗ Dynamic registration failed: HTTP {e.response.status_code}")
|
||||||
|
print(f"Response: {e.response.text}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ Dynamic registration failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def check_introspection_endpoint(self, discovery: dict) -> bool:
|
||||||
|
"""Check if token introspection endpoint exists."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("4. Token Introspection Endpoint")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
introspection_endpoint = discovery.get("introspection_endpoint")
|
||||||
|
|
||||||
|
if introspection_endpoint:
|
||||||
|
print(f"URL: {introspection_endpoint}")
|
||||||
|
print("✓ Introspection endpoint available")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ Introspection endpoint NOT available")
|
||||||
|
print("Note: Will need to use userinfo endpoint for token validation")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def print_summary(
|
||||||
|
self, discovery: dict, jwks_available: bool, registration_available: bool
|
||||||
|
):
|
||||||
|
"""Print implementation summary."""
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("IMPLEMENTATION SUMMARY")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
print("\n📋 Nextcloud OIDC Capabilities:")
|
||||||
|
print(" ✓ Discovery endpoint: Available")
|
||||||
|
print(
|
||||||
|
f" {'✓' if jwks_available else '✗'} JWKS endpoint: {'Available' if jwks_available else 'Not Available'}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {'✓' if registration_available else '✗'} Dynamic registration: {'Available' if registration_available else 'Not Available'}"
|
||||||
|
)
|
||||||
|
print(f" {'✗'} Token introspection: Not Available (use userinfo)")
|
||||||
|
|
||||||
|
print("\n🔑 Token Format:")
|
||||||
|
if jwks_available:
|
||||||
|
print(" ✓ JWT access tokens: SUPPORTED (RFC 9068)")
|
||||||
|
print(" - Must be enabled per-client in OIDC settings")
|
||||||
|
print(" - Default: Opaque tokens")
|
||||||
|
else:
|
||||||
|
print(" - Opaque tokens only")
|
||||||
|
|
||||||
|
print("\n🔐 Authentication Strategy:")
|
||||||
|
print(" Primary: Userinfo endpoint validation")
|
||||||
|
print(" Alternative: JWT validation (if enabled per-client)")
|
||||||
|
|
||||||
|
print("\n📦 Required Scopes:")
|
||||||
|
scopes = discovery.get("scopes_supported", [])
|
||||||
|
print(f" Available: {', '.join(scopes)}")
|
||||||
|
print(" Recommended for MCP: openid profile email")
|
||||||
|
|
||||||
|
print("\n👤 User Context Extraction:")
|
||||||
|
print(" - Username: 'sub' or 'preferred_username' claim")
|
||||||
|
print(" - From: JWT claims OR userinfo endpoint")
|
||||||
|
print(" - Groups: Available via 'roles' or 'groups' scope")
|
||||||
|
|
||||||
|
print("\n⚙️ Configuration Requirements:")
|
||||||
|
if registration_available:
|
||||||
|
print(" ✓ Dynamic registration enabled - zero-config deployment possible")
|
||||||
|
print(" - Clients expire after 3600s (1 hour)")
|
||||||
|
print(" - Max 100 dynamic clients per instance")
|
||||||
|
print(" - BruteForce protection enabled")
|
||||||
|
else:
|
||||||
|
print(" ✗ Dynamic registration disabled - manual client setup required")
|
||||||
|
print(" Admin must create client via: occ oidc:create")
|
||||||
|
|
||||||
|
print("\n📝 Endpoints:")
|
||||||
|
print(f" Authorization: {discovery.get('authorization_endpoint')}")
|
||||||
|
print(f" Token: {discovery.get('token_endpoint')}")
|
||||||
|
print(f" Userinfo: {discovery.get('userinfo_endpoint')}")
|
||||||
|
print(f" JWKS: {discovery.get('jwks_uri')}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run verification tests."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Nextcloud OIDC Verification Script")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Get Nextcloud URL
|
||||||
|
nextcloud_url = input(
|
||||||
|
"\nEnter Nextcloud URL (e.g., https://cloud.coutinho.io): "
|
||||||
|
).strip()
|
||||||
|
if not nextcloud_url:
|
||||||
|
nextcloud_url = "https://cloud.coutinho.io"
|
||||||
|
|
||||||
|
verifier = NextcloudOIDCVerifier(nextcloud_url)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Get discovery document
|
||||||
|
discovery = await verifier.get_discovery()
|
||||||
|
|
||||||
|
# 2. Check JWKS
|
||||||
|
jwks_uri = discovery.get("jwks_uri")
|
||||||
|
jwks_available = False
|
||||||
|
if jwks_uri:
|
||||||
|
jwks = await verifier.get_jwks(jwks_uri)
|
||||||
|
jwks_available = len(jwks.get("keys", [])) > 0
|
||||||
|
|
||||||
|
# 3. Test dynamic registration
|
||||||
|
registration_endpoint = discovery.get("registration_endpoint")
|
||||||
|
if registration_endpoint:
|
||||||
|
print("\nTest dynamic registration? (y/n): ", end="")
|
||||||
|
test_reg = input().strip().lower()
|
||||||
|
if test_reg == "y":
|
||||||
|
client_info = await verifier.test_dynamic_registration(
|
||||||
|
registration_endpoint
|
||||||
|
)
|
||||||
|
registration_available = client_info is not None
|
||||||
|
else:
|
||||||
|
registration_available = True
|
||||||
|
print("Skipping dynamic registration test")
|
||||||
|
else:
|
||||||
|
registration_available = False
|
||||||
|
|
||||||
|
# 4. Check introspection
|
||||||
|
await verifier.check_introspection_endpoint(discovery)
|
||||||
|
|
||||||
|
# 5. Print summary
|
||||||
|
verifier.print_summary(discovery, jwks_available, registration_available)
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Verification complete!")
|
||||||
|
print(f"{'=' * 60}\n")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await verifier.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
+235
-1
@@ -1,8 +1,10 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
@@ -13,19 +15,71 @@ from nextcloud_mcp_server.client import NextcloudClient
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_nextcloud(
|
||||||
|
host: str, max_attempts: int = 30, delay: float = 2.0
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Wait for Nextcloud server to be ready by checking the status endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Nextcloud host URL
|
||||||
|
max_attempts: Maximum number of connection attempts
|
||||||
|
delay: Delay between attempts in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if server is ready, False otherwise
|
||||||
|
"""
|
||||||
|
logger.info(f"Waiting for Nextcloud server at {host} to be ready...")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
# Try to hit the status endpoint
|
||||||
|
response = await client.get(f"{host}/status.php")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data.get("installed"):
|
||||||
|
logger.info(
|
||||||
|
f"Nextcloud server is ready (version: {data.get('versionstring', 'unknown')})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (httpx.RequestError, httpx.TimeoutException) as e:
|
||||||
|
logger.debug(f"Attempt {attempt}/{max_attempts}: {e}")
|
||||||
|
|
||||||
|
if attempt < max_attempts:
|
||||||
|
logger.info(
|
||||||
|
f"Nextcloud not ready yet, waiting {delay}s... (attempt {attempt}/{max_attempts})"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
f"Nextcloud server at {host} did not become ready after {max_attempts} attempts"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create a NextcloudClient instance for integration tests.
|
Fixture to create a NextcloudClient instance for integration tests.
|
||||||
Uses environment variables for configuration.
|
Uses environment variables for configuration.
|
||||||
|
Waits for Nextcloud to be ready before proceeding.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
|
assert os.getenv("NEXTCLOUD_HOST"), "NEXTCLOUD_HOST env var not set"
|
||||||
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
|
assert os.getenv("NEXTCLOUD_USERNAME"), "NEXTCLOUD_USERNAME env var not set"
|
||||||
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
|
assert os.getenv("NEXTCLOUD_PASSWORD"), "NEXTCLOUD_PASSWORD env var not set"
|
||||||
|
|
||||||
|
host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
|
||||||
|
# Wait for Nextcloud to be ready
|
||||||
|
if not await wait_for_nextcloud(host):
|
||||||
|
pytest.fail(f"Nextcloud server at {host} is not ready")
|
||||||
|
|
||||||
logger.info("Creating session-scoped NextcloudClient from environment variables.")
|
logger.info("Creating session-scoped NextcloudClient from environment variables.")
|
||||||
client = NextcloudClient.from_env()
|
client = NextcloudClient.from_env()
|
||||||
# Optional: Perform a quick check like getting capabilities to ensure connection works
|
|
||||||
|
# Perform a quick check to ensure connection works
|
||||||
try:
|
try:
|
||||||
await client.capabilities()
|
await client.capabilities()
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -396,3 +450,183 @@ async def temporary_board_with_card(
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
|
logger.error(f"Unexpected error deleting temporary card {card.id}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_oauth_token(nextcloud_url: str, username: str, password: str) -> str:
|
||||||
|
"""
|
||||||
|
Get an OAuth access token from Nextcloud OIDC using Resource Owner Password flow.
|
||||||
|
|
||||||
|
This is a helper function for testing only - it bypasses the normal OAuth flow
|
||||||
|
to directly obtain a token for automated testing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_url: Nextcloud base URL
|
||||||
|
username: Nextcloud username
|
||||||
|
password: Nextcloud password
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Access token string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If token acquisition fails
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
|
||||||
|
|
||||||
|
logger.info(f"Getting OAuth token for testing from {nextcloud_url}")
|
||||||
|
|
||||||
|
# Perform OIDC discovery
|
||||||
|
async with httpx.AsyncClient() as http_client:
|
||||||
|
discovery_url = f"{nextcloud_url}/.well-known/openid-configuration"
|
||||||
|
logger.debug(f"Fetching OIDC discovery from: {discovery_url}")
|
||||||
|
|
||||||
|
discovery_response = await http_client.get(discovery_url)
|
||||||
|
if discovery_response.status_code != 200:
|
||||||
|
raise Exception(f"OIDC discovery failed: {discovery_response.status_code}")
|
||||||
|
|
||||||
|
oidc_config = discovery_response.json()
|
||||||
|
token_endpoint = oidc_config.get("token_endpoint")
|
||||||
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
||||||
|
|
||||||
|
if not token_endpoint or not registration_endpoint:
|
||||||
|
raise Exception("OIDC discovery missing required endpoints")
|
||||||
|
|
||||||
|
logger.debug(f"Token endpoint: {token_endpoint}")
|
||||||
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||||
|
|
||||||
|
# Get or register an OAuth client
|
||||||
|
client_info = await load_or_register_client(
|
||||||
|
nextcloud_url=nextcloud_url,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
storage_path=".nextcloud_oauth_test_client.json",
|
||||||
|
redirect_uris=["http://localhost:8000/oauth/callback"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use client credentials to get a token via password grant
|
||||||
|
# Note: This requires the OIDC app to support Resource Owner Password flow
|
||||||
|
token_response = await http_client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "password",
|
||||||
|
"client_id": client_info.client_id,
|
||||||
|
"client_secret": client_info.client_secret,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"scope": "openid profile email",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_response.status_code != 200:
|
||||||
|
logger.error(f"Failed to get OAuth token: {token_response.text}")
|
||||||
|
raise Exception(f"Token request failed: {token_response.status_code}")
|
||||||
|
|
||||||
|
token_data = token_response.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
raise Exception("No access_token in response")
|
||||||
|
|
||||||
|
logger.info("Successfully obtained OAuth access token for testing")
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def oauth_token() -> str:
|
||||||
|
"""
|
||||||
|
Fixture to obtain an OAuth access token for integration tests.
|
||||||
|
|
||||||
|
This uses the Resource Owner Password flow to get a token without
|
||||||
|
requiring interactive browser authentication.
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
|
|
||||||
|
if not all([nextcloud_host, username, password]):
|
||||||
|
pytest.skip(
|
||||||
|
"OAuth token fixture requires NEXTCLOUD_HOST, USERNAME, and PASSWORD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for Nextcloud to be ready
|
||||||
|
if not await wait_for_nextcloud(nextcloud_host):
|
||||||
|
pytest.fail(f"Nextcloud server at {nextcloud_host} is not ready")
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = await get_oauth_token(nextcloud_host, username, password)
|
||||||
|
return token
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to obtain OAuth token: {e}")
|
||||||
|
pytest.skip(f"Could not obtain OAuth token for testing: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_oauth_client(oauth_token: str) -> AsyncGenerator[NextcloudClient, Any]:
|
||||||
|
"""
|
||||||
|
Fixture to create a NextcloudClient instance using OAuth authentication.
|
||||||
|
Uses the oauth_token fixture to get an access token.
|
||||||
|
"""
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
|
|
||||||
|
if not all([nextcloud_host, username]):
|
||||||
|
pytest.skip("OAuth client fixture requires NEXTCLOUD_HOST and USERNAME")
|
||||||
|
|
||||||
|
logger.info(f"Creating OAuth NextcloudClient for user: {username}")
|
||||||
|
client = NextcloudClient.from_token(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
token=oauth_token,
|
||||||
|
username=username,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the OAuth client works
|
||||||
|
try:
|
||||||
|
await client.capabilities()
|
||||||
|
logger.info("OAuth NextcloudClient initialized and capabilities checked.")
|
||||||
|
yield client
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize OAuth NextcloudClient: {e}")
|
||||||
|
pytest.fail(f"Failed to connect to Nextcloud with OAuth token: {e}")
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_oauth_client() -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""
|
||||||
|
Fixture to create an MCP client session for OAuth integration tests.
|
||||||
|
Connects to the OAuth-enabled MCP server on port 8001.
|
||||||
|
"""
|
||||||
|
logger.info("Creating Streamable HTTP client for OAuth MCP server")
|
||||||
|
streamable_context = streamablehttp_client("http://127.0.0.1:8001/mcp")
|
||||||
|
session_context = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
read_stream, write_stream, _ = await streamable_context.__aenter__()
|
||||||
|
session_context = ClientSession(read_stream, write_stream)
|
||||||
|
session = await session_context.__aenter__()
|
||||||
|
await session.initialize()
|
||||||
|
logger.info("OAuth MCP client session initialized successfully")
|
||||||
|
|
||||||
|
yield session
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up in reverse order, ignoring task scope issues
|
||||||
|
if session_context is not None:
|
||||||
|
try:
|
||||||
|
await session_context.__aexit__(None, None, None)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "cancel scope" in str(e):
|
||||||
|
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Error closing OAuth session: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing OAuth session: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await streamable_context.__aexit__(None, None, None)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "cancel scope" in str(e):
|
||||||
|
logger.debug(f"Ignoring cancel scope teardown issue: {e}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Error closing OAuth streamable HTTP client: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing OAuth streamable HTTP client: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"""Integration tests for OAuth authentication."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthClient:
|
||||||
|
"""Test OAuth-authenticated NextcloudClient."""
|
||||||
|
|
||||||
|
async def test_oauth_client_capabilities(self, nc_oauth_client: NextcloudClient):
|
||||||
|
"""Test that OAuth client can fetch capabilities."""
|
||||||
|
capabilities = await nc_oauth_client.capabilities()
|
||||||
|
|
||||||
|
assert capabilities is not None
|
||||||
|
assert "version" in capabilities
|
||||||
|
logger.info(
|
||||||
|
f"OAuth client successfully fetched capabilities: {capabilities.get('version')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test_oauth_client_notes_list(self, nc_oauth_client: NextcloudClient):
|
||||||
|
"""Test that OAuth client can list notes."""
|
||||||
|
notes = await nc_oauth_client.notes.get_notes()
|
||||||
|
|
||||||
|
assert isinstance(notes, list)
|
||||||
|
logger.info(f"OAuth client successfully listed {len(notes)} notes")
|
||||||
|
|
||||||
|
async def test_oauth_client_create_note(self, nc_oauth_client: NextcloudClient):
|
||||||
|
"""Test that OAuth client can create and delete a note."""
|
||||||
|
# Create note
|
||||||
|
note_title = "OAuth Test Note"
|
||||||
|
note_content = "This note was created with OAuth authentication"
|
||||||
|
|
||||||
|
created_note = await nc_oauth_client.notes.create_note(
|
||||||
|
title=note_title, content=note_content
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created_note is not None
|
||||||
|
assert created_note.get("title") == note_title
|
||||||
|
note_id = created_note.get("id")
|
||||||
|
assert note_id is not None
|
||||||
|
|
||||||
|
logger.info(f"OAuth client successfully created note with ID: {note_id}")
|
||||||
|
|
||||||
|
# Clean up - delete the note
|
||||||
|
try:
|
||||||
|
await nc_oauth_client.notes.delete_note(note_id=note_id)
|
||||||
|
logger.info(f"OAuth client successfully deleted note {note_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to clean up test note {note_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthTokenValidation:
|
||||||
|
"""Test OAuth token validation and bearer auth."""
|
||||||
|
|
||||||
|
async def test_token_in_request_headers(
|
||||||
|
self, nc_oauth_client: NextcloudClient, oauth_token: str
|
||||||
|
):
|
||||||
|
"""Verify that bearer token is being used in requests."""
|
||||||
|
# The client should be using BearerAuth
|
||||||
|
assert nc_oauth_client._auth is not None
|
||||||
|
|
||||||
|
# Make a request and verify it works
|
||||||
|
capabilities = await nc_oauth_client.capabilities()
|
||||||
|
assert capabilities is not None
|
||||||
|
|
||||||
|
logger.info("OAuth bearer token is correctly included in requests")
|
||||||
|
|
||||||
|
async def test_invalid_token_fails(self):
|
||||||
|
"""Test that an invalid token results in authentication failure."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import BearerAuth
|
||||||
|
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
if not nextcloud_host:
|
||||||
|
pytest.skip("NEXTCLOUD_HOST not set")
|
||||||
|
|
||||||
|
# Create client with invalid token using BearerAuth
|
||||||
|
invalid_client = NextcloudClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
username="testuser",
|
||||||
|
auth=BearerAuth("invalid_token_12345"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attempt to use the client should fail with 401
|
||||||
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
|
with pytest.raises(HTTPStatusError) as exc_info:
|
||||||
|
await invalid_client.capabilities()
|
||||||
|
|
||||||
|
assert exc_info.value.response.status_code == 401
|
||||||
|
|
||||||
|
await invalid_client.close()
|
||||||
|
logger.info("Invalid OAuth token correctly rejected")
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthMCPIntegration:
|
||||||
|
"""Test OAuth integration with MCP server."""
|
||||||
|
|
||||||
|
@pytest.mark.skip(
|
||||||
|
reason="OAuth MCP server integration requires full OAuth flow implementation"
|
||||||
|
)
|
||||||
|
async def test_mcp_oauth_server_connection(self, nc_mcp_oauth_client):
|
||||||
|
"""Test connection to OAuth-enabled MCP server."""
|
||||||
|
# This test is currently skipped because the OAuth MCP server
|
||||||
|
# requires the full OAuth authorization flow to be implemented
|
||||||
|
# in the MCP SDK and app.py
|
||||||
|
|
||||||
|
# Once implemented, this test should:
|
||||||
|
# 1. Connect to the OAuth MCP server
|
||||||
|
# 2. Verify tools are available
|
||||||
|
# 3. Call a tool and verify it works with OAuth auth
|
||||||
|
|
||||||
|
result = await nc_mcp_oauth_client.list_tools()
|
||||||
|
assert result is not None
|
||||||
|
assert len(result.tools) > 0
|
||||||
|
|
||||||
|
logger.info(f"OAuth MCP server has {len(result.tools)} tools available")
|
||||||
Reference in New Issue
Block a user