Skip to content

Commit cbe0a60

Browse files
committed
Merge remote swift-sdk and main
2 parents 5561c9b + c86ddf2 commit cbe0a60

File tree

10 files changed

+251
-103
lines changed

10 files changed

+251
-103
lines changed

.prettierignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ examples/basic-server-vanillajs/**/*.tsx
99
sdk/swift/.build/
1010
examples/basic-host-swift/.build/
1111
examples/basic-host-swift/build/
12-
examples/basic-host-kotlin/.gradle/
13-
examples/basic-host-kotlin/build/
1412

1513
# Swift build artifacts
1614
swift/.build/
15+
swift/.build
16+
examples/basic-host-swift/.build

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

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ struct ContentView: View {
2424
ForEach(viewModel.activeToolCalls) { toolCall in
2525
ToolCallCard(
2626
toolCallInfo: toolCall,
27-
onRemove: { viewModel.removeToolCall(toolCall) }
27+
onRemove: { Task { await viewModel.removeToolCall(toolCall) } }
2828
)
2929
.id(toolCall.id)
3030
}
@@ -47,6 +47,20 @@ struct ContentView: View {
4747
}
4848
.navigationTitle("MCP Host")
4949
.navigationBarTitleDisplayMode(.inline)
50+
.overlay(alignment: .bottom) {
51+
if let toast = viewModel.toastMessage {
52+
Text(toast)
53+
.font(.footnote)
54+
.foregroundColor(.white)
55+
.padding(.horizontal, 16)
56+
.padding(.vertical, 10)
57+
.background(Color.red.opacity(0.9))
58+
.cornerRadius(8)
59+
.padding(.bottom, 80) // Above toolbar
60+
.transition(.move(edge: .bottom).combined(with: .opacity))
61+
.animation(.easeInOut, value: viewModel.toastMessage)
62+
}
63+
}
5064
}
5165
}
5266

