Adds a native Nextcloud app "Astroglobe" that provides: - Personal settings: OAuth authorization for background MCP access - Admin settings: Server status and vector sync monitoring - API endpoints for MCP server communication The app uses PKCE OAuth flow to obtain tokens for the MCP server, enabling features like background vector sync per ADR-018. Includes: - PHP app structure (controllers, services, settings) - Vue.js frontend components - Docker compose mount configuration - Installation hook for development testing - ADR-018 documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
76 KiB
ADR-018: Nextcloud PHP App for Settings and Management UI
Status: Proposed Date: 2025-12-14 Related: ADR-011 (AppAPI Architecture - Rejected), ADR-008 (MCP Sampling)
Context
The Nextcloud MCP Server currently provides a browser-based administrative interface at the /app endpoint, implemented as part of the standalone MCP server using Starlette routing. This interface provides:
- User information and session management
- Vector sync status monitoring with real-time updates
- Interactive vector visualization with 2D PCA plots
- Webhook management (admin only)
- OAuth login/logout flows
While this approach works functionally, it has several limitations:
Current Architecture Limitations
1. Separate Authentication System
- Users must authenticate separately to access
/appendpoint - Browser OAuth flow creates session cookies independent of Nextcloud
- No integration with Nextcloud's existing user sessions
- Duplicates authentication logic that Nextcloud already provides
2. Deployment Complexity
/appendpoint must be exposed alongside MCP protocol endpoints- Requires separate routing, templates, static file serving in MCP server
- Mixing concerns: MCP protocol handler + web UI in same codebase
- Users must bookmark/remember separate URL (e.g.,
mcp-server.example.com/app)
3. Limited Integration
- Cannot appear in Nextcloud's settings interface
- No integration with Nextcloud's design system
- Missing Nextcloud features: notifications, activity stream, search
- Doesn't follow Nextcloud UX patterns users are familiar with
4. Mobile and Accessibility
- Must implement responsive design separately
- Accessibility features reimplemented instead of using NC's framework
- No integration with Nextcloud mobile apps
5. Maintenance Burden
- Must maintain HTML templates, CSS, JavaScript in Python codebase
- Jinja2 templating separate from Nextcloud's template system
- Static file serving and caching handled manually
- HTMX and Alpine.js dependencies managed separately
Why Not ExApp Architecture?
In ADR-011, we extensively investigated running the MCP server as a Nextcloud ExApp (External Application). This would have provided native Nextcloud integration but was rejected due to fundamental protocol incompatibilities:
Critical Limitations of ExApp Architecture:
- ❌ No MCP sampling - AppAPI proxy blocks bidirectional communication required for RAG
- ❌ No real-time progress updates - Stateless request/response proxy prevents server→client notifications
- ❌ Buffered-only streaming - ExApp proxy accumulates responses, preventing incremental updates
- ❌ No persistent connections - MCP protocol features like elicitation impossible
Validation from Nextcloud's Own Projects:
- Nextcloud's Context Agent ExApp faces identical limitations
- Works around them by using Task Processing API instead of MCP protocol
- Confirms limitations are architectural, not implementation-specific
Conclusion from ADR-011:
The hybrid OAuth + AppAPI architecture is not viable for this project's use case. While AppAPI ExApps provide value for in-app Nextcloud integration, the architectural constraints fundamentally conflict with MCP's protocol requirements for external client integration.
Therefore: MCP server must remain standalone with OAuth mode to support full MCP protocol capabilities.
The Solution: Nextcloud PHP App for UI Only
Instead of running the MCP server as an ExApp (which breaks the protocol), we can create a lightweight Nextcloud PHP app that provides only the UI while the MCP server remains standalone:
Key Insight: The UI doesn't need to be in the same process as the MCP protocol handler. We can separate concerns:
- MCP Server (Python): Protocol handling, background workers, vector sync, sampling support
- Nextcloud PHP App: UI only, delegates all operations to MCP server via management API
This gives us native Nextcloud integration without the ExApp protocol limitations.
Decision
We will migrate the /app administrative interface to a standalone Nextcloud PHP app while keeping the MCP server as a standalone service with OAuth mode.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Nextcloud PHP App (UI Only) │
│ ├─ OAuth Client (PKCE flow via NC OIDC) │
│ ├─ Personal Settings Panel │
│ ├─ Admin Settings Panel │
│ ├─ Vector Visualization Page (Vue.js) │
│ ├─ Webhook Management (admins) │
│ └─ Session Management (revoke access) │
└──────────────────────┬──────────────────────────────────────┘
│ (Management API - HTTP REST)
│ - Authentication: OAuth Bearer Token
│ - Same token audience as MCP clients
│ - Token validated by UnifiedTokenVerifier
▼
┌─────────────────────────────────────────────────────────────┐
│ Standalone MCP Server (OAuth Mode) │
│ ├─ /mcp/* - MCP Protocol Endpoints (FastMCP) │
│ │ └─ Full sampling/elicitation support │
│ ├─ /api/v1/* - Management API (NEW) │
│ │ ├─ /status - Server health, version │
│ │ ├─ /users/{id}/session - User session details │
│ │ ├─ /users/{id}/revoke - Revoke background access │
│ │ ├─ /vector-sync/status - Indexing metrics │
│ │ └─ /vector-viz/search - Search API for visualization │
│ ├─ OAuth Endpoints (existing) │
│ │ ├─ /oauth/authorize - Client authorization │
│ │ ├─ /oauth/callback - OAuth callback │
│ │ └─ /oauth/token - Token endpoint │
│ └─ Background Workers │
│ ├─ Vector sync scanner │
│ └─ Webhook processors │
└──────────────────────┬──────────────────────────────────────┘
│
▼
Nextcloud APIs
(Notes, Calendar, Files, etc.)
Communication Flow
PHP App OAuth Flow:
1. User visits NC Personal Settings → PHP App detects no MCP token
2. PHP App redirects to NC OIDC → User authorizes PHP app
3. NC OIDC issues JWT with aud="http://localhost:8001" (MCP server)
4. PHP App stores token, redirects back to settings
5. PHP App calls MCP Management API with Bearer token
6. MCP Server validates token (same verifier as MCP clients)
User Views Settings:
User → NC Web UI → PHP App → Management API → MCP Server
(GET /api/v1/status)
Authorization: Bearer <oauth_token>
User Revokes Access:
User → NC Web UI → PHP App → Management API → MCP Server
(click revoke) (POST /api/v1/users/{id}/revoke)
Authorization: Bearer <oauth_token>
→ Delete refresh token
User Tests Vector Search:
User → NC Web UI → PHP App → Management API → MCP Server
(enter query) (POST /api/v1/vector-viz/search)
→ Execute hybrid search
→ Return results + PCA coordinates
MCP Client Uses Server:
Claude Desktop → MCP Server /mcp/sse endpoint
↓
Full MCP protocol with sampling ✅
(Same token audience: MCP server URL)
Core Principles
-
Separation of Concerns
- MCP server handles protocol, background jobs, vector operations
- PHP app handles UI rendering and user interaction
- Clear API boundary with versioned REST endpoints
-
Single Source of Truth
- MCP server owns all business logic and state
- PHP app is stateless, delegates to management API
- No duplication of authentication, authorization, or data processing
-
Native Nextcloud Integration
- Follows NC settings panel conventions
- Uses NC design system and components
- Integrates with NC session management
- Appears in standard NC settings navigation
-
Backwards Compatibility
- Existing
/appendpoint remains during migration - Users can choose which UI to use
- Deprecated in Release N, removed in Release N+2
- Existing
-
MCP Protocol Integrity
- No changes to MCP server architecture (remains OAuth standalone)
- Full sampling, elicitation, streaming support preserved
- External MCP clients unaffected
Implementation Details
Phase 1: Add Management API to MCP Server
Create new REST API endpoints alongside existing MCP protocol endpoints:
# nextcloud_mcp_server/api/management.py
from starlette.routing import Route
from starlette.responses import JSONResponse
from nextcloud_mcp_server.auth.management_auth import require_admin_or_self
@app.get("/api/v1/status")
async def get_server_status(request: Request) -> JSONResponse:
"""Server health and version info.
Public endpoint, no authentication required.
Returns basic server information for health checks.
"""
from nextcloud_mcp_server import __version__
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
return JSONResponse({
"version": __version__,
"auth_mode": "oauth" if settings.enable_oauth else "basic",
"vector_sync_enabled": settings.vector_sync_enabled,
"uptime_seconds": get_uptime(),
"management_api_version": "v1",
})
@app.get("/api/v1/users/{user_id}/session")
@require_admin_or_self
async def get_user_session(request: Request, user_id: str) -> JSONResponse:
"""Get user session details.
Requires authentication. Users can view their own session,
admins can view any session.
Returns:
- session_id: User identifier
- background_access_granted: Whether refresh token exists
- background_access_details: Flow type, scopes, provisioned_at
- idp_profile: User profile from identity provider (if cached)
"""
storage = request.app.state.storage
# Get session metadata
refresh_token_data = await storage.get_refresh_token(user_id)
if not refresh_token_data:
return JSONResponse({
"session_id": user_id,
"background_access_granted": False,
})
# Get cached user profile
profile = await storage.get_user_profile(user_id)
return JSONResponse({
"session_id": user_id,
"background_access_granted": True,
"background_access_details": {
"flow_type": refresh_token_data.get("flow_type", "unknown"),
"provisioned_at": refresh_token_data.get("provisioned_at"),
"scopes": refresh_token_data.get("scopes", "N/A"),
"token_audience": refresh_token_data.get("token_audience", "unknown"),
},
"idp_profile": profile,
})
@app.post("/api/v1/users/{user_id}/revoke")
@require_admin_or_self
async def revoke_user_access(request: Request, user_id: str) -> JSONResponse:
"""Revoke background access for user.
Deletes the refresh token, preventing background operations
from running on behalf of this user.
Requires authentication. Users can revoke their own access,
admins can revoke any user's access.
"""
storage = request.app.state.storage
await storage.delete_refresh_token(user_id)
logger.info(f"Revoked background access for user: {user_id}")
return JSONResponse({
"success": True,
"message": f"Background access revoked for user {user_id}",
})
@app.get("/api/v1/vector-sync/status")
async def get_vector_sync_status(request: Request) -> JSONResponse:
"""Vector sync metrics.
Public endpoint, no authentication required.
Returns real-time indexing status and metrics.
Requires: VECTOR_SYNC_ENABLED=true
"""
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404
)
# Get metrics from document manager
from nextcloud_mcp_server.search.document_manager import get_indexing_metrics
metrics = await get_indexing_metrics()
return JSONResponse({
"status": metrics.get("status", "unknown"),
"indexed_documents": metrics.get("indexed_count", 0),
"pending_documents": metrics.get("pending_count", 0),
"last_sync_time": metrics.get("last_sync_time"),
"documents_per_second": metrics.get("docs_per_second", 0),
"errors_24h": metrics.get("error_count_24h", 0),
})
@app.post("/api/v1/vector-viz/search")
@require_authenticated_user # Requires valid OAuth token
async def vector_search(request: Request) -> JSONResponse:
"""Execute semantic search for visualization.
AUTHENTICATION REQUIRED: User must be authenticated via OAuth token.
Results are filtered to only include the authenticated user's documents.
Request body:
- query: Search query string
- algorithm: "semantic", "bm25", or "hybrid" (default)
- limit: Number of results (default: 10, max: 50)
- include_pca: Whether to include PCA coordinates for 2D plot
Returns:
- results: Array of matching documents with scores (user's documents only)
- pca_coordinates: 2D coordinates for visualization (if requested)
- algorithm_used: Which search algorithm was used
- total_documents: Total documents in corpus for this user
"""
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404
)
# Get authenticated user from OAuth token
user_id, _ = await validate_token_and_get_user(request)
data = await request.json()
query = data.get("query", "")
algorithm = data.get("algorithm", "hybrid")
limit = min(int(data.get("limit", 10)), 50)
include_pca = data.get("include_pca", True)
if not query:
return JSONResponse({"error": "Query is required"}, status_code=400)
# Execute search filtered to user's documents
from nextcloud_mcp_server.search.hybrid import search_documents
results = await search_documents(
query=query,
filters={"user_id": user_id}, # CRITICAL: Filter by authenticated user
algorithm=algorithm,
limit=limit,
include_pca=include_pca,
)
return JSONResponse(results)
Authentication for Management API:
The management API uses the same OAuth token verification as MCP clients. The PHP app obtains tokens through PKCE flow with the same audience as MCP clients (the MCP server URL).
# nextcloud_mcp_server/api/management.py
async def validate_token_and_get_user(request: Request) -> tuple[str, dict]:
"""Validate OAuth bearer token and extract user ID.
Uses the same UnifiedTokenVerifier as MCP client connections.
Token audience must match the MCP server URL.
Returns:
Tuple of (user_id, token_info)
Raises:
ValueError: If token is invalid or missing
"""
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get token verifier from app state (set in starlette_lifespan)
token_verifier = request.app.state.oauth_context["token_verifier"]
# Validate token - handles both JWT and opaque tokens
access_token = await token_verifier.verify_token(token)
if not access_token:
raise ValueError("Token validation failed")
# Extract user ID from token
user_id = access_token.resource
if not user_id:
raise ValueError("Token missing user identifier")
return user_id, {
"sub": user_id,
"client_id": access_token.client_id,
"scopes": access_token.scopes,
}
Key Design Points:
- Same token verifier: PHP app tokens validated by same
UnifiedTokenVerifieras MCP clients - Same audience: PHP app OAuth client configured with
resource_urlmatching MCP server URL - Self-service only: Users can only access/revoke their own sessions (token user_id must match path)
- No shared secrets: No API keys needed - OAuth provides authentication and authorization
Add routes to app.py:
# In nextcloud_mcp_server/app.py
if settings.management_api_enabled:
from nextcloud_mcp_server.api.management import (
get_server_status,
get_user_session,
revoke_user_access,
get_vector_sync_status,
vector_search,
)
routes.extend([
Route("/api/v1/status", get_server_status, methods=["GET"]),
Route("/api/v1/users/{user_id}/session", get_user_session, methods=["GET"]),
Route("/api/v1/users/{user_id}/revoke", revoke_user_access, methods=["POST"]),
Route("/api/v1/vector-sync/status", get_vector_sync_status, methods=["GET"]),
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"]),
])
logger.info("Management API enabled at /api/v1/*")
Phase 2: Create Nextcloud PHP App
Directory Structure:
apps/nextcloud_mcp_admin/
├── appinfo/
│ ├── info.xml # App metadata, dependencies
│ ├── routes.php # Route definitions
│ └── app.php # App initialization
├── lib/
│ ├── AppInfo/
│ │ └── Application.php # App container setup
│ ├── Controller/
│ │ ├── SettingsController.php # Settings page controller
│ │ ├── VizController.php # Vector viz controller
│ │ └── ApiController.php # Proxy to management API
│ ├── Service/
│ │ └── McpServerClient.php # HTTP client wrapper
│ └── Settings/
│ ├── AdminSettings.php # Admin settings section
│ └── PersonalSettings.php # Personal settings section
├── templates/
│ ├── settings/
│ │ ├── admin.php # Admin panel template
│ │ └── personal.php # User panel template
│ └── vector-viz.php # Vector viz page
├── js/
│ ├── admin-settings.js # Admin panel Vue.js app
│ ├── personal-settings.js # Personal panel Vue.js app
│ └── vector-viz.js # Vector viz (port from /app/static)
├── css/
│ └── styles.css # App styles
└── img/
└── app.svg # App icon
Example: McpServerClient.php
<?php
namespace OCA\MCPServerUI\Service;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
/**
* HTTP client for MCP Server Management API.
*
* Uses OAuth Bearer tokens for authentication. The PHP app obtains tokens
* through PKCE flow with the same audience as MCP clients.
*/
class McpServerClient {
private $httpClient;
private $config;
private $logger;
private $serverUrl;
public function __construct(
IClientService $clientService,
IConfig $config,
LoggerInterface $logger
) {
$this->httpClient = $clientService->newClient();
$this->config = $config;
$this->logger = $logger;
// Internal URL for server-to-server communication
$this->serverUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
}
/**
* Get server status (version, auth mode, features)
* Public endpoint - no authentication required.
*/
public function getStatus(): array {
try {
$response = $this->httpClient->get($this->serverUrl . '/api/v1/status');
return json_decode($response->getBody(), true);
} catch (\Exception $e) {
$this->logger->error('Failed to get MCP server status: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* Get user session details.
* Requires OAuth bearer token with matching user_id.
*
* @param string $userId The user ID to query
* @param string $accessToken OAuth access token from PHP app's token storage
*/
public function getUserSession(string $userId, string $accessToken): array {
try {
$response = $this->httpClient->get(
$this->serverUrl . "/api/v1/users/$userId/session",
[
'headers' => [
'Authorization' => "Bearer $accessToken"
]
]
);
return json_decode($response->getBody(), true);
} catch (\Exception $e) {
$this->logger->error("Failed to get session for user $userId: " . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* Revoke user's background access.
* Requires OAuth bearer token with matching user_id.
*
* @param string $userId The user ID whose access to revoke
* @param string $accessToken OAuth access token from PHP app's token storage
*/
public function revokeUserAccess(string $userId, string $accessToken): array {
try {
$response = $this->httpClient->post(
$this->serverUrl . "/api/v1/users/$userId/revoke",
[
'headers' => [
'Authorization' => "Bearer $accessToken"
]
]
);
return json_decode($response->getBody(), true);
} catch (\Exception $e) {
$this->logger->error("Failed to revoke access for user $userId: " . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* Get vector sync status.
* Public endpoint - no authentication required.
*/
public function getVectorSyncStatus(): array {
try {
$response = $this->httpClient->get($this->serverUrl . '/api/v1/vector-sync/status');
return json_decode($response->getBody(), true);
} catch (\Exception $e) {
$this->logger->error('Failed to get vector sync status: ' . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* Execute vector search for authenticated user.
*
* AUTHENTICATION: OAuth Bearer token required.
* Results are filtered to the user associated with the token.
*
* @param string $query Search query
* @param string $accessToken OAuth access token for the user
* @param string $algorithm Search algorithm: 'semantic', 'bm25', or 'hybrid'
* @param int $limit Maximum results
* @param bool $includePca Include PCA visualization coordinates
*/
public function search(
string $query,
string $accessToken,
string $algorithm = 'hybrid',
int $limit = 10,
bool $includePca = false
): array {
try {
$response = $this->httpClient->post(
$this->serverUrl . '/api/v1/search',
[
'headers' => [
'Authorization' => "Bearer $accessToken",
],
'json' => [
'query' => $query,
'algorithm' => $algorithm,
'limit' => $limit,
'include_pca' => $includePca,
'include_chunks' => true,
]
]
);
return json_decode($response->getBody(), true);
} catch (\Exception $e) {
$this->logger->error("Failed to execute search: " . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
/**
* Get the public MCP server URL (for OAuth redirect_uri, display).
*/
public function getPublicServerUrl(): string {
return $this->config->getSystemValue('mcp_server_public_url', $this->serverUrl);
}
/**
* Get the internal MCP server URL (for API calls).
*/
public function getServerUrl(): string {
return $this->serverUrl;
}
}
Example: PersonalSettings.php
<?php
namespace OCA\NextcloudMcpAdmin\Settings;
use OCA\NextcloudMcpAdmin\Service\McpServerClient;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IUserSession;
use OCP\Settings\ISettings;
class Personal implements ISettings {
private $client;
private $userSession;
public function __construct(
McpServerClient $client,
IUserSession $userSession
) {
$this->client = $client;
$this->userSession = $userSession;
}
/**
* @return TemplateResponse
*/
public function getForm() {
$user = $this->userSession->getUser();
if (!$user) {
return new TemplateResponse('nextcloud_mcp_admin', 'error', [
'message' => 'User not authenticated'
]);
}
$userId = $user->getUID();
// Fetch data from MCP server
$serverStatus = $this->client->getStatus();
$userSession = $this->client->getUserSession($userId);
$parameters = [
'userId' => $userId,
'serverStatus' => $serverStatus,
'session' => $userSession,
'vectorSyncEnabled' => $serverStatus['vector_sync_enabled'] ?? false,
'backgroundAccessGranted' => $userSession['background_access_granted'] ?? false,
];
return new TemplateResponse('nextcloud_mcp_admin', 'settings/personal', $parameters);
}
/**
* @return string the section ID (e.g. 'additional')
*/
public function getSection() {
return 'additional';
}
/**
* @return int priority (lower = higher up)
*/
public function getPriority() {
return 50;
}
}
Example: AdminSettings.php
<?php
namespace OCA\NextcloudMcpAdmin\Settings;
use OCA\NextcloudMcpAdmin\Service\McpServerClient;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\Settings\ISettings;
class Admin implements ISettings {
private $client;
public function __construct(McpServerClient $client) {
$this->client = $client;
}
/**
* @return TemplateResponse
*/
public function getForm() {
// Fetch data from MCP server
$serverStatus = $this->client->getStatus();
$vectorSyncStatus = $this->client->getVectorSyncStatus();
$parameters = [
'serverStatus' => $serverStatus,
'vectorSyncStatus' => $vectorSyncStatus,
'serverUrl' => $this->config->getSystemValue('mcp_server_url'),
];
return new TemplateResponse('nextcloud_mcp_admin', 'settings/admin', $parameters);
}
/**
* @return string the section ID
*/
public function getSection() {
return 'ai'; // Appears in "Artificial Intelligence" section
}
/**
* @return int priority
*/
public function getPriority() {
return 10;
}
}
Example Template: personal.php
<?php
script('nextcloud_mcp_admin', 'personal-settings');
style('nextcloud_mcp_admin', 'styles');
?>
<div id="mcp-personal-settings" class="section">
<h2><?php p($l->t('Nextcloud MCP Server')); ?></h2>
<?php if (!empty($_['session']['error'])): ?>
<div class="warning">
<p><?php p($_['session']['error']); ?></p>
</div>
<?php else: ?>
<div class="mcp-status-card">
<h3><?php p($l->t('Session Information')); ?></h3>
<table>
<tr>
<td><strong><?php p($l->t('User ID')); ?></strong></td>
<td><code><?php p($_['userId']); ?></code></td>
</tr>
<tr>
<td><strong><?php p($l->t('Background Access')); ?></strong></td>
<td>
<?php if ($_['backgroundAccessGranted']): ?>
<span class="badge badge-success">✓ Granted</span>
<?php else: ?>
<span class="badge badge-neutral">Not Granted</span>
<?php endif; ?>
</td>
</tr>
</table>
<?php if ($_['backgroundAccessGranted']): ?>
<div class="mcp-background-details">
<h4><?php p($l->t('Background Access Details')); ?></h4>
<table>
<tr>
<td><strong><?php p($l->t('Flow Type')); ?></strong></td>
<td><?php p($_['session']['background_access_details']['flow_type']); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Provisioned At')); ?></strong></td>
<td><?php p($_['session']['background_access_details']['provisioned_at']); ?></td>
</tr>
<tr>
<td><strong><?php p($l->t('Scopes')); ?></strong></td>
<td><code><?php p($_['session']['background_access_details']['scopes']); ?></code></td>
</tr>
</table>
<form method="post" action="<?php p($urlGenerator->linkToRoute('nextcloud_mcp_admin.api.revokeAccess')); ?>">
<input type="hidden" name="requesttoken" value="<?php p($_['requesttoken']); ?>">
<button type="submit" class="button warning" onclick="return confirm('<?php p($l->t('Are you sure you want to revoke background access?')); ?>');">
<?php p($l->t('Revoke Background Access')); ?>
</button>
</form>
</div>
<?php endif; ?>
</div>
<?php if ($_['vectorSyncEnabled']): ?>
<div class="mcp-vector-viz">
<h3><?php p($l->t('Vector Visualization')); ?></h3>
<p><?php p($l->t('Test semantic search and visualize results.')); ?></p>
<a href="<?php p($urlGenerator->linkToRoute('nextcloud_mcp_admin.viz.index')); ?>" class="button primary">
<?php p($l->t('Open Vector Visualization')); ?>
</a>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
Phase 3: Configuration
PHP App OAuth Client Registration:
The PHP app needs an OAuth client registered with Nextcloud OIDC that:
- Uses PKCE flow (public client, no client secret)
- Has
resource_urlset to MCP server URL (for token audience) - Includes scopes for accessing MCP server features
# Register OAuth client for PHP app
php occ oidc:create \
"MCP Server UI" \
"http://localhost:8080/apps/mcpserverui/oauth/callback" \
--client_id="nextcloudMcpServerUIPublicClient" \
--type=public \
--flow=code \
--token_type=jwt \
--resource_url="http://localhost:8001" \
--allowed_scopes="openid profile email notes:read notes:write calendar:read ..."
Nextcloud config/config.php:
<?php
$CONFIG = array(
// ... existing configuration
/**
* MCP Server Internal URL
*
* URL for PHP app to reach MCP server management API.
* In Docker, this is the internal container network URL.
*/
'mcp_server_url' => 'http://mcp-oauth:8001',
/**
* MCP Server Public URL
*
* URL users/browsers see. Used for OAuth audience and display.
* Must match the resource_url configured for the OAuth client.
*/
'mcp_server_public_url' => 'http://localhost:8001',
);
MCP Server .env:
# === OAuth Configuration ===
NEXTCLOUD_HOST=http://app:80 # Internal URL for API calls
NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080 # Public URL for token issuer
# OIDC Discovery
OIDC_DISCOVERY_URL=http://app:80/index.php/apps/oidc/.well-known/openid-configuration
# Token Audience
TOKEN_AUDIENCE=http://localhost:8001 # Must match PHP app's resource_url
# === Management API ===
# Automatically enabled in OAuth mode - uses same token verifier as MCP clients
# No separate API key needed
# === Disable Legacy Browser UI ===
ENABLE_BROWSER_UI=false
# === Vector Sync Configuration ===
VECTOR_SYNC_ENABLED=true
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# ... etc
Migration Path
Release 0.53.0: Dual UI Support (Both Active)
Changes:
- ✅ Add management API to MCP server (
/api/v1/*) - ✅ Create Nextcloud PHP app (
apps/nextcloud_mcp_admin) - ✅ Keep existing
/appendpoint active - ✅ Add deprecation notice to
/appUI
User Impact:
- Users can choose which UI to use
- Documentation explains migration path
- Both UIs fully functional
Config:
ENABLE_BROWSER_UI=true # Default: keep legacy UI active
MANAGEMENT_API_ENABLED=true # New: enable API for NC app
Release 0.54.0: Transition (NC App Recommended)
Changes:
- ✅ NC PHP app is recommended approach in docs
- ✅
/appendpoint shows migration banner - ⚠️ Default
ENABLE_BROWSER_UI=falsefor new installations - ✅ Existing deployments continue working (opt-in migration)
User Impact:
- New installations use NC app by default
- Existing installations see migration prompt
- Legacy
/appstill works if explicitly enabled
Config:
ENABLE_BROWSER_UI=false # Changed default for new installs
MANAGEMENT_API_ENABLED=true # Required for NC app
Release 0.56.0: Deprecation (NC App Only)
Changes:
- ❌ Remove
/appendpoint code entirely - ❌ Remove browser OAuth routes
- ❌ Remove HTML templates, static files
- ✅ NC PHP app is the only UI
User Impact:
- Cleaner codebase
- Simpler deployment
- Native Nextcloud integration only
Config:
# ENABLE_BROWSER_UI removed (no longer exists)
MANAGEMENT_API_ENABLED=true # Required
Benefits
✅ MCP Protocol Integrity
Preserves Full MCP Capabilities:
- ✅ Sampling support - Server can request LLM completions from client (ADR-008)
- ✅ Elicitation support - Server can request user input via protocol
- ✅ Bidirectional streaming - Real-time progress updates, notifications
- ✅ Persistent connections - SSE/WebSocket support for MCP clients
- ✅ External client integration - Claude Desktop, custom clients work unchanged
No ExApp Limitations:
- The MCP server remains standalone with OAuth mode
- No AppAPI proxy blocking bidirectional communication
- All MCP protocol features work as designed
- Context Agent limitations (ADR-011) don't apply
✅ Native Nextcloud Integration
Seamless User Experience:
- Settings appear in standard Nextcloud settings interface
- Uses Nextcloud session (no separate login required)
- Follows Nextcloud design system and UX patterns
- Responsive design via Nextcloud's framework
- Accessibility built-in (NC accessibility standards)
Familiar Navigation:
- Personal settings:
/settings/user/additional - Admin settings:
/settings/admin/ai - Integrated with NC settings search
- Standard NC notifications and activity feed
✅ Simplified Deployment
For Users:
- One-click install from Nextcloud app store
- No separate URL to remember
- Single authentication (Nextcloud session)
- Managed through NC app management interface
For Administrators:
- MCP server remains standalone (Docker, systemd, etc.)
- Configure once in
config.php(server URLs only) - Register OAuth client once via
occ oidc:create - Update MCP server independently of Nextcloud
- Monitor via standard NC admin interface
✅ Better Security Model
Unified OAuth Authentication:
- Management API uses same OAuth tokens as MCP clients
- PHP app is another OAuth client with MCP server audience
- Single token verifier validates both MCP clients and PHP app
- No shared secrets or API keys required
Clear Boundaries:
- Read-only operations public (status, vector-sync)
- Write operations require valid OAuth token with matching user_id
- Users can only access/revoke their own sessions (self-service)
Least Privilege:
- PHP app has minimal permissions (UI rendering only)
- Tokens scoped to specific user and operations
- MCP server owns all business logic and state
- Audit trail via MCP server logs (user_id from token)
✅ Reduced Maintenance Burden
Separation of Concerns:
- MCP server: Python, FastMCP, protocol handling
- PHP app: Nextcloud integration, UI rendering
- No mixing of templating systems (Jinja2 removed)
- No duplicate authentication logic
Simplified Codebase:
- Remove
/approutes, templates, static files (~2000 LOC) - Remove browser OAuth flow (replaced by API authentication)
- Remove session management middleware
- Cleaner dependency tree (no HTMX, Alpine.js in Python)
✅ Multi-Tenant Support
Flexible Architecture:
- One NC PHP app → Multiple MCP servers (configure per-tenant)
- One MCP server → Multiple NC instances (shared management API)
- API key per tenant for isolation
- Independent scaling of UI and protocol layers
Drawbacks and Mitigations
Increased Deployment Complexity
Drawback: Users must deploy two components (MCP server + NC app) instead of one.
Mitigation:
- NC app is one-click install from app store
- MCP server deployment unchanged (Docker, systemd)
- Clear documentation with step-by-step guides
- Docker Compose example showing both components
OAuth Client Registration
Drawback: Requires registering OAuth client for PHP app with correct audience.
Mitigation:
- One-time setup via
occ oidc:createcommand - Clear documentation with exact command to run
- Installation hook automates client registration in development
- Standard OAuth PKCE flow - well-understood security model
Network Dependency
Drawback: NC app depends on MCP server being reachable via network.
Mitigation:
- Use internal URLs (localhost, Docker networks)
- Graceful degradation if server unavailable
- Clear error messages with troubleshooting steps
- Health check endpoint (
/api/v1/status)
Code Duplication (UI Logic)
Drawback: UI rendering logic exists in both Python templates and PHP templates.
Mitigation:
- Phase 1: Port existing Jinja2 templates to PHP
- Phase 2: Remove Python templates when NC app is stable
- Single source of truth: MCP server owns all business logic
- PHP app is stateless view layer only
Alternatives Considered
Alternative 1: Keep Current /app Endpoint
Description: Continue with standalone browser UI at /app.
Pros:
- No changes required
- Works today
- Simple deployment (one component)
Cons:
- ❌ Separate authentication system
- ❌ No Nextcloud integration
- ❌ Maintains Python templating burden
- ❌ Users must remember separate URL
Rejected Because: Users want native NC integration, and maintaining dual UIs is high maintenance burden.
Alternative 2: Run MCP Server as ExApp
Description: Deploy MCP server as Nextcloud ExApp (investigated in ADR-011).
Pros:
- ✅ Deep Nextcloud integration
- ✅ Native UI components
- ✅ One-click installation
Cons:
- ❌ No MCP sampling - AppAPI proxy blocks bidirectional protocol
- ❌ No real-time progress - Request/response model only
- ❌ Buffered streaming - Not incremental
- ❌ Fundamental protocol incompatibility
Rejected Because: ExApp architecture cannot support MCP protocol requirements. See ADR-011 for comprehensive analysis.
Alternative 3: Embed MCP Server in Nextcloud
Description: Port entire MCP server to PHP, run inside Nextcloud process.
Pros:
- ✅ Maximum integration
- ✅ Single deployment artifact
- ✅ No network boundary
Cons:
- ❌ Massive rewrite - 20,000+ LOC Python → PHP
- ❌ No async support - PHP lacks Python's async/await model
- ❌ Performance issues - Background workers in request context
- ❌ Dependency hell - Qdrant, embeddings, vector ops in PHP
- ❌ Loss of ecosystem - FastMCP, httpx, pydantic, anyio
Rejected Because: Impractical to port and would lose Python ecosystem benefits.
Alternative 4: Iframe Embedding
Description: Keep /app endpoint, embed in NC using iframe.
Pros:
- ✅ Minimal changes
- ✅ Works quickly
Cons:
- ❌ Poor UX - Iframe scroll issues, double nav bars
- ❌ Security issues - CORS, CSP complexity
- ❌ Mobile unfriendly - Terrible on small screens
- ❌ Still separate auth - Doesn't solve login problem
Rejected Because: Iframe embedding provides poor user experience and doesn't solve core problems.
Alternative 5: Nextcloud Frontend App (Vue.js SPA)
Description: Build NC frontend app (JavaScript only), talk to management API.
Pros:
- ✅ Modern frontend framework
- ✅ Rich interactivity
- ✅ API-driven architecture
Cons:
- ❌ More complex - Requires JavaScript build pipeline
- ❌ Deployment overhead - Webpack, npm, CI/CD for frontend
- ❌ Not standard NC pattern - Most NC apps use server-side templates
- ❌ Accessibility harder - Must implement manually
Rejected Because: Traditional NC PHP app provides better integration and simpler deployment.
Implementation Plan
Phase 1: Management API (Week 1-2)
Goal: Add REST API to MCP server without breaking existing functionality.
Tasks:
- Create
nextcloud_mcp_server/api/management.py - Implement core endpoints:
GET /api/v1/statusGET /api/v1/users/{id}/sessionPOST /api/v1/users/{id}/revokeGET /api/v1/vector-sync/statusPOST /api/v1/vector-viz/search
- Implement
management_auth.pywithrequire_admin_or_self - Add API key authentication support
- Add routes to
app.py(behindMANAGEMENT_API_ENABLEDflag) - Write integration tests for all endpoints
- Update
.env.examplewith new config options
Success Criteria:
- All management endpoints return correct data
- API key authentication works
/appendpoint unchanged and working- Tests pass
Phase 2: Nextcloud PHP App Scaffolding (Week 3-4)
Goal: Create basic NC app structure that can display data.
Tasks:
- Create app directory structure
- Write
appinfo/info.xmlwith metadata - Implement
McpServerClient.phpservice - Create
PersonalSettings.phpandAdminSettings.php - Port basic templates from Jinja2 to PHP
- Add simple CSS styling
- Test installation in development NC instance
Success Criteria:
- App installs without errors
- Personal settings panel appears
- Admin settings panel appears
- Can connect to MCP server API
- Displays basic user info
Phase 3: Feature Parity (Week 5-7)
Goal: Port all /app features to NC PHP app.
Tasks:
- Vector Sync Tab:
- Port auto-refresh HTMX functionality
- Display real-time metrics
- Sync status indicators
- Vector Visualization Tab:
- Port Plotly.js integration
- Port
vector-viz.jsfrom/app/static - Interactive search interface
- 2D PCA visualization
- Webhook Management:
- Admin-only tab
- Enable/disable presets
- Status display
- Session Management:
- Display session details
- Revoke access button
- OAuth flow integration
- Polish UI/UX:
- Responsive design
- Loading states
- Error handling
Success Criteria:
- All
/appfeatures available in NC app - Feature parity achieved
- UI polished and responsive
Phase 4: Documentation and Migration (Week 8)
Goal: Prepare for release with clear migration path.
Tasks:
- Write this ADR (ADR-018)
- Update
docs/installation.mdwith NC app instructions - Update
docs/configuration.mdwith management API settings - Create migration guide for existing
/appusers - Add deprecation notice to
/appendpoint - Create demo video showing NC app features
- Update README with new architecture diagram
Success Criteria:
- Documentation complete and accurate
- Migration path clear
- Deprecation notices visible
Phase 5: Release 0.53.0 (Week 9-10)
Goal: Ship dual UI support (both /app and NC app work).
Tasks:
- Final testing of management API
- Final testing of NC app
- Release notes
- Publish NC app to app store
- Tag release v0.53.0
- Monitor for issues
Success Criteria:
- Both UIs functional
- No regressions
- Users can migrate at own pace
Phase 6: Deprecation (Release 0.54.0, ~3 months later)
Goal: Make NC app the default for new installations.
Tasks:
- Change default
ENABLE_BROWSER_UI=false - Add migration banner to
/appendpoint - Update all documentation to recommend NC app
- Announce deprecation timeline
Success Criteria:
- New users default to NC app
- Existing users notified of migration
Phase 7: Removal (Release 0.56.0, ~6 months later)
Goal: Remove legacy /app code entirely.
Tasks:
- Remove
/approutes fromapp.py - Remove
auth/browser_oauth_routes.py - Remove templates (
auth/templates/) - Remove static files (
auth/static/) - Remove session authentication middleware
- Update tests to remove
/appreferences - Simplify dependencies (remove Jinja2 if only for
/app)
Success Criteria:
- Cleaner codebase (~2000 LOC removed)
- NC app is only UI
- All tests pass
Success Metrics
Technical Metrics:
- Management API response time <100ms (p95)
- Zero MCP protocol regressions
- 95%+ feature parity with
/appendpoint - <5% error rate on API calls
User Experience Metrics:
- 90%+ of users migrate to NC app within 6 months
- <10 support requests related to NC app setup
- Positive feedback on UX integration
- Reduced time-to-first-use for new users
Maintenance Metrics:
- 30%+ reduction in UI-related code
- Fewer dependency updates (no browser JS in Python)
- Cleaner separation of concerns (API vs UI)
- Faster feature development (standard NC patterns)
Optional Enhancement: Unified Search Provider
Background
Nextcloud's Unified Search (introduced in Nextcloud 20) provides a pluggable architecture where apps register search providers. This allows the MCP server's semantic search capabilities to appear in Nextcloud's global search bar, providing users with AI-powered search results alongside traditional file/app searches.
References:
- Nextcloud Developer Manual: Search
- Nextcloud 28+ supports
IFilteringProviderfor advanced filtering - Nextcloud 32+ supports
IExternalProviderfor privacy-aware external searches
Architecture
The PHP app can register a search provider that delegates semantic search to the MCP server's management API:
User types in NC search bar → Unified Search → PHP App Search Provider
│
│ (user_id from NC session)
▼
POST /api/v1/search
Authorization: Bearer <token>
Body: { query, user_id }
│
▼
MCP Server validates token,
filters results by user_id
│
▼
Only user's documents returned
User-Scoped Search (Security Model)
Critical Requirement: Search results must be filtered to only include documents the searching user has permission to access. The vector database contains documents from potentially multiple users, and returning unfiltered results would be a serious security vulnerability.
Permission Model
Phase 1: Owner-Only Filtering (Initial Implementation)
The simplest and most secure approach filters results to documents owned by the searching user:
| Document Type | Filter Logic |
|---|---|
| Notes | metadata.user_id == searching_user |
| Files | metadata.user_id == searching_user |
| Deck Cards | metadata.user_id == searching_user (card creator) |
| Calendar Events | metadata.user_id == searching_user |
Limitation: Users cannot find content shared with them. This is acceptable for initial implementation because:
- It's secure by default (no accidental data leakage)
- Covers the primary use case (searching your own content)
- Shared content support can be added incrementally
Phase 2: Shared Content Support (Future Enhancement)
To support searching shared content, additional metadata must be indexed:
# Extended metadata for sharing support
document_metadata = {
"user_id": "alice", # Owner
"shared_with_users": ["bob", "charlie"], # Direct shares
"shared_with_groups": ["developers"], # Group shares
"is_public": False, # Public link exists
"share_permissions": "read", # read, write, reshare
}
Search filter becomes:
filter = (
(metadata.user_id == searching_user) |
(searching_user in metadata.shared_with_users) |
(any(g in user_groups for g in metadata.shared_with_groups)) |
(metadata.is_public == True)
)
Challenges for Phase 2:
- Share metadata becomes stale when shares change
- Requires webhook integration with NC sharing events
- Group membership lookups add latency
- Complex ACL models (federated shares, circles) are hard to index
Authentication Flow
The search endpoint uses the same OAuth authentication as all other protected endpoints:
┌─────────────────────────────────────────────────────────────────────────┐
│ PHP App (Search Provider) │
│ │
│ 1. Receives IUser $user from Nextcloud session │
│ 2. Gets OAuth token for user (via NC OIDC, cached) │
│ 3. Calls MCP server with Bearer token │
└────────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ MCP Server /api/v1/search │
│ │
│ 1. Validates OAuth Bearer token (same as other endpoints) │
│ 2. Extracts user_id from token (sub claim) │
│ 3. Queries vector DB with filter: user_id == token.sub │
│ 4. Returns only documents owned by authenticated user │
└─────────────────────────────────────────────────────────────────────────┘
This uses the existing OAuth infrastructure - no additional authentication mechanism needed. The PHP app obtains tokens through NC's OIDC provider, same as MCP clients.
Implementation
Search Provider Class:
<?php
declare(strict_types=1);
namespace OCA\MCPServerUI\Search;
use OCA\MCPServerUI\AppInfo\Application;
use OCA\MCPServerUI\Service\McpServerClient;
use OCA\MCPServerUI\Service\TokenService;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\Search\IExternalProvider;
use OCP\Search\IProvider;
use OCP\Search\ISearchQuery;
use OCP\Search\SearchResult;
use OCP\Search\SearchResultEntry;
/**
* Unified Search provider for MCP Server semantic search.
*
* Delegates search queries to the MCP server's vector search API,
* returning semantically relevant results from indexed Nextcloud content
* (notes, files, calendar, deck cards).
*
* Implements IExternalProvider (NC 32+) because search is performed
* by an external service (MCP server with vector database).
*/
class SemanticSearchProvider implements IProvider, IExternalProvider {
public function __construct(
private McpServerClient $client,
private TokenService $tokenService, // Manages OAuth tokens for users
private IL10N $l10n,
private IURLGenerator $urlGenerator,
) {
}
/**
* Unique identifier for this search provider.
* Prefixed with app ID to avoid conflicts.
*/
public function getId(): string {
return Application::APP_ID . '_semantic';
}
/**
* Display name shown in search results grouping.
*/
public function getName(): string {
return $this->l10n->t('AI Search');
}
/**
* Order in search results. Lower = higher priority.
* Use negative value when user is in our app's context.
*/
public function getOrder(string $route, array $routeParameters): int {
if (str_contains($route, Application::APP_ID)) {
return -1; // Prioritize when in MCP Server UI
}
return 40; // Above most apps, below files/mail
}
/**
* Indicates this is an external search provider (NC 32+).
* External providers are disabled by default in Unified Search UI
* for privacy reasons - user must opt-in via toggle.
*/
public function isExternalProvider(): bool {
return true;
}
/**
* Execute semantic search via MCP server.
*
* SECURITY: Results are filtered server-side to only include documents
* owned by the searching user. User identity comes from OAuth token.
*/
public function search(IUser $user, ISearchQuery $query): SearchResult {
$term = $query->getTerm();
$limit = $query->getLimit();
$cursor = $query->getCursor();
// Skip empty queries
if (empty(trim($term))) {
return SearchResult::complete($this->getName(), []);
}
// Get OAuth token for user (cached, refreshed automatically)
try {
$accessToken = $this->tokenService->getAccessToken($user->getUID());
} catch (\Exception $e) {
// User hasn't authorized the app yet - return empty results
return SearchResult::complete($this->getName(), []);
}
// Check if MCP server is available and vector sync enabled
$status = $this->client->getStatus();
if (!empty($status['error']) || !($status['vector_sync_enabled'] ?? false)) {
return SearchResult::complete($this->getName(), []);
}
// Execute semantic search with OAuth token
// Server extracts user_id from token - results filtered to that user's documents
$offset = $cursor ? (int)$cursor : 0;
$results = $this->client->search(
query: $term,
accessToken: $accessToken, // User identity from OAuth token
algorithm: 'hybrid',
limit: $limit,
);
if (!empty($results['error'])) {
return SearchResult::complete($this->getName(), []);
}
// Transform results to SearchResultEntry objects
$entries = [];
foreach ($results['results'] ?? [] as $result) {
$entries[] = $this->transformResult($result);
}
// Return paginated if more results might exist
$totalFound = $results['total_found'] ?? count($entries);
if (count($entries) >= $limit && $totalFound > $offset + $limit) {
return SearchResult::paginated(
$this->getName(),
$entries,
$offset + $limit
);
}
return SearchResult::complete($this->getName(), $entries);
}
/**
* Transform MCP search result to Nextcloud SearchResultEntry.
*/
private function transformResult(array $result): SearchResultEntry {
$docType = $result['doc_type'] ?? 'unknown';
$title = $result['title'] ?? 'Untitled';
$excerpt = $result['excerpt'] ?? '';
$score = $result['score'] ?? 0;
// Build resource URL based on document type
$resourceUrl = $this->buildResourceUrl($result);
// Build thumbnail URL based on document type
$thumbnailUrl = $this->buildThumbnailUrl($docType);
// Subline shows document type and relevance score
$subline = sprintf(
'%s • %.0f%% relevant',
$this->getDocTypeLabel($docType),
$score * 100
);
$entry = new SearchResultEntry(
$thumbnailUrl,
$title,
$subline,
$resourceUrl,
'', // icon class (empty, using thumbnail)
false // not rounded
);
// Add optional attributes for mobile clients
$entry->addAttribute('type', $docType);
$entry->addAttribute('score', (string)$score);
if (isset($result['id'])) {
$entry->addAttribute('docId', (string)$result['id']);
}
return $entry;
}
/**
* Build URL to navigate to the original document.
*/
private function buildResourceUrl(array $result): string {
$docType = $result['doc_type'] ?? 'unknown';
$id = $result['id'] ?? null;
$path = $result['path'] ?? null;
return match ($docType) {
'note' => $id
? $this->urlGenerator->linkToRoute('notes.page.index') . '#/note/' . $id
: $this->urlGenerator->linkToRoute('notes.page.index'),
'file' => $path
? $this->urlGenerator->linkToRoute('files.view.index', [
'dir' => dirname($path),
'scrollto' => basename($path),
])
: $this->urlGenerator->linkToRoute('files.view.index'),
'deck_card' => isset($result['board_id'], $result['card_id'])
? $this->urlGenerator->linkToRoute('deck.page.index') .
"#!/board/{$result['board_id']}/card/{$result['card_id']}"
: $this->urlGenerator->linkToRoute('deck.page.index'),
'calendar_event' => $this->urlGenerator->linkToRoute('calendar.view.index'),
default => $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index'),
};
}
/**
* Get thumbnail URL for document type.
*/
private function buildThumbnailUrl(string $docType): string {
return match ($docType) {
'note' => $this->urlGenerator->imagePath('notes', 'app.svg'),
'file' => $this->urlGenerator->imagePath('files', 'app.svg'),
'deck_card' => $this->urlGenerator->imagePath('deck', 'app.svg'),
'calendar_event' => $this->urlGenerator->imagePath('calendar', 'app.svg'),
default => $this->urlGenerator->imagePath(Application::APP_ID, 'app.svg'),
};
}
/**
* Get human-readable label for document type.
*/
private function getDocTypeLabel(string $docType): string {
return match ($docType) {
'note' => $this->l10n->t('Note'),
'file' => $this->l10n->t('File'),
'deck_card' => $this->l10n->t('Deck Card'),
'calendar_event' => $this->l10n->t('Calendar'),
'news_item' => $this->l10n->t('News'),
default => $this->l10n->t('Document'),
};
}
}
Provider Registration in Application.php:
<?php
declare(strict_types=1);
namespace OCA\MCPServerUI\AppInfo;
use OCA\MCPServerUI\Search\SemanticSearchProvider;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
class Application extends App implements IBootstrap {
public const APP_ID = 'mcpserverui';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
// Register unified search provider
$context->registerSearchProvider(SemanticSearchProvider::class);
// ... other registrations
}
public function boot(IBootContext $context): void {
// ... boot logic
}
}
TokenService - Managing OAuth Tokens:
The TokenService handles obtaining and caching OAuth tokens for users:
<?php
declare(strict_types=1);
namespace OCA\MCPServerUI\Service;
use OCP\IConfig;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Manages OAuth access tokens for MCP server communication.
*
* Tokens are obtained through NC's OIDC provider and cached per-user.
* The service handles token refresh automatically when tokens expire.
*/
class TokenService {
public function __construct(
private IConfig $config,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
}
/**
* Get a valid access token for the user.
*
* Returns cached token if still valid, otherwise refreshes or
* throws exception if user hasn't authorized the app.
*
* @throws \Exception If user hasn't authorized the app
*/
public function getAccessToken(string $userId): string {
// Check for cached token
$tokenData = $this->getCachedToken($userId);
if ($tokenData && !$this->isExpired($tokenData)) {
return $tokenData['access_token'];
}
// Try to refresh if we have a refresh token
if ($tokenData && isset($tokenData['refresh_token'])) {
try {
return $this->refreshToken($userId, $tokenData['refresh_token']);
} catch (\Exception $e) {
$this->logger->warning("Token refresh failed for user $userId: " . $e->getMessage());
}
}
// No valid token - user needs to authorize
throw new \Exception("User $userId has not authorized the app");
}
/**
* Store token data after user authorization.
*/
public function storeToken(string $userId, array $tokenData): void {
$tokenData['stored_at'] = time();
$this->config->setUserValue(
$userId,
Application::APP_ID,
'oauth_token',
json_encode($tokenData)
);
}
/**
* Check if user has authorized the app.
*/
public function hasAuthorized(string $userId): bool {
try {
$this->getAccessToken($userId);
return true;
} catch (\Exception $e) {
return false;
}
}
private function getCachedToken(string $userId): ?array {
$data = $this->config->getUserValue($userId, Application::APP_ID, 'oauth_token', '');
return $data ? json_decode($data, true) : null;
}
private function isExpired(array $tokenData): bool {
if (!isset($tokenData['expires_in'], $tokenData['stored_at'])) {
return true;
}
// Add 60s buffer before expiry
return time() > ($tokenData['stored_at'] + $tokenData['expires_in'] - 60);
}
private function refreshToken(string $userId, string $refreshToken): string {
// Call NC OIDC token endpoint with refresh_token grant
// ... implementation details ...
}
}
Privacy Considerations
The search provider implements IExternalProvider (Nextcloud 32+) because:
- External Processing: Search queries are sent to the MCP server, which may run on a different host
- Vector Database: Embeddings are stored in an external Qdrant instance
- User Consent: NC's Unified Search UI shows external providers with a toggle, requiring user opt-in
For Nextcloud versions before 32, the provider should check user preferences before executing searches:
public function search(IUser $user, ISearchQuery $query): SearchResult {
// Check user preference for external search
$enabled = $this->config->getUserValue(
$user->getUID(),
Application::APP_ID,
'enable_unified_search',
'false'
);
if ($enabled !== 'true') {
return SearchResult::complete($this->getName(), []);
}
// ... proceed with search
}
Advanced Filtering (Nextcloud 28+)
For Nextcloud 28+, implement IFilteringProvider to support advanced search filters:
<?php
declare(strict_types=1);
namespace OCA\MCPServerUI\Search;
use OCP\Search\FilterDefinition;
use OCP\Search\IFilteringProvider;
class SemanticSearchProvider implements IFilteringProvider, IExternalProvider {
// ... existing methods ...
/**
* Declare supported standard filters.
*/
public function getSupportedFilters(): array {
return [
'term', // Search query
'since', // Date filter (document modified after)
'until', // Date filter (document modified before)
];
}
/**
* Declare custom filters specific to this provider.
*/
public function getCustomFilters(): array {
return [
new FilterDefinition('doc_type', FilterDefinition::TYPE_STRING),
new FilterDefinition('min_score', FilterDefinition::TYPE_FLOAT),
];
}
/**
* Alternate IDs that trigger this provider.
*/
public function getAlternateIds(): array {
return ['semantic', 'ai'];
}
public function search(IUser $user, ISearchQuery $query): SearchResult {
// Retrieve filters
$term = $query->getTerm();
$since = $query->getFilter('since')?->get();
$docType = $query->getFilter('doc_type')?->get();
$minScore = $query->getFilter('min_score')?->get() ?? 0.0;
// Pass filters to MCP server
$results = $this->client->searchWithFilters(
query: $term,
doc_types: $docType ? [$docType] : null,
score_threshold: $minScore,
modified_after: $since?->format('c'),
);
// ... transform and return results
}
}
MCP Server API Extension
The search endpoint uses OAuth authentication (same as other protected endpoints) and includes visualization support:
from starlette.responses import JSONResponse
from starlette.requests import Request
from nextcloud_mcp_server.api.management_auth import require_authenticated_user
@app.post("/api/v1/search")
@require_authenticated_user # Same OAuth validation as other endpoints
async def unified_search(request: Request) -> JSONResponse:
"""Search endpoint for Nextcloud Unified Search provider and vector visualization.
AUTHENTICATION: OAuth Bearer token required (same as other protected endpoints).
User identity extracted from token - results filtered to that user's documents.
Parameters:
- query: Search query string (required)
- algorithm: "semantic", "bm25", or "hybrid" (default: "hybrid")
- doc_types: Filter by document type (optional)
- score_threshold: Minimum relevance score (optional, default: 0.0)
- limit: Max results (default: 20, max: 100)
- offset: Pagination offset (default: 0)
- include_pca: Include 2D PCA coordinates for visualization (default: false)
- include_chunks: Include matched text chunks/snippets (default: true)
Returns:
- results: Array of matching documents (user's documents only)
- Each result includes: id, title, doc_type, score, excerpt
- If include_chunks: matched_chunks with highlighted text
- If include_pca: pca_x, pca_y coordinates
- total_found: Total matching documents for pagination
- pca_data: Global PCA data for visualization (if include_pca)
- query_point: [x, y] coordinates of the query
- corpus_sample: Sample of corpus points for context
Security:
- User ID extracted from validated OAuth token
- Results ALWAYS filtered by user_id from token
- No cross-user data leakage possible
"""
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled"},
status_code=404
)
# Extract user_id from validated OAuth token
user_id, _ = await validate_token_and_get_user(request)
data = await request.json()
query = data.get("query", "")
if not query:
return JSONResponse({"results": [], "total_found": 0})
# Execute search with mandatory user filter
from nextcloud_mcp_server.search.hybrid import search_documents
results = await search_documents(
query=query,
# CRITICAL: Filter by user_id from OAuth token
filters={"user_id": user_id},
algorithm=data.get("algorithm", "hybrid"),
doc_types=data.get("doc_types"),
score_threshold=data.get("score_threshold", 0.0),
limit=min(data.get("limit", 20), 100),
offset=data.get("offset", 0),
include_pca=data.get("include_pca", False),
include_chunks=data.get("include_chunks", True),
)
return JSONResponse({
"results": results["results"],
"total_found": results.get("total_found", len(results["results"])),
"algorithm_used": results.get("algorithm_used", "hybrid"),
# Visualization data (only if requested)
"pca_data": results.get("pca_data") if data.get("include_pca") else None,
})
Updated McpServerClient.php:
/**
* Execute semantic search for authenticated user.
*
* Results are automatically filtered to the user associated with the OAuth token.
*
* @param string $query Search query
* @param string $accessToken OAuth access token for the user
* @param string $algorithm Search algorithm: 'semantic', 'bm25', or 'hybrid'
* @param int $limit Maximum results to return
* @param int $offset Pagination offset
* @param bool $includePca Include PCA coordinates for visualization
* @param bool $includeChunks Include matched text chunks/snippets
*/
public function search(
string $query,
string $accessToken,
string $algorithm = 'hybrid',
int $limit = 20,
int $offset = 0,
bool $includePca = false,
bool $includeChunks = true
): array {
try {
$response = $this->httpClient->post(
$this->serverUrl . '/api/v1/search',
[
'headers' => [
'Authorization' => "Bearer $accessToken",
'Content-Type' => 'application/json',
],
'json' => [
'query' => $query,
'algorithm' => $algorithm,
'limit' => $limit,
'offset' => $offset,
'include_pca' => $includePca,
'include_chunks' => $includeChunks,
]
]
);
return json_decode($response->getBody(), true);
} catch (\Exception $e) {
$this->logger->error("Search failed: " . $e->getMessage());
return ['error' => $e->getMessage()];
}
}
Benefits
- Native Integration: Semantic search appears in Nextcloud's global search bar
- Unified Experience: Users search once, get results from all sources
- Privacy-Aware: External provider status informs users about data flow
- Mobile Support: Results include attributes for mobile clients
- Advanced Filtering: Date ranges, document types, relevance thresholds
Implementation Timeline
This is an optional enhancement that can be added after the core PHP app is stable:
- Phase 5+: Add basic search provider (implements
IProvider) - Phase 6+: Add advanced filtering (implements
IFilteringProvider) - Phase 7+: Add external provider marking (implements
IExternalProviderfor NC 32+)
Testing
<?php
namespace OCA\MCPServerUI\Tests\Search;
use OCA\MCPServerUI\Search\SemanticSearchProvider;
use OCA\MCPServerUI\Service\McpServerClient;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\Search\ISearchQuery;
use PHPUnit\Framework\TestCase;
class SemanticSearchProviderTest extends TestCase {
public function testSearchReturnsEmptyForDisabledVectorSync(): void {
$client = $this->createMock(McpServerClient::class);
$client->method('getStatus')->willReturn([
'vector_sync_enabled' => false,
]);
$provider = new SemanticSearchProvider(
$client,
$this->createMock(IL10N::class),
$this->createMock(IURLGenerator::class),
);
$user = $this->createMock(IUser::class);
$query = $this->createMock(ISearchQuery::class);
$query->method('getTerm')->willReturn('test query');
$result = $provider->search($user, $query);
$this->assertEmpty($result->getEntries());
}
public function testSearchTransformsResults(): void {
$client = $this->createMock(McpServerClient::class);
$client->method('getStatus')->willReturn([
'vector_sync_enabled' => true,
]);
$client->method('search')->willReturn([
'results' => [
[
'doc_type' => 'note',
'title' => 'Test Note',
'excerpt' => 'This is a test...',
'score' => 0.85,
'id' => 123,
],
],
'total_found' => 1,
]);
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnArgument(0);
$urlGenerator = $this->createMock(IURLGenerator::class);
$urlGenerator->method('linkToRoute')->willReturn('/apps/notes');
$urlGenerator->method('imagePath')->willReturn('/apps/notes/img/app.svg');
$provider = new SemanticSearchProvider($client, $l10n, $urlGenerator);
$user = $this->createMock(IUser::class);
$query = $this->createMock(ISearchQuery::class);
$query->method('getTerm')->willReturn('test');
$query->method('getLimit')->willReturn(20);
$result = $provider->search($user, $query);
$entries = $result->getEntries();
$this->assertCount(1, $entries);
$this->assertEquals('Test Note', $entries[0]->getTitle());
}
}
Related Documentation
To Update
docs/installation.md- Add NC PHP app installation sectiondocs/configuration.md- Document management API settingsdocs/authentication.md- Clarify OAuth for MCP vs NC app accessREADME.md- Update architecture diagram
To Create
docs/management-api.md- API reference for NC app developersdocs/nextcloud-app-installation.md- Step-by-step NC app setupdocs/migration-from-app-endpoint.md- Guide for existing usersdocs/development-nc-app.md- Developer guide for NC app
Related ADRs
- ADR-011: AppAPI Architecture (Rejected) - Explains why ExApp doesn't work
- ADR-008: MCP Sampling for Semantic Search - Requires standalone server
- ADR-004: Progressive Consent - OAuth architecture preserved
Conclusion
Creating a Nextcloud PHP app for settings and management UI provides the best of both worlds:
✅ Full MCP Protocol Support:
- Standalone server preserves sampling, elicitation, streaming
- No ExApp limitations (ADR-011 findings)
- External MCP clients work unchanged
✅ Native Nextcloud Integration:
- Settings in standard NC interface
- Single sign-on (NC sessions)
- Familiar UX for NC users
- Mobile and accessibility built-in
✅ Clean Architecture:
- Separation of concerns (protocol vs UI)
- Single source of truth (MCP server owns logic)
- Versioned API contract
- Independent scaling and deployment
This architecture solves the original goal: Enable MCP sampling and advanced features while migrating the /app interface to a Nextcloud app. The management API provides a clean integration point, and the gradual migration path ensures existing users aren't disrupted.