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() {
+10
View File
@@ -583,6 +583,16 @@
<string name="mcp_servers_label_transport">전송: %1$s</string>
<string name="mcp_servers_label_command">명령어:</string>
<string name="mcp_servers_action_test">테스트</string>
<string name="mcp_servers_action_test_all">모두 테스트</string>
<string name="mcp_servers_action_import_json">JSON 가져오기</string>
<string name="mcp_servers_import_dialog_title">MCP 서버 가져오기</string>
<string name="mcp_servers_import_dialog_desc">Claude Desktop / Cursor mcpServers JSON 스니펫 또는 서버 정의를 붙여넣으세요.</string>
<string name="mcp_servers_import_dialog_paste">클립보드 붙여넣기</string>
<string name="mcp_servers_import_dialog_importing">가져오는 중…</string>
<string name="mcp_servers_status_healthy">정상</string>
<string name="mcp_servers_status_needs_auth">인증 필요</string>
<string name="mcp_servers_status_error">오류</string>
<string name="mcp_servers_status_testing">테스트 중…</string>
<string name="mcp_servers_add_server">서버 추가</string>
<string name="mcp_servers_add_http">HTTP URL</string>
<string name="mcp_servers_add_stdio">Stdio 명령어</string>
+10
View File
@@ -720,6 +720,16 @@
<string name="mcp_servers_label_transport">传输:%1$s</string>
<string name="mcp_servers_label_command">命令:</string>
<string name="mcp_servers_action_test">测试</string>
<string name="mcp_servers_action_test_all">测试全部</string>
<string name="mcp_servers_action_import_json">导入 JSON</string>
<string name="mcp_servers_import_dialog_title">导入 MCP 服务器</string>
<string name="mcp_servers_import_dialog_desc">粘贴 Claude Desktop / Cursor mcpServers JSON 片段或服务器定义。</string>
<string name="mcp_servers_import_dialog_paste">从剪贴板粘贴</string>
<string name="mcp_servers_import_dialog_importing">导入中…</string>
<string name="mcp_servers_status_healthy">正常</string>
<string name="mcp_servers_status_needs_auth">需要认证</string>
<string name="mcp_servers_status_error">错误</string>
<string name="mcp_servers_status_testing">测试中…</string>
<string name="mcp_servers_add_server">添加服务器</string>
<string name="mcp_servers_add_http">HTTP 地址</string>
<string name="mcp_servers_empty_title">没有 MCP 服务器</string>
+10
View File
@@ -812,6 +812,16 @@
<string name="mcp_servers_label_transport">Transport: %1$s</string>
<string name="mcp_servers_label_command">Command:</string>
<string name="mcp_servers_action_test">Test</string>
<string name="mcp_servers_action_test_all">Test All</string>
<string name="mcp_servers_action_import_json">Import JSON</string>
<string name="mcp_servers_import_dialog_title">Import MCP Servers</string>
<string name="mcp_servers_import_dialog_desc">Paste Claude Desktop / Cursor mcpServers JSON snippet or server definition.</string>
<string name="mcp_servers_import_dialog_paste">Paste Clipboard</string>
<string name="mcp_servers_import_dialog_importing">Importing…</string>
<string name="mcp_servers_status_healthy">Healthy</string>
<string name="mcp_servers_status_needs_auth">Needs Auth</string>
<string name="mcp_servers_status_error">Error</string>
<string name="mcp_servers_status_testing">Testing…</string>
<string name="mcp_servers_add_server">Add server</string>
<string name="mcp_servers_add_http">HTTP URL</string>
<string name="mcp_servers_empty_title">No MCP servers</string>
@@ -0,0 +1,244 @@
package com.m57.hermescontrol.ui.mcp
import com.m57.hermescontrol.data.model.McpServerTestResponse
import com.m57.hermescontrol.data.model.McpServerToolInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class McpJsonParserTest {
@Test
fun testParseClaudeDesktopMcpServersWrapper() {
val json =
"""
{
"mcpServers": {
"sqlite": {
"command": "uvx",
"args": ["mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"],
"env": {
"DEBUG": "1"
}
},
"github": {
"url": "https://api.github.com/mcp",
"headers": {
"Authorization": "Bearer gh_secret_123"
}
}
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(2, parsed.size)
val sqlite = parsed.find { it.name == "sqlite" }
assertNotNull(sqlite)
assertEquals("uvx", sqlite?.command)
assertEquals(listOf("mcp-server-sqlite", "--db-path", "/path/to/db.sqlite"), sqlite?.args)
assertEquals(mapOf("DEBUG" to "1"), sqlite?.env)
assertEquals("none", sqlite?.auth)
val github = parsed.find { it.name == "github" }
assertNotNull(github)
assertEquals("https://api.github.com/mcp", github?.url)
assertEquals("header", github?.auth)
assertEquals("gh_secret_123", github?.bearerToken)
}
@Test
fun testParseSnakeCaseMcpServersWrapper() {
val json =
"""
{
"mcp_servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
assertEquals("filesystem", parsed[0].name)
assertEquals("npx", parsed[0].command)
assertEquals(listOf("-y", "@modelcontextprotocol/server-filesystem", "/tmp"), parsed[0].args)
}
@Test
fun testParseDirectServerMap() {
val json =
"""
{
"postgres": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/db"]
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
assertEquals("postgres", parsed[0].name)
assertEquals("npx", parsed[0].command)
}
@Test
fun testParseSingleServerObject() {
val json =
"""
{
"name": "custom-tools",
"command": "python3",
"args": ["-m", "mcp_server"],
"auth": "oauth"
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
assertEquals("custom-tools", parsed[0].name)
assertEquals("python3", parsed[0].command)
assertEquals("oauth", parsed[0].auth)
}
@Test
fun testParseInvalidJsonReturnsEmpty() {
val parsed = McpJsonParser.parse("not json at all")
assertTrue(parsed.isEmpty())
}
@Test
fun testParseMalformedArgsAndEnvDoesNotCrash() {
val json =
"""
{
"mcpServers": {
"faulty": {
"command": "npx",
"args": ["valid", {"nested": "bad"}, ["array"], 123],
"env": {
"VALID_KEY": "valid_val",
"BAD_KEY": {"nested": "bad"},
"BAD_LIST": [1, 2]
}
}
}
}
""".trimIndent()
val parsed = McpJsonParser.parse(json)
assertEquals(1, parsed.size)
val server = parsed[0]
assertEquals("faulty", server.name)
assertEquals(listOf("valid", "123"), server.args)
assertEquals(mapOf("VALID_KEY" to "valid_val"), server.env)
}
@Test
fun testTokenOverheadEstimator() {
val tools =
listOf(
McpServerToolInfo(name = "read_file", schemaChars = 400),
McpServerToolInfo(name = "write_file", schemaChars = 401),
McpServerToolInfo(name = "no_chars", schemaChars = null),
)
// ceil(400/4) = 100, ceil(401/4) = 101 -> sum = 201
val est = McpTokenEstimator.estimateTokens(tools)
assertEquals(201, est)
val formatted = McpTokenEstimator.formatTokenOverhead(tools.size, est)
assertEquals("3 tools • ~201 tokens", formatted)
}
@Test
fun testTokenOverheadLargeFormatting() {
val tools =
listOf(
McpServerToolInfo(name = "tool_huge", schemaChars = 12800),
)
// ceil(12800/4) = 3200 tokens -> ~3.2k tokens
val est = McpTokenEstimator.estimateTokens(tools)
assertEquals(3200, est)
val formatted = McpTokenEstimator.formatTokenOverhead(49, est)
assertEquals("49 tools • ~3.2k tokens", formatted)
}
@Test
fun testTokenOverheadNoSchemaChars() {
val tools =
listOf(
McpServerToolInfo(name = "tool1", schemaChars = null),
McpServerToolInfo(name = "tool2", schemaChars = 0),
)
val est = McpTokenEstimator.estimateTokens(tools)
assertNull(est)
assertEquals("2 tools", McpTokenEstimator.formatTokenOverhead(2, est))
}
@Test
fun testHealthStatusResolution() {
// In flight
assertEquals(
McpHealthStatus.TESTING,
McpHealthStatus.resolve(
isTesting = true,
testResult = null,
serverStatus = "ok",
serverError = null,
),
)
// Healthy result
assertEquals(
McpHealthStatus.HEALTHY,
McpHealthStatus.resolve(
isTesting = false,
testResult = McpServerTestResponse(ok = true),
serverStatus = null,
serverError = null,
),
)
// Auth needed
assertEquals(
McpHealthStatus.AUTH_REQUIRED,
McpHealthStatus.resolve(
isTesting = false,
testResult = McpServerTestResponse(ok = false, error = "OAuth authentication required"),
serverStatus = null,
serverError = null,
),
)
// Error
assertEquals(
McpHealthStatus.ERROR,
McpHealthStatus.resolve(
isTesting = false,
testResult = McpServerTestResponse(ok = false, error = "Process exited with 1"),
serverStatus = null,
serverError = null,
),
)
// Fallback from status
assertEquals(
McpHealthStatus.HEALTHY,
McpHealthStatus.resolve(
isTesting = false,
testResult = null,
serverStatus = "running",
serverError = null,
),
)
}
}