@@ -188,30 +202,44 @@ struct ToolCallCard: View {
188202
VStack(alignment: .leading, spacing: 8) {
189203
// Header
190204
HStack {
191-
Text(toolCallInfo.tool.name)
192-
.font(.subheadline.bold())
193-
.foregroundColor(.primary)
205+
VStack(alignment: .leading, spacing: 2) {
206+
Text(toolCallInfo.serverName)
207+
.font(.caption)
208+
.foregroundColor(.secondary)
209+
Text(toolCallInfo.tool.name)
210+
.font(.subheadline.bold())
211+
.foregroundColor(.primary)
212+
}
194213

195214
Spacer()
196215

197-
Text(toolCallInfo.state.description)
198-
.font(.caption2)
199-
.padding(.horizontal, 6)
200-
.padding(.vertical, 2)
201-
.background(stateColor.opacity(0.15))
202-
.foregroundColor(stateColor)
203-
.cornerRadius(4)
216+
if toolCallInfo.isTearingDown {
217+
HStack(spacing: 4) {
218+
ProgressView().scaleEffect(0.6)
219+
Text("Closing...")
220+
.font(.caption2)
221+
}
222+
.foregroundColor(.secondary)
223+
} else {
224+
Text(toolCallInfo.state.description)
225+
.font(.caption2)
226+
.padding(.horizontal, 6)
227+
.padding(.vertical, 2)
228+
.background(stateColor.opacity(0.15))
229+
.foregroundColor(stateColor)
230+
.cornerRadius(4)
204231

205-
Button { withAnimation { isInputExpanded.toggle() } } label: {
206-
Image(systemName: isInputExpanded ? "chevron.up" : "chevron.down")
207-
.font(.caption)
208-
.foregroundColor(.secondary)
209-
}
232+
Button { withAnimation { isInputExpanded.toggle() } } label: {
233+
Image(systemName: isInputExpanded ? "chevron.up" : "chevron.down")
234+
.font(.caption)
235+
.foregroundColor(.secondary)
236+
}
210237

211-
Button(action: onRemove) {
212-
Image(systemName: "xmark")
213-
.font(.caption)
214-
.foregroundColor(.secondary)
238+
Button(action: onRemove) {
239+
Image(systemName: "xmark")
240+
.font(.caption)
241+
.foregroundColor(.secondary)
242+
}
215243
}
216244
}
217245

@@ -272,6 +300,8 @@ struct ToolCallCard: View {
272300
.padding(10)
273301
.background(Color(UIColor.secondarySystemBackground))
274302
.cornerRadius(10)
303+
.opacity(toolCallInfo.isTearingDown ? 0.5 : 1.0)
304+
.animation(.easeInOut(duration: 0.2), value: toolCallInfo.isTearingDown)
275305
}
276306

277307
private var stateColor: Color {

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

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import Foundation
22
import SwiftUI
33
import MCP
44
import McpApps
5+
import os.log
6+
7+
private let logger = Logger(subsystem: "com.example.BasicHostSwift", category: "ToolCall")
58

69
/// View model managing MCP server connection and tool execution.
710
@MainActor
@@ -22,6 +25,15 @@ class McpHostViewModel: ObservableObject {
2225
}
2326
@Published var activeToolCalls: [ToolCallInfo] = []
2427
@Published var errorMessage: String?
28+
@Published var toastMessage: String?
29+
30+
func showToast(_ message: String, duration: TimeInterval = 3.0) {
31+
toastMessage = message
32+
Task {
33+
try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
34+
await MainActor.run { self.toastMessage = nil }
35+
}
36+
}
2537

2638
/// Known MCP servers (matches examples/servers.json)
2739
static let knownServers = [
@@ -134,7 +146,12 @@ class McpHostViewModel: ObservableObject {
134146
throw ToolCallError.invalidJson
135147
}
136148

149+
let serverName = selectedServerIndex >= 0 && selectedServerIndex < Self.knownServers.count
150+
? Self.knownServers[selectedServerIndex].0
151+
: "Custom Server"
152+
137153
let toolCallInfo = ToolCallInfo(
154+
serverName: serverName,
138155
tool: tool,
139156
input: inputDict,
140157
client: client,
@@ -234,7 +251,10 @@ class McpHostViewModel: ObservableObject {
234251
}
235252
}
236253

237-
func removeToolCall(_ toolCall: ToolCallInfo) {
254+
func removeToolCall(_ toolCall: ToolCallInfo) async {
255+
if let error = await toolCall.teardown() {
256+
showToast(error)
257+
}
238258
activeToolCalls.removeAll { $0.id == toolCall.id }
239259
}
240260
}
@@ -268,6 +288,7 @@ struct ToolResult {
268288
@MainActor
269289
class ToolCallInfo: ObservableObject, Identifiable {
270290
let id = UUID()
291+
let serverName: String
271292
let tool: Tool
272293
let input: [String: Any]
273294
let client: Client
@@ -281,14 +302,17 @@ class ToolCallInfo: ObservableObject, Identifiable {
281302
@Published var result: ToolResult?
282303
@Published var state: ExecutionState = .calling
283304
@Published var error: String?
305+
@Published var isTearingDown = false
284306

285307
init(
308+
serverName: String,
286309
tool: Tool,
287310
input: [String: Any],
288311
client: Client,
289312
hostInfo: Implementation,
290313
hostCapabilities: McpUiHostCapabilities
291314
) {
315+
self.serverName = serverName
292316
self.tool = tool
293317
self.input = input
294318
self.client = client
@@ -374,37 +398,38 @@ class ToolCallInfo: ObservableObject, Identifiable {
374398
bridge.onInitialized = { [weak self] in
375399
Task { @MainActor in
376400
guard let self = self else { return }
377-
try? await bridge.sendToolInput(
401+
let params = McpUiToolInputParams(
378402
arguments: self.input.mapValues { AnyCodable($0) }
379403
)
404+
try? await bridge.sendToolInput(params)
380405
if let result = self.result {
381406
try? await self.sendToolResult(result, to: bridge)
382407
}
383408
}
384409
}
385410

386-
bridge.onMessage = { role, content in
387-
print("[Host] Message from Guest UI: \(role)")
411+
bridge.onMessage = { params in
412+
print("[Host] Message from Guest UI: \(params.role)")
388413
return McpUiMessageResult(isError: false)
389414
}
390415

391-
bridge.onOpenLink = { url in
392-
print("[Host] Open link request: \(url)")
393-
if let urlObj = URL(string: url) {
416+
bridge.onOpenLink = { params in
417+
print("[Host] Open link request: \(params.url)")
418+
if let urlObj = URL(string: params.url) {
394419
await MainActor.run {
395420
UIApplication.shared.open(urlObj)
396421
}
397422
}
398423
return McpUiOpenLinkResult(isError: false)
399424
}
400425

401-
bridge.onLoggingMessage = { level, data, logger in
402-
print("[Host] Guest UI log [\(level)]: \(data.value)")
426+
bridge.onLoggingMessage = { params in
427+
print("[Host] Guest UI log [\(params.level)]: \(params.data.value)")
403428
}
404429

405-
bridge.onSizeChange = { [weak self] width, height in
406-
print("[Host] Size change: \(width ?? 0) x \(height ?? 0)")
407-
if let height = height {
430+
bridge.onSizeChange = { [weak self] params in
431+
print("[Host] Size change: \(params.width ?? 0) x \(params.height ?? 0)")
432+
if let height = params.height {
408433
Task { @MainActor in
409434
self?.preferredHeight = CGFloat(height)
410435
}
@@ -455,24 +480,66 @@ class ToolCallInfo: ObservableObject, Identifiable {
455480
]
456481
}
457482

483+
logger.info("Connecting bridge to transport...")
458484
try await bridge.connect(transport)
459485
self.appBridge = bridge
486+
logger.info("AppBridge is now set and ready")
460487
}
461488

462489
private func sendToolResult(_ result: ToolResult, to bridge: AppBridge) async throws {
463-
try await bridge.sendToolResult([
464-
"content": AnyCodable(result.content.map { c -> [String: Any] in
465-
switch c {
466-
case .text(let text):
467-
return ["type": "text", "text": text]
468-
case .image(let data, let mimeType, _):
469-
return ["type": "image", "data": data, "mimeType": mimeType]
470-
default:
471-
return ["type": "text", "text": ""]
490+
let contentItems: [McpUiToolResultNotificationParamsContentItem] = result.content.compactMap { c in
491+
switch c {
492+
case .text(let text):
493+
return .text(McpUiToolResultNotificationParamsContentItemText(type: "text", text: text))
494+
case .image(let data, let mimeType, _):
495+
return .image(McpUiToolResultNotificationParamsContentItemImage(type: "image", data: data, mimeType: mimeType))
496+
default:
497+
return nil
498+
}
499+
}
500+
let params = McpUiToolResultParams(content: contentItems, isError: result.isError)
501+
try await bridge.sendToolResult(params)
502+
}
503+
504+
/// Teardown the app bridge before removing the tool call
505+
/// Returns an error message if teardown failed, nil on success
506+
func teardown() async -> String? {
507+
// Prevent double-tap
508+
guard !isTearingDown else {
509+
logger.info("Teardown already in progress, skipping")
510+
return nil
511+
}
512+
isTearingDown = true
513+
logger.info("Starting teardown, appBridge exists: \(self.appBridge != nil)")
514+
515+
var errorMessage: String?
516+
517+
if let bridge = appBridge {
518+
// Only send teardown if the bridge is initialized
519+
// If not initialized, the app hasn't done anything worth saving
520+
let isReady = await bridge.isReady()
521+
if isReady {
522+
logger.info("Sending teardown request...")
523+
do {
524+
_ = try await bridge.sendResourceTeardown()
525+
logger.info("Teardown request completed successfully")
526+
} catch {
527+
logger.error("Teardown request failed: \(String(describing: error))")
528+
errorMessage = "Teardown failed: app may not have saved data"
472529
}
473-
}),
474-
"isError": AnyCodable(result.isError ?? false)
475-
])
530+
} else {
531+
logger.info("Skipping teardown - bridge not yet initialized")
532+
}
533+
534+
logger.info("Closing bridge...")
535+
await bridge.close()
536+
logger.info("Bridge closed")
537+
} else {
538+
logger.warning("No bridge to teardown (appBridge is nil)")
539+
}
540+
appBridge = nil
541+
logger.info("Teardown complete, will remove card from list")
542+
return errorMessage
476543
}
477544
}
478545

examples/basic-host-swift/scripts/logs.sh

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,8 @@ echo "📋 Streaming logs from $BUNDLE_ID on '$SIMULATOR'..."
99
echo " Press Ctrl+C to stop"
1010
echo ""
1111

12-
# Stream logs, filtering for our app
12+
# Stream logs, filtering for our app (both OSLog and print statements)
1313
xcrun simctl spawn "$SIMULATOR" log stream \
1414
--predicate "subsystem == '$BUNDLE_ID' OR process == 'BasicHostSwift'" \
15-
--style compact 2>/dev/null || \
16-
xcrun simctl spawn "$SIMULATOR" log stream \
17-
--predicate "process == 'BasicHostSwift'" \
15+
--level debug \
1816
--style compact

examples/basic-host/src/implementation.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,19 @@ function hookInitializedCallback(appBridge: AppBridge): Promise<void> {
207207
}
208208

209209

210-
export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge {
210+
export function newAppBridge(serverInfo: ServerInfo, toolCallInfo: ToolCallInfo, iframe: HTMLIFrameElement): AppBridge {
211211
const serverCapabilities = serverInfo.client.getServerCapabilities();
212212
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
213213
openLinks: {},
214214
serverTools: serverCapabilities?.tools,
215215
serverResources: serverCapabilities?.resources,
216+
}, {
217+
hostContext: {
218+
toolInfo: {
219+
id: crypto.randomUUID(), // We don't have the actual request ID, use a random one
220+
tool: toolCallInfo.tool,
221+
},
222+
},
216223
});
217224

218225
// Register all handlers before calling connect(). The Guest UI can start

examples/basic-host/src/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
269269
// Outside of Strict Mode, this `useEffect` runs only once per
270270
// `toolCallInfo`.
271271
if (firstTime) {
272-
const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
272+
const appBridge = newAppBridge(toolCallInfo.serverInfo, toolCallInfo, iframe);
273273
appBridgeRef.current = appBridge;
274274
initializeApp(iframe, appBridge, toolCallInfo);
275275
}

0 commit comments

Comments
 (0)