* 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 args: List<String>?,
|
||||||
val env: Map<String, String>?,
|
val env: Map<String, String>?,
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
|
val status: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class McpServerToggleRequest(
|
data class McpServerToggleRequest(
|
||||||
val enabled: Boolean,
|
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.ActionResponse
|
||||||
import com.m57.hermescontrol.data.model.ActionStatusResponse
|
import com.m57.hermescontrol.data.model.ActionStatusResponse
|
||||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
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.AgentPluginInstallBody
|
||||||
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
|
import com.m57.hermescontrol.data.model.AuxiliaryModelsResponse
|
||||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
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.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanTask
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
import com.m57.hermescontrol.data.model.LogResponse
|
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.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryResponse
|
import com.m57.hermescontrol.data.model.MemoryResponse
|
||||||
@@ -334,6 +338,30 @@ interface HermesApiService {
|
|||||||
@Path("name") name: String,
|
@Path("name") name: String,
|
||||||
): Response<Unit>
|
): 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")
|
@GET("api/webhooks")
|
||||||
suspend fun getWebhooks(): Response<WebhooksResponse>
|
suspend fun getWebhooks(): Response<WebhooksResponse>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.ui.mcp
|
package com.m57.hermescontrol.ui.mcp
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -31,20 +49,27 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.m57.hermescontrol.R
|
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.ErrorState
|
||||||
import com.m57.hermescontrol.ui.common.HermesScaffold
|
import com.m57.hermescontrol.ui.common.HermesScaffold
|
||||||
import com.m57.hermescontrol.ui.common.LoadingState
|
import com.m57.hermescontrol.ui.common.LoadingState
|
||||||
import com.m57.hermescontrol.ui.common.NavIcon
|
import com.m57.hermescontrol.ui.common.NavIcon
|
||||||
import com.m57.hermescontrol.ui.common.SearchBar
|
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.ToastEffect
|
||||||
import com.m57.hermescontrol.ui.common.listContentPadding
|
import com.m57.hermescontrol.ui.common.listContentPadding
|
||||||
import com.m57.hermescontrol.ui.common.listItemSpacing
|
import com.m57.hermescontrol.ui.common.listItemSpacing
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun McpServersScreen(
|
fun McpServersScreen(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -52,7 +77,7 @@ fun McpServersScreen(
|
|||||||
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
|
viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val spacing = LocalSpacing.current
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val filteredServers =
|
val filteredServers =
|
||||||
@@ -64,10 +89,20 @@ fun McpServersScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
val filteredCatalog =
|
||||||
viewModel.loadServers()
|
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)
|
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||||
|
|
||||||
HermesScaffold(
|
HermesScaffold(
|
||||||
@@ -80,7 +115,6 @@ fun McpServersScreen(
|
|||||||
state.isLoading && state.servers.isEmpty() -> {
|
state.isLoading && state.servers.isEmpty() -> {
|
||||||
LoadingState(modifier = Modifier.padding(paddingValues))
|
LoadingState(modifier = Modifier.padding(paddingValues))
|
||||||
}
|
}
|
||||||
|
|
||||||
state.errorMessage != null -> {
|
state.errorMessage != null -> {
|
||||||
ErrorState(
|
ErrorState(
|
||||||
message = state.errorMessage ?: "",
|
message = state.errorMessage ?: "",
|
||||||
@@ -88,123 +122,554 @@ fun McpServersScreen(
|
|||||||
modifier = Modifier.padding(paddingValues),
|
modifier = Modifier.padding(paddingValues),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Box(Modifier.fillMaxSize()) {
|
LazyColumn(
|
||||||
if (state.isLoading && state.servers.isEmpty()) {
|
modifier = Modifier.fillMaxSize(),
|
||||||
CircularProgressIndicator()
|
contentPadding = listContentPadding,
|
||||||
} else if (state.errorMessage != null && state.servers.isEmpty()) {
|
verticalArrangement = listItemSpacing,
|
||||||
Column(
|
) {
|
||||||
modifier = Modifier.padding(16.dp),
|
// ── 1. Add server ────────────────────────────────────
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
item(key = "add-header") {
|
||||||
) {
|
AddServerSection(state, viewModel, spacing)
|
||||||
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Button(onClick = { viewModel.loadServers() }) {
|
// ── 2. Search ────────────────────────────────────────
|
||||||
Text(stringResource(R.string.action_retry))
|
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 {
|
} else if (filteredServers.isEmpty()) {
|
||||||
LazyColumn(
|
item(key = "no-match") {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Text(
|
||||||
contentPadding = listContentPadding,
|
text = "No servers match \"$query\"",
|
||||||
verticalArrangement = listItemSpacing,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
) {
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
item {
|
modifier = Modifier.padding(spacing.md),
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.McpServer
|
||||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
@@ -17,12 +20,32 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
enum class AddServerMode { HTTP, Stdio }
|
||||||
|
|
||||||
data class McpServersUiState(
|
data class McpServersUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isActionRunning: Boolean = false,
|
|
||||||
val servers: List<McpServer> = emptyList(),
|
val servers: List<McpServer> = emptyList(),
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val toastMessage: 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 :
|
class McpServersViewModel :
|
||||||
@@ -31,6 +54,8 @@ class McpServersViewModel :
|
|||||||
private val _uiState = MutableStateFlow(McpServersUiState())
|
private val _uiState = MutableStateFlow(McpServersUiState())
|
||||||
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────
|
||||||
|
|
||||||
fun loadServers() {
|
fun loadServers() {
|
||||||
safeLaunchLoad(
|
safeLaunchLoad(
|
||||||
apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
|
apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
|
||||||
@@ -54,6 +79,8 @@ class McpServersViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Server toggle ────────────────────────────────────────
|
||||||
|
|
||||||
fun toggleServer(server: McpServer) {
|
fun toggleServer(server: McpServer) {
|
||||||
val originalEnabled = server.enabled
|
val originalEnabled = server.enabled
|
||||||
val targetEnabled = !originalEnabled
|
val targetEnabled = !originalEnabled
|
||||||
@@ -71,7 +98,10 @@ class McpServersViewModel :
|
|||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall {
|
safeApiCall {
|
||||||
ApiClient.hermesApi.toggleMcpServer(server.name, McpServerToggleRequest(targetEnabled))
|
ApiClient.hermesApi.toggleMcpServer(
|
||||||
|
server.name,
|
||||||
|
McpServerToggleRequest(targetEnabled),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
@@ -82,7 +112,6 @@ class McpServersViewModel :
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
|
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
|
||||||
}
|
}
|
||||||
@@ -91,57 +120,135 @@ class McpServersViewModel :
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun testServer(name: String) {
|
fun testServer(name: String) {
|
||||||
_uiState.update { it.copy(isActionRunning = true) }
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(toastMessage = "Testing server '$name'…") }
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
|
||||||
it.copy(
|
|
||||||
isActionRunning = false,
|
|
||||||
toastMessage = "Server '$name' tested successfully",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") }
|
||||||
it.copy(
|
|
||||||
isActionRunning = false,
|
|
||||||
toastMessage = "Server '$name' test failed: ${result.error.message}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteServer(name: String) {
|
fun deleteServer(name: String) {
|
||||||
_uiState.update { it.copy(isActionRunning = true) }
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) }
|
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) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isActionRunning = false,
|
addingServer = false,
|
||||||
toastMessage = "Server '$name' deleted successfully",
|
showAddForm = false,
|
||||||
|
addServerName = "",
|
||||||
|
addServerUrl = "",
|
||||||
|
addServerCommand = "",
|
||||||
|
addServerArgs = "",
|
||||||
|
toastMessage = "Server '${request.name}' added",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadServers()
|
loadServers()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isActionRunning = false,
|
addingServer = false,
|
||||||
toastMessage = "Failed to delete server: ${result.error.message}",
|
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(
|
private fun revertToggle(
|
||||||
name: String,
|
name: String,
|
||||||
originalEnabled: Boolean,
|
originalEnabled: Boolean,
|
||||||
|
|||||||
@@ -601,8 +601,35 @@
|
|||||||
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
||||||
<string name="mcp_servers_label_command">Command:</string>
|
<string name="mcp_servers_label_command">Command:</string>
|
||||||
<string name="mcp_servers_action_test">Test</string>
|
<string name="mcp_servers_action_test">Test</string>
|
||||||
|
<string name="mcp_servers_add_server">Add server</string>
|
||||||
<!-- Gateway Screen -->
|
<string name="mcp_servers_add_http">HTTP URL</string>
|
||||||
|
<string name="mcp_servers_add_stdio">Stdio command</string>
|
||||||
|
<string name="mcp_servers_field_name">Server name</string>
|
||||||
|
<string name="mcp_servers_field_url">URL (e.g. http://localhost:8000/sse)</string>
|
||||||
|
<string name="mcp_servers_field_command">Command (e.g. npx)</string>
|
||||||
|
<string name="mcp_servers_field_args">Arguments</string>
|
||||||
|
<string name="mcp_servers_action_submit">Add server</string>
|
||||||
|
<string name="mcp_servers_action_adding">Adding…</string>
|
||||||
|
<string name="mcp_servers_status_running">running</string>
|
||||||
|
<string name="mcp_servers_status_stopped">stopped</string>
|
||||||
|
<string name="mcp_servers_status_error">error</string>
|
||||||
|
<string name="mcp_servers_status_unconfigured">unconfigured</string>
|
||||||
|
<string name="mcp_servers_action_restart">Restart</string>
|
||||||
|
<string name="mcp_servers_env_vars">Env vars</string>
|
||||||
|
<string name="mcp_servers_env_add">Add env var</string>
|
||||||
|
<string name="mcp_servers_env_key">Key</string>
|
||||||
|
<string name="mcp_servers_env_value">Value</string>
|
||||||
|
<string name="mcp_servers_env_no_vars">No environment variables configured.</string>
|
||||||
|
<string name="mcp_servers_section_catalog">Catalog</string>
|
||||||
|
<string name="mcp_servers_catalog_search">Search catalog…</string>
|
||||||
|
<string name="mcp_servers_catalog_loading">Loading catalog…</string>
|
||||||
|
<string name="mcp_servers_catalog_empty">No catalog entries match your search.</string>
|
||||||
|
<string name="mcp_servers_catalog_install">Install</string>
|
||||||
|
<string name="mcp_servers_catalog_installing">Installing…</string>
|
||||||
|
<string name="mcp_servers_catalog_source">source: %1$s</string>
|
||||||
|
<string name="mcp_servers_catalog_required_env">Required env vars</string>
|
||||||
|
|
||||||
|
<!-- Gateway Screen -->
|
||||||
<string name="gateway_status_running">GATEWAY RUNNING</string>
|
<string name="gateway_status_running">GATEWAY RUNNING</string>
|
||||||
<string name="gateway_status_stopped">GATEWAY STOPPED</string>
|
<string name="gateway_status_stopped">GATEWAY STOPPED</string>
|
||||||
<string name="gateway_label_version">Version: %1$s</string>
|
<string name="gateway_label_version">Version: %1$s</string>
|
||||||
|
|||||||
Reference in new issue
Block a user