feat(mcp): MCP dashboard enhancements — JSON config import, token overhead badges, and batch health testing (#1037)
This commit is contained in:
9 files changed
+963
-17
No files matched your search
@@ -1,4 +1,5 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -18,6 +19,22 @@ data class McpServer(
|
||||
val status: String? = null,
|
||||
val error: String? = null,
|
||||
val auth: String? = null,
|
||||
val tools: List<String>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class McpServerTestResponse(
|
||||
val ok: Boolean = false,
|
||||
val error: String? = null,
|
||||
val tools: List<McpServerToolInfo> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class McpServerToolInfo(
|
||||
val name: String,
|
||||
val description: String? = null,
|
||||
@SerialName("schema_chars")
|
||||
val schemaChars: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -51,6 +51,7 @@ import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||
import com.m57.hermescontrol.data.model.McpCatalogResponse
|
||||
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
||||
@@ -519,7 +520,7 @@ interface HermesApiService {
|
||||
@POST("api/mcp/servers/{name}/test")
|
||||
suspend fun testMcpServer(
|
||||
@Path("name") name: String,
|
||||
): Response<Unit>
|
||||
): Response<McpServerTestResponse>
|
||||
|
||||
@DELETE("api/mcp/servers/{name}")
|
||||
suspend fun deleteMcpServer(
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package com.m57.hermescontrol.ui.mcp
|
||||
|
||||
import com.m57.hermescontrol.data.model.AddMcpServerRequest
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToolInfo
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Client-side parser for MCP server JSON configs (Claude Desktop / Cursor formats, single server JSON).
|
||||
* Issue #1029.
|
||||
*/
|
||||
object McpJsonParser {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses pasted JSON text into a list of [AddMcpServerRequest] models.
|
||||
* Returns an empty list if no valid server entries are found or if parsing fails.
|
||||
*/
|
||||
fun parse(text: String): List<AddMcpServerRequest> {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
|
||||
val rootElement: JsonElement =
|
||||
try {
|
||||
json.parseToJsonElement(trimmed)
|
||||
} catch (_: Exception) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
if (rootElement !is JsonObject) return emptyList()
|
||||
|
||||
// 1. Check for wrapped "mcpServers" or "mcp_servers" container
|
||||
val wrapper = rootElement["mcpServers"] ?: rootElement["mcp_servers"]
|
||||
if (wrapper is JsonObject) {
|
||||
return parseServerMap(wrapper)
|
||||
}
|
||||
|
||||
// 2. Check if the root object itself is a single server definition (has "command" or "url" directly)
|
||||
if (isServerObject(rootElement)) {
|
||||
val name =
|
||||
rootElement["name"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
?: inferName(rootElement)
|
||||
val req = parseSingleServer(name, rootElement)
|
||||
return if (req != null) listOf(req) else emptyList()
|
||||
}
|
||||
|
||||
// 3. Check if the root object is a dictionary of name -> serverObject
|
||||
val mapEntries = parseServerMap(rootElement)
|
||||
if (mapEntries.isNotEmpty()) {
|
||||
return mapEntries
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun parseServerMap(obj: JsonObject): List<AddMcpServerRequest> {
|
||||
val results = mutableListOf<AddMcpServerRequest>()
|
||||
for ((name, element) in obj) {
|
||||
if (element is JsonObject && isServerObject(element)) {
|
||||
val req = parseSingleServer(name, element)
|
||||
if (req != null) {
|
||||
results.add(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun isServerObject(obj: JsonObject): Boolean =
|
||||
obj.containsKey("command") || obj.containsKey("url") || obj.containsKey("transport")
|
||||
|
||||
private fun parseSingleServer(
|
||||
name: String,
|
||||
obj: JsonObject,
|
||||
): AddMcpServerRequest? {
|
||||
val cleanName = name.trim()
|
||||
if (cleanName.isEmpty()) return null
|
||||
|
||||
val url =
|
||||
obj["url"]
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val command =
|
||||
obj["command"]
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
if (url == null && command == null) return null
|
||||
|
||||
val args: List<String>? =
|
||||
when (val argsElement = obj["args"]) {
|
||||
is JsonArray -> {
|
||||
argsElement.mapNotNull {
|
||||
(it as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val env: Map<String, String>? =
|
||||
when (val envElement = obj["env"]) {
|
||||
is JsonObject -> {
|
||||
envElement.entries
|
||||
.mapNotNull { (k, v) ->
|
||||
(v as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.let { k to it }
|
||||
}.toMap()
|
||||
.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Auth detection from "auth" or "headers"
|
||||
var auth = (obj["auth"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.lowercase()
|
||||
var bearerToken = (obj["bearerToken"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||
|
||||
val headers = obj["headers"]
|
||||
if (headers is JsonObject) {
|
||||
val authHeader =
|
||||
(headers["Authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||
?: (headers["authorization"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||
if (authHeader != null) {
|
||||
auth = "header"
|
||||
if (authHeader.startsWith("Bearer ", ignoreCase = true)) {
|
||||
bearerToken = authHeader.substring(7).trim()
|
||||
} else {
|
||||
bearerToken = authHeader.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val finalAuth =
|
||||
when (auth) {
|
||||
"oauth" -> "oauth"
|
||||
"header" -> "header"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
return AddMcpServerRequest(
|
||||
name = cleanName,
|
||||
url = if (command == null) url else null,
|
||||
command = command,
|
||||
args = args,
|
||||
env = env,
|
||||
auth = finalAuth,
|
||||
bearerToken = if (finalAuth == "header") bearerToken else null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun inferName(obj: JsonObject): String {
|
||||
val command = (obj["command"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim()
|
||||
val args = obj["args"]
|
||||
if (!command.isNullOrEmpty()) {
|
||||
if (args is JsonArray && args.isNotEmpty()) {
|
||||
val firstArg = (args[0] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
|
||||
if (firstArg != null && !firstArg.startsWith("-")) {
|
||||
return sanitizeName(firstArg.split("/").last())
|
||||
}
|
||||
}
|
||||
return sanitizeName(command.split("/").last())
|
||||
}
|
||||
val url = (obj["url"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull?.trim()
|
||||
if (!url.isNullOrEmpty()) {
|
||||
return try {
|
||||
val host = java.net.URI(url).host ?: "server"
|
||||
host.split(".").firstOrNull { it != "api" && it != "www" && it != "mcp" } ?: "server"
|
||||
} catch (_: Exception) {
|
||||
"server"
|
||||
}
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
|
||||
private fun sanitizeName(raw: String): String =
|
||||
raw
|
||||
.replace(Regex("""\.(cjs|js|mjs|py|ts)$""", RegexOption.IGNORE_CASE), "")
|
||||
.replace(Regex("""^(mcp-server-|server-|mcp-)"""), "")
|
||||
.replace(Regex("""(-mcp-server|-mcp|-server)$"""), "")
|
||||
.ifEmpty { "server" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Token overhead estimator for MCP server tool schemas (issue #1029).
|
||||
*/
|
||||
object McpTokenEstimator {
|
||||
/**
|
||||
* Approximate token overhead for a server's tool schemas.
|
||||
* Sum of ceil(schema_chars / 4.0) over tools carrying valid schema_chars.
|
||||
*/
|
||||
fun estimateTokens(tools: List<McpServerToolInfo>): Int? {
|
||||
var total = 0
|
||||
var sawValid = false
|
||||
for (tool in tools) {
|
||||
val chars = tool.schemaChars
|
||||
if (chars != null && chars > 0) {
|
||||
total += ceil(chars / 4.0).toInt()
|
||||
sawValid = true
|
||||
}
|
||||
}
|
||||
return if (sawValid) total else null
|
||||
}
|
||||
|
||||
fun formatTokenOverhead(
|
||||
toolCount: Int,
|
||||
tokenEstimate: Int?,
|
||||
): String {
|
||||
if (tokenEstimate == null) {
|
||||
return if (toolCount == 1) "1 tool" else "$toolCount tools"
|
||||
}
|
||||
val tokenFormatted =
|
||||
if (tokenEstimate >= 1000) {
|
||||
String.format(Locale.US, "%.1fk", tokenEstimate / 1000.0)
|
||||
} else {
|
||||
tokenEstimate.toString()
|
||||
}
|
||||
val toolStr = if (toolCount == 1) "1 tool" else "$toolCount tools"
|
||||
return "$toolStr • ~$tokenFormatted tokens"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health status for visual color-coded badges (issue #1029).
|
||||
*/
|
||||
enum class McpHealthStatus {
|
||||
HEALTHY,
|
||||
AUTH_REQUIRED,
|
||||
ERROR,
|
||||
TESTING,
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun resolve(
|
||||
isTesting: Boolean,
|
||||
testResult: McpServerTestResponse?,
|
||||
serverStatus: String?,
|
||||
serverError: String?,
|
||||
): McpHealthStatus {
|
||||
if (isTesting) return TESTING
|
||||
if (testResult != null) {
|
||||
if (testResult.ok) return HEALTHY
|
||||
val err = (testResult.error ?: "").lowercase()
|
||||
if (err.contains("oauth") || err.contains("auth") || err.contains("token")) {
|
||||
return AUTH_REQUIRED
|
||||
}
|
||||
return ERROR
|
||||
}
|
||||
if (!serverError.isNullOrBlank()) {
|
||||
val err = serverError.lowercase()
|
||||
if (err.contains("oauth") || err.contains("auth") || err.contains("token")) {
|
||||
return AUTH_REQUIRED
|
||||
}
|
||||
return ERROR
|
||||
}
|
||||
if (serverStatus != null) {
|
||||
return when (serverStatus.lowercase()) {
|
||||
"running", "ok", "connected", "healthy" -> HEALTHY
|
||||
"error", "failed" -> ERROR
|
||||
else -> UNKNOWN
|
||||
}
|
||||
}
|
||||
return UNKNOWN
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,11 +22,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Build
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Science
|
||||
import androidx.compose.material.icons.filled.Storage
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -41,6 +45,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
@@ -54,8 +59,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -90,6 +97,7 @@ fun McpServersScreen(
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val spacing = LocalSpacing.current
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var query by remember { mutableStateOf("") }
|
||||
var showDetail by remember { mutableStateOf<McpServer?>(null) }
|
||||
|
||||
@@ -123,6 +131,32 @@ fun McpServersScreen(
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadServers() },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.testAllServers() },
|
||||
enabled = !state.isTestingAll && state.servers.any { it.enabled },
|
||||
) {
|
||||
if (state.isTestingAll) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Science,
|
||||
contentDescription = stringResource(R.string.mcp_servers_action_test_all),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleImportDialog() },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.UploadFile,
|
||||
contentDescription = stringResource(R.string.mcp_servers_action_import_json),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
when {
|
||||
state.isLoading && state.servers.isEmpty() -> {
|
||||
@@ -272,6 +306,95 @@ fun McpServersScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (state.showImportDialog) {
|
||||
androidx.compose.material3.AlertDialog(
|
||||
onDismissRequest = { viewModel.toggleImportDialog() },
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.UploadFile,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_title))
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(spacing.sm))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val clip = clipboardManager.getText()?.text
|
||||
if (!clip.isNullOrBlank()) {
|
||||
viewModel.updateImportJsonInput(clip)
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_import_dialog_paste),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
OutlinedTextField(
|
||||
value = state.importJsonInput,
|
||||
onValueChange = viewModel::updateImportJsonInput,
|
||||
placeholder = {
|
||||
Text(
|
||||
"{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}",
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
)
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(180.dp),
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { viewModel.submitImportJson() },
|
||||
enabled = !state.isImportingJson && state.importJsonInput.isNotBlank(),
|
||||
) {
|
||||
if (state.isImportingJson) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_import_dialog_importing))
|
||||
} else {
|
||||
Text(stringResource(R.string.mcp_servers_action_submit))
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { viewModel.toggleImportDialog() }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
||||
@@ -325,15 +448,29 @@ private fun AddServerSection(
|
||||
icon = Icons.Filled.Add,
|
||||
title = stringResource(R.string.mcp_servers_add_server),
|
||||
trailing = {
|
||||
TextButton(
|
||||
onClick = { viewModel.toggleAddForm() },
|
||||
colors =
|
||||
ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(if (state.showAddForm) "Hide" else "New")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||
TextButton(
|
||||
onClick = { viewModel.toggleImportDialog() },
|
||||
colors =
|
||||
ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_action_import_json))
|
||||
}
|
||||
TextButton(
|
||||
onClick = { viewModel.toggleAddForm() },
|
||||
colors =
|
||||
ButtonDefaults.textButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
) {
|
||||
Text(if (state.showAddForm) "Hide" else "New")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -463,6 +600,9 @@ private fun ServerCard(
|
||||
onOpenBrowser: (String) -> Unit,
|
||||
) {
|
||||
var showEnv by remember { mutableStateOf(false) }
|
||||
val isTesting = server.name in state.testingServers
|
||||
val testResult = state.serverTestResults[server.name]
|
||||
val healthStatus = McpHealthStatus.resolve(isTesting, testResult, server.status, server.error)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
@@ -491,13 +631,77 @@ private fun ServerCard(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.sm))
|
||||
ServerStatusBadge(server.status)
|
||||
when (healthStatus) {
|
||||
McpHealthStatus.HEALTHY -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_healthy),
|
||||
status = StatusBadgeType.SUCCESS,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.AUTH_REQUIRED -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_needs_auth),
|
||||
status = StatusBadgeType.WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.ERROR -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_error),
|
||||
status = StatusBadgeType.ERROR,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.TESTING -> {
|
||||
StatusBadge(
|
||||
text = stringResource(R.string.mcp_servers_status_testing),
|
||||
status = StatusBadgeType.INFO,
|
||||
)
|
||||
}
|
||||
|
||||
McpHealthStatus.UNKNOWN -> {
|
||||
ServerStatusBadge(server.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
// Tool count & schema token overhead badge (issue #1029)
|
||||
val toolInfos = testResult?.tools
|
||||
val toolCount = toolInfos?.size ?: server.tools?.size
|
||||
if (toolCount != null && toolCount > 0) {
|
||||
val tokenEst = toolInfos?.let { McpTokenEstimator.estimateTokens(it) }
|
||||
val label = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||
Spacer(modifier = Modifier.height(spacing.xs))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = spacing.xs + 2.dp, vertical = 2.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Build,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = server.enabled,
|
||||
@@ -556,12 +760,22 @@ private fun ServerCard(
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.testServer(server.name) },
|
||||
enabled = !isTesting,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
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)
|
||||
if (isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(spacing.xs))
|
||||
Text(stringResource(R.string.mcp_servers_status_testing), maxLines = 1)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.model.McpCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerToggleRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
@@ -14,6 +15,8 @@ import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.ui.common.ToastHost
|
||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -38,6 +41,14 @@ data class McpServersUiState(
|
||||
val addServerAuth: String = "none", // "none" | "header" | "oauth"
|
||||
val addServerBearerToken: String = "",
|
||||
val addingServer: Boolean = false,
|
||||
// JSON Import (issue #1029)
|
||||
val showImportDialog: Boolean = false,
|
||||
val importJsonInput: String = "",
|
||||
val isImportingJson: Boolean = false,
|
||||
// Batch health & tool test results (issue #1029)
|
||||
val serverTestResults: Map<String, McpServerTestResponse> = emptyMap(),
|
||||
val testingServers: Set<String> = emptySet(),
|
||||
val isTestingAll: Boolean = false,
|
||||
// Env vars for editing
|
||||
val editingEnvFor: String? = null,
|
||||
val envKeyInput: String = "",
|
||||
@@ -69,6 +80,8 @@ class McpServersViewModel :
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
servers = data.servers.orEmpty(),
|
||||
serverTestResults = emptyMap(),
|
||||
testingServers = emptySet(),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -126,23 +139,105 @@ class McpServersViewModel :
|
||||
|
||||
fun testServer(name: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(toastMessage = "Testing server '$name'…") }
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
testingServers = it.testingServers + name,
|
||||
toastMessage = "Testing server '$name'…",
|
||||
)
|
||||
}
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' tested — OK") }
|
||||
val response = result.data
|
||||
val toolCount = response.tools.size
|
||||
val tokenEst = McpTokenEstimator.estimateTokens(response.tools)
|
||||
val tokenStr = McpTokenEstimator.formatTokenOverhead(toolCount, tokenEst)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
serverTestResults = state.serverTestResults + (name to response),
|
||||
testingServers = state.testingServers - name,
|
||||
toastMessage =
|
||||
if (response.ok) {
|
||||
"Server '$name' tested — OK ($tokenStr)"
|
||||
} else {
|
||||
"Server '$name' test failed: ${response.error ?: "unknown error"}"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Server '$name' test failed: ${result.error.message}") }
|
||||
val errResp = McpServerTestResponse(ok = false, error = result.error.message)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
serverTestResults = state.serverTestResults + (name to errResp),
|
||||
testingServers = state.testingServers - name,
|
||||
toastMessage = "Server '$name' test failed: ${result.error.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun testAllServers() {
|
||||
val enabledServers = _uiState.value.servers.filter { it.enabled }
|
||||
if (enabledServers.isEmpty()) {
|
||||
_uiState.update { it.copy(toastMessage = "No enabled servers to test") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
val names = enabledServers.map { it.name }.toSet()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTestingAll = true,
|
||||
testingServers = it.testingServers + names,
|
||||
toastMessage = "Testing ${enabledServers.size} server(s)…",
|
||||
)
|
||||
}
|
||||
|
||||
val testJobs =
|
||||
enabledServers.map { server ->
|
||||
async(Dispatchers.IO) {
|
||||
val res = safeApiCall { ApiClient.hermesApi.testMcpServer(server.name) }
|
||||
server.name to res
|
||||
}
|
||||
}
|
||||
|
||||
val results = testJobs.awaitAll()
|
||||
val newResults = mutableMapOf<String, McpServerTestResponse>()
|
||||
var passCount = 0
|
||||
|
||||
for ((name, result) in results) {
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
newResults[name] = result.data
|
||||
if (result.data.ok) passCount++
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
newResults[name] = McpServerTestResponse(ok = false, error = result.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val failCount = enabledServers.size - passCount
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isTestingAll = false,
|
||||
serverTestResults = state.serverTestResults + newResults,
|
||||
testingServers = state.testingServers - names,
|
||||
toastMessage =
|
||||
"Tested ${enabledServers.size} servers: $passCount passed, $failCount failed",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteServer(name: String) {
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
@@ -162,6 +257,64 @@ class McpServersViewModel :
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON Import (issue #1029) ────────────────────────────
|
||||
|
||||
fun toggleImportDialog() {
|
||||
_uiState.update { it.copy(showImportDialog = !it.showImportDialog, importJsonInput = "") }
|
||||
}
|
||||
|
||||
fun updateImportJsonInput(v: String) {
|
||||
_uiState.update { it.copy(importJsonInput = v) }
|
||||
}
|
||||
|
||||
fun submitImportJson() {
|
||||
val input = _uiState.value.importJsonInput
|
||||
val requests = McpJsonParser.parse(input)
|
||||
if (requests.isEmpty()) {
|
||||
_uiState.update { it.copy(toastMessage = "Invalid JSON or no MCP servers found in input") }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isImportingJson = true) }
|
||||
viewModelScope.launch {
|
||||
val importJobs =
|
||||
requests.map { req ->
|
||||
async(Dispatchers.IO) {
|
||||
val result = safeApiCall { ApiClient.hermesApi.addMcpServer(req) }
|
||||
req to result
|
||||
}
|
||||
}
|
||||
|
||||
val results = importJobs.awaitAll()
|
||||
var successCount = 0
|
||||
val errors = mutableListOf<String>()
|
||||
|
||||
for ((req, result) in results) {
|
||||
when (result) {
|
||||
is NetworkResult.Success -> successCount++
|
||||
is NetworkResult.Failure -> errors.add("${req.name}: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.update { state ->
|
||||
val errSummary = errors.joinToString("; ")
|
||||
val msg =
|
||||
when {
|
||||
errors.isEmpty() -> "Successfully imported $successCount server(s)"
|
||||
successCount > 0 -> "Imported $successCount server(s), ${errors.size} failed: $errSummary"
|
||||
else -> "Failed to import: $errSummary"
|
||||
}
|
||||
state.copy(
|
||||
isImportingJson = false,
|
||||
showImportDialog = false,
|
||||
importJsonInput = "",
|
||||
toastMessage = msg,
|
||||
)
|
||||
}
|
||||
loadServers()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Add server form ──────────────────────────────────────
|
||||
|
||||
fun toggleAddForm() {
|
||||
|
||||
Reference in new issue
Block a user