Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions nextcloud_mcp_server/auth/context_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import logging
import time

import httpx
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context

Expand All @@ -20,11 +21,52 @@

logger = logging.getLogger(__name__)


async def _fetch_user_profile(base_url: str, token: str) -> tuple[str | None, str | None]:
"""Fetch user profile (email and display name) from Nextcloud API.

Args:
base_url: Nextcloud base URL
token: OAuth bearer token

Returns:
Tuple of (email, display_name). Either or both may be None if not available.
"""
try:
async with httpx.AsyncClient(
base_url=base_url,
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
) as client:
response = await client.get(
"/ocs/v2.php/cloud/user",
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
response.raise_for_status()

data = response.json()
# Nextcloud OCS API returns data in ocs.data
user_data = data.get("ocs", {}).get("data", {})

email = user_data.get("email")
display_name = user_data.get("displayname")

logger.debug(
f"Fetched user profile: email={email}, display_name={display_name}"
)
return email, display_name

except Exception as e:
logger.warning(f"Failed to fetch user profile: {e}")
# Don't fail the whole request if profile fetch fails
return None, None


# Token exchange cache: token_hash -> (exchanged_token, expiry_timestamp)
_exchange_cache: dict[str, tuple[str, float]] = {}


def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
async def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
"""
Create NextcloudClient for multi-audience mode (no exchange needed).

Expand Down Expand Up @@ -69,10 +111,22 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient:
f"(no exchange needed)"
)

# Fetch user profile for organizer field
email, display_name = await _fetch_user_profile(base_url, access_token.token)

if email or display_name:
logger.debug(f"Using user profile: email={email}, display_name={display_name}")
else:
logger.debug("No user profile available, using username only")

# Token was validated to have MCP audience
# Nextcloud will validate its own audience independently
return NextcloudClient.from_token(
base_url=base_url, token=access_token.token, username=username
base_url=base_url,
token=access_token.token,
username=username,
user_email=email,
user_display_name=display_name,
)

except AttributeError as e:
Expand Down Expand Up @@ -143,8 +197,16 @@ async def get_session_client_from_context(
f"Using cached exchanged token (expires in {expiry - time.time():.1f}s)"
)
oauth_token_cache_hits_total.labels(hit="true").inc()
# Fetch user profile for organizer field
email, display_name = await _fetch_user_profile(base_url, cached_token)
if email or display_name:
logger.debug(f"Using cached user profile: email={email}, display_name={display_name}")
return NextcloudClient.from_token(
base_url=base_url, token=cached_token, username=username
base_url=base_url,
token=cached_token,
username=username,
user_email=email,
user_display_name=display_name,
)
else:
logger.debug("Cached token expired, removing from cache")
Expand Down Expand Up @@ -178,9 +240,18 @@ async def get_session_client_from_context(
# Clean up expired cache entries
_cleanup_exchange_cache()

# Fetch user profile for organizer field
email, display_name = await _fetch_user_profile(base_url, exchanged_token)
if email or display_name:
logger.debug(f"Using user profile from exchanged token: email={email}, display_name={display_name}")

# Create client with exchanged token
return NextcloudClient.from_token(
base_url=base_url, token=exchanged_token, username=username
base_url=base_url,
token=exchanged_token,
username=username,
user_email=email,
user_display_name=display_name,
)

except AttributeError as e:
Expand Down
34 changes: 29 additions & 5 deletions nextcloud_mcp_server/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,17 @@ async def handle_async_request(self, request: Request) -> Response:
class NextcloudClient:
"""Main Nextcloud client that orchestrates all app clients."""

def __init__(self, base_url: str, username: str, auth: Auth | None = None):
def __init__(
self,
base_url: str,
username: str,
auth: Auth | None = None,
user_email: str | None = None,
user_display_name: str | None = None,
):
self.username = username
self.user_email = user_email
self.user_display_name = user_display_name
self._client = AsyncClient(
base_url=base_url,
auth=auth,
Expand All @@ -77,7 +86,7 @@ def __init__(self, base_url: str, username: str, auth: Auth | None = None):
self.webdav = WebDAVClient(self._client, username)
self.tables = TablesClient(self._client, username)
self.calendar = CalendarClient(
base_url, username, auth
base_url, username, auth, user_email, user_display_name
) # Uses AsyncDavClient internally
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
Expand All @@ -99,24 +108,39 @@ def from_env(cls):
username = os.environ["NEXTCLOUD_USERNAME"]
password = os.environ["NEXTCLOUD_PASSWORD"]
# 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), user_email=None, user_display_name=None)

@classmethod
def from_token(cls, base_url: str, token: str, username: str):
def from_token(
cls,
base_url: str,
token: str,
username: str,
user_email: str | None = None,
user_display_name: str | None = None,
):
"""Create NextcloudClient with OAuth bearer token.

Args:
base_url: Nextcloud base URL
token: OAuth access token
username: Nextcloud username
user_email: Optional email address from user profile
user_display_name: Optional display name from user profile

Returns:
NextcloudClient configured with bearer token authentication
"""
from ..auth import BearerAuth # noqa: PLC0415

logger.info(f"Creating NC Client for user '{username}' using OAuth token")
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
return cls(
base_url=base_url,
username=username,
auth=BearerAuth(token),
user_email=user_email,
user_display_name=user_display_name,
)

async def capabilities(self):
response = await self._client.get(
Expand Down
Loading