Skip to content

Commit 2dfdb57

Browse files
committed
feat(basic-host-kotlin): implement MCP server auto-discovery
Replace hardcoded knownServers list with automatic server discovery: - Start from port 3101 (BASE_PORT) - Increment port until connection times out (1 second timeout) - Use server name from MCP initialization (client.serverVersion?.name) - Add 'Re-scan servers' menu item to UI Changes: - McpHostViewModel.kt: Add DiscoveredServer data class, discoverServers() and tryConnect() functions, StateFlows for discoveredServers and isDiscovering states - MainActivity.kt: Update ServerPicker and BottomToolbar to use discovered servers with re-scan capability
1 parent 185b468 commit 2dfdb57

File tree

2 files changed

+129
-24
lines changed

2 files changed

+129
-24
lines changed

examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/MainActivity.kt

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ fun McpHostApp(viewModel: McpHostViewModel = viewModel()) {
5959
val selectedTool by viewModel.selectedTool.collectAsState()
6060
val selectedServerIndex by viewModel.selectedServerIndex.collectAsState()
6161
val toolInputJson by viewModel.toolInputJson.collectAsState()
62+
val discoveredServers by viewModel.discoveredServers.collectAsState()
63+
val isDiscovering by viewModel.isDiscovering.collectAsState()
6264

6365
var isInputExpanded by remember { mutableStateOf(false) }
6466
val listState = rememberLazyListState()
@@ -78,6 +80,8 @@ fun McpHostApp(viewModel: McpHostViewModel = viewModel()) {
7880
BottomToolbar(
7981
connectionState = connectionState,
8082
selectedServerIndex = selectedServerIndex,
83+
discoveredServers = discoveredServers,
84+
isDiscovering = isDiscovering,
8185
tools = tools,
8286
selectedTool = selectedTool,
8387
toolInputJson = toolInputJson,
@@ -86,7 +90,8 @@ fun McpHostApp(viewModel: McpHostViewModel = viewModel()) {
8690
onToolSelect = { viewModel.selectTool(it) },
8791
onInputChange = { viewModel.updateToolInput(it) },
8892
onExpandToggle = { isInputExpanded = !isInputExpanded },
89-
onCallTool = { viewModel.callTool() }
93+
onCallTool = { viewModel.callTool() },
94+
onRescan = { viewModel.discoverServers() }
9095
)
9196
}
9297
) { paddingValues ->
@@ -121,6 +126,8 @@ fun McpHostApp(viewModel: McpHostViewModel = viewModel()) {
121126
fun BottomToolbar(
122127
connectionState: ConnectionState,
123128
selectedServerIndex: Int,
129+
discoveredServers: List<DiscoveredServer>,
130+
isDiscovering: Boolean,
124131
tools: List<ToolInfo>,
125132
selectedTool: ToolInfo?,
126133
toolInputJson: String,
@@ -129,7 +136,8 @@ fun BottomToolbar(
129136
onToolSelect: (ToolInfo) -> Unit,
130137
onInputChange: (String) -> Unit,
131138
onExpandToggle: () -> Unit,
132-
onCallTool: () -> Unit
139+
onCallTool: () -> Unit,
140+
onRescan: () -> Unit
133141
) {
134142
val isConnected = connectionState is ConnectionState.Connected
135143

@@ -154,7 +162,15 @@ fun BottomToolbar(
154162
horizontalArrangement = Arrangement.spacedBy(8.dp),
155163
verticalAlignment = Alignment.CenterVertically
156164
) {
157-
ServerPicker(selectedServerIndex, connectionState, onServerSelect, Modifier.weight(1f))
165+
ServerPicker(
166+
discoveredServers = discoveredServers,
167+
selectedServerIndex = selectedServerIndex,
168+
isDiscovering = isDiscovering,
169+
connectionState = connectionState,
170+
onServerSelect = onServerSelect,
171+
onRescan = onRescan,
172+
modifier = Modifier.weight(1f)
173+
)
158174

159175
if (isConnected) {
160176
ToolPicker(tools, selectedTool, onToolSelect, Modifier.weight(1f))
@@ -176,36 +192,57 @@ fun BottomToolbar(
176192

177193
@Composable
178194
fun ServerPicker(
195+
discoveredServers: List<DiscoveredServer>,
179196
selectedServerIndex: Int,
197+
isDiscovering: Boolean,
180198
connectionState: ConnectionState,
181199
onServerSelect: (Int) -> Unit,
200+
onRescan: () -> Unit,
182201
modifier: Modifier = Modifier
183202
) {
184203
var expanded by remember { mutableStateOf(false) }
185204

186205
Box(modifier = modifier.clickable { expanded = true }.padding(8.dp)) {
187206
Row(verticalAlignment = Alignment.CenterVertically) {
188207
Text(
189-
text = if (selectedServerIndex in knownServers.indices) knownServers[selectedServerIndex].first else "Custom",
208+
text = when {
209+
isDiscovering -> "Scanning..."
210+
selectedServerIndex in discoveredServers.indices -> discoveredServers[selectedServerIndex].name
211+
discoveredServers.isEmpty() -> "No servers"
212+
else -> "Select server"
213+
},
190214
style = MaterialTheme.typography.bodySmall
191215
)
192216
Icon(Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(16.dp))
193-
if (connectionState is ConnectionState.Connecting) {
217+
if (isDiscovering || connectionState is ConnectionState.Connecting) {
194218
Spacer(modifier = Modifier.width(4.dp))
195219
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 2.dp)
196220
}
197221
}
198222

199223
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
200-
knownServers.forEachIndexed { index, (name, _) ->
224+
if (discoveredServers.isEmpty() && !isDiscovering) {
201225
DropdownMenuItem(
202-
text = { Text(name) },
226+
text = { Text("No servers found") },
227+
onClick = { },
228+
enabled = false
229+
)
230+
}
231+
discoveredServers.forEachIndexed { index, server ->
232+
DropdownMenuItem(
233+
text = { Text(server.name) },
203234
onClick = { expanded = false; onServerSelect(index) },
204235
leadingIcon = if (index == selectedServerIndex && connectionState is ConnectionState.Connected) {
205236
{ Icon(Icons.Default.Check, contentDescription = null) }
206237
} else null
207238
)
208239
}
240+
HorizontalDivider()
241+
DropdownMenuItem(
242+
text = { Text("Re-scan servers") },
243+
onClick = { expanded = false; onRescan() },
244+
leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = "Re-scan") }
245+
)
209246
}
210247
}
211248
}

examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/McpHostViewModel.kt

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,22 +19,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
1919
import kotlinx.coroutines.flow.StateFlow
2020
import kotlinx.coroutines.flow.asStateFlow
2121
import kotlinx.coroutines.launch
22+
import kotlinx.coroutines.withTimeout
2223
import kotlinx.serialization.json.*
2324
import kotlinx.serialization.encodeToString
2425

2526
private const val TAG = "McpHostViewModel"
2627

27-
// Known servers - using /sse endpoint for SSE transport (Kotlin SDK)
28-
val knownServers = listOf(
29-
"basic-server-react" to "http://10.0.2.2:3101/sse",
30-
"basic-server-vanillajs" to "http://10.0.2.2:3102/sse",
31-
"budget-allocator-server" to "http://10.0.2.2:3103/sse",
32-
"cohort-heatmap-server" to "http://10.0.2.2:3104/sse",
33-
"customer-segmentation-server" to "http://10.0.2.2:3105/sse",
34-
"scenario-modeler-server" to "http://10.0.2.2:3106/sse",
35-
"system-monitor-server" to "http://10.0.2.2:3107/sse",
36-
"threejs-server" to "http://10.0.2.2:3108/sse",
37-
)
28+
data class DiscoveredServer(val name: String, val url: String)
3829

3930
data class ToolInfo(
4031
val name: String,
@@ -74,10 +65,24 @@ data class ToolCallState(
7465
class McpHostViewModel : ViewModel() {
7566
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
7667

68+
companion object {
69+
const val BASE_PORT = 3101
70+
const val DISCOVERY_TIMEOUT_MS = 1000L
71+
// Android emulator uses 10.0.2.2 for host machine's localhost
72+
const val BASE_HOST = "10.0.2.2"
73+
}
74+
7775
// Connection state
7876
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
7977
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
8078

79+
// Server discovery
80+
private val _discoveredServers = MutableStateFlow<List<DiscoveredServer>>(emptyList())
81+
val discoveredServers: StateFlow<List<DiscoveredServer>> = _discoveredServers.asStateFlow()
82+
83+
private val _isDiscovering = MutableStateFlow(false)
84+
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
85+
8186
// Tools
8287
private val _tools = MutableStateFlow<List<ToolInfo>>(emptyList())
8388
val tools: StateFlow<List<ToolInfo>> = _tools.asStateFlow()
@@ -108,8 +113,69 @@ class McpHostViewModel : ViewModel() {
108113
)
109114

110115
init {
111-
// Auto-connect on launch
112-
connect()
116+
// Start discovery on launch
117+
discoverServers()
118+
}
119+
120+
fun discoverServers() {
121+
viewModelScope.launch {
122+
_isDiscovering.value = true
123+
_discoveredServers.value = emptyList()
124+
125+
val discovered = mutableListOf<DiscoveredServer>()
126+
var port = BASE_PORT
127+
128+
while (true) {
129+
val url = "http://$BASE_HOST:$port/sse"
130+
val serverName = tryConnect(url)
131+
132+
if (serverName != null) {
133+
discovered.add(DiscoveredServer(serverName, url))
134+
_discoveredServers.value = discovered.toList()
135+
Log.i(TAG, "Discovered server: $serverName at $url")
136+
port++
137+
} else {
138+
Log.i(TAG, "No server at port $port, stopping discovery")
139+
break
140+
}
141+
}
142+
143+
_isDiscovering.value = false
144+
Log.i(TAG, "Discovery complete, found ${discovered.size} servers")
145+
146+
// Auto-connect to first discovered server
147+
if (discovered.isNotEmpty()) {
148+
_selectedServerIndex.value = 0
149+
connect()
150+
}
151+
}
152+
}
153+
154+
private suspend fun tryConnect(url: String): String? {
155+
return try {
156+
withTimeout(DISCOVERY_TIMEOUT_MS) {
157+
val httpClient = HttpClient(CIO) {
158+
install(SSE)
159+
}
160+
try {
161+
val transport = SseClientTransport(httpClient, url)
162+
val client = Client(
163+
clientInfo = io.modelcontextprotocol.kotlin.sdk.types.Implementation(
164+
name = "BasicHostKotlin",
165+
version = "1.0.0"
166+
)
167+
)
168+
client.connect(transport)
169+
val serverName = client.serverVersion?.name ?: url
170+
serverName
171+
} finally {
172+
httpClient.close()
173+
}
174+
}
175+
} catch (e: Exception) {
176+
Log.d(TAG, "Discovery failed for $url: ${e.message}")
177+
null
178+
}
113179
}
114180

115181
fun selectTool(tool: ToolInfo) {
@@ -134,8 +200,9 @@ class McpHostViewModel : ViewModel() {
134200
}
135201

136202
fun connect() {
137-
val serverUrl = if (_selectedServerIndex.value >= 0 && _selectedServerIndex.value < knownServers.size) {
138-
knownServers[_selectedServerIndex.value].second
203+
val servers = _discoveredServers.value
204+
val serverUrl = if (_selectedServerIndex.value >= 0 && _selectedServerIndex.value < servers.size) {
205+
servers[_selectedServerIndex.value].url
139206
} else {
140207
return
141208
}
@@ -202,8 +269,9 @@ class McpHostViewModel : ViewModel() {
202269
val tool = _selectedTool.value ?: return
203270
val client = mcpClient ?: return
204271

205-
val serverName = if (_selectedServerIndex.value in knownServers.indices) {
206-
knownServers[_selectedServerIndex.value].first
272+
val servers = _discoveredServers.value
273+
val serverName = if (_selectedServerIndex.value in servers.indices) {
274+
servers[_selectedServerIndex.value].name
207275
} else "Custom"
208276

209277
val toolCall = ToolCallState(

0 commit comments

Comments
 (0)