fix(mcp): show Installed state for catalog entries already installed (#1405)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user