Skip to content

Commit 1b219cf

Browse files
committed
feat(swift): implement MCP server auto-discovery in Swift host
Replace hardcoded knownServers list with automatic server discovery: - Start from port 3101 and increment until connection times out (1 second) - Get server name from MCP initialization response (serverInfo.name) - Add Re-scan button to UI for manual re-discovery - Show 'Scanning...' progress indicator during discovery - Auto-connect to first discovered server Uses withThrowingTaskGroup for proper timeout handling with concurrent connect and sleep tasks.
1 parent c86ddf2 commit 1b219cf

File tree

2 files changed

+106
-29
lines changed

2 files changed

+106
-29
lines changed

examples/basic-host-swift/Sources/BasicHostApp/ContentView.swift

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ struct ContentView: View {
99
var body: some View {
1010
content
1111
.task {
12-
// Auto-connect to first server on launch
13-
await viewModel.connect()
12+
// Discover available servers on launch
13+
await viewModel.discoverServers()
1414
}
1515
}
1616

@@ -128,16 +128,22 @@ struct ContentView: View {
128128

129129
private var serverPicker: some View {
130130
Menu {
131-
ForEach(Array(McpHostViewModel.knownServers.enumerated()), id: \.offset) { index, server in
132-
Button(action: {
133-
Task {
134-
await viewModel.switchServer(to: index)
135-
}
136-
}) {
137-
HStack {
138-
Text(server.0)
139-
if viewModel.selectedServerIndex == index && viewModel.connectionState == .connected {
140-
Image(systemName: "checkmark")
131+
if viewModel.isDiscovering {
132+
Text("Discovering servers...")
133+
} else if viewModel.discoveredServers.isEmpty {
134+
Text("No servers found")
135+
} else {
136+
ForEach(Array(viewModel.discoveredServers.enumerated()), id: \.offset) { index, server in
137+
Button(action: {
138+
Task {
139+
await viewModel.switchServer(to: index)
140+
}
141+
}) {
142+
HStack {
143+
Text(server.name)
144+
if viewModel.selectedServerIndex == index && viewModel.connectionState == .connected {
145+
Image(systemName: "checkmark")
146+
}
141147
}
142148
}
143149
}
@@ -147,12 +153,20 @@ struct ContentView: View {
147153
viewModel.selectedServerIndex = -1
148154
viewModel.connectionState = .disconnected
149155
}
156+
Divider()
157+
Button("Re-scan") {
158+
Task { await viewModel.discoverServers() }
159+
}
150160
} label: {
151161
HStack {
152162
Text(serverLabel)
153163
.lineLimit(1)
154164
Image(systemName: "chevron.down")
155165
.font(.caption2)
166+
if viewModel.isDiscovering {
167+
ProgressView()
168+
.scaleEffect(0.6)
169+
}
156170
}
157171
.font(.caption)
158172
}
@@ -178,8 +192,11 @@ struct ContentView: View {
178192
}
179193

180194
private var serverLabel: String {
181-
if viewModel.selectedServerIndex >= 0 && viewModel.selectedServerIndex < McpHostViewModel.knownServers.count {
182-
return McpHostViewModel.knownServers[viewModel.selectedServerIndex].0
195+
if viewModel.isDiscovering {
196+
return "Scanning..."
197+
}
198+
if viewModel.selectedServerIndex >= 0 && viewModel.selectedServerIndex < viewModel.discoveredServers.count {
199+
return viewModel.discoveredServers[viewModel.selectedServerIndex].name
183200
}
184201
return "Custom"
185202
}

examples/basic-host-swift/Sources/BasicHostApp/McpHostViewModel.swift

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,16 @@ class McpHostViewModel: ObservableObject {
3535
}
3636
}
3737

38-
/// Known MCP servers (matches examples/servers.json)
39-
static let knownServers = [
40-
("basic-server-react", "http://localhost:3101/mcp"),
41-
("basic-server-vanillajs", "http://localhost:3102/mcp"),
42-
("budget-allocator-server", "http://localhost:3103/mcp"),
43-
("cohort-heatmap-server", "http://localhost:3104/mcp"),
44-
("customer-segmentation-server", "http://localhost:3105/mcp"),
45-
("scenario-modeler-server", "http://localhost:3106/mcp"),
46-
("system-monitor-server", "http://localhost:3107/mcp"),
47-
("threejs-server", "http://localhost:3108/mcp"),
48-
]
38+
/// Discovered MCP servers (name, url pairs)
39+
@Published var discoveredServers: [(name: String, url: String)] = []
40+
41+
/// Whether server discovery is in progress
42+
@Published var isDiscovering = false
43+
44+
/// Discovery configuration
45+
static let basePort = 3101
46+
static let discoveryTimeout: TimeInterval = 1.0 // 1 second
47+
static let baseHost = "localhost"
4948

5049
/// Selected server index (-1 for custom URL)
5150
@Published var selectedServerIndex: Int = 0
@@ -55,8 +54,8 @@ class McpHostViewModel: ObservableObject {
5554

5655
/// Computed server URL based on selection
5756
var serverUrlString: String {
58-
if selectedServerIndex >= 0 && selectedServerIndex < Self.knownServers.count {
59-
return Self.knownServers[selectedServerIndex].1
57+
if selectedServerIndex >= 0 && selectedServerIndex < discoveredServers.count {
58+
return discoveredServers[selectedServerIndex].url
6059
}
6160
return customServerUrl
6261
}
@@ -76,6 +75,67 @@ class McpHostViewModel: ObservableObject {
7675

7776
init() {}
7877

78+
// MARK: - Server Discovery
79+
80+
/// Discover available MCP servers starting from basePort
81+
func discoverServers() async {
82+
isDiscovering = true
83+
discoveredServers = []
84+
var port = Self.basePort
85+
86+
while true {
87+
let url = "http://\(Self.baseHost):\(port)/mcp"
88+
89+
if let serverName = await tryConnect(url: url, timeout: Self.discoveryTimeout) {
90+
discoveredServers.append((name: serverName, url: url))
91+
port += 1
92+
} else {
93+
// Connection failed or timed out - stop discovery
94+
break
95+
}
96+
}
97+
98+
isDiscovering = false
99+
100+
// Auto-connect to first discovered server
101+
if !discoveredServers.isEmpty {
102+
selectedServerIndex = 0
103+
await connect()
104+
}
105+
}
106+
107+
/// Try to connect to a server URL with timeout, returns server name on success
108+
private func tryConnect(url: String, timeout: TimeInterval) async -> String? {
109+
guard let serverUrl = URL(string: url) else { return nil }
110+
111+
do {
112+
let result = try await withThrowingTaskGroup(of: String.self) { group in
113+
group.addTask {
114+
let client = Client(name: self.hostInfo.name, version: self.hostInfo.version)
115+
let transport = HTTPClientTransport(endpoint: serverUrl)
116+
let initResult = try await client.connect(transport: transport)
117+
let name = initResult.serverInfo.name
118+
await client.disconnect()
119+
return name
120+
}
121+
122+
group.addTask {
123+
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
124+
throw CancellationError()
125+
}
126+
127+
guard let result = try await group.next() else {
128+
throw CancellationError()
129+
}
130+
group.cancelAll()
131+
return result
132+
}
133+
return result
134+
} catch {
135+
return nil
136+
}
137+
}
138+
79139
// MARK: - Connection Management
80140

81141
func connect() async {
@@ -146,8 +206,8 @@ class McpHostViewModel: ObservableObject {
146206
throw ToolCallError.invalidJson
147207
}
148208

149-
let serverName = selectedServerIndex >= 0 && selectedServerIndex < Self.knownServers.count
150-
? Self.knownServers[selectedServerIndex].0
209+
let serverName = selectedServerIndex >= 0 && selectedServerIndex < discoveredServers.count
210+
? discoveredServers[selectedServerIndex].name
151211
: "Custom Server"
152212

153213
let toolCallInfo = ToolCallInfo(

0 commit comments

Comments
 (0)