diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt index cf351590..69ba7c59 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/McpServer.kt @@ -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? = null, +) + +@Serializable +data class McpServerTestResponse( + val ok: Boolean = false, + val error: String? = null, + val tools: List = emptyList(), +) + +@Serializable +data class McpServerToolInfo( + val name: String, + val description: String? = null, + @SerialName("schema_chars") + val schemaChars: Int? = null, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index e6d5149c..3be6c802 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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 + ): Response @DELETE("api/mcp/servers/{name}") suspend fun deleteMcpServer( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpJsonParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpJsonParser.kt new file mode 100644 index 00000000..393a35fa --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpJsonParser.kt @@ -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 { + 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 { + val results = mutableListOf() + 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? = + when (val argsElement = obj["args"]) { + is JsonArray -> { + argsElement.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull + } + } + + else -> { + null + } + } + + val env: Map? = + 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): 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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index 9e02924a..8548b5c7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -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(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)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt index de370b11..4d1d119e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersViewModel.kt @@ -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 = emptyMap(), + val testingServers: Set = 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() + 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() + + 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() { diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 748d77e4..fea9892d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -583,6 +583,16 @@ 전송: %1$s 명령어: 테스트 + 모두 테스트 + JSON 가져오기 + MCP 서버 가져오기 + Claude Desktop / Cursor mcpServers JSON 스니펫 또는 서버 정의를 붙여넣으세요. + 클립보드 붙여넣기 + 가져오는 중… + 정상 + 인증 필요 + 오류 + 테스트 중… 서버 추가 HTTP URL Stdio 명령어 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 00e6c408..552b6b27 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -720,6 +720,16 @@ 传输:%1$s 命令: 测试 + 测试全部 + 导入 JSON + 导入 MCP 服务器 + 粘贴 Claude Desktop / Cursor mcpServers JSON 片段或服务器定义。 + 从剪贴板粘贴 + 导入中… + 正常 + 需要认证 + 错误 + 测试中… 添加服务器 HTTP 地址 没有 MCP 服务器 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ae0b120..656ee4a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -812,6 +812,16 @@ Transport: %1$s Command: Test + Test All + Import JSON + Import MCP Servers + Paste Claude Desktop / Cursor mcpServers JSON snippet or server definition. + Paste Clipboard + Importing… + Healthy + Needs Auth + Error + Testing… Add server HTTP URL No MCP servers diff --git a/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpJsonParserTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpJsonParserTest.kt new file mode 100644 index 00000000..720f9d3d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/mcp/McpJsonParserTest.kt @@ -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, + ), + ) + } +}