feat(#444): MCP tab parity — add servers, catalog, env vars, restart, diagnostics (#462)

* 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 <hermes@agent.local>
This commit is contained in:
M57andHermes Agent authored and GitHub committed 2026-07-03 22:27:28 +03:00
1 parent d181851670
commit 5be4075d95
5 files changed
+962 -145

No files matched your search

@@ -12,8 +12,44 @@ data class McpServer(
val args: List<String>?,
val env: Map<String, String>?,
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<String>? = null,
val env: Map<String, String>? = null,
)
data class McpCatalogResponse(
val entries: List<McpCatalogEntry>,
)
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<String>? = null,
val env: List<McpCatalogEnvVar>? = 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<String, String>? = null,
)
@@ -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<Unit>
@POST("api/mcp/servers")
suspend fun addMcpServer(
@Body body: AddMcpServerRequest,
): Response<McpServer>
@PUT("api/mcp/servers/{name}")
suspend fun updateMcpServer(
@Path("name") name: String,
@Body body: Map<String, Any>,
): Response<McpServer>
@POST("api/mcp/servers/{name}/restart")
suspend fun restartMcpServer(
@Path("name") name: String,
): Response<Unit>
@GET("api/mcp/catalog")
suspend fun getMcpCatalog(): Response<McpCatalogResponse>
@POST("api/mcp/catalog/install")
suspend fun installMcpCatalogEntry(
@Body body: McpCatalogInstallRequest,
): Response<Map<String, Any>>
@GET("api/webhooks")
suspend fun getWebhooks(): Response<WebhooksResponse>
@@ -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<McpCatalogEntry>,
) {
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)
},
)
}
}
}
}
@@ -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<McpServer> = 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<McpCatalogEntry> = emptyList(),
val catalogLoading: Boolean = false,
val catalogError: String? = null,
val installingCatalogEntry: String? = null,
val catalogInstallEnv: Map<String, String> = emptyMap(),
)
class McpServersViewModel :
@@ -31,6 +54,8 @@ class McpServersViewModel :
private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _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,