refactor: integrate token exchange into unified get_client() pattern

Resolves the token exchange implementation gap where get_session_client()
was implemented but never used by tools. Unifies token acquisition into a
single async get_client() method that handles both pass-through and token
exchange modes transparently.

Core Changes:
- Make get_client() async and merge token exchange logic into it
- Remove scopes parameter from token exchange (Nextcloud doesn't support OAuth scopes)
- Update all 8 tool modules to use await get_client(ctx)
- Fix provisioning decorator to skip checks in BasicAuth mode

Token Acquisition Modes:
1. BasicAuth: Returns shared client (no token operations)
2. OAuth pass-through (default): Verifies and passes Flow 1 token to Nextcloud
3. OAuth token exchange (opt-in): Exchanges Flow 1 token for ephemeral token via RFC 8693

Key Architectural Clarifications:
- Progressive Consent (Flow 1/2) = Authorization architecture
- Token Exchange = Token acquisition pattern during tool execution
- Refresh tokens from Flow 2 are NEVER used for tool calls (only background jobs)
- Nextcloud scopes are "soft-scopes" enforced by MCP server, not IdP

Documentation Updates:
- ADR-004: Added comprehensive token acquisition patterns section
- CRITICAL-TOKEN-EXCHANGE-PATTERN.md: Updated to reflect implementation status
- CLAUDE.md: Updated architectural patterns with async get_client()

Testing:
- All 36 unit tests passing
- All 4 smoke tests passing (BasicAuth mode)
- Linting issues fixed (ruff)

Configuration:
ENABLE_TOKEN_EXCHANGE=false (default) - pass-through mode
ENABLE_TOKEN_EXCHANGE=true (opt-in) - token exchange mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-03 19:45:47 +01:00
parent 636bfd416f
commit 71e77e95bc
18 changed files with 1819 additions and 647 deletions
+11 -11
View File
@@ -28,7 +28,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
DirectoryListing with files, total_count, directories_count, files_count, and total_size
"""
client = get_client(ctx)
client = await get_client(ctx)
items = await client.webdav.list_directory(path)
# Convert to FileInfo models
@@ -76,7 +76,7 @@ def configure_webdav_tools(mcp: FastMCP):
result = await nc_webdav_read_file("Images/photo.jpg")
logger.info(result['encoding']) # 'base64'
"""
client = get_client(ctx)
client = await get_client(ctx)
content, content_type = await client.webdav.read_file(path)
# Check if this is a parseable document (PDF, DOCX, etc.)
@@ -143,7 +143,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating success
"""
client = get_client(ctx)
client = await get_client(ctx)
# Handle base64 encoded content
if content_type and "base64" in content_type.lower():
@@ -167,7 +167,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code (201 for created, 405 if already exists)
"""
client = get_client(ctx)
client = await get_client(ctx)
return await client.webdav.create_directory(path)
@mcp.tool()
@@ -181,7 +181,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if not found)
"""
client = get_client(ctx)
client = await get_client(ctx)
return await client.webdav.delete_resource(path)
@mcp.tool()
@@ -199,7 +199,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
"""
client = get_client(ctx)
client = await get_client(ctx)
return await client.webdav.move_resource(
source_path, destination_path, overwrite
)
@@ -219,7 +219,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
"""
client = get_client(ctx)
client = await get_client(ctx)
return await client.webdav.copy_resource(
source_path, destination_path, overwrite
)
@@ -249,7 +249,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
client = await get_client(ctx)
# Build where conditions based on filters
conditions = []
@@ -355,7 +355,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
client = await get_client(ctx)
results = await client.webdav.find_by_name(
pattern=pattern, scope=scope, limit=limit
)
@@ -382,7 +382,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
SearchFilesResponse with list of matching files
"""
client = get_client(ctx)
client = await get_client(ctx)
results = await client.webdav.find_by_type(
mime_type=mime_type, scope=scope, limit=limit
)
@@ -408,7 +408,7 @@ def configure_webdav_tools(mcp: FastMCP):
Returns:
SearchFilesResponse with list of favorite files
"""
client = get_client(ctx)
client = await get_client(ctx)
results = await client.webdav.list_favorites(scope=scope, limit=limit)
file_infos = [FileInfo(**result) for result in results]
return SearchFilesResponse(