fix(mcp): show Installed state for catalog entries already installed (#1405)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 13:15:30 +03:00
1 parent 81b5e6d84b
commit 6810ef02fe
6 files changed
+92 -22

No files matched your search

@@ -1,6 +1,9 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class McpServersResponse(
@@ -19,12 +22,21 @@ data class McpServer(
val status: String? = null,
val error: String? = null,
val auth: String? = null,
val tools: List<String>? = null,
val tools: JsonElement? = null,
val source: String = "config",
val plugin: String? = null,
) {
// #1283: plugin-provided servers are managed by their owning plugin.
val isPluginOwned: Boolean get() = source == "plugin"
// Backend sends `tools` as null, a name list, or a `{include, exclude}` filter object.
val toolCount: Int?
get() =
when (val t = tools) {
is JsonArray -> t.size
is JsonObject -> (t["include"] as? JsonArray)?.size
else -> null
}
}
@Serializable
@@ -71,13 +83,15 @@ data class McpCatalogEntry(
val url: String? = null,
val command: String? = null,
val args: List<String>? = null,
val env: List<McpCatalogEnvVar>? = null,
@SerialName("required_env") val env: List<McpCatalogEnvVar>? = null,
val installed: Boolean = false,
val enabled: Boolean = false,
)
@Serializable
data class McpCatalogEnvVar(
val key: String,
val label: String? = null,
@SerialName("name") val key: String,
@SerialName("prompt") val label: String? = null,
val description: String? = null,
val required: Boolean = false,
)
@@ -585,6 +585,7 @@ class McpServersViewModel(
)
}
loadServers(forceRefresh = true)
loadCatalog()
}
is NetworkResult.Failure -> {
@@ -9,11 +9,17 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -47,6 +53,7 @@ fun CatalogView(
filteredCatalog: List<McpCatalogEntry>,
modifier: Modifier = Modifier,
) {
val installedNames = remember(state.servers) { state.servers.map { it.name }.toSet() }
Column(modifier = modifier.fillMaxSize()) {
SearchBar(
query = state.catalogQuery,
@@ -88,6 +95,7 @@ fun CatalogView(
state = state,
viewModel = viewModel,
spacing = spacing,
isInstalled = entry.installed || entry.name in installedNames,
)
}
}
@@ -103,6 +111,7 @@ fun CatalogEntryCard(
viewModel: McpServersViewModel,
spacing: Spacing,
modifier: Modifier = Modifier,
isInstalled: Boolean = false,
) {
var showInstallForm by remember { mutableStateOf(false) }
val isInstalling = state.installingCatalogEntry == entry.name
@@ -131,23 +140,31 @@ fun CatalogEntryCard(
)
}
}
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)
if (isInstalled) {
FilledTonalButton(onClick = {}, enabled = false) {
Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(spacing.xs))
Text(stringResource(R.string.mcp_servers_catalog_installed))
}
} else {
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)
},
)
}
}
}
@@ -161,7 +178,7 @@ fun CatalogEntryCard(
}
// Install form with env vars
AnimatedVisibility(visible = showInstallForm) {
AnimatedVisibility(visible = showInstallForm && !isInstalled) {
Column(modifier = Modifier.padding(top = spacing.sm)) {
entry.env?.let { envVars ->
if (envVars.isNotEmpty()) {
@@ -151,7 +151,7 @@ fun ServerCard(
// Tool count & schema token overhead badge (issue #1029)
val toolInfos = testResult?.tools
val toolCount = toolInfos?.size ?: server.tools?.size
val toolCount = toolInfos?.size ?: server.toolCount
if (toolCount != null && toolCount > 0) {
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)