feat(mcp): MCP dashboard enhancements — JSON config import, token overhead badges, and batch health testing (#1037)
This commit is contained in:
9 files changed
+963
-17
No files matched your search
@@ -1,4 +1,5 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -18,6 +19,22 @@ data class McpServer(
|
|||||||
val status: String? = null,
|
val status: String? = null,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val auth: String? = null,
|
val auth: String? = null,
|
||||||
|
val tools: List<String>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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
|
@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.McpCatalogResponse
|
||||||
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||||
import com.m57.hermescontrol.data.model.McpServer
|
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.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||||
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
import com.m57.hermescontrol.data.model.MemoryProviderConfigResponse
|
||||||
@@ -519,7 +520,7 @@ interface HermesApiService {
|
|||||||
@POST("api/mcp/servers/{name}/test")
|
@POST("api/mcp/servers/{name}/test")
|
||||||
suspend fun testMcpServer(
|
suspend fun testMcpServer(
|
||||||
@Path("name") name: String,
|
@Path("name") name: String,
|
||||||
): Response<Unit>
|
): Response<McpServerTestResponse>
|
||||||
|
|
||||||
@DELETE("api/mcp/servers/{name}")
|
@DELETE("api/mcp/servers/{name}")
|
||||||
suspend fun deleteMcpServer(
|
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.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
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.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Key
|
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.Science
|
||||||
import androidx.compose.material.icons.filled.Storage
|
import androidx.compose.material.icons.filled.Storage
|
||||||
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -41,6 +45,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.SecondaryTabRow
|
import androidx.compose.material3.SecondaryTabRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -54,8 +59,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
@@ -90,6 +97,7 @@ fun McpServersScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
var showDetail by remember { mutableStateOf<McpServer?>(null) }
|
var showDetail by remember { mutableStateOf<McpServer?>(null) }
|
||||||
|
|
||||||
@@ -123,6 +131,32 @@ fun McpServersScreen(
|
|||||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||||
isRefreshing = state.isLoading,
|
isRefreshing = state.isLoading,
|
||||||
onRefresh = { viewModel.loadServers() },
|
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 ->
|
) { paddingValues ->
|
||||||
when {
|
when {
|
||||||
state.isLoading && state.servers.isEmpty() -> {
|
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
|
// ── Section header (MCP-specific: distinct icon + neutral title so it reads
|
||||||
@@ -325,15 +448,29 @@ private fun AddServerSection(
|
|||||||
icon = Icons.Filled.Add,
|
icon = Icons.Filled.Add,
|
||||||
title = stringResource(R.string.mcp_servers_add_server),
|
title = stringResource(R.string.mcp_servers_add_server),
|
||||||
trailing = {
|
trailing = {
|
||||||
TextButton(
|
Row(horizontalArrangement = Arrangement.spacedBy(spacing.xs)) {
|
||||||
onClick = { viewModel.toggleAddForm() },
|
TextButton(
|
||||||
colors =
|
onClick = { viewModel.toggleImportDialog() },
|
||||||
ButtonDefaults.textButtonColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
ButtonDefaults.textButtonColors(
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
),
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
) {
|
),
|
||||||
Text(if (state.showAddForm) "Hide" else "New")
|
) {
|
||||||
|
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,
|
onOpenBrowser: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var showEnv by remember { mutableStateOf(false) }
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
@@ -491,13 +631,77 @@ private fun ServerCard(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
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(
|
||||||
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
text = stringResource(R.string.mcp_servers_label_transport, server.transport ?: "stdio"),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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(
|
Switch(
|
||||||
checked = server.enabled,
|
checked = server.enabled,
|
||||||
@@ -556,12 +760,22 @@ private fun ServerCard(
|
|||||||
}
|
}
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { viewModel.testServer(server.name) },
|
onClick = { viewModel.testServer(server.name) },
|
||||||
|
enabled = !isTesting,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Science, contentDescription = null, modifier = Modifier.size(16.dp))
|
if (isTesting) {
|
||||||
Spacer(modifier = Modifier.width(spacing.xs))
|
CircularProgressIndicator(
|
||||||
Text(stringResource(R.string.mcp_servers_action_test), maxLines = 1)
|
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) }) {
|
IconButton(onClick = { viewModel.deleteServer(server.name) }) {
|
||||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.action_delete))
|
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.McpCatalogInstallRequest
|
||||||
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
import com.m57.hermescontrol.data.model.McpOAuthFlowResponse
|
||||||
import com.m57.hermescontrol.data.model.McpServer
|
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.McpServerToggleRequest
|
||||||
import com.m57.hermescontrol.data.remote.ApiClient
|
import com.m57.hermescontrol.data.remote.ApiClient
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
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.ToastHost
|
||||||
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
import com.m57.hermescontrol.ui.common.safeLaunchLoad
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -38,6 +41,14 @@ data class McpServersUiState(
|
|||||||
val addServerAuth: String = "none", // "none" | "header" | "oauth"
|
val addServerAuth: String = "none", // "none" | "header" | "oauth"
|
||||||
val addServerBearerToken: String = "",
|
val addServerBearerToken: String = "",
|
||||||
val addingServer: Boolean = false,
|
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
|
// Env vars for editing
|
||||||
val editingEnvFor: String? = null,
|
val editingEnvFor: String? = null,
|
||||||
val envKeyInput: String = "",
|
val envKeyInput: String = "",
|
||||||
@@ -69,6 +80,8 @@ class McpServersViewModel :
|
|||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
servers = data.servers.orEmpty(),
|
servers = data.servers.orEmpty(),
|
||||||
|
serverTestResults = emptyMap(),
|
||||||
|
testingServers = emptySet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -126,23 +139,105 @@ class McpServersViewModel :
|
|||||||
|
|
||||||
fun testServer(name: String) {
|
fun testServer(name: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(toastMessage = "Testing server '$name'…") }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
testingServers = it.testingServers + name,
|
||||||
|
toastMessage = "Testing server '$name'…",
|
||||||
|
)
|
||||||
|
}
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is NetworkResult.Success -> {
|
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 -> {
|
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) {
|
fun deleteServer(name: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result =
|
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 ──────────────────────────────────────
|
// ── Add server form ──────────────────────────────────────
|
||||||
|
|
||||||
fun toggleAddForm() {
|
fun toggleAddForm() {
|
||||||
|
|||||||
@@ -583,6 +583,16 @@
|
|||||||
<string name="mcp_servers_label_transport">전송: %1$s</string>
|
<string name="mcp_servers_label_transport">전송: %1$s</string>
|
||||||
<string name="mcp_servers_label_command">명령어:</string>
|
<string name="mcp_servers_label_command">명령어:</string>
|
||||||
<string name="mcp_servers_action_test">테스트</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_server">서버 추가</string>
|
||||||
<string name="mcp_servers_add_http">HTTP URL</string>
|
<string name="mcp_servers_add_http">HTTP URL</string>
|
||||||
<string name="mcp_servers_add_stdio">Stdio 명령어</string>
|
<string name="mcp_servers_add_stdio">Stdio 명령어</string>
|
||||||
|
|||||||
@@ -720,6 +720,16 @@
|
|||||||
<string name="mcp_servers_label_transport">传输:%1$s</string>
|
<string name="mcp_servers_label_transport">传输:%1$s</string>
|
||||||
<string name="mcp_servers_label_command">命令:</string>
|
<string name="mcp_servers_label_command">命令:</string>
|
||||||
<string name="mcp_servers_action_test">测试</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_server">添加服务器</string>
|
||||||
<string name="mcp_servers_add_http">HTTP 地址</string>
|
<string name="mcp_servers_add_http">HTTP 地址</string>
|
||||||
<string name="mcp_servers_empty_title">没有 MCP 服务器</string>
|
<string name="mcp_servers_empty_title">没有 MCP 服务器</string>
|
||||||
|
|||||||
@@ -812,6 +812,16 @@
|
|||||||
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
<string name="mcp_servers_label_transport">Transport: %1$s</string>
|
||||||
<string name="mcp_servers_label_command">Command:</string>
|
<string name="mcp_servers_label_command">Command:</string>
|
||||||
<string name="mcp_servers_action_test">Test</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_server">Add server</string>
|
||||||
<string name="mcp_servers_add_http">HTTP URL</string>
|
<string name="mcp_servers_add_http">HTTP URL</string>
|
||||||
<string name="mcp_servers_empty_title">No MCP servers</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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user