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
+76 -6

No files matched your search

@@ -1,6 +1,9 @@
package com.m57.hermescontrol.data.model package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable @Serializable
data class McpServersResponse( data class McpServersResponse(
@@ -19,12 +22,21 @@ data class McpServer(
val status: String? = null, val status: String? = null,
val error: String? = null, val error: String? = null,
val auth: String? = null, val auth: String? = null,
val tools: List<String>? = null, val tools: JsonElement? = null,
val source: String = "config", val source: String = "config",
val plugin: String? = null, val plugin: String? = null,
) { ) {
// #1283: plugin-provided servers are managed by their owning plugin. // #1283: plugin-provided servers are managed by their owning plugin.
val isPluginOwned: Boolean get() = source == "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 @Serializable
@@ -71,13 +83,15 @@ data class McpCatalogEntry(
val url: String? = null, val url: String? = null,
val command: String? = null, val command: String? = null,
val args: List<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 @Serializable
data class McpCatalogEnvVar( data class McpCatalogEnvVar(
val key: String, @SerialName("name") val key: String,
val label: String? = null, @SerialName("prompt") val label: String? = null,
val description: String? = null, val description: String? = null,
val required: Boolean = false, val required: Boolean = false,
) )
@@ -585,6 +585,7 @@ class McpServersViewModel(
) )
} }
loadServers(forceRefresh = true) loadServers(forceRefresh = true)
loadCatalog()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -9,11 +9,17 @@ 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.material.icons.Icons
import androidx.compose.material.icons.filled.Check
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.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -47,6 +53,7 @@ fun CatalogView(
filteredCatalog: List<McpCatalogEntry>, filteredCatalog: List<McpCatalogEntry>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val installedNames = remember(state.servers) { state.servers.map { it.name }.toSet() }
Column(modifier = modifier.fillMaxSize()) { Column(modifier = modifier.fillMaxSize()) {
SearchBar( SearchBar(
query = state.catalogQuery, query = state.catalogQuery,
@@ -88,6 +95,7 @@ fun CatalogView(
state = state, state = state,
viewModel = viewModel, viewModel = viewModel,
spacing = spacing, spacing = spacing,
isInstalled = entry.installed || entry.name in installedNames,
) )
} }
} }
@@ -103,6 +111,7 @@ fun CatalogEntryCard(
viewModel: McpServersViewModel, viewModel: McpServersViewModel,
spacing: Spacing, spacing: Spacing,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isInstalled: Boolean = false,
) { ) {
var showInstallForm by remember { mutableStateOf(false) } var showInstallForm by remember { mutableStateOf(false) }
val isInstalling = state.installingCatalogEntry == entry.name val isInstalling = state.installingCatalogEntry == entry.name
@@ -131,6 +140,13 @@ fun CatalogEntryCard(
) )
} }
} }
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( Button(
onClick = { onClick = {
if (entry.env?.isNotEmpty() == true) { if (entry.env?.isNotEmpty() == true) {
@@ -150,6 +166,7 @@ fun CatalogEntryCard(
) )
} }
} }
}
entry.description?.let { entry.description?.let {
Spacer(modifier = Modifier.height(spacing.xs)) Spacer(modifier = Modifier.height(spacing.xs))
@@ -161,7 +178,7 @@ fun CatalogEntryCard(
} }
// Install form with env vars // Install form with env vars
AnimatedVisibility(visible = showInstallForm) { AnimatedVisibility(visible = showInstallForm && !isInstalled) {
Column(modifier = Modifier.padding(top = spacing.sm)) { Column(modifier = Modifier.padding(top = spacing.sm)) {
entry.env?.let { envVars -> entry.env?.let { envVars ->
if (envVars.isNotEmpty()) { if (envVars.isNotEmpty()) {
@@ -151,7 +151,7 @@ fun ServerCard(
// Tool count & schema token overhead badge (issue #1029) // Tool count & schema token overhead badge (issue #1029)
val toolInfos = testResult?.tools val toolInfos = testResult?.tools
val toolCount = toolInfos?.size ?: server.tools?.size val toolCount = toolInfos?.size ?: server.toolCount
if (toolCount != null && toolCount > 0) { if (toolCount != null && toolCount > 0) {
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) } val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst) val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
+1
View File
@@ -1843,4 +1843,5 @@
<string name="mcp_tab_installed">Installed</string> <string name="mcp_tab_installed">Installed</string>
<string name="mcp_tab_catalog">Catalog</string> <string name="mcp_tab_catalog">Catalog</string>
<string name="mcp_servers_catalog_empty_title">No catalog entries</string> <string name="mcp_servers_catalog_empty_title">No catalog entries</string>
<string name="mcp_servers_catalog_installed">Installed</string>
</resources> </resources>
@@ -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<McpServersResponse>(body).servers
assertEquals(listOf<Int?>(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<McpCatalogResponse>(body).entries.single()
assertTrue(entry.installed)
assertEquals("GH_TOKEN", entry.env?.single()?.key)
assertEquals("Token", entry.env?.single()?.label)
assertNull(entry.url)
}
}