From 6810ef02feabbcd1a58ecc0b3b3f13ba90c5e84c Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:15:30 +0300 Subject: [PATCH] fix(mcp): show Installed state for catalog entries already installed (#1405) --- .../m57/hermescontrol/data/model/McpServer.kt | 22 ++++++-- .../ui/mcp/McpServersViewModel.kt | 1 + .../ui/mcp/components/CatalogSection.kt | 51 ++++++++++++------- .../ui/mcp/components/ServerCard.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../data/model/McpServerParsingTest.kt | 37 ++++++++++++++ 6 files changed, 92 insertions(+), 22 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index 2e856c48..3ce29ce3 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -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? = 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? = null, - val env: List? = null, + @SerialName("required_env") val env: List? = 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, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index 8232801b..fbbb910e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -585,6 +585,7 @@ class McpServersViewModel( ) } loadServers(forceRefresh = true) + loadCatalog() } is NetworkResult.Failure -> { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt index 2e2865d3..5aaf7abf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/CatalogSection.kt @@ -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, 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()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt index b5b22347..da6ca944 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/ServerCard.kt @@ -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) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b4023d78..87ac0e5d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1843,4 +1843,5 @@ Installed Catalog No catalog entries + Installed diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt new file mode 100644 index 00000000..0d633ca4 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/McpServerParsingTest.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class McpServerParsingTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `servers decode tools as null, list, or filter object`() { + val body = + """{"servers":[ + {"name":"a","enabled":true,"tools":null}, + {"name":"b","enabled":true,"tools":["x","y"]}, + {"name":"c","enabled":true,"tools":{"exclude":["docs","*_radar*"]}}, + {"name":"d","enabled":true,"tools":{"include":["one","two","three"]}} + ]}""" + val servers = json.decodeFromString(body).servers + assertEquals(listOf(null, 2, null, 3), servers.map { it.toolCount }) + } + + @Test + fun `catalog entry decodes backend required_env and installed flags`() { + val body = + """{"entries":[{"name":"gh","description":"d","source":"nous","transport":"stdio", + "required_env":[{"name":"GH_TOKEN","prompt":"Token","required":true}], + "installed":true,"enabled":false,"needs_install":false}],"diagnostics":[]}""" + val entry = json.decodeFromString(body).entries.single() + assertTrue(entry.installed) + assertEquals("GH_TOKEN", entry.env?.single()?.key) + assertEquals("Token", entry.env?.single()?.label) + assertNull(entry.url) + } +}