From 5be4075d95ce06b709dadd755024fb0c625dbfe4 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:27:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(#444):=20MCP=20tab=20parity=20=E2=80=94=20?= =?UTF-8?q?add=20servers,=20catalog,=20env=20vars,=20restart,=20diagnostic?= =?UTF-8?q?s=20(#462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#444): add MCP API endpoints and data models - Add McpServer.status and McpServer.error fields for diagnostics - Add AddMcpServerRequest, McpCatalogResponse/Entry/EnvVar models - Add McpServerTestResponse model - Add POST addMcpServer, PUT updateMcpServer, POST restartMcpServer - Add GET getMcpCatalog, POST installMcpCatalogEntry * feat(#444): add server form, enriched server cards, catalog section - Add HTTP/stdio inline server form with tabs - Enriched server cards with status badge, restart, env var editing - Add/edit/remove environment variables per server - Catalog section with search, result cards, install flow with env vars - Status badge colors: running=green, error=red, stopped/gray - Server diagnostics shown as error badges on cards - New string resources for all MCP UI elements * fix(#444): add missing McpServer import to HermesApiService * fix(#444): cleanup review issues — dead code, spinner, unused strings - Remove unused serverDiagnostics from UiState - Remove unused showDiagnostics local state in ServerCard - Remove unused McpServerTestResponse model - Remove redundant refreshDiagnostics() alias - Replace catalog loading text with CircularProgressIndicator - Remove unused string resources (action_restarting, diagnostics, catalog_load) * fix(#444): remove remaining showDiagnostics reference at line 348 --------- Co-authored-by: Hermes Agent --- .../m57/hermescontrol/data/model/McpServer.kt | 36 + .../data/remote/HermesApiService.kt | 28 + .../hermescontrol/ui/mcp/McpServersScreen.kt | 705 +++++++++++++++--- .../ui/mcp/McpServersViewModel.kt | 307 +++++++- app/src/main/res/values/strings.xml | 31 +- 5 files changed, 962 insertions(+), 145 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 933fea7a..5adda21e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -12,8 +12,44 @@ data class McpServer( val args: List?, val env: Map?, val enabled: Boolean, + val status: String? = null, + val error: String? = null, ) data class McpServerToggleRequest( val enabled: Boolean, ) + +data class AddMcpServerRequest( + val name: String, + val url: String? = null, + val command: String? = null, + val args: List? = null, + val env: Map? = null, +) + +data class McpCatalogResponse( + val entries: List, +) + +data class McpCatalogEntry( + val name: String, + val description: String? = null, + val source: String? = null, + val url: String? = null, + val command: String? = null, + val args: List? = null, + val env: List? = null, +) + +data class McpCatalogEnvVar( + val key: String, + val label: String? = null, + val description: String? = null, + val required: Boolean = false, +) + +data class McpCatalogInstallRequest( + val name: String, + val env: Map? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 00204f38..ebaf3f7f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.AchievementsResponse import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.ActiveProfileResponse +import com.m57.hermescontrol.data.model.AddMcpServerRequest import com.m57.hermescontrol.data.model.AgentPluginInstallBody import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse import com.m57.hermescontrol.data.model.BulkDeleteRequest @@ -32,6 +33,9 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.LogResponse +import com.m57.hermescontrol.data.model.McpCatalogInstallRequest +import com.m57.hermescontrol.data.model.McpCatalogResponse +import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServersResponse import com.m57.hermescontrol.data.model.MemoryResponse @@ -334,6 +338,30 @@ interface HermesApiService { @Path("name") name: String, ): Response + @POST("api/mcp/servers") + suspend fun addMcpServer( + @Body body: AddMcpServerRequest, + ): Response + + @PUT("api/mcp/servers/{name}") + suspend fun updateMcpServer( + @Path("name") name: String, + @Body body: Map, + ): Response + + @POST("api/mcp/servers/{name}/restart") + suspend fun restartMcpServer( + @Path("name") name: String, + ): Response + + @GET("api/mcp/catalog") + suspend fun getMcpCatalog(): Response + + @POST("api/mcp/catalog/install") + suspend fun installMcpCatalogEntry( + @Body body: McpCatalogInstallRequest, + ): Response> + @GET("api/webhooks") suspend fun getWebhooks(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 5595c6a6..5e70c0b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.mcp +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -9,17 +10,34 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Science +import androidx.compose.material.icons.filled.Storage import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -31,20 +49,27 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.McpCatalogEntry +import com.m57.hermescontrol.data.model.McpServer +import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing -@OptIn(ExperimentalMaterial3Api::class) @Composable fun McpServersScreen( modifier: Modifier = Modifier, @@ -52,7 +77,7 @@ fun McpServersScreen( viewModel: McpServersViewModel = viewModel { McpServersViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - + val spacing = LocalSpacing.current var query by remember { mutableStateOf("") } val filteredServers = @@ -64,10 +89,20 @@ fun McpServersScreen( } } - LaunchedEffect(Unit) { - viewModel.loadServers() - } + val filteredCatalog = + remember(state.catalogQuery, state.catalogEntries) { + val q = state.catalogQuery.trim().lowercase() + if (q.isEmpty()) { + state.catalogEntries + } else { + state.catalogEntries.filter { + it.name.lowercase().contains(q) || + it.description?.lowercase()?.contains(q) == true + } + } + } + LaunchedEffect(Unit) { viewModel.loadServers() } ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) HermesScaffold( @@ -80,7 +115,6 @@ fun McpServersScreen( state.isLoading && state.servers.isEmpty() -> { LoadingState(modifier = Modifier.padding(paddingValues)) } - state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: "", @@ -88,123 +122,554 @@ fun McpServersScreen( modifier = Modifier.padding(paddingValues), ) } - else -> { - Box(Modifier.fillMaxSize()) { - if (state.isLoading && state.servers.isEmpty()) { - CircularProgressIndicator() - } else if (state.errorMessage != null && state.servers.isEmpty()) { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { viewModel.loadServers() }) { - Text(stringResource(R.string.action_retry)) - } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + // ── 1. Add server ──────────────────────────────────── + item(key = "add-header") { + AddServerSection(state, viewModel, spacing) + } + + // ── 2. Search ──────────────────────────────────────── + item(key = "search") { + SearchBar( + query = query, + onQueryChange = { query = it }, + placeholder = "Search MCP servers...", + ) + } + + // ── 3. Server list ─────────────────────────────────── + if (filteredServers.isEmpty() && query.isEmpty()) { + item(key = "empty") { + EmptyState( + title = "No MCP servers", + subtitle = "Add a server above or from the catalog.", + ) } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listContentPadding, - verticalArrangement = listItemSpacing, - ) { - item { - SearchBar( - query = query, - onQueryChange = { query = it }, - placeholder = "Search MCP servers...", - ) - } - items(filteredServers, key = { it.name }) { server -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = - if (server.enabled) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant - }, - ), - ) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(16.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = server.name.replaceFirstChar { it.uppercase() }, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - text = - stringResource( - R.string.mcp_servers_label_transport, - server.transport ?: "stdio", - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - Switch( - checked = server.enabled, - onCheckedChange = { viewModel.toggleServer(server) }, - ) - } - - if (server.command != null) { - Spacer(modifier = Modifier.height(8.dp)) - val fullCmd = "${server.command} ${server.args.orEmpty().joinToString(" ")}" - Text( - text = stringResource(R.string.mcp_servers_label_command), - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = fullCmd, - style = - MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - ), - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - OutlinedButton( - onClick = { viewModel.testServer(server.name) }, - modifier = Modifier.padding(end = 8.dp), - ) { - Text(stringResource(R.string.mcp_servers_action_test)) - } - - OutlinedButton( - onClick = { viewModel.deleteServer(server.name) }, - ) { - Text(stringResource(R.string.action_delete)) - } - } - } - } - } + } else if (filteredServers.isEmpty()) { + item(key = "no-match") { + Text( + text = "No servers match \"$query\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(spacing.md), + ) } } + + items(filteredServers, key = { "server:${it.name}" }) { server -> + ServerCard(server = server, state = state, viewModel = viewModel, spacing = spacing) + } + + // ── 4. Catalog ────────────────────────────────────── + item(key = "catalog-header") { + CatalogSection( + state = state, + viewModel = viewModel, + spacing = spacing, + filteredCatalog = filteredCatalog, + ) + } + } + } + } + } +} + +// ── Sections ────────────────────────────────────────────────────── + +@Composable +private fun AddServerSection( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: com.m57.hermescontrol.theme.Spacing, +) { + SectionHeader( + title = stringResource(R.string.mcp_servers_add_server), + trailing = { + TextButton(onClick = { viewModel.toggleAddForm() }) { + Text(if (state.showAddForm) "Hide" else "New") + } + }, + ) + + AnimatedVisibility(visible = state.showAddForm) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + TabRow(selectedTabIndex = if (state.addMode == AddServerMode.HTTP) 0 else 1) { + Tab( + selected = state.addMode == AddServerMode.HTTP, + onClick = { viewModel.setAddMode(AddServerMode.HTTP) }, + text = { Text(stringResource(R.string.mcp_servers_add_http)) }, + ) + Tab( + selected = state.addMode == AddServerMode.Stdio, + onClick = { viewModel.setAddMode(AddServerMode.Stdio) }, + text = { Text(stringResource(R.string.mcp_servers_add_stdio)) }, + ) + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + OutlinedTextField( + value = state.addServerName, + onValueChange = viewModel::updateAddServerName, + label = { Text(stringResource(R.string.mcp_servers_field_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + + if (state.addMode == AddServerMode.HTTP) { + OutlinedTextField( + value = state.addServerUrl, + onValueChange = viewModel::updateAddServerUrl, + label = { Text(stringResource(R.string.mcp_servers_field_url)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + } else { + OutlinedTextField( + value = state.addServerCommand, + onValueChange = viewModel::updateAddServerCommand, + label = { Text(stringResource(R.string.mcp_servers_field_command)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + OutlinedTextField( + value = state.addServerArgs, + onValueChange = viewModel::updateAddServerArgs, + label = { Text(stringResource(R.string.mcp_servers_field_args)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(spacing.md)) + Button( + onClick = { viewModel.submitAddServer() }, + enabled = !state.addingServer, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (state.addingServer) { + stringResource(R.string.mcp_servers_action_adding) + } else { + stringResource(R.string.mcp_servers_action_submit) + }, + ) + } + } + } + } +} + +@Composable +private fun ServerCard( + server: McpServer, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: com.m57.hermescontrol.theme.Spacing, +) { + var showEnv by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (server.enabled) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(spacing.md)) { + // Header row: name + status + toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = server.name.replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.width(spacing.sm)) + ServerStatusBadge(server.status) + } + Text( + text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = server.enabled, + onCheckedChange = { viewModel.toggleServer(server) }, + ) + } + + // URL or Command + if (server.url != null) { + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = server.url, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + } else if (server.command != null) { + Spacer(modifier = Modifier.height(spacing.sm)) + Text( + text = stringResource(R.string.mcp_servers_label_command), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${server.command} ${server.args.orEmpty().joinToString(" ")}", + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + ) + } + + // Error diagnostics + server.error?.let { error -> + if (error.isNotBlank()) { + Spacer(modifier = Modifier.height(spacing.sm)) + StatusBadge( + text = error, + status = StatusBadgeType.ERROR, + ) + } + } + + Spacer(modifier = Modifier.height(spacing.sm)) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + FilledTonalButton( + onClick = { viewModel.testServer(server.name) }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1) + } + FilledTonalButton( + onClick = { viewModel.restartServer(server.name) }, + modifier = Modifier.weight(1f), + ) { + Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_action_restart), maxLines = 1) + } + IconButton(onClick = { viewModel.deleteServer(server.name) }) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete)) + } + } + + // Env vars toggle + TextButton( + onClick = { showEnv = !showEnv }, + modifier = Modifier.fillMaxWidth(), + ) { + Icon(Icons.Filled.Storage, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_env_vars)) + } + + AnimatedVisibility(visible = showEnv) { + EnvVarSection(server = server, state = state, viewModel = viewModel, spacing = spacing) + } + } + } +} + +@Composable +private fun EnvVarSection( + server: McpServer, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: com.m57.hermescontrol.theme.Spacing, +) { + val isEditing = state.editingEnvFor == server.name + val env = server.env ?: emptyMap() + + if (env.isEmpty() && !isEditing) { + Text( + text = stringResource(R.string.mcp_servers_env_no_vars), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.xs), + ) + } else { + env.forEach { (key, value) -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = key, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold) + Text( + text = if (value.length > 30) "${value.take(15)}…${value.takeLast(10)}" else value, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { viewModel.removeEnvVar(server.name, key) }) { + Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) + } + } + } + } + + if (isEditing) { + HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = state.envKeyInput, + onValueChange = viewModel::updateEnvKey, + label = { Text(stringResource(R.string.mcp_servers_env_key)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + OutlinedTextField( + value = state.envValueInput, + onValueChange = viewModel::updateEnvValue, + label = { Text(stringResource(R.string.mcp_servers_env_value)) }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + } + Spacer(modifier = Modifier.height(spacing.sm)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing.sm), + ) { + Button(onClick = { viewModel.addEnvVar(server.name) }) { + Text(stringResource(R.string.mcp_servers_env_add)) + } + OutlinedButton(onClick = { viewModel.stopEditingEnv() }) { + Text(stringResource(R.string.action_cancel)) + } + } + } else { + TextButton(onClick = { viewModel.startEditingEnv(server) }) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(modifier = Modifier.width(spacing.xs)) + Text(stringResource(R.string.mcp_servers_env_add)) + } + } +} + +@Composable +private fun ServerStatusBadge(status: String?) { + if (status == null) return + val badgeType = + when (status.lowercase()) { + "running", "ok", "connected" -> StatusBadgeType.SUCCESS + "error", "failed" -> StatusBadgeType.ERROR + "stopped" -> StatusBadgeType.NEUTRAL + else -> StatusBadgeType.NEUTRAL + } + StatusBadge(text = status, status = badgeType) +} + +// ── Catalog ───────────────────────────────────────────────────── + +@Composable +private fun CatalogSection( + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: com.m57.hermescontrol.theme.Spacing, + filteredCatalog: List, +) { + val catalogExpanded = remember { mutableStateOf(false) } + + SectionHeader( + title = stringResource(R.string.mcp_servers_section_catalog), + trailing = { + TextButton(onClick = { + catalogExpanded.value = !catalogExpanded.value + if (catalogExpanded.value && state.catalogEntries.isEmpty() && !state.catalogLoading) { + viewModel.loadCatalog() + } + }) { + Text(if (catalogExpanded.value) "Hide" else "Browse") + } + }, + ) + + AnimatedVisibility(visible = catalogExpanded.value) { + Column(modifier = Modifier.fillMaxWidth()) { + // Search + OutlinedTextField( + value = state.catalogQuery, + onValueChange = viewModel::updateCatalogQuery, + label = { Text(stringResource(R.string.mcp_servers_catalog_search)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(bottom = spacing.sm), + ) + + // Loading / error / content + when { + state.catalogLoading -> { + Spacer(modifier = Modifier.height(spacing.sm)) + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + Spacer(modifier = Modifier.height(spacing.sm)) + } + state.catalogError != null -> { + ErrorState( + message = state.catalogError ?: "", + onRetry = { viewModel.loadCatalog() }, + ) + } + filteredCatalog.isEmpty() -> { + Text( + text = stringResource(R.string.mcp_servers_catalog_empty), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = spacing.md), + ) + } + else -> { + filteredCatalog.forEach { entry -> + CatalogEntryCard( + entry = entry, + state = state, + viewModel = viewModel, + spacing = spacing, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + } + } + } + } + } +} + +@Composable +private fun CatalogEntryCard( + entry: McpCatalogEntry, + state: McpServersUiState, + viewModel: McpServersViewModel, + spacing: com.m57.hermescontrol.theme.Spacing, +) { + var showInstallForm by remember { mutableStateOf(false) } + val isInstalling = state.installingCatalogEntry == entry.name + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(modifier = Modifier.padding(spacing.md)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + entry.source?.let { + Text( + text = stringResource(R.string.mcp_servers_catalog_source, it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Button( + onClick = { + if (entry.env?.isNotEmpty() == true) { + showInstallForm = !showInstallForm + } else { + viewModel.installCatalogEntry(entry) + } + }, + enabled = !isInstalling, + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } + } + + entry.description?.let { + Spacer(modifier = Modifier.height(spacing.xs)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Install form with env vars + AnimatedVisibility(visible = showInstallForm) { + Column(modifier = Modifier.padding(top = spacing.sm)) { + entry.env?.let { envVars -> + if (envVars.isNotEmpty()) { + Text( + text = stringResource(R.string.mcp_servers_catalog_required_env), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.height(spacing.sm)) + envVars.forEach { envVar -> + val label = envVar.label ?: envVar.key + val currentValue = state.catalogInstallEnv[envVar.key] ?: "" + OutlinedTextField( + value = currentValue, + onValueChange = { viewModel.updateCatalogEnvVar(envVar.key, it) }, + label = { Text(label) }, + placeholder = envVar.description?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(spacing.sm)) + } + } + } + Button( + onClick = { viewModel.installCatalogEntry(entry) }, + enabled = !isInstalling, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (isInstalling) { + stringResource(R.string.mcp_servers_catalog_installing) + } else { + stringResource(R.string.mcp_servers_catalog_install) + }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index c53b2e49..db9391ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -2,6 +2,9 @@ package com.m57.hermescontrol.ui.mcp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.AddMcpServerRequest +import com.m57.hermescontrol.data.model.McpCatalogEntry +import com.m57.hermescontrol.data.model.McpCatalogInstallRequest import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.remote.ApiClient @@ -17,12 +20,32 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +enum class AddServerMode { HTTP, Stdio } + data class McpServersUiState( val isLoading: Boolean = false, - val isActionRunning: Boolean = false, val servers: List = emptyList(), val errorMessage: String? = null, val toastMessage: String? = null, + // Add server form + val showAddForm: Boolean = false, + val addMode: AddServerMode = AddServerMode.HTTP, + val addServerName: String = "", + val addServerUrl: String = "", + val addServerCommand: String = "", + val addServerArgs: String = "", + val addingServer: Boolean = false, + // Env vars for editing + val editingEnvFor: String? = null, + val envKeyInput: String = "", + val envValueInput: String = "", + // Catalog + val catalogQuery: String = "", + val catalogEntries: List = emptyList(), + val catalogLoading: Boolean = false, + val catalogError: String? = null, + val installingCatalogEntry: String? = null, + val catalogInstallEnv: Map = emptyMap(), ) class McpServersViewModel : @@ -31,6 +54,8 @@ class McpServersViewModel : private val _uiState = MutableStateFlow(McpServersUiState()) val uiState: StateFlow = _uiState.asStateFlow() + // ── Data loading ────────────────────────────────────────── + fun loadServers() { safeLaunchLoad( apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } }, @@ -54,6 +79,8 @@ class McpServersViewModel : ) } + // ── Server toggle ──────────────────────────────────────── + fun toggleServer(server: McpServer) { val originalEnabled = server.enabled val targetEnabled = !originalEnabled @@ -71,7 +98,10 @@ class McpServersViewModel : val result = withContext(Dispatchers.IO) { safeApiCall { - ApiClient.hermesApi.toggleMcpServer(server.name, McpServerToggleRequest(targetEnabled)) + ApiClient.hermesApi.toggleMcpServer( + server.name, + McpServerToggleRequest(targetEnabled), + ) } } when (result) { @@ -82,7 +112,6 @@ class McpServersViewModel : ) } } - is NetworkResult.Failure -> { revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}") } @@ -91,57 +120,135 @@ class McpServersViewModel : } fun testServer(name: String) { - _uiState.update { it.copy(isActionRunning = true) } viewModelScope.launch { + _uiState.update { it.copy(toastMessage = "Testing server '$name'…") } val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.testMcpServer(name) } } when (result) { is NetworkResult.Success -> { - _uiState.update { - it.copy( - isActionRunning = false, - toastMessage = "Server '$name' tested successfully", - ) - } + _uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") } } - is NetworkResult.Failure -> { - _uiState.update { - it.copy( - isActionRunning = false, - toastMessage = "Server '$name' test failed: ${result.error.message}", - ) - } + _uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") } } } } } fun deleteServer(name: String) { - _uiState.update { it.copy(isActionRunning = true) } viewModelScope.launch { val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) } } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Server '$name' deleted") } + loadServers() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to delete server: ${result.error.message}") } + } + } + } + } + + fun restartServer(name: String) { + viewModelScope.launch { + _uiState.update { it.copy(toastMessage = "Restarting server '$name'…") } + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.restartMcpServer(name) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Server '$name' restarted") } + loadServers() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to restart server: ${result.error.message}") } + } + } + } + } + + // ── Add server form ────────────────────────────────────── + + fun toggleAddForm() { + _uiState.update { it.copy(showAddForm = !it.showAddForm) } + } + + fun setAddMode(mode: AddServerMode) { + _uiState.update { it.copy(addMode = mode) } + } + + fun updateAddServerName(v: String) { + _uiState.update { it.copy(addServerName = v) } + } + + fun updateAddServerUrl(v: String) { + _uiState.update { it.copy(addServerUrl = v) } + } + + fun updateAddServerCommand(v: String) { + _uiState.update { it.copy(addServerCommand = v) } + } + + fun updateAddServerArgs(v: String) { + _uiState.update { it.copy(addServerArgs = v) } + } + + fun submitAddServer() { + val state = _uiState.value + if (state.addServerName.isBlank()) { + _uiState.update { it.copy(toastMessage = "Server name is required") } + return + } + _uiState.update { it.copy(addingServer = true) } + viewModelScope.launch { + val request = + AddMcpServerRequest( + name = state.addServerName.trim(), + url = if (state.addMode == AddServerMode.HTTP) state.addServerUrl.trim().ifBlank { null } else null, + command = + if (state.addMode == AddServerMode.Stdio) { + state.addServerCommand.trim().ifBlank { null } + } else { + null + }, + args = + if (state.addMode == AddServerMode.Stdio && state.addServerArgs.isNotBlank()) { + state.addServerArgs.trim().split("\\s+".toRegex()).filter { it.isNotEmpty() } + } else { + null + }, + ) + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.addMcpServer(request) } + } when (result) { is NetworkResult.Success -> { _uiState.update { it.copy( - isActionRunning = false, - toastMessage = "Server '$name' deleted successfully", + addingServer = false, + showAddForm = false, + addServerName = "", + addServerUrl = "", + addServerCommand = "", + addServerArgs = "", + toastMessage = "Server '${request.name}' added", ) } loadServers() } - is NetworkResult.Failure -> { _uiState.update { it.copy( - isActionRunning = false, - toastMessage = "Failed to delete server: ${result.error.message}", + addingServer = false, + toastMessage = "Failed to add server: ${result.error.message}", ) } } @@ -149,6 +256,160 @@ class McpServersViewModel : } } + // ── Env var editing ────────────────────────────────────── + + fun startEditingEnv(server: McpServer) { + _uiState.update { it.copy(editingEnvFor = server.name, envKeyInput = "", envValueInput = "") } + } + + fun stopEditingEnv() { + _uiState.update { it.copy(editingEnvFor = null, envKeyInput = "", envValueInput = "") } + } + + fun updateEnvKey(v: String) { + _uiState.update { it.copy(envKeyInput = v) } + } + + fun updateEnvValue(v: String) { + _uiState.update { it.copy(envValueInput = v) } + } + + fun addEnvVar(serverName: String) { + val state = _uiState.value + val key = state.envKeyInput.trim() + val value = state.envValueInput.trim() + if (key.isBlank()) { + _uiState.update { it.copy(toastMessage = "Key is required") } + return + } + viewModelScope.launch { + val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() + val updatedEnv = existingEnv + (key to value) + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + envKeyInput = "", + envValueInput = "", + toastMessage = "Env var '$key' added", + ) + } + loadServers() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to add env var: ${result.error.message}") } + } + } + } + } + + fun removeEnvVar( + serverName: String, + key: String, + ) { + viewModelScope.launch { + val state = _uiState.value + val existingEnv = state.servers.find { it.name == serverName }?.env ?: emptyMap() + val updatedEnv = existingEnv - key + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.updateMcpServer(serverName, mapOf("env" to updatedEnv)) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Env var '$key' removed") } + loadServers() + } + is NetworkResult.Failure -> { + _uiState.update { it.copy(toastMessage = "Failed to remove env var: ${result.error.message}") } + } + } + } + } + + // ── Catalog ────────────────────────────────────────────── + + fun loadCatalog() { + _uiState.update { it.copy(catalogLoading = true, catalogError = null) } + viewModelScope.launch { + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.getMcpCatalog() } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + catalogLoading = false, + catalogEntries = result.data.entries.orEmpty(), + ) + } + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + catalogLoading = false, + catalogError = "Failed to load catalog: ${result.error.message}", + ) + } + } + } + } + } + + fun updateCatalogQuery(v: String) { + _uiState.update { it.copy(catalogQuery = v) } + } + + fun installCatalogEntry(entry: McpCatalogEntry) { + val state = _uiState.value + _uiState.update { it.copy(installingCatalogEntry = entry.name) } + viewModelScope.launch { + val request = + McpCatalogInstallRequest( + name = entry.name, + env = state.catalogInstallEnv.ifEmpty { null }, + ) + val result = + withContext(Dispatchers.IO) { + safeApiCall { ApiClient.hermesApi.installMcpCatalogEntry(request) } + } + when (result) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + installingCatalogEntry = null, + catalogInstallEnv = emptyMap(), + toastMessage = "Catalog entry '${entry.name}' installed", + ) + } + loadServers() + } + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + installingCatalogEntry = null, + toastMessage = "Failed to install: ${result.error.message}", + ) + } + } + } + } + } + + fun updateCatalogEnvVar( + key: String, + value: String, + ) { + _uiState.update { it.copy(catalogInstallEnv = it.catalogInstallEnv + (key to value)) } + } + + // ── Helpers ────────────────────────────────────────────── + private fun revertToggle( name: String, originalEnabled: Boolean, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 437d0c1f..f394d3c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -601,8 +601,35 @@ Transport: %1$s Command: Test - - + Add server + HTTP URL + Stdio command + Server name + URL (e.g. http://localhost:8000/sse) + Command (e.g. npx) + Arguments + Add server + Adding… + running + stopped + error + unconfigured + Restart + Env vars + Add env var + Key + Value + No environment variables configured. + Catalog + Search catalog… + Loading catalog… + No catalog entries match your search. + Install + Installing… + source: %1$s + Required env vars + + GATEWAY RUNNING GATEWAY STOPPED Version: %1$s