<?php
declare(strict_types=1);
namespace OCA\Astrolabe\Service;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
/**
* HTTP client for communicating with the MCP server's management API.
*
* This service wraps the MCP server's REST API endpoints defined in ADR-018.
* It handles authentication via OAuth bearer tokens and provides typed methods
* for all management operations.
*/
class McpServerClient {
private $httpClient;
private $config;
private $logger;
private $baseUrl;
public function __construct(
IClientService $clientService,
IConfig $config,
LoggerInterface $logger,
) {
$this->httpClient = $clientService->newClient();
$this->config = $config;
$this->logger = $logger;
// Get MCP server configuration from Nextcloud config
$this->baseUrl = $this->config->getSystemValue('mcp_server_url', 'http://localhost:8000');
}
/**
* Get server status (version, auth mode, features).
*
* Public endpoint - no authentication required.
*
* @return array{
* version?: string,
* auth_mode?: string,
* vector_sync_enabled?: bool,
* uptime_seconds?: int,
* management_api_version?: string,
* error?: string
* }
*/
public function getStatus(): array {
try {
$response = $this->httpClient->get($this->baseUrl . '/api/v1/status');
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to get MCP server status', [
'error' => $e->getMessage(),
'server_url' => $this->baseUrl,
]);
return ['error' => $e->getMessage()];
}
}
/**
* Get user session details.
*
* Requires authentication via OAuth bearer token.
*
* @param string $userId The user ID to query
* @param string $token OAuth bearer token
* @return array{
* session_id?: string,
* background_access_granted?: bool,
* background_access_details?: array,
* idp_profile?: array,
* error?: string
* }
*/
public function getUserSession(string $userId, string $token): array {
try {
$response = $this->httpClient->get(
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/session',
[
'headers' => [
'Authorization' => 'Bearer ' . $token
]
]
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error("Failed to get session for user $userId", [
'error' => $e->getMessage(),
'user_id' => $userId,
]);
return ['error' => $e->getMessage()];
}
}
/**
* Revoke user's background access (delete refresh token).
*
* Requires authentication via OAuth bearer token.
*
* @param string $userId The user ID whose access to revoke
* @param string $token OAuth bearer token
* @return array{success?: bool, message?: string, error?: string}
*/
public function revokeUserAccess(string $userId, string $token): array {
try {
$response = $this->httpClient->post(
$this->baseUrl . '/api/v1/users/' . urlencode($userId) . '/revoke',
[
'headers' => [
'Authorization' => 'Bearer ' . $token
]
]
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error("Failed to revoke access for user $userId", [
'error' => $e->getMessage(),
'user_id' => $userId,
]);
return ['error' => $e->getMessage()];
}
}
/**
* Get vector sync status (indexing metrics).
*
* Public endpoint - no authentication required.
* Only available if VECTOR_SYNC_ENABLED=true on server.
*
* @return array{
* status?: string,
* indexed_documents?: int,
* pending_documents?: int,
* last_sync_time?: string,
* documents_per_second?: float,
* errors_24h?: int,
* error?: string
* }
*/
public function getVectorSyncStatus(): array {
try {
$response = $this->httpClient->get($this->baseUrl . '/api/v1/vector-sync/status');
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to get vector sync status', [
'error' => $e->getMessage(),
]);
return ['error' => $e->getMessage()];
}
}
/**
* Execute semantic search for vector visualization.
*
* Requires OAuth bearer token for user-filtered search.
* Only available if VECTOR_SYNC_ENABLED=true on server.
*
* @param string $query Search query string
* @param string $algorithm Search algorithm: "semantic", "bm25", or "hybrid"
* @param int $limit Number of results (max 50)
* @param bool $includePca Whether to include PCA coordinates for 2D plot
* @param array|null $docTypes Document types to filter (e.g., ['note', 'file'])
* @param string|null $token OAuth bearer token for authentication
* @return array{
* results?: array,
* pca_coordinates?: array,
* algorithm_used?: string,
* total_documents?: int,
* error?: string
* }
*/
public function search(
string $query,
string $algorithm = 'hybrid',
int $limit = 10,
bool $includePca = true,
?array $docTypes = null,
?string $token = null,
): array {
try {
$requestBody = [
'query' => $query,
'algorithm' => $algorithm,
'limit' => min($limit, 50), // Enforce max limit
'include_pca' => $includePca,
];
// Add doc_types filter if specified
if ($docTypes !== null && count($docTypes) > 0) {
$requestBody['doc_types'] = $docTypes;
}
$options = ['json' => $requestBody];
// Add authorization header if token provided
if ($token !== null) {
$options['headers'] = [
'Authorization' => 'Bearer ' . $token
];
}
$response = $this->httpClient->post(
$this->baseUrl . '/api/v1/vector-viz/search',
$options
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to execute search', [
'error' => $e->getMessage(),
'query' => $query,
'algorithm' => $algorithm,
]);
return ['error' => $e->getMessage()];
}
}
/**
* Execute semantic search for Nextcloud Unified Search.
*
* Simplified search method specifically for the unified search provider.
* Uses OAuth bearer token for authentication and user-scoped filtering.
*
* @param string $query Search query string
* @param string $token OAuth bearer token for authentication
* @param int $limit Maximum number of results (default: 20)
* @param int $offset Pagination offset (default: 0)
* @param string $algorithm Search algorithm: hybrid, semantic, or bm25 (default: hybrid)
* @param string $fusion Fusion method for hybrid: rrf or dbsf (default: rrf)
* @param float $scoreThreshold Minimum score threshold 0-1 (default: 0)
* @return array{
* results?: array<array{
* id?: string|int,
* title?: string,
* doc_type?: string,
* excerpt?: string,
* score?: float,
* path?: string,
* board_id?: int,
* card_id?: int
* }>,
* total_found?: int,
* algorithm_used?: string,
* error?: string
* }
*/
public function searchForUnifiedSearch(
string $query,
string $token,
int $limit = 20,
int $offset = 0,
string $algorithm = 'hybrid',
string $fusion = 'rrf',
float $scoreThreshold = 0.0,
): array {
try {
$response = $this->httpClient->post(
$this->baseUrl . '/api/v1/search',
[
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
'json' => [
'query' => $query,
'algorithm' => $algorithm,
'fusion' => $fusion,
'score_threshold' => $scoreThreshold,
'limit' => min($limit, 100),
'offset' => $offset,
'include_pca' => false,
'include_chunks' => true,
]
]
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Unified search failed', [
'error' => $e->getMessage(),
'query' => $query,
]);
return ['error' => $e->getMessage()];
}
}
/**
* Check if the MCP server is reachable and API key is valid.
*
* @return bool True if server is reachable and healthy
*/
public function isServerReachable(): bool {
$status = $this->getStatus();
return !isset($status['error']);
}
/**
* Get the configured MCP server internal URL (for API calls).
*
* @return string The internal base URL
*/
public function getServerUrl(): string {
return $this->baseUrl;
}
/**
* Get the public MCP server URL (for display, OAuth audience).
*
* Falls back to internal URL if public URL not configured.
*
* @return string The public URL users/browsers see
*/
public function getPublicServerUrl(): string {
return $this->config->getSystemValue('mcp_server_public_url', $this->baseUrl);
}
/**
* Get the OAuth client ID from system config.
*
* The Astrolabe app has its own OAuth client (separate from MCP server's client).
* Client ID must be configured in config.php for OAuth functionality to work.
*
* @return string OAuth client ID or empty string if not configured
*/
public function getClientId(): string {
$clientId = $this->config->getSystemValue('astrolabe_client_id', '');
if (empty($clientId)) {
$this->logger->warning('astrolabe_client_id is not configured in config.php - OAuth functionality will not work');
return '';
}
$this->logger->debug('Using client ID from system config: ' . substr($clientId, 0, 8) . '...');
return $clientId;
}
/**
* List all registered webhooks for a user.
*
* Requires OAuth bearer token for authentication.
*
* @param string $token OAuth bearer token
* @return array{
* webhooks?: array<array{
* id?: int,
* event?: string,
* uri?: string,
* event_filter?: array,
* enabled?: bool
* }>,
* error?: string
* }
*/
public function listWebhooks(string $token): array {
try {
$response = $this->httpClient->get(
$this->baseUrl . '/api/v1/webhooks',
[
'headers' => [
'Authorization' => 'Bearer ' . $token
]
]
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to list webhooks', [
'error' => $e->getMessage(),
]);
return ['error' => $e->getMessage()];
}
}
/**
* Create a new webhook registration.
*
* Requires OAuth bearer token for authentication.
*
* @param string $event Event type (e.g., "\\OCA\\Files::postCreate")
* @param string $uri Callback URI for webhook notifications
* @param array|null $eventFilter Optional event filter parameters
* @param string $token OAuth bearer token
* @return array{
* id?: int,
* event?: string,
* uri?: string,
* event_filter?: array,
* enabled?: bool,
* error?: string
* }
*/
public function createWebhook(
string $event,
string $uri,
?array $eventFilter,
string $token,
): array {
try {
$requestBody = [
'event' => $event,
'uri' => $uri,
];
if ($eventFilter !== null) {
$requestBody['event_filter'] = $eventFilter;
}
$response = $this->httpClient->post(
$this->baseUrl . '/api/v1/webhooks',
[
'headers' => [
'Authorization' => 'Bearer ' . $token,
'Content-Type' => 'application/json',
],
'json' => $requestBody
]
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to create webhook', [
'error' => $e->getMessage(),
'event' => $event,
]);
return ['error' => $e->getMessage()];
}
}
/**
* Delete a webhook registration.
*
* Requires OAuth bearer token for authentication.
*
* @param int $webhookId Webhook ID to delete
* @param string $token OAuth bearer token
* @return array{success?: bool, error?: string}
*/
public function deleteWebhook(int $webhookId, string $token): array {
try {
$response = $this->httpClient->delete(
$this->baseUrl . '/api/v1/webhooks/' . $webhookId,
[
'headers' => [
'Authorization' => 'Bearer ' . $token
]
]
);
// Successful DELETE may return 204 No Content
if ($response->getStatusCode() === 204) {
return ['success' => true];
}
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to delete webhook', [
'error' => $e->getMessage(),
'webhook_id' => $webhookId,
]);
return ['error' => $e->getMessage()];
}
}
/**
* Get list of installed Nextcloud apps.
*
* Used to filter webhook presets based on available apps.
* Requires OAuth bearer token for authentication.
*
* @param string $token OAuth bearer token
* @return array{
* apps?: array<string>,
* error?: string
* }
*/
public function getInstalledApps(string $token): array {
try {
$response = $this->httpClient->get(
$this->baseUrl . '/api/v1/apps',
[
'headers' => [
'Authorization' => 'Bearer ' . $token
]
]
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to get installed apps', [
'error' => $e->getMessage(),
]);
return ['error' => $e->getMessage()];
}
}
/**
* Get chunk context (text, surrounding context, page image).
*
* Requires OAuth bearer token for authentication.
*
* @param string $docType Document type
* @param string $docId Document ID
* @param int $start Start offset
* @param int $end End offset
* @param string $token OAuth bearer token
* @return array
*/
public function getChunkContext(
string $docType,
string $docId,
int $start,
int $end,
string $token,
): array {
try {
$response = $this->httpClient->get(
$this->baseUrl . '/api/v1/chunk-context',
[
'headers' => [
'Authorization' => 'Bearer ' . $token
],
'query' => [
'doc_type' => $docType,
'doc_id' => $docId,
'start' => $start,
'end' => $end,
'context' => 500
]
]
);
$data = json_decode($response->getBody(), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid JSON response from server');
}
return $data;
} catch (\Exception $e) {
$this->logger->error('Failed to get chunk context', [
'error' => $e->getMessage(),
'doc_type' => $docType,
'doc_id' => $docId,
]);
return ['error' => $e->getMessage()];
}
}
}