feat(mcp): MCP dashboard enhancements — JSON config import, token overhead badges, and batch health testing (#1037)

This commit is contained in:
M57 authored and GitHub committed 2026-09-01 20:18:45 +03:00
1 parent d8385a2b02
commit 6ec277af8b
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() {