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