* 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:
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,
|
||||
|
||||
Reference in new issue
Block a user