diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 70ee9333..a18c1adc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,6 +56,19 @@ jobs:
- run: npm run prettier
+ swift:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build Swift SDK
+ working-directory: swift
+ run: swift build
+
+ - name: Test Swift SDK
+ working-directory: swift
+ run: swift test
+
e2e:
runs-on: ubuntu-latest
steps:
diff --git a/.prettierignore b/.prettierignore
index e2a4cdb1..b30ba831 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,13 @@ examples/basic-server-react/**/*.ts
examples/basic-server-react/**/*.tsx
examples/basic-server-vanillajs/**/*.ts
examples/basic-server-vanillajs/**/*.tsx
+
+# Swift package manager build artifacts
+sdk/swift/.build/
+examples/basic-host-swift/.build/
+examples/basic-host-swift/build/
+
+# Swift build artifacts
+swift/.build/
+swift/.build
+examples/basic-host-swift/.build
diff --git a/examples/basic-host-swift/.gitignore b/examples/basic-host-swift/.gitignore
new file mode 100644
index 00000000..d19eb45c
--- /dev/null
+++ b/examples/basic-host-swift/.gitignore
@@ -0,0 +1,34 @@
+# Xcode
+.DS_Store
+*/build/*
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+xcuserdata/
+*.xccheckout
+*.moved-aside
+DerivedData/
+*.hmap
+*.ipa
+*.xcuserstate
+project.xcworkspace/
+
+# Swift Package Manager
+.build/
+.swiftpm/
+
+# CocoaPods
+Pods/
+
+# Intermediate outputs
+intermediate-outputs/
+build/
+.build/
+*.xcodeproj/
+DerivedData/
+Package.resolved
diff --git a/examples/basic-host-swift/Package.swift b/examples/basic-host-swift/Package.swift
new file mode 100644
index 00000000..2fc5fcc9
--- /dev/null
+++ b/examples/basic-host-swift/Package.swift
@@ -0,0 +1,32 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "BasicHostSwift",
+ platforms: [
+ .iOS(.v16),
+ .macOS(.v13)
+ ],
+ products: [
+ .executable(
+ name: "BasicHostSwift",
+ targets: ["BasicHostApp"]
+ ),
+ ],
+ dependencies: [
+ // Local MCP Apps Swift SDK
+ .package(path: "../../swift"),
+ // MCP Swift SDK for MCP client (using spec-update branch with _meta support)
+ .package(url: "https://github.com/ajevans99/swift-sdk.git", branch: "spec-update"),
+ ],
+ targets: [
+ .executableTarget(
+ name: "BasicHostApp",
+ dependencies: [
+ .product(name: "McpApps", package: "swift"),
+ .product(name: "MCP", package: "swift-sdk"),
+ ],
+ path: "Sources/BasicHostApp"
+ ),
+ ]
+)
diff --git a/examples/basic-host-swift/README.md b/examples/basic-host-swift/README.md
new file mode 100644
index 00000000..6aaaf692
--- /dev/null
+++ b/examples/basic-host-swift/README.md
@@ -0,0 +1,326 @@
+# Basic Host Swift Example
+
+A minimal iOS app demonstrating how to host MCP Apps in a WKWebView using the Swift SDK.
+
+## Quick Start
+
+### Using Scripts (Recommended)
+
+```bash
+# One-shot build and run
+./scripts/run.sh
+
+# Development mode with auto-reload on file changes
+./scripts/dev.sh
+
+# View app logs
+./scripts/logs.sh
+
+# Take a screenshot
+./scripts/screenshot.sh
+```
+
+> **Note:** `dev.sh` requires `fswatch`. Install with: `brew install fswatch`
+
+### Using Xcode
+
+1. **Open in Xcode:**
+
+ ```bash
+ open Package.swift
+ ```
+
+2. **Wait for SPM** to resolve dependencies (McpApps, MCP SDK)
+
+3. **Select iOS Simulator** from the device dropdown (e.g., "iPhone 17 Pro")
+
+4. **Run** (⌘R)
+
+> **Note:** If no simulators appear, install them via:
+> Xcode → Settings → Components → iOS Simulators → Download
+
+## Scripts
+
+| Script | Description |
+| ------------------------- | ------------------------------------------------- |
+| `./scripts/dev.sh` | Watch mode - rebuilds and reloads on file changes |
+| `./scripts/run.sh` | Build and run once |
+| `./scripts/build.sh` | Build only |
+| `./scripts/logs.sh` | Stream app logs from simulator |
+| `./scripts/screenshot.sh` | Take a screenshot |
+| `./scripts/clean.sh` | Clean build artifacts |
+
+All scripts accept an optional simulator name: `./scripts/run.sh "iPhone 16"`
+
+This example shows the complete flow of:
+
+1. Connecting to an MCP server
+2. Listing available tools
+3. Calling a tool with arguments
+4. Loading the tool's UI in a WKWebView
+5. Using AppBridge to communicate with the Guest UI
+
+## Features
+
+- **MCP Server Connection**: Connects to an MCP server via StreamableHTTP transport
+- **Tool Discovery**: Lists all available tools from the connected server
+- **Dynamic Tool Calling**: Select and call any tool with JSON arguments
+- **WebView Integration**: Displays tool UIs in WKWebView with full AppBridge support
+- **MCP Server Forwarding**: Guest UIs can call server tools and read resources through the host
+- **Multiple Tool UIs**: Display multiple tool UIs simultaneously
+
+## Architecture
+
+```
+┌─────────────────────────────────────────────┐
+│ ContentView (SwiftUI) │
+│ - Connection controls │
+│ - Tool selection and input │
+│ - List of active tool calls │
+└──────────────────┬──────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────┐
+│ McpHostViewModel (ObservableObject) │
+│ - MCP client management │
+│ - Tool calling logic │
+│ - AppBridge lifecycle │
+└──────────────────┬──────────────────────────┘
+ │
+ ┌────────────┼────────────┐
+ ▼ ▼ ▼
+┌──────────┐ ┌──────────┐ ┌──────────┐
+│ MCP │ │ AppBridge│ │ WKWebView│
+│ Client │ │ │ │ (Guest) │
+└──────────┘ └──────────┘ └──────────┘
+ │ │ │
+ ▼ ▼ ▼
+ MCP Server Transport Guest UI (HTML)
+```
+
+## Project Structure
+
+```
+Sources/BasicHostApp/
+├── BasicHostApp.swift # SwiftUI App entry point
+├── ContentView.swift # Main view with tool list and WebView
+├── McpHostViewModel.swift # ObservableObject for MCP logic
+└── WebViewContainer.swift # UIViewRepresentable wrapper for WKWebView
+```
+
+## Key Implementation Points
+
+### 1. MCP Client Setup
+
+The app uses the MCP Swift SDK's `StreamableHTTPClientTransport` to connect to an MCP server:
+
+```swift
+let client = MCPClient(
+ info: ClientInfo(name: "BasicHostSwift", version: "1.0.0")
+)
+let transport = StreamableHTTPClientTransport(
+ endpoint: URL(string: "http://localhost:3001/mcp")!
+)
+try await client.connect(transport: transport)
+```
+
+### 2. Tool Discovery
+
+Tools are listed using the standard MCP protocol:
+
+```swift
+let toolsList = try await client.listTools()
+self.tools = toolsList.tools
+```
+
+### 3. Tool UI Resource Loading
+
+When a tool has a UI resource (indicated by `ui/resourceUri` in `_meta`), the app:
+
+1. Calls the tool to get the result
+2. Reads the UI resource HTML from the server
+3. Extracts CSP configuration from resource metadata
+4. Loads the HTML in a WKWebView
+
+```swift
+let resource = try await client.readResource(
+ ReadResourceRequest(uri: "ui://tool-name")
+)
+let html = resource.contents.first?.text
+```
+
+### 4. AppBridge Integration
+
+The app uses `WKWebViewTransport` to enable AppBridge communication:
+
+```swift
+let transport = WKWebViewTransport(webView: webView)
+try await transport.start()
+
+let bridge = AppBridge(
+ hostInfo: hostInfo,
+ hostCapabilities: hostCapabilities
+)
+try await bridge.connect(transport)
+```
+
+### 5. AppBridge Callbacks
+
+The host implements all AppBridge callbacks:
+
+- `onInitialized`: Called when Guest UI is ready
+- `onMessage`: Handle messages from Guest UI
+- `onOpenLink`: Open URLs in system browser
+- `onLoggingMessage`: Receive log messages
+- `onSizeChange`: Handle UI resize requests
+- `onToolCall`: Forward tool calls to MCP server
+- `onResourceRead`: Forward resource reads to MCP server
+
+## Getting Started
+
+### Prerequisites
+
+- Xcode 15+ (for Swift 6.0 support)
+- iOS 15+ device or simulator
+- An MCP server running on `http://localhost:3001/mcp`
+
+### Building
+
+1. Open the package in Xcode:
+
+ ```bash
+ cd examples/basic-host-swift
+ open Package.swift
+ ```
+
+2. Select a simulator or device as the run destination
+
+3. Build and run the app (Cmd+R)
+
+### Testing with an Example Server
+
+You can test this host with any of the example MCP servers in this repository:
+
+```bash
+# Terminal 1: Start an example server
+cd examples/qr-server
+npm install
+npm start
+
+# The server will run on http://localhost:3001
+```
+
+Then run the iOS app and tap "Connect".
+
+### Configuration
+
+To connect to a different MCP server, modify the `serverUrl` in `McpHostViewModel.swift`:
+
+```swift
+init(serverUrl: URL = URL(string: "http://localhost:3001/mcp")!) {
+ self.serverUrl = serverUrl
+}
+```
+
+Or add UI controls to configure the server URL dynamically.
+
+## Usage
+
+1. **Connect**: Tap the "Connect" button to connect to the MCP server
+2. **Select Tool**: Choose a tool from the picker
+3. **Provide Input**: Edit the JSON input for the tool (default is `{}`)
+4. **Call Tool**: Tap "Call Tool" to execute
+5. **View Results**:
+ - If the tool has a UI, it will be displayed in a WebView
+ - If not, the result will be shown as JSON text
+6. **Multiple UIs**: You can call multiple tools, each will be displayed in its own card
+
+## Understanding the Flow
+
+### Tool with UI Resource
+
+For tools that provide a UI (e.g., QR code generator):
+
+```
+1. User calls tool
+ ↓
+2. Tool executed on server
+ ↓
+3. Host reads UI resource (HTML)
+ ↓
+4. HTML loaded in WKWebView
+ ↓
+5. AppBridge connects to Guest UI
+ ↓
+6. Tool input sent to Guest UI
+ ↓
+7. Tool result sent to Guest UI
+ ↓
+8. Guest UI renders the result
+```
+
+### Tool without UI Resource
+
+For tools without UI:
+
+```
+1. User calls tool
+ ↓
+2. Tool executed on server
+ ↓
+3. Result displayed as text
+```
+
+## MCP Server Forwarding
+
+Guest UIs can make requests back to the MCP server through the host:
+
+- **Tools**: Guest UI can call server tools via `tools/call`
+- **Resources**: Guest UI can read server resources via `resources/read`
+
+This is implemented in the `onToolCall` and `onResourceRead` callbacks.
+
+## Security Considerations
+
+This is a **basic example** intended for learning. In a production app, you should:
+
+1. **Validate server URLs**: Ensure users can only connect to trusted servers
+2. **Sanitize HTML**: Review UI resource HTML for malicious content
+3. **Implement CSP**: Enforce Content Security Policy from resource metadata
+4. **Limit navigation**: Restrict WebView navigation to prevent redirects
+5. **Handle errors gracefully**: Improve error handling and user feedback
+6. **Secure credentials**: Don't hardcode server URLs or credentials
+
+## Limitations
+
+- No sandbox proxy (unlike the web example which uses double-iframe isolation)
+- Server URL is hardcoded (should be configurable)
+- No error recovery mechanisms
+- No loading states for long-running operations
+- Basic UI with minimal styling
+
+## Extending This Example
+
+Ideas for improvements:
+
+- Add server URL configuration UI
+- Implement server discovery/selection
+- Add loading indicators
+- Improve error handling and retry logic
+- Add WebView sandbox restrictions
+- Implement CSP enforcement
+- Add tool call history
+- Support streaming tool results
+- Add dark mode support
+- Implement host context updates (theme, viewport, etc.)
+
+## See Also
+
+- [MCP Apps Swift SDK](../../swift/README.md)
+- [MCP Apps Specification](../../docs/specification.md)
+- [Basic Host (Web)](../basic-host/README.md) - Web-based reference implementation
+- [Example Servers](../) - MCP servers with UIs to test with
+
+## License
+
+MIT
diff --git a/examples/basic-host-swift/Sources/BasicHostApp/BasicHostApp.swift b/examples/basic-host-swift/Sources/BasicHostApp/BasicHostApp.swift
new file mode 100644
index 00000000..f7b60862
--- /dev/null
+++ b/examples/basic-host-swift/Sources/BasicHostApp/BasicHostApp.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+/// Basic Host Swift Example
+///
+/// This is a minimal iOS app demonstrating how to host MCP Apps in a WKWebView.
+/// It connects to an MCP server, lists available tools, and displays their UIs.
+///
+/// Key Flow:
+/// 1. Connect to MCP server (via McpHostViewModel)
+/// 2. List available tools
+/// 3. User selects a tool and provides input
+/// 4. Call the tool and get its UI resource
+/// 5. Load the HTML in WKWebView
+/// 6. Use AppBridge to communicate with the Guest UI
+@main
+struct BasicHostApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+ }
+}
diff --git a/examples/basic-host-swift/Sources/BasicHostApp/ContentView.swift b/examples/basic-host-swift/Sources/BasicHostApp/ContentView.swift
new file mode 100644
index 00000000..9726b472
--- /dev/null
+++ b/examples/basic-host-swift/Sources/BasicHostApp/ContentView.swift
@@ -0,0 +1,318 @@
+import SwiftUI
+import MCP
+import McpApps
+
+struct ContentView: View {
+ @StateObject private var viewModel = McpHostViewModel()
+ @State private var isInputExpanded = false
+
+ var body: some View {
+ content
+ .task {
+ // Auto-connect to first server on launch
+ await viewModel.connect()
+ }
+ }
+
+ private var content: some View {
+ NavigationView {
+ VStack(spacing: 0) {
+ // Tool calls area (scrollable, like chat)
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(viewModel.activeToolCalls) { toolCall in
+ ToolCallCard(
+ toolCallInfo: toolCall,
+ onRemove: { Task { await viewModel.removeToolCall(toolCall) } }
+ )
+ .id(toolCall.id)
+ }
+ }
+ .padding()
+ }
+ .onChange(of: viewModel.activeToolCalls.count) { _ in
+ if let last = viewModel.activeToolCalls.last {
+ withAnimation {
+ proxy.scrollTo(last.id, anchor: .bottom)
+ }
+ }
+ }
+ }
+
+ Divider()
+
+ // Bottom toolbar
+ bottomToolbar
+ }
+ .navigationTitle("MCP Host")
+ .navigationBarTitleDisplayMode(.inline)
+ .overlay(alignment: .bottom) {
+ if let toast = viewModel.toastMessage {
+ Text(toast)
+ .font(.footnote)
+ .foregroundColor(.white)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(Color.red.opacity(0.9))
+ .cornerRadius(8)
+ .padding(.bottom, 80) // Above toolbar
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ .animation(.easeInOut, value: viewModel.toastMessage)
+ }
+ }
+ }
+ }
+
+ // MARK: - Bottom Toolbar
+
+ private var bottomToolbar: some View {
+ VStack(spacing: 8) {
+ // Expanded input area
+ if isInputExpanded {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Input (JSON)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ TextEditor(text: $viewModel.toolInputJson)
+ .font(.system(.caption, design: .monospaced))
+ .frame(height: 80)
+ .padding(4)
+ .background(Color(UIColor.systemGray6))
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(isValidJson ? Color.gray.opacity(0.3) : Color.red, lineWidth: 1)
+ )
+ }
+ .padding(.horizontal)
+ .transition(.move(edge: .bottom).combined(with: .opacity))
+ }
+
+ // Compact toolbar row
+ HStack(spacing: 8) {
+ // Server picker
+ serverPicker
+ .frame(maxWidth: .infinity)
+
+ // Tool picker (only if connected)
+ if viewModel.connectionState == .connected {
+ toolPicker
+ .frame(maxWidth: .infinity)
+
+ // Expand/collapse button
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ isInputExpanded.toggle()
+ }
+ } label: {
+ Image(systemName: isInputExpanded ? "chevron.down" : "chevron.up")
+ .font(.caption)
+ }
+ .buttonStyle(.bordered)
+
+ // Call button
+ Button("Call") {
+ Task { await viewModel.callTool() }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.selectedTool == nil || !isValidJson)
+ }
+
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+ .background(Color(UIColor.systemBackground))
+ }
+
+ private var serverPicker: some View {
+ Menu {
+ ForEach(Array(McpHostViewModel.knownServers.enumerated()), id: \.offset) { index, server in
+ Button(action: {
+ Task {
+ await viewModel.switchServer(to: index)
+ }
+ }) {
+ HStack {
+ Text(server.0)
+ if viewModel.selectedServerIndex == index && viewModel.connectionState == .connected {
+ Image(systemName: "checkmark")
+ }
+ }
+ }
+ }
+ Divider()
+ Button("Custom URL...") {
+ viewModel.selectedServerIndex = -1
+ viewModel.connectionState = .disconnected
+ }
+ } label: {
+ HStack {
+ Text(serverLabel)
+ .lineLimit(1)
+ Image(systemName: "chevron.down")
+ .font(.caption2)
+ }
+ .font(.caption)
+ }
+ }
+
+ private var toolPicker: some View {
+ Menu {
+ ForEach(viewModel.tools, id: \.name) { tool in
+ Button(tool.name) {
+ viewModel.selectedTool = tool
+ }
+ }
+ } label: {
+ HStack {
+ Text(viewModel.selectedTool?.name ?? "Select tool")
+ .lineLimit(1)
+ Image(systemName: "chevron.down")
+ .font(.caption2)
+ }
+ .font(.caption)
+ }
+ .disabled(viewModel.tools.isEmpty)
+ }
+
+ private var serverLabel: String {
+ if viewModel.selectedServerIndex >= 0 && viewModel.selectedServerIndex < McpHostViewModel.knownServers.count {
+ return McpHostViewModel.knownServers[viewModel.selectedServerIndex].0
+ }
+ return "Custom"
+ }
+
+ private var isValidJson: Bool {
+ guard let data = viewModel.toolInputJson.data(using: .utf8) else { return false }
+ return (try? JSONSerialization.jsonObject(with: data)) != nil
+ }
+}
+
+// MARK: - Tool Call Card
+
+struct ToolCallCard: View {
+ @ObservedObject var toolCallInfo: ToolCallInfo
+ let onRemove: () -> Void
+
+ @State private var isInputExpanded = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // Header
+ HStack {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(toolCallInfo.serverName)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Text(toolCallInfo.tool.name)
+ .font(.subheadline.bold())
+ .foregroundColor(.primary)
+ }
+
+ Spacer()
+
+ if toolCallInfo.isTearingDown {
+ HStack(spacing: 4) {
+ ProgressView().scaleEffect(0.6)
+ Text("Closing...")
+ .font(.caption2)
+ }
+ .foregroundColor(.secondary)
+ } else {
+ Text(toolCallInfo.state.description)
+ .font(.caption2)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(stateColor.opacity(0.15))
+ .foregroundColor(stateColor)
+ .cornerRadius(4)
+
+ Button { withAnimation { isInputExpanded.toggle() } } label: {
+ Image(systemName: isInputExpanded ? "chevron.up" : "chevron.down")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Button(action: onRemove) {
+ Image(systemName: "xmark")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ // Collapsible input
+ if isInputExpanded {
+ if let jsonData = try? JSONSerialization.data(withJSONObject: toolCallInfo.input, options: .prettyPrinted),
+ let jsonString = String(data: jsonData, encoding: .utf8) {
+ Text(jsonString)
+ .font(.system(.caption2, design: .monospaced))
+ .padding(6)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(UIColor.systemGray6))
+ .cornerRadius(4)
+ }
+ }
+
+ // Content
+ if let error = toolCallInfo.error {
+ Text(error)
+ .font(.caption)
+ .foregroundColor(.white)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.red.opacity(0.8))
+ .cornerRadius(6)
+ } else if toolCallInfo.state == .ready, toolCallInfo.htmlContent != nil {
+ WebViewContainer(toolCallInfo: toolCallInfo)
+ .frame(height: toolCallInfo.preferredHeight)
+ .cornerRadius(6)
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.gray.opacity(0.2)))
+ } else if toolCallInfo.state == .completed, let result = toolCallInfo.result {
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(Array(result.content.enumerated()), id: \.offset) { _, content in
+ switch content {
+ case .text(let text):
+ Text(text)
+ .font(.system(.caption, design: .monospaced))
+ .padding(6)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color(UIColor.systemGray6))
+ .cornerRadius(4)
+ default:
+ EmptyView()
+ }
+ }
+ }
+ } else if toolCallInfo.state == .calling || toolCallInfo.state == .loadingUi {
+ HStack {
+ ProgressView().scaleEffect(0.7)
+ Text(toolCallInfo.state.description)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 20)
+ }
+ }
+ .padding(10)
+ .background(Color(UIColor.secondarySystemBackground))
+ .cornerRadius(10)
+ .opacity(toolCallInfo.isTearingDown ? 0.5 : 1.0)
+ .animation(.easeInOut(duration: 0.2), value: toolCallInfo.isTearingDown)
+ }
+
+ private var stateColor: Color {
+ switch toolCallInfo.state {
+ case .calling, .loadingUi: return .orange
+ case .ready, .completed: return .green
+ case .error: return .red
+ }
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/examples/basic-host-swift/Sources/BasicHostApp/Info.plist b/examples/basic-host-swift/Sources/BasicHostApp/Info.plist
new file mode 100644
index 00000000..40feba9a
--- /dev/null
+++ b/examples/basic-host-swift/Sources/BasicHostApp/Info.plist
@@ -0,0 +1,55 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ MCP Apps Host
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UILaunchScreen
+
+ UIImageName
+
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+ NSAllowsArbitraryLoadsInWebContent
+
+
+
+
diff --git a/examples/basic-host-swift/Sources/BasicHostApp/McpHostViewModel.swift b/examples/basic-host-swift/Sources/BasicHostApp/McpHostViewModel.swift
new file mode 100644
index 00000000..aad13ce0
--- /dev/null
+++ b/examples/basic-host-swift/Sources/BasicHostApp/McpHostViewModel.swift
@@ -0,0 +1,615 @@
+import Foundation
+import SwiftUI
+import MCP
+import McpApps
+import os.log
+
+private let logger = Logger(subsystem: "com.example.BasicHostSwift", category: "ToolCall")
+
+// Helper extension to convert MCP Value to Any for use with AnyCodable
+extension Value {
+ func toAny() -> Any {
+ switch self {
+ case .null: return NSNull()
+ case .bool(let b): return b
+ case .int(let i): return i
+ case .double(let d): return d
+ case .string(let s): return s
+ case .data(_, let d): return d.base64EncodedString()
+ case .array(let arr): return arr.map { $0.toAny() }
+ case .object(let dict): return dict.mapValues { $0.toAny() }
+ }
+ }
+}
+
+/// View model managing MCP server connection and tool execution.
+@MainActor
+class McpHostViewModel: ObservableObject {
+ // MARK: - Published State
+
+ @Published var connectionState: ConnectionState = .disconnected
+ @Published var tools: [Tool] = []
+ @Published var toolInputJson: String = "{}"
+
+ /// Selected tool - updates input JSON with defaults when changed
+ @Published var selectedTool: Tool? {
+ didSet {
+ if let tool = selectedTool {
+ toolInputJson = generateDefaultInput(for: tool)
+ }
+ }
+ }
+ @Published var activeToolCalls: [ToolCallInfo] = []
+ @Published var errorMessage: String?
+ @Published var toastMessage: String?
+
+ func showToast(_ message: String, duration: TimeInterval = 3.0) {
+ toastMessage = message
+ Task {
+ try? await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
+ await MainActor.run { self.toastMessage = nil }
+ }
+ }
+
+ /// Known MCP servers (matches examples/servers.json)
+ static let knownServers = [
+ ("basic-server-react", "http://localhost:3101/mcp"),
+ ("basic-server-vanillajs", "http://localhost:3102/mcp"),
+ ("budget-allocator-server", "http://localhost:3103/mcp"),
+ ("cohort-heatmap-server", "http://localhost:3104/mcp"),
+ ("customer-segmentation-server", "http://localhost:3105/mcp"),
+ ("scenario-modeler-server", "http://localhost:3106/mcp"),
+ ("system-monitor-server", "http://localhost:3107/mcp"),
+ ("threejs-server", "http://localhost:3108/mcp"),
+ ]
+
+ /// Selected server index (-1 for custom URL)
+ @Published var selectedServerIndex: Int = 0
+
+ /// Custom server URL (used when selectedServerIndex is -1)
+ @Published var customServerUrl: String = ""
+
+ /// Computed server URL based on selection
+ var serverUrlString: String {
+ if selectedServerIndex >= 0 && selectedServerIndex < Self.knownServers.count {
+ return Self.knownServers[selectedServerIndex].1
+ }
+ return customServerUrl
+ }
+
+ // MARK: - Private State
+
+ private var mcpClient: Client?
+
+ private let hostInfo = Implementation(name: "BasicHostSwift", version: "1.0.0")
+
+ private let hostCapabilities = McpUiHostCapabilities(
+ openLinks: EmptyCapability(),
+ serverTools: ServerToolsCapability(),
+ serverResources: ServerResourcesCapability(),
+ logging: EmptyCapability()
+ )
+
+ init() {}
+
+ // MARK: - Connection Management
+
+ func connect() async {
+ connectionState = .connecting
+ errorMessage = nil
+
+ do {
+ guard let serverUrl = URL(string: serverUrlString) else {
+ throw ConnectionError.invalidUrl(serverUrlString)
+ }
+
+ let client = Client(name: hostInfo.name, version: hostInfo.version)
+ let transport = HTTPClientTransport(endpoint: serverUrl)
+ _ = try await client.connect(transport: transport)
+
+ self.mcpClient = client
+
+ let (toolsList, _) = try await client.listTools()
+ self.tools = toolsList
+
+ connectionState = .connected
+
+ if !tools.isEmpty {
+ selectedTool = tools[0]
+ }
+ } catch {
+ connectionState = .error(error.localizedDescription)
+ errorMessage = "Failed to connect: \(error.localizedDescription)"
+ }
+ }
+
+ func disconnect() async {
+ await mcpClient?.disconnect()
+ mcpClient = nil
+ tools = []
+ selectedTool = nil
+ connectionState = .disconnected
+ }
+
+ /// Switch to a different server (disconnect current, connect to new)
+ func switchServer(to index: Int) async {
+ // Don't switch if already on this server and connected
+ if index == selectedServerIndex && connectionState == .connected {
+ return
+ }
+
+ // Disconnect from current server if connected
+ if connectionState == .connected {
+ await disconnect()
+ }
+
+ // Update selection and connect
+ selectedServerIndex = index
+ await connect()
+ }
+
+ // MARK: - Tool Execution
+
+ func callTool() async {
+ guard let tool = selectedTool else { return }
+ guard let client = mcpClient else { return }
+
+ errorMessage = nil
+
+ do {
+ guard let inputData = toolInputJson.data(using: .utf8),
+ let inputDict = try JSONSerialization.jsonObject(with: inputData) as? [String: Any] else {
+ throw ToolCallError.invalidJson
+ }
+
+ let serverName = selectedServerIndex >= 0 && selectedServerIndex < Self.knownServers.count
+ ? Self.knownServers[selectedServerIndex].0
+ : "Custom Server"
+
+ let toolCallInfo = ToolCallInfo(
+ serverName: serverName,
+ tool: tool,
+ input: inputDict,
+ client: client,
+ hostInfo: hostInfo,
+ hostCapabilities: hostCapabilities
+ )
+
+ activeToolCalls.append(toolCallInfo)
+
+ Task {
+ await toolCallInfo.execute()
+ }
+
+ } catch {
+ errorMessage = "Failed to call tool: \(error.localizedDescription)"
+ }
+ }
+
+ /// Generate default input JSON from tool's inputSchema
+ private func generateDefaultInput(for tool: Tool) -> String {
+ var defaults: [String: Any] = [:]
+
+ // inputSchema is typically: { "type": "object", "properties": { ... }, "required": [...] }
+ guard case .object(let schema) = tool.inputSchema,
+ case .object(let properties)? = schema["properties"] else {
+ return "{}"
+ }
+
+ // Get required fields
+ var requiredFields = Set()
+ if case .array(let required)? = schema["required"] {
+ for item in required {
+ if case .string(let field) = item {
+ requiredFields.insert(field)
+ }
+ }
+ }
+
+ for (key, propSchema) in properties {
+ guard case .object(let prop) = propSchema else { continue }
+
+ // Check for explicit default value
+ if let defaultValue = prop["default"] {
+ defaults[key] = valueToAny(defaultValue)
+ continue
+ }
+
+ // For required fields or all fields, generate appropriate defaults
+ if let typeValue = prop["type"] {
+ if case .string(let type) = typeValue {
+ switch type {
+ case "string":
+ // Check for enum values
+ if case .array(let enumValues)? = prop["enum"], let first = enumValues.first {
+ defaults[key] = valueToAny(first)
+ } else {
+ defaults[key] = ""
+ }
+ case "number":
+ defaults[key] = 0.0
+ case "integer":
+ defaults[key] = 0
+ case "boolean":
+ defaults[key] = false
+ case "array":
+ defaults[key] = []
+ case "object":
+ defaults[key] = [:]
+ default:
+ if requiredFields.contains(key) {
+ defaults[key] = NSNull()
+ }
+ }
+ }
+ }
+ }
+
+ // Convert to JSON string
+ if let data = try? JSONSerialization.data(withJSONObject: defaults, options: [.prettyPrinted, .sortedKeys]),
+ let json = String(data: data, encoding: .utf8) {
+ return json
+ }
+ return "{}"
+ }
+
+ /// Convert MCP Value to Any for JSON serialization
+ private func valueToAny(_ value: Value) -> Any {
+ switch value {
+ case .string(let s): return s
+ case .int(let i): return i
+ case .double(let d): return d
+ case .bool(let b): return b
+ case .array(let arr): return arr.map { valueToAny($0) }
+ case .object(let obj): return obj.mapValues { valueToAny($0) }
+ case .null: return NSNull()
+ case .data(_, let d): return d.base64EncodedString()
+ }
+ }
+
+ func removeToolCall(_ toolCall: ToolCallInfo) async {
+ if let error = await toolCall.teardown() {
+ showToast(error)
+ }
+ activeToolCalls.removeAll { $0.id == toolCall.id }
+ }
+}
+
+// MARK: - Connection State
+
+enum ConnectionState: Equatable {
+ case disconnected
+ case connecting
+ case connected
+ case error(String)
+
+ var description: String {
+ switch self {
+ case .disconnected: return "Disconnected"
+ case .connecting: return "Connecting..."
+ case .connected: return "Connected"
+ case .error(let message): return "Error: \(message)"
+ }
+ }
+}
+
+// MARK: - Tool Call Info
+
+/// Tool result type matching MCP SDK's callTool return
+struct ToolResult {
+ let content: [Tool.Content]
+ let structuredContent: Value?
+ let isError: Bool?
+}
+
+@MainActor
+class ToolCallInfo: ObservableObject, Identifiable {
+ let id = UUID()
+ let serverName: String
+ let tool: Tool
+ let input: [String: Any]
+ let client: Client
+ let hostInfo: Implementation
+ let hostCapabilities: McpUiHostCapabilities
+
+ @Published var appBridge: AppBridge?
+ @Published var htmlContent: String?
+ @Published var cspConfig: CspConfig?
+ @Published var preferredHeight: CGFloat = 350
+ @Published var result: ToolResult?
+ @Published var state: ExecutionState = .calling
+ @Published var error: String?
+ @Published var isTearingDown = false
+
+ init(
+ serverName: String,
+ tool: Tool,
+ input: [String: Any],
+ client: Client,
+ hostInfo: Implementation,
+ hostCapabilities: McpUiHostCapabilities
+ ) {
+ self.serverName = serverName
+ self.tool = tool
+ self.input = input
+ self.client = client
+ self.hostInfo = hostInfo
+ self.hostCapabilities = hostCapabilities
+ }
+
+ func execute() async {
+ do {
+ state = .calling
+
+ // Convert input to MCP Value type
+ let arguments = input.compactMapValues { value -> Value? in
+ if let str = value as? String { return .string(str) }
+ if let num = value as? Int { return .int(num) }
+ if let num = value as? Double { return .double(num) }
+ if let bool = value as? Bool { return .bool(bool) }
+ return nil
+ }
+
+ // Use send() directly to get full result including structuredContent
+ let request = CallTool.request(.init(name: tool.name, arguments: arguments))
+ let callResult = try await client.send(request)
+ self.result = ToolResult(
+ content: callResult.content,
+ structuredContent: callResult.structuredContent,
+ isError: callResult.isError
+ )
+
+ if let uiResourceUri = getUiResourceUri(from: tool) {
+ state = .loadingUi
+ try await loadUiResource(uri: uiResourceUri)
+ state = .ready
+ } else {
+ state = .completed
+ }
+ } catch {
+ state = .error
+ self.error = error.localizedDescription
+ }
+ }
+
+ private func getUiResourceUri(from tool: Tool) -> String? {
+ // Access the _meta field to get the UI resource URI (requires swift-sdk with _meta support)
+ if let meta = tool._meta,
+ let uriValue = meta[McpAppsConfig.resourceUriMetaKey],
+ case .string(let uri) = uriValue {
+ return uri
+ }
+ return nil
+ }
+
+ private func loadUiResource(uri: String) async throws {
+ let contents = try await client.readResource(uri: uri)
+
+ guard let content = contents.first else {
+ throw ToolCallError.noResourceContent
+ }
+
+ guard content.mimeType == McpAppsConfig.resourceMimeType else {
+ throw ToolCallError.invalidMimeType(content.mimeType ?? "unknown")
+ }
+
+ if let text = content.text {
+ htmlContent = text
+ } else if let blob = content.blob,
+ let data = Data(base64Encoded: blob),
+ let text = String(data: data, encoding: .utf8) {
+ htmlContent = text
+ } else {
+ throw ToolCallError.invalidHtmlContent
+ }
+ }
+
+ func setupAppBridge(transport: WKWebViewTransport) async throws {
+ let bridge = AppBridge(
+ hostInfo: hostInfo,
+ hostCapabilities: hostCapabilities,
+ options: HostOptions(
+ hostContext: McpUiHostContext(
+ theme: .light,
+ displayMode: .inline,
+ platform: .mobile,
+ deviceCapabilities: DeviceCapabilities(touch: true, hover: false)
+ )
+ )
+ )
+
+ bridge.onInitialized = { [weak self] in
+ Task { @MainActor in
+ guard let self = self else { return }
+ let arguments = self.input.mapValues { AnyCodable($0) }
+ try? await bridge.sendToolInput(arguments: arguments)
+ if let result = self.result {
+ try? await self.sendToolResult(result, to: bridge)
+ }
+ }
+ }
+
+ bridge.onMessage = { role, content in
+ return McpUiMessageResult(isError: false)
+ }
+
+ bridge.onOpenLink = { url in
+ if let urlObj = URL(string: url) {
+ await MainActor.run {
+ UIApplication.shared.open(urlObj)
+ }
+ }
+ return McpUiOpenLinkResult(isError: false)
+ }
+
+ bridge.onLoggingMessage = { level, data, logger in
+ // Guest UI logs can be captured here if needed
+ }
+
+ bridge.onSizeChange = { [weak self] width, height in
+ if let height = height {
+ Task { @MainActor in
+ self?.preferredHeight = CGFloat(height)
+ }
+ }
+ }
+
+ bridge.onToolCall = { [weak self] toolName, arguments in
+ guard let self = self else { throw ToolCallError.clientNotAvailable }
+
+ let args = arguments?.compactMapValues { value -> Value? in
+ if let str = value.value as? String { return .string(str) }
+ if let num = value.value as? Int { return .int(num) }
+ if let num = value.value as? Double { return .double(num) }
+ if let bool = value.value as? Bool { return .bool(bool) }
+ return nil
+ }
+
+ let (content, isError) = try await self.client.callTool(name: toolName, arguments: args)
+
+ return [
+ "content": AnyCodable(content.map { c -> [String: Any] in
+ switch c {
+ case .text(let text):
+ return ["type": "text", "text": text]
+ case .image(let data, let mimeType, _):
+ return ["type": "image", "data": data, "mimeType": mimeType]
+ default:
+ return ["type": "text", "text": ""]
+ }
+ }),
+ "isError": AnyCodable(isError ?? false)
+ ]
+ }
+
+ bridge.onResourceRead = { [weak self] uri in
+ guard let self = self else { throw ToolCallError.clientNotAvailable }
+
+ let contents = try await self.client.readResource(uri: uri)
+
+ return [
+ "contents": AnyCodable(contents.map { c in
+ var dict: [String: Any] = ["uri": c.uri]
+ if let text = c.text { dict["text"] = text }
+ if let blob = c.blob { dict["blob"] = blob }
+ if let mimeType = c.mimeType { dict["mimeType"] = mimeType }
+ return dict
+ })
+ ]
+ }
+
+ logger.info("Connecting bridge to transport...")
+ try await bridge.connect(transport)
+ self.appBridge = bridge
+ logger.info("AppBridge is now set and ready")
+ }
+
+ private func sendToolResult(_ result: ToolResult, to bridge: AppBridge) async throws {
+ let contentDicts: [[String: Any]] = result.content.compactMap { c in
+ switch c {
+ case .text(let text):
+ return ["type": "text", "text": text]
+ case .image(let data, let mimeType, _):
+ return ["type": "image", "data": data, "mimeType": mimeType]
+ default:
+ return nil
+ }
+ }
+ var params: [String: AnyCodable] = [
+ "content": AnyCodable(contentDicts),
+ "isError": AnyCodable(result.isError ?? false)
+ ]
+ // Include structuredContent if present
+ if let sc = result.structuredContent {
+ params["structuredContent"] = AnyCodable(sc.toAny())
+ }
+ try await bridge.sendToolResult(params)
+ }
+
+ /// Teardown the app bridge before removing the tool call
+ /// Returns an error message if teardown failed, nil on success
+ func teardown() async -> String? {
+ // Prevent double-tap
+ guard !isTearingDown else {
+ logger.info("Teardown already in progress, skipping")
+ return nil
+ }
+ isTearingDown = true
+ logger.info("Starting teardown, appBridge exists: \(self.appBridge != nil)")
+
+ var errorMessage: String?
+
+ if let bridge = appBridge {
+ // Only send teardown if the bridge is initialized
+ // If not initialized, the app hasn't done anything worth saving
+ let isReady = await bridge.isReady()
+ if isReady {
+ logger.info("Sending teardown request...")
+ do {
+ _ = try await bridge.sendResourceTeardown()
+ logger.info("Teardown request completed successfully")
+ } catch {
+ logger.error("Teardown request failed: \(String(describing: error))")
+ errorMessage = "Teardown failed: app may not have saved data"
+ }
+ } else {
+ logger.info("Skipping teardown - bridge not yet initialized")
+ }
+
+ logger.info("Closing bridge...")
+ await bridge.close()
+ logger.info("Bridge closed")
+ } else {
+ logger.warning("No bridge to teardown (appBridge is nil)")
+ }
+ appBridge = nil
+ logger.info("Teardown complete, will remove card from list")
+ return errorMessage
+ }
+}
+
+// MARK: - Execution State
+
+enum ExecutionState {
+ case calling, loadingUi, ready, completed, error
+
+ var description: String {
+ switch self {
+ case .calling: return "Calling..."
+ case .loadingUi: return "Loading UI..."
+ case .ready: return "Ready"
+ case .completed: return "Completed"
+ case .error: return "Error"
+ }
+ }
+}
+
+// MARK: - Errors
+
+enum ToolCallError: LocalizedError {
+ case invalidJson
+ case noResourceContent
+ case invalidMimeType(String)
+ case invalidHtmlContent
+ case clientNotAvailable
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidJson: return "Invalid JSON input"
+ case .noResourceContent: return "No content in UI resource"
+ case .invalidMimeType(let m): return "Invalid MIME type: \(m)"
+ case .invalidHtmlContent: return "Invalid HTML content"
+ case .clientNotAvailable: return "MCP client not available"
+ }
+ }
+}
+
+enum ConnectionError: LocalizedError {
+ case invalidUrl(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidUrl(let url): return "Invalid URL: \(url)"
+ }
+ }
+}
diff --git a/examples/basic-host-swift/Sources/BasicHostApp/WebViewContainer.swift b/examples/basic-host-swift/Sources/BasicHostApp/WebViewContainer.swift
new file mode 100644
index 00000000..d5969d07
--- /dev/null
+++ b/examples/basic-host-swift/Sources/BasicHostApp/WebViewContainer.swift
@@ -0,0 +1,181 @@
+import SwiftUI
+import WebKit
+import McpApps
+
+/// SwiftUI wrapper for WKWebView with MCP Apps integration.
+///
+/// This view:
+/// 1. Creates a WKWebView instance
+/// 2. Sets up WKWebViewTransport for AppBridge communication
+/// 3. Loads the UI HTML from the tool call
+/// 4. Handles AppBridge initialization and lifecycle
+struct WebViewContainer: UIViewRepresentable {
+ /// Tool call information containing HTML and AppBridge setup
+ @ObservedObject var toolCallInfo: ToolCallInfo
+
+ func makeUIView(context: Context) -> WKWebView {
+ // Create web view configuration
+ let configuration = WKWebViewConfiguration()
+
+ // Enable JavaScript
+ configuration.preferences.javaScriptEnabled = true
+
+ // Add console log handler
+ let contentController = configuration.userContentController
+ contentController.add(context.coordinator, name: "consoleLog")
+
+ // Inject console override script
+ let consoleScript = WKUserScript(source: """
+ (function() {
+ var originalLog = console.log;
+ var originalError = console.error;
+ var originalWarn = console.warn;
+ console.log = function() {
+ originalLog.apply(console, arguments);
+ window.webkit.messageHandlers.consoleLog.postMessage({level: 'log', args: Array.from(arguments).map(String)});
+ };
+ console.error = function() {
+ originalError.apply(console, arguments);
+ window.webkit.messageHandlers.consoleLog.postMessage({level: 'error', args: Array.from(arguments).map(String)});
+ };
+ console.warn = function() {
+ originalWarn.apply(console, arguments);
+ window.webkit.messageHandlers.consoleLog.postMessage({level: 'warn', args: Array.from(arguments).map(String)});
+ };
+ })();
+ """, injectionTime: .atDocumentStart, forMainFrameOnly: false)
+ contentController.addUserScript(consoleScript)
+
+ // Create web view
+ let webView = WKWebView(frame: .zero, configuration: configuration)
+ webView.scrollView.isScrollEnabled = true
+ webView.isOpaque = false
+ webView.backgroundColor = .clear
+
+ // Set up navigation delegate
+ webView.navigationDelegate = context.coordinator
+
+ return webView
+ }
+
+ func updateUIView(_ webView: WKWebView, context: Context) {
+ // Only load content once when HTML is available
+ guard let html = toolCallInfo.htmlContent,
+ context.coordinator.hasLoadedContent == false else {
+ return
+ }
+
+ context.coordinator.hasLoadedContent = true
+
+ // Create transport and set up AppBridge
+ Task {
+ do {
+ // Create transport with the webView
+ let transport = await WKWebViewTransport(webView: webView, handlerName: "mcpBridge")
+
+ // Start transport (registers message handler)
+ try await transport.start()
+
+ // Set up AppBridge with callbacks
+ try await toolCallInfo.setupAppBridge(transport: transport)
+
+ // Inject viewport meta and bridge script
+ let injectedContent = """
+
+
+ """
+
+ // Inject at the beginning of or
+ var modifiedHtml = html
+ if let headRange = html.range(of: "", options: .caseInsensitive) {
+ modifiedHtml.insert(contentsOf: injectedContent, at: headRange.upperBound)
+ } else if let htmlRange = html.range(of: "", options: .caseInsensitive) {
+ // Find end of tag
+ if let tagEnd = html.range(of: ">", range: htmlRange.upperBound..\(injectedContent)", at: tagEnd.upperBound)
+ }
+ } else {
+ // Prepend to beginning
+ modifiedHtml = injectedContent + html
+ }
+
+ await MainActor.run {
+ print("[WebViewContainer] Loading HTML with injected bridge script")
+ webView.loadHTMLString(modifiedHtml, baseURL: nil)
+ }
+ } catch {
+ print("[WebViewContainer] Failed to set up AppBridge: \(error)")
+ await MainActor.run {
+ toolCallInfo.state = .error
+ toolCallInfo.error = "Failed to set up AppBridge: \(error.localizedDescription)"
+ }
+ }
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator()
+ }
+
+ /// Coordinator to handle WebView navigation and console logging
+ class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
+ var hasLoadedContent = false
+
+ // Handle console log messages from JavaScript
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
+ if message.name == "consoleLog",
+ let body = message.body as? [String: Any],
+ let level = body["level"] as? String,
+ let args = body["args"] as? [String] {
+ let text = args.joined(separator: " ")
+ print("[JS \(level.uppercased())] \(text)")
+ }
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFinish navigation: WKNavigation!
+ ) {
+ print("[WebViewContainer] WebView finished loading")
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFail navigation: WKNavigation!,
+ withError error: Error
+ ) {
+ print("[WebViewContainer] WebView navigation failed: \(error)")
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ didFailProvisionalNavigation navigation: WKNavigation!,
+ withError error: Error
+ ) {
+ print("[WebViewContainer] WebView provisional navigation failed: \(error)")
+ }
+
+ func webView(
+ _ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
+ ) {
+ // Allow all navigation for now
+ // In a production app, you might want to restrict navigation
+ decisionHandler(.allow)
+ }
+ }
+}
diff --git a/examples/basic-host-swift/scripts/build.sh b/examples/basic-host-swift/scripts/build.sh
new file mode 100755
index 00000000..5fb0c4be
--- /dev/null
+++ b/examples/basic-host-swift/scripts/build.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+# Build the app for iOS Simulator
+# Usage: ./scripts/build.sh [simulator-name]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+BUILD_DIR="$PROJECT_DIR/.build"
+SIMULATOR="${1:-iPhone 17 Pro}"
+APP_NAME="BasicHostSwift"
+
+cd "$PROJECT_DIR"
+
+echo "Building $APP_NAME for '$SIMULATOR'..."
+
+xcodebuild -scheme "$APP_NAME" \
+ -destination "platform=iOS Simulator,name=$SIMULATOR" \
+ -derivedDataPath "$BUILD_DIR" \
+ build 2>&1 | grep -E "(error:|warning:.*$APP_NAME|BUILD)" || true
+
+if [ ${PIPESTATUS[0]} -eq 0 ]; then
+ echo "✅ Build succeeded"
+ echo " Output: $BUILD_DIR/Build/Products/Debug-iphonesimulator/$APP_NAME"
+else
+ echo "❌ Build failed"
+ exit 1
+fi
diff --git a/examples/basic-host-swift/scripts/clean.sh b/examples/basic-host-swift/scripts/clean.sh
new file mode 100755
index 00000000..b317d4c8
--- /dev/null
+++ b/examples/basic-host-swift/scripts/clean.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+# Clean build artifacts
+# Usage: ./scripts/clean.sh
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+echo "🧹 Cleaning build artifacts..."
+
+rm -rf "$PROJECT_DIR/.build"
+rm -rf "$PROJECT_DIR/.swiftpm"
+rm -rf "$PROJECT_DIR/build"
+rm -rf "$PROJECT_DIR/Package.resolved"
+
+echo "✅ Clean complete"
diff --git a/examples/basic-host-swift/scripts/dev.sh b/examples/basic-host-swift/scripts/dev.sh
new file mode 100755
index 00000000..5c62e538
--- /dev/null
+++ b/examples/basic-host-swift/scripts/dev.sh
@@ -0,0 +1,145 @@
+#!/bin/bash
+# Development script: builds, installs, runs, and watches for changes
+# Usage: ./scripts/dev.sh [simulator-name]
+#
+# Requires: fswatch (brew install fswatch)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+BUILD_DIR="$PROJECT_DIR/.build"
+SIMULATOR="${1:-iPhone 17 Pro}"
+BUNDLE_ID="com.example.BasicHostSwift"
+APP_NAME="BasicHostSwift"
+
+cd "$PROJECT_DIR"
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+log() { echo -e "${BLUE}[dev]${NC} $1"; }
+success() { echo -e "${GREEN}[dev]${NC} $1"; }
+warn() { echo -e "${YELLOW}[dev]${NC} $1"; }
+error() { echo -e "${RED}[dev]${NC} $1"; }
+
+# Check for fswatch
+if ! command -v fswatch &> /dev/null; then
+ error "fswatch is required. Install with: brew install fswatch"
+ exit 1
+fi
+
+# Boot simulator if needed
+boot_simulator() {
+ log "Checking simulator '$SIMULATOR'..."
+ if ! xcrun simctl list devices | grep -q "$SIMULATOR.*Booted"; then
+ log "Booting simulator..."
+ xcrun simctl boot "$SIMULATOR" 2>/dev/null || true
+ sleep 2
+ fi
+ # Open Simulator.app to show the window
+ open -a Simulator
+}
+
+# Build the app
+build_app() {
+ log "Building..."
+ if xcodebuild -scheme "$APP_NAME" \
+ -destination "platform=iOS Simulator,name=$SIMULATOR" \
+ -derivedDataPath "$BUILD_DIR" \
+ build 2>&1 | grep -E "(error:|warning:.*$APP_NAME|BUILD)"; then
+ return 0
+ fi
+ # Check if build succeeded even if grep didn't match
+ if [ ${PIPESTATUS[0]} -eq 0 ]; then
+ return 0
+ fi
+ return 1
+}
+
+# Create app bundle and install
+install_app() {
+ log "Installing..."
+ local PRODUCTS_DIR="$BUILD_DIR/Build/Products/Debug-iphonesimulator"
+
+ # Create app bundle if it doesn't exist
+ mkdir -p "$PRODUCTS_DIR/$APP_NAME.app"
+ cp "$PRODUCTS_DIR/$APP_NAME" "$PRODUCTS_DIR/$APP_NAME.app/"
+
+ # Copy Info.plist
+ cat > "$PRODUCTS_DIR/$APP_NAME.app/Info.plist" << 'EOF'
+
+
+
+
+ CFBundleExecutable
+ BasicHostSwift
+ CFBundleIdentifier
+ com.example.BasicHostSwift
+ CFBundleName
+ MCP Host
+ CFBundleVersion
+ 1
+ CFBundleShortVersionString
+ 1.0
+ UILaunchScreen
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ MinimumOSVersion
+ 16.0
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+
+
+EOF
+
+ # Sign and install
+ codesign --force --sign - "$PRODUCTS_DIR/$APP_NAME.app" 2>/dev/null
+ xcrun simctl install "$SIMULATOR" "$PRODUCTS_DIR/$APP_NAME.app"
+}
+
+# Launch the app
+launch_app() {
+ log "Launching..."
+ xcrun simctl terminate "$SIMULATOR" "$BUNDLE_ID" 2>/dev/null || true
+ xcrun simctl launch "$SIMULATOR" "$BUNDLE_ID"
+}
+
+# Full rebuild cycle
+rebuild() {
+ echo ""
+ log "Rebuilding... ($(date '+%H:%M:%S'))"
+
+ if build_app; then
+ success "Build succeeded"
+ install_app
+ launch_app
+ success "App reloaded!"
+ else
+ error "Build failed"
+ fi
+}
+
+# Initial build
+boot_simulator
+rebuild
+
+# Watch for changes
+log "Watching for changes in Sources/..."
+log "Press Ctrl+C to stop"
+echo ""
+
+fswatch -o "$PROJECT_DIR/Sources" | while read -r; do
+ rebuild
+done
diff --git a/examples/basic-host-swift/scripts/logs.sh b/examples/basic-host-swift/scripts/logs.sh
new file mode 100755
index 00000000..9ded0f72
--- /dev/null
+++ b/examples/basic-host-swift/scripts/logs.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# Stream logs from the app running in simulator
+# Usage: ./scripts/logs.sh [simulator-name]
+
+SIMULATOR="${1:-iPhone 17 Pro}"
+BUNDLE_ID="com.example.BasicHostSwift"
+
+echo "📋 Streaming logs from $BUNDLE_ID on '$SIMULATOR'..."
+echo " Press Ctrl+C to stop"
+echo ""
+
+# Stream logs, filtering for our app (both OSLog and print statements)
+xcrun simctl spawn "$SIMULATOR" log stream \
+ --predicate "subsystem == '$BUNDLE_ID' OR process == 'BasicHostSwift'" \
+ --level debug \
+ --style compact
diff --git a/examples/basic-host-swift/scripts/run.sh b/examples/basic-host-swift/scripts/run.sh
new file mode 100755
index 00000000..85d11217
--- /dev/null
+++ b/examples/basic-host-swift/scripts/run.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+# Build and run the app in iOS Simulator (one-shot, no watching)
+# Usage: ./scripts/run.sh [simulator-name]
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+BUILD_DIR="$PROJECT_DIR/.build"
+SIMULATOR="${1:-iPhone 17 Pro}"
+BUNDLE_ID="com.example.BasicHostSwift"
+APP_NAME="BasicHostSwift"
+
+cd "$PROJECT_DIR"
+
+echo "🔨 Building..."
+"$SCRIPT_DIR/build.sh" "$SIMULATOR"
+
+echo ""
+echo "📱 Booting simulator..."
+xcrun simctl boot "$SIMULATOR" 2>/dev/null || true
+open -a Simulator
+
+echo "📦 Installing..."
+PRODUCTS_DIR="$BUILD_DIR/Build/Products/Debug-iphonesimulator"
+
+# Create app bundle
+mkdir -p "$PRODUCTS_DIR/$APP_NAME.app"
+cp "$PRODUCTS_DIR/$APP_NAME" "$PRODUCTS_DIR/$APP_NAME.app/"
+
+cat > "$PRODUCTS_DIR/$APP_NAME.app/Info.plist" << 'EOF'
+
+
+
+
+ CFBundleExecutable
+ BasicHostSwift
+ CFBundleIdentifier
+ com.example.BasicHostSwift
+ CFBundleName
+ MCP Host
+ CFBundleVersion
+ 1
+ CFBundleShortVersionString
+ 1.0
+ UILaunchScreen
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+
+ MinimumOSVersion
+ 16.0
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
+
+
+EOF
+
+codesign --force --sign - "$PRODUCTS_DIR/$APP_NAME.app" 2>/dev/null
+xcrun simctl install "$SIMULATOR" "$PRODUCTS_DIR/$APP_NAME.app"
+
+echo "🚀 Launching..."
+xcrun simctl terminate "$SIMULATOR" "$BUNDLE_ID" 2>/dev/null || true
+xcrun simctl launch "$SIMULATOR" "$BUNDLE_ID"
+
+echo ""
+echo "✅ App is running in $SIMULATOR"
diff --git a/examples/basic-host-swift/scripts/screenshot.sh b/examples/basic-host-swift/scripts/screenshot.sh
new file mode 100755
index 00000000..cdd8080d
--- /dev/null
+++ b/examples/basic-host-swift/scripts/screenshot.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+# Take a screenshot of the simulator
+# Usage: ./scripts/screenshot.sh [output-file] [simulator-name]
+
+OUTPUT="${1:-screenshot.png}"
+SIMULATOR="${2:-iPhone 17 Pro}"
+
+echo "📸 Taking screenshot of '$SIMULATOR'..."
+xcrun simctl io "$SIMULATOR" screenshot "$OUTPUT"
+echo "✅ Saved to $OUTPUT"
+
+# Open the screenshot
+open "$OUTPUT"
diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts
index 703ab6a5..98bced6f 100644
--- a/examples/basic-host/src/implementation.ts
+++ b/examples/basic-host/src/implementation.ts
@@ -207,12 +207,19 @@ function hookInitializedCallback(appBridge: AppBridge): Promise {
}
-export function newAppBridge(serverInfo: ServerInfo, iframe: HTMLIFrameElement): AppBridge {
+export function newAppBridge(serverInfo: ServerInfo, toolCallInfo: ToolCallInfo, iframe: HTMLIFrameElement): AppBridge {
const serverCapabilities = serverInfo.client.getServerCapabilities();
const appBridge = new AppBridge(serverInfo.client, IMPLEMENTATION, {
openLinks: {},
serverTools: serverCapabilities?.tools,
serverResources: serverCapabilities?.resources,
+ }, {
+ hostContext: {
+ toolInfo: {
+ id: crypto.randomUUID(), // We don't have the actual request ID, use a random one
+ tool: toolCallInfo.tool,
+ },
+ },
});
// Register all handlers before calling connect(). The Guest UI can start
diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx
index 8584326e..4e148548 100644
--- a/examples/basic-host/src/index.tsx
+++ b/examples/basic-host/src/index.tsx
@@ -269,7 +269,7 @@ function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppI
// Outside of Strict Mode, this `useEffect` runs only once per
// `toolCallInfo`.
if (firstTime) {
- const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe);
+ const appBridge = newAppBridge(toolCallInfo.serverInfo, toolCallInfo, iframe);
appBridgeRef.current = appBridge;
initializeApp(iframe, appBridge, toolCallInfo);
}
diff --git a/scripts/generate-swift-types.ts b/scripts/generate-swift-types.ts
new file mode 100644
index 00000000..af9ecc17
--- /dev/null
+++ b/scripts/generate-swift-types.ts
@@ -0,0 +1,628 @@
+#!/usr/bin/env npx tsx
+/**
+ * Generate Swift types from MCP Apps JSON Schema
+ *
+ * This generator:
+ * 1. Identifies structurally equivalent types and deduplicates them
+ * 2. Uses simpler names for common patterns
+ * 3. Creates type aliases for compatibility
+ */
+
+import { readFileSync, writeFileSync, mkdirSync } from "fs";
+import { dirname, join } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const PROJECT_DIR = join(__dirname, "..");
+const SCHEMA_FILE = join(PROJECT_DIR, "src/generated/schema.json");
+const OUTPUT_FILE = join(
+ PROJECT_DIR,
+ "swift/Sources/McpApps/Generated/SchemaTypes.swift",
+);
+
+interface JsonSchema {
+ type?: string;
+ const?: string;
+ description?: string;
+ properties?: Record;
+ additionalProperties?: boolean | JsonSchema;
+ required?: string[];
+ anyOf?: JsonSchema[];
+ $ref?: string;
+ items?: JsonSchema;
+ enum?: string[];
+}
+
+interface SchemaDoc {
+ $defs: Record;
+}
+
+// Types defined in the header that should not be regenerated
+const HEADER_DEFINED_TYPES = new Set([
+ "EmptyCapability",
+ "AnyCodable",
+ "Implementation",
+ "TextContent",
+ "LogLevel",
+ "HostOptions",
+ "CspConfig",
+]);
+
+// Canonical type names - map verbose inline names to simpler ones
+const CANONICAL_NAMES: Record = {
+ // Capabilities
+ McpUiInitializeResultHostCapabilities: "McpUiHostCapabilities",
+ McpUiInitializeResultHostCapabilitiesServerTools: "ServerToolsCapability",
+ McpUiInitializeResultHostCapabilitiesServerResources:
+ "ServerResourcesCapability",
+ McpUiInitializeRequestParamsAppCapabilities: "McpUiAppCapabilities",
+ McpUiInitializeRequestParamsAppCapabilitiesTools: "AppToolsCapability",
+ McpUiHostContextChangedNotificationParamsDeviceCapabilities:
+ "DeviceCapabilities",
+ McpUiInitializeResultHostContextDeviceCapabilities: "DeviceCapabilities",
+
+ // Context types
+ McpUiInitializeResultHostContext: "McpUiHostContext",
+ McpUiHostContextChangedNotificationParams: "McpUiHostContext",
+
+ // Enums
+ McpUiInitializeResultHostContextTheme: "McpUiTheme",
+ McpUiHostContextChangedNotificationParamsTheme: "McpUiTheme",
+ McpUiInitializeResultHostContextDisplayMode: "McpUiDisplayMode",
+ McpUiHostContextChangedNotificationParamsDisplayMode: "McpUiDisplayMode",
+ McpUiInitializeResultHostContextPlatform: "McpUiPlatform",
+ McpUiHostContextChangedNotificationParamsPlatform: "McpUiPlatform",
+
+ // Viewport
+ McpUiInitializeResultHostContextViewport: "Viewport",
+ McpUiHostContextChangedNotificationParamsViewport: "Viewport",
+
+ // Safe area
+ McpUiInitializeResultHostContextSafeAreaInsets: "SafeAreaInsets",
+ McpUiHostContextChangedNotificationParamsSafeAreaInsets: "SafeAreaInsets",
+
+ // Implementation
+ McpUiInitializeResultHostInfo: "Implementation",
+ McpUiInitializeRequestParamsAppInfo: "Implementation",
+};
+
+// Types to skip (will use the canonical version)
+const SKIP_TYPES = new Set(Object.keys(CANONICAL_NAMES));
+
+// Empty object types
+const EMPTY_TYPES = new Set();
+
+// Track generated types
+const generatedTypes = new Set();
+const typeDefinitions: string[] = [];
+
+function toSwiftPropertyName(name: string): string {
+ // Replace invalid Swift identifier characters
+ return name
+ .replace(/[.\/:]/g, "_")
+ .replace(/-/g, "_")
+ .replace(/^_+/, "") // Remove leading underscores
+ .replace(/_+$/, ""); // Remove trailing underscores
+}
+
+function getCanonicalName(name: string): string {
+ return CANONICAL_NAMES[name] || name;
+}
+
+function isEmptyObject(schema: JsonSchema): boolean {
+ return (
+ schema.type === "object" &&
+ schema.additionalProperties === false &&
+ (!schema.properties || Object.keys(schema.properties).length === 0)
+ );
+}
+
+// Check if anyOf represents a discriminated union (objects with "type" const field)
+function isDiscriminatedUnion(variants: JsonSchema[]): boolean {
+ return variants.every((v) => {
+ if (v.type !== "object" || !v.properties?.type) return false;
+ const typeField = v.properties.type;
+ return typeField.const !== undefined || typeField.type === "string";
+ });
+}
+
+// Get the discriminator value from a variant
+function getDiscriminatorValue(variant: JsonSchema): string | null {
+ const typeField = variant.properties?.type;
+ if (typeField?.const) return typeField.const as string;
+ return null;
+}
+
+// Check if anyOf is a field-based union (differentiated by presence of fields like text/blob)
+function isFieldBasedUnion(variants: JsonSchema[]): string | null {
+ if (variants.length !== 2) return null;
+
+ // Find the differentiating required field
+ const req0 = new Set(variants[0].required || []);
+ const req1 = new Set(variants[1].required || []);
+
+ // Find fields unique to each variant
+ const unique0 = [...req0].filter((f) => !req1.has(f));
+ const unique1 = [...req1].filter((f) => !req0.has(f));
+
+ if (unique0.length === 1 && unique1.length === 1) {
+ return `${unique0[0]}|${unique1[0]}`;
+ }
+ return null;
+}
+
+// Generate Swift enum for field-based union (e.g., text vs blob)
+function generateFieldBasedUnion(
+ name: string,
+ variants: JsonSchema[],
+ fields: string,
+ defs: Record,
+): void {
+ const canonical = getCanonicalName(name);
+ if (generatedTypes.has(canonical)) return;
+ generatedTypes.add(canonical);
+
+ const [field0, field1] = fields.split("|");
+ const case0 = field0.replace(/-/g, "");
+ const case1 = field1.replace(/-/g, "");
+ const struct0 = canonical + capitalize(case0);
+ const struct1 = canonical + capitalize(case1);
+
+ // Generate the associated structs
+ generateStruct(struct0, variants[0], defs);
+ generateStruct(struct1, variants[1], defs);
+
+ typeDefinitions.push(`public enum ${canonical}: Codable, Sendable, Equatable {
+ case ${case0}(${struct0})
+ case ${case1}(${struct1})
+
+ public init(from decoder: Decoder) throws {
+ // Try decoding each variant
+ if let v = try? ${struct0}(from: decoder) {
+ self = .${case0}(v)
+ } else if let v = try? ${struct1}(from: decoder) {
+ self = .${case1}(v)
+ } else {
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot decode ${canonical}"))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .${case0}(let v): try v.encode(to: encoder)
+ case .${case1}(let v): try v.encode(to: encoder)
+ }
+ }
+}`);
+}
+
+// Generate Swift enum for discriminated union
+function generateDiscriminatedUnion(
+ name: string,
+ variants: JsonSchema[],
+ defs: Record,
+): void {
+ const canonical = getCanonicalName(name);
+ if (generatedTypes.has(canonical)) return;
+ generatedTypes.add(canonical);
+
+ const cases: string[] = [];
+ const decodeCases: string[] = [];
+ const encodeCases: string[] = [];
+
+ for (const variant of variants) {
+ const discriminator = getDiscriminatorValue(variant);
+ if (!discriminator) continue;
+
+ const caseName = discriminator.replace(/-/g, "").replace(/_/g, "");
+ const structName = canonical + capitalize(caseName);
+
+ // Generate the associated struct
+ generateStruct(structName, variant, defs);
+
+ cases.push(` case ${caseName}(${structName})`);
+ decodeCases.push(
+ ` case "${discriminator}": self = .${caseName}(try ${structName}(from: decoder))`,
+ );
+ encodeCases.push(
+ ` case .${caseName}(let v): try v.encode(to: encoder)`,
+ );
+ }
+
+ typeDefinitions.push(`public enum ${canonical}: Codable, Sendable, Equatable {
+${cases.join("\n")}
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: .type)
+ switch type {
+${decodeCases.join("\n")}
+ default:
+ throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \\(type)")
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+${encodeCases.join("\n")}
+ }
+ }
+}`);
+}
+
+function toSwiftType(
+ schema: JsonSchema,
+ contextName: string,
+ defs: Record,
+): string {
+ if (schema.$ref) {
+ const refName = schema.$ref.replace("#/$defs/", "");
+ return getCanonicalName(refName);
+ }
+
+ if (schema.anyOf) {
+ // Check if it's a simple string enum (all const strings)
+ const allConsts = schema.anyOf.every((s) => s.const !== undefined);
+ if (allConsts) {
+ const canonical = getCanonicalName(contextName);
+ if (!generatedTypes.has(canonical)) {
+ generateEnum(contextName, schema);
+ }
+ return canonical;
+ }
+
+ // Check if it's a discriminated union (objects with "type" const field)
+ const discriminatedUnion = isDiscriminatedUnion(schema.anyOf);
+ if (discriminatedUnion) {
+ const canonical = getCanonicalName(contextName);
+ if (!generatedTypes.has(canonical)) {
+ generateDiscriminatedUnion(contextName, schema.anyOf, defs);
+ }
+ return canonical;
+ }
+
+ // Check if it's a field-based union (e.g., text vs blob)
+ const fieldUnion = isFieldBasedUnion(schema.anyOf);
+ if (fieldUnion) {
+ const canonical = getCanonicalName(contextName);
+ if (!generatedTypes.has(canonical)) {
+ generateFieldBasedUnion(contextName, schema.anyOf, fieldUnion, defs);
+ }
+ return canonical;
+ }
+
+ return "AnyCodable";
+ }
+
+ if (schema.const) {
+ return "String";
+ }
+
+ switch (schema.type) {
+ case "string":
+ return "String";
+ case "number":
+ return "Double";
+ case "integer":
+ return "Int";
+ case "boolean":
+ return "Bool";
+ case "array":
+ if (schema.items) {
+ return `[${toSwiftType(schema.items, contextName + "Item", defs)}]`;
+ }
+ return "[AnyCodable]";
+ case "object":
+ if (isEmptyObject(schema)) {
+ EMPTY_TYPES.add(contextName);
+ return "EmptyCapability";
+ }
+ // If it has properties defined, generate a struct (even with additionalProperties)
+ if (schema.properties && Object.keys(schema.properties).length > 0) {
+ const canonical = getCanonicalName(contextName);
+ if (!generatedTypes.has(canonical)) {
+ generateStruct(contextName, schema, defs);
+ }
+ return canonical;
+ }
+ // Handle typed additionalProperties (e.g., Record)
+ if (
+ schema.additionalProperties &&
+ typeof schema.additionalProperties === "object" &&
+ Object.keys(schema.additionalProperties).length > 0
+ ) {
+ const valueType = toSwiftType(
+ schema.additionalProperties,
+ contextName + "Value",
+ defs,
+ );
+ return `[String: ${valueType}]`;
+ }
+ // Fallback to [String: AnyCodable] for open objects
+ if (schema.additionalProperties || !schema.properties) {
+ return "[String: AnyCodable]";
+ }
+ const canonical = getCanonicalName(contextName);
+ if (!generatedTypes.has(canonical) && schema.properties) {
+ generateStruct(contextName, schema, defs);
+ }
+ return canonical;
+ default:
+ return "AnyCodable";
+ }
+}
+
+function generateEnum(name: string, schema: JsonSchema): void {
+ const canonical = getCanonicalName(name);
+ if (generatedTypes.has(canonical)) return;
+ generatedTypes.add(canonical);
+
+ const cases = schema
+ .anyOf!.filter((s) => s.const)
+ .map((s) => {
+ const value = s.const!;
+ const caseName = value.replace(/-/g, "").replace(/\//g, "");
+ return ` case ${caseName} = "${value}"`;
+ });
+
+ const desc = formatSwiftDocComment(schema.description);
+ typeDefinitions.push(`${desc}public enum ${canonical}: String, Codable, Sendable, Equatable {
+${cases.join("\n")}
+}`);
+}
+
+function generateStruct(
+ name: string,
+ schema: JsonSchema,
+ defs: Record,
+): void {
+ const canonical = getCanonicalName(name);
+ if (generatedTypes.has(canonical)) return;
+ if (EMPTY_TYPES.has(name)) return;
+ if (HEADER_DEFINED_TYPES.has(canonical)) return; // Defined in header
+ generatedTypes.add(canonical);
+
+ const props = schema.properties || {};
+ const required = new Set(schema.required || []);
+
+ const properties: Array<{
+ swiftName: string;
+ jsonName: string;
+ type: string;
+ isOptional: boolean;
+ description?: string;
+ }> = [];
+
+ for (const [propName, propSchema] of Object.entries(props)) {
+ const swiftName = toSwiftPropertyName(propName);
+ const contextTypeName = name + capitalize(swiftName);
+
+ let swiftType: string;
+ if (isEmptyObject(propSchema)) {
+ swiftType = "EmptyCapability";
+ } else {
+ swiftType = toSwiftType(propSchema, contextTypeName, defs);
+ }
+
+ properties.push({
+ swiftName,
+ jsonName: propName,
+ type: swiftType,
+ isOptional: !required.has(propName),
+ description: propSchema.description,
+ });
+ }
+
+ const propLines = properties
+ .map((p) => {
+ const desc = p.description
+ ? p.description.split('\n').map(line => ` /// ${line}`).join('\n') + '\n'
+ : "";
+ const typeDecl = p.isOptional ? `${p.type}?` : p.type;
+ return `${desc} public var ${p.swiftName}: ${typeDecl}`;
+ })
+ .join("\n");
+
+ const needsCodingKeys = properties.some((p) => p.swiftName !== p.jsonName);
+ let codingKeys = "";
+ if (needsCodingKeys) {
+ const keyLines = properties
+ .map((p) =>
+ p.swiftName !== p.jsonName
+ ? ` case ${p.swiftName} = "${p.jsonName}"`
+ : ` case ${p.swiftName}`,
+ )
+ .join("\n");
+ codingKeys = `
+
+ private enum CodingKeys: String, CodingKey {
+${keyLines}
+ }`;
+ }
+
+ const initParams = properties
+ .map((p) => {
+ const defaultValue = p.isOptional ? " = nil" : "";
+ return ` ${p.swiftName}: ${p.isOptional ? p.type + "?" : p.type}${defaultValue}`;
+ })
+ .join(",\n");
+
+ const initAssignments = properties
+ .map((p) => ` self.${p.swiftName} = ${p.swiftName}`)
+ .join("\n");
+
+ const desc = formatSwiftDocComment(schema.description);
+ typeDefinitions.push(`${desc}public struct ${canonical}: Codable, Sendable, Equatable {
+${propLines}${codingKeys}
+
+ public init(
+${initParams}
+ ) {
+${initAssignments}
+ }
+}`);
+}
+
+function capitalize(s: string): string {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+function formatSwiftDocComment(description: string | undefined): string {
+ if (!description) return "";
+ // Split into lines and prefix each with ///
+ return description
+ .split('\n')
+ .map(line => `/// ${line}`)
+ .join('\n') + '\n';
+}
+
+function generate(): string {
+ const schema: SchemaDoc = JSON.parse(readFileSync(SCHEMA_FILE, "utf-8"));
+ const defs = schema.$defs;
+
+ const header = `// Generated from src/generated/schema.json
+// DO NOT EDIT - Run: npx tsx scripts/generate-swift-types.ts
+
+import Foundation
+
+// MARK: - Helper Types
+
+/// Empty capability marker (matches TypeScript \`{}\`)
+public struct EmptyCapability: Codable, Sendable, Equatable {
+ public init() {}
+}
+
+/// Type-erased value for dynamic JSON
+public struct AnyCodable: Codable, Equatable, @unchecked Sendable {
+ public let value: Any
+
+ public init(_ value: Any) { self.value = value }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() { self.value = NSNull() }
+ else if let bool = try? container.decode(Bool.self) { self.value = bool }
+ else if let int = try? container.decode(Int.self) { self.value = int }
+ else if let double = try? container.decode(Double.self) { self.value = double }
+ else if let string = try? container.decode(String.self) { self.value = string }
+ else if let array = try? container.decode([AnyCodable].self) { self.value = array.map { $0.value } }
+ else if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict.mapValues { $0.value } }
+ else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode") }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch value {
+ case is NSNull: try container.encodeNil()
+ case let v as Bool: try container.encode(v)
+ case let v as Int: try container.encode(v)
+ case let v as Double: try container.encode(v)
+ case let v as String: try container.encode(v)
+ case let v as [Any]: try container.encode(v.map { AnyCodable($0) })
+ case let v as [String: Any]: try container.encode(v.mapValues { AnyCodable($0) })
+ default: throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Cannot encode"))
+ }
+ }
+
+ public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
+ switch (lhs.value, rhs.value) {
+ case is (NSNull, NSNull): return true
+ case let (l as Bool, r as Bool): return l == r
+ case let (l as Int, r as Int): return l == r
+ case let (l as Double, r as Double): return l == r
+ case let (l as String, r as String): return l == r
+ default: return false
+ }
+ }
+}
+
+/// Application/host identification
+public struct Implementation: Codable, Sendable, Equatable {
+ public var name: String
+ public var version: String
+ public var title: String?
+
+ public init(name: String, version: String, title: String? = nil) {
+ self.name = name
+ self.version = version
+ self.title = title
+ }
+}
+
+/// Text content block
+public struct TextContent: Codable, Sendable {
+ public var type: String = "text"
+ public var text: String
+ public init(text: String) { self.text = text }
+}
+
+/// Log level
+public enum LogLevel: String, Codable, Sendable {
+ case debug, info, notice, warning, error, critical, alert, emergency
+}
+
+/// Host options
+public struct HostOptions: Sendable {
+ public var hostContext: McpUiHostContext
+ public init(hostContext: McpUiHostContext = McpUiHostContext()) {
+ self.hostContext = hostContext
+ }
+}
+
+/// CSP configuration
+public struct CspConfig: Codable, Sendable {
+ public var connectDomains: [String]?
+ public var resourceDomains: [String]?
+ public init(connectDomains: [String]? = nil, resourceDomains: [String]? = nil) {
+ self.connectDomains = connectDomains
+ self.resourceDomains = resourceDomains
+ }
+}
+
+// MARK: - Type Aliases for Compatibility
+
+public typealias McpUiInitializeParams = McpUiInitializeRequestParams
+public typealias McpUiMessageParams = McpUiMessageRequestParams
+public typealias McpUiOpenLinkParams = McpUiOpenLinkRequestParams
+public typealias ServerToolsCapability = McpUiHostCapabilitiesServerTools
+public typealias ServerResourcesCapability = McpUiHostCapabilitiesServerResources
+public typealias AppToolsCapability = McpUiAppCapabilitiesTools
+public typealias ContentBlock = McpUiMessageRequestParamsContentItem
+public typealias CallToolResult = McpUiToolResultNotificationParams
+public typealias ResourceContent = McpUiMessageRequestParamsContentItemResourceResource
+
+// MARK: - Generated Types
+`;
+
+ // Process all definitions in order
+ for (const [name, defSchema] of Object.entries(defs)) {
+ if (defSchema.anyOf && defSchema.anyOf.every((s) => s.const)) {
+ generateEnum(name, defSchema);
+ } else if (defSchema.type === "object") {
+ generateStruct(name, defSchema, defs);
+ }
+ }
+
+ return header + "\n" + typeDefinitions.join("\n\n") + "\n";
+}
+
+try {
+ console.log("🔧 Generating Swift types from schema.json...");
+ const code = generate();
+
+ mkdirSync(dirname(OUTPUT_FILE), { recursive: true });
+ writeFileSync(OUTPUT_FILE, code);
+
+ console.log(`✅ Generated: ${OUTPUT_FILE}`);
+ console.log(` Types: ${generatedTypes.size}`);
+ console.log(
+ ` Deduplicated: ${Object.keys(CANONICAL_NAMES).length} inline types`,
+ );
+} catch (error) {
+ console.error("❌ Generation failed:", error);
+ process.exit(1);
+}
diff --git a/swift/.gitignore b/swift/.gitignore
new file mode 100644
index 00000000..30bcfa4e
--- /dev/null
+++ b/swift/.gitignore
@@ -0,0 +1 @@
+.build/
diff --git a/swift/Package.resolved b/swift/Package.resolved
new file mode 100644
index 00000000..bbb662ca
--- /dev/null
+++ b/swift/Package.resolved
@@ -0,0 +1,42 @@
+{
+ "originHash" : "8f403c2c4a637cdbe439b2460b1a3a27fa2e25371a073fafd23a1a3c4a138313",
+ "pins" : [
+ {
+ "identity" : "eventsource",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/mattt/eventsource.git",
+ "state" : {
+ "revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-log",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-log.git",
+ "state" : {
+ "revision" : "b1fa4ef41fe21b13120c034854042d12c43f66b2",
+ "version" : "1.7.1"
+ }
+ },
+ {
+ "identity" : "swift-sdk",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ajevans99/swift-sdk.git",
+ "state" : {
+ "branch" : "spec-update",
+ "revision" : "0dd973424da4b407a03e55e16476c11388f3f97f"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
+ "version" : "1.6.3"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/swift/Package.swift b/swift/Package.swift
new file mode 100644
index 00000000..81255235
--- /dev/null
+++ b/swift/Package.swift
@@ -0,0 +1,36 @@
+// swift-tools-version: 6.0
+import PackageDescription
+
+let package = Package(
+ name: "McpApps",
+ platforms: [
+ .iOS(.v16),
+ .macOS(.v13),
+ .tvOS(.v16),
+ .watchOS(.v9)
+ ],
+ products: [
+ .library(
+ name: "McpApps",
+ targets: ["McpApps"]
+ ),
+ ],
+ dependencies: [
+ // MCP Swift SDK for core types (using spec-update branch with _meta support)
+ .package(url: "https://github.com/ajevans99/swift-sdk.git", branch: "spec-update"),
+ ],
+ targets: [
+ .target(
+ name: "McpApps",
+ dependencies: [
+ .product(name: "MCP", package: "swift-sdk"),
+ ],
+ path: "Sources/McpApps"
+ ),
+ .testTarget(
+ name: "McpAppsTests",
+ dependencies: ["McpApps"],
+ path: "Tests/McpAppsTests"
+ ),
+ ]
+)
diff --git a/swift/README.md b/swift/README.md
new file mode 100644
index 00000000..497fe2c0
--- /dev/null
+++ b/swift/README.md
@@ -0,0 +1,277 @@
+# MCP Apps Swift SDK
+
+Swift SDK for hosting MCP Apps in iOS/macOS applications.
+
+## Overview
+
+This SDK enables native Apple platform applications to host MCP Apps (interactive UIs) in WKWebViews. It provides the `AppBridge` actor that handles:
+
+- Initialization handshake with the Guest UI
+- Sending tool input and results
+- Receiving messages and link open requests
+- Host context updates (theme, viewport, etc.)
+
+## Installation
+
+### Swift Package Manager
+
+Add to your `Package.swift`:
+
+```swift
+dependencies: [
+ .package(url: "https://github.com/modelcontextprotocol/ext-apps.git", from: "0.1.0")
+]
+```
+
+Or in Xcode: File → Add Package Dependencies → Enter the repository URL.
+
+## Usage
+
+### Basic Setup
+
+```swift
+import McpApps
+
+// Create the AppBridge
+let bridge = AppBridge(
+ hostInfo: Implementation(name: "MyApp", version: "1.0.0"),
+ hostCapabilities: McpUiHostCapabilities(
+ openLinks: true,
+ serverTools: ServerToolsCapability(),
+ logging: true
+ )
+)
+
+// Set up callbacks
+await bridge.onInitialized = {
+ print("Guest UI initialized")
+ // Now safe to send tool input
+ try await bridge.sendToolInput(arguments: [
+ "location": AnyCodable("NYC")
+ ])
+}
+
+await bridge.onSizeChange = { width, height in
+ print("UI size changed: \(width ?? 0)x\(height ?? 0)")
+}
+
+await bridge.onMessage = { role, content in
+ print("Message from UI: \(role)")
+ return McpUiMessageResult() // Return success
+}
+
+await bridge.onOpenLink = { url in
+ // Open URL in Safari
+ if let url = URL(string: url) {
+ UIApplication.shared.open(url)
+ }
+ return McpUiOpenLinkResult() // Return success
+}
+
+// Connect to the Guest UI via transport
+try await bridge.connect(webViewTransport)
+```
+
+### Sending Tool Data
+
+```swift
+// Send complete tool arguments
+try await bridge.sendToolInput(arguments: [
+ "query": AnyCodable("weather forecast"),
+ "units": AnyCodable("metric")
+])
+
+// Send streaming partial arguments
+try await bridge.sendToolInputPartial(arguments: [
+ "query": AnyCodable("weather")
+])
+
+// Send tool result
+try await bridge.sendToolResult([
+ "content": AnyCodable([
+ ["type": "text", "text": "Sunny, 72°F"]
+ ])
+])
+```
+
+### Updating Host Context
+
+```swift
+try await bridge.setHostContext(McpUiHostContext(
+ theme: .dark,
+ displayMode: .inline,
+ viewport: Viewport(width: 800, height: 600),
+ locale: "en-US",
+ platform: .mobile
+))
+```
+
+### Graceful Shutdown
+
+```swift
+// Before removing the WebView
+let _ = try await bridge.sendResourceTeardown()
+// Now safe to remove WebView
+```
+
+## Platform Support
+
+- iOS 16+
+- macOS 13+
+- tvOS 16+
+- watchOS 9+
+
+## Building
+
+```bash
+# Build the SDK
+swift build
+
+# Run tests (requires Xcode on macOS)
+swift test
+
+# Build for release
+swift build -c release
+```
+
+## Types
+
+### Host Context
+
+The `McpUiHostContext` provides rich environment information to the Guest UI:
+
+| Field | Type | Description |
+| -------------------- | -------------------- | ----------------------------------- |
+| `theme` | `McpUiTheme` | `.light` or `.dark` |
+| `displayMode` | `McpUiDisplayMode` | `.inline`, `.fullscreen`, or `.pip` |
+| `viewport` | `Viewport` | Current dimensions |
+| `locale` | `String` | BCP 47 locale (e.g., "en-US") |
+| `timeZone` | `String` | IANA timezone |
+| `platform` | `McpUiPlatform` | `.web`, `.desktop`, or `.mobile` |
+| `deviceCapabilities` | `DeviceCapabilities` | Touch/hover support |
+| `safeAreaInsets` | `SafeAreaInsets` | Safe area boundaries |
+
+### Host Capabilities
+
+Declare what features your host supports:
+
+```swift
+McpUiHostCapabilities(
+ openLinks: true, // Can open external URLs
+ serverTools: ServerToolsCapability(listChanged: true),
+ serverResources: ServerResourcesCapability(listChanged: true),
+ logging: true // Accepts log messages
+)
+```
+
+## Actor-Based Concurrency
+
+The `AppBridge` is implemented as a Swift actor, ensuring thread-safe access to all its properties and methods. All public methods are async and should be called with `await`.
+
+## MCP Server Forwarding
+
+The AppBridge can forward tool and resource requests from the Guest UI to an MCP server. This enables Guest UIs to call server tools and read server resources through the bridge.
+
+### Setting Up Tool Call Forwarding
+
+```swift
+// Advertise server tool capability
+let bridge = AppBridge(
+ hostInfo: Implementation(name: "MyApp", version: "1.0.0"),
+ hostCapabilities: McpUiHostCapabilities(
+ serverTools: ServerToolsCapability()
+ )
+)
+
+// Set up callback to forward tools/call requests
+await bridge.onToolCall = { toolName, arguments in
+ // Forward to your MCP client/server
+ let result = try await mcpClient.callTool(
+ name: toolName,
+ arguments: arguments
+ )
+
+ // Return result in MCP CallToolResult format
+ return [
+ "content": AnyCodable(result.content),
+ "isError": AnyCodable(result.isError ?? false)
+ ]
+}
+```
+
+### Setting Up Resource Read Forwarding
+
+```swift
+// Advertise server resource capability
+let bridge = AppBridge(
+ hostInfo: Implementation(name: "MyApp", version: "1.0.0"),
+ hostCapabilities: McpUiHostCapabilities(
+ serverResources: ServerResourcesCapability()
+ )
+)
+
+// Set up callback to forward resources/read requests
+await bridge.onResourceRead = { uri in
+ // Forward to your MCP client/server
+ let resource = try await mcpClient.readResource(uri: uri)
+
+ // Return resource in MCP ReadResourceResult format
+ return [
+ "contents": AnyCodable(resource.contents.map { content in
+ [
+ "uri": content.uri,
+ "mimeType": content.mimeType,
+ "text": content.text
+ ]
+ })
+ ]
+}
+```
+
+### Complete Example with MCP SDK
+
+```swift
+import MCP
+import McpApps
+
+// Create MCP client (connected to an MCP server)
+let mcpClient = MCPClient(/* ... */)
+try await mcpClient.connect()
+
+// Create AppBridge with forwarding callbacks
+let bridge = AppBridge(
+ hostInfo: Implementation(name: "MyApp", version: "1.0.0"),
+ hostCapabilities: McpUiHostCapabilities(
+ openLinks: true,
+ serverTools: ServerToolsCapability(),
+ serverResources: ServerResourcesCapability(),
+ logging: true
+ )
+)
+
+// Set up tool call forwarding
+await bridge.onToolCall = { toolName, arguments in
+ try await mcpClient.callTool(name: toolName, arguments: arguments)
+}
+
+// Set up resource read forwarding
+await bridge.onResourceRead = { uri in
+ try await mcpClient.readResource(uri: uri)
+}
+
+// Connect and use the bridge
+try await bridge.connect(webViewTransport)
+```
+
+## Integration with MCP SDK
+
+This SDK is designed to work with the official [Swift MCP SDK](https://github.com/modelcontextprotocol/swift-sdk). Import both packages to get full MCP functionality:
+
+```swift
+import MCP
+import McpApps
+```
+
+## License
+
+MIT License - see the main repository for details.
diff --git a/swift/Sources/McpApps/AppBridge.swift b/swift/Sources/McpApps/AppBridge.swift
new file mode 100644
index 00000000..7efe394a
--- /dev/null
+++ b/swift/Sources/McpApps/AppBridge.swift
@@ -0,0 +1,289 @@
+import Foundation
+
+/// Handler type for request callbacks.
+public typealias RequestHandler = @Sendable ([String: AnyCodable]?) async throws -> AnyCodable
+
+/// Handler type for notification callbacks.
+public typealias NotificationHandler = @Sendable ([String: AnyCodable]?) async -> Void
+
+/// Host-side bridge for communicating with a single Guest UI (App).
+public actor AppBridge {
+ private let hostInfo: Implementation
+ private let hostCapabilities: McpUiHostCapabilities
+ private var hostContext: McpUiHostContext
+ private var transport: (any McpAppsTransport)?
+
+ private var appCapabilities: McpUiAppCapabilities?
+ private var appInfo: Implementation?
+ private var isInitialized: Bool = false
+ private var nextRequestId: Int = 1
+
+ private var pendingRequests: [JSONRPCId: CheckedContinuation] = [:]
+
+ // Callbacks - using nonisolated(unsafe) for callback storage
+ nonisolated(unsafe) public var onInitialized: (@Sendable () -> Void)?
+ nonisolated(unsafe) public var onSizeChange: (@Sendable (Int?, Int?) -> Void)?
+ nonisolated(unsafe) public var onMessage: (@Sendable (String, [ContentBlock]) async -> McpUiMessageResult)?
+ nonisolated(unsafe) public var onOpenLink: (@Sendable (String) async -> McpUiOpenLinkResult)?
+ nonisolated(unsafe) public var onLoggingMessage: (@Sendable (LogLevel, AnyCodable, String?) -> Void)?
+ nonisolated(unsafe) public var onPing: (@Sendable () -> Void)?
+ nonisolated(unsafe) public var onToolCall: (@Sendable (String, [String: AnyCodable]?) async throws -> [String: AnyCodable])?
+ nonisolated(unsafe) public var onResourceRead: (@Sendable (String) async throws -> [String: AnyCodable])?
+
+ public init(
+ hostInfo: Implementation,
+ hostCapabilities: McpUiHostCapabilities,
+ options: HostOptions = HostOptions()
+ ) {
+ self.hostInfo = hostInfo
+ self.hostCapabilities = hostCapabilities
+ self.hostContext = options.hostContext
+ }
+
+ // MARK: - Connection
+
+ public func connect(_ transport: any McpAppsTransport) async throws {
+ self.transport = transport
+ try await transport.start()
+
+ Task {
+ for try await message in await transport.incoming {
+ await handleMessage(message)
+ }
+ }
+ }
+
+ public func close() async {
+ await transport?.close()
+ transport = nil
+ }
+
+ // MARK: - Message Handling
+
+ private func handleMessage(_ message: JSONRPCMessage) async {
+ switch message {
+ case .request(let request):
+ await handleRequest(request)
+ case .notification(let notification):
+ await handleNotification(notification)
+ case .response(let response):
+ handleResponse(response)
+ case .error(let error):
+ handleErrorResponse(error)
+ }
+ }
+
+ private func handleRequest(_ request: JSONRPCRequest) async {
+ do {
+ let result: AnyCodable
+ switch request.method {
+ case "ui/initialize":
+ result = try await handleInitialize(request.params)
+ case "ui/message":
+ result = try await handleMessageRequest(request.params)
+ case "ui/open-link":
+ result = try await handleOpenLink(request.params)
+ case "ping":
+ onPing?()
+ result = AnyCodable([:])
+ case "tools/call":
+ result = try await handleToolCall(request.params)
+ case "resources/read":
+ result = try await handleResourceRead(request.params)
+ default:
+ await sendError(id: request.id, code: JSONRPCError.methodNotFound, message: "Method not found: \(request.method)")
+ return
+ }
+ let response = JSONRPCResponse(id: request.id, result: result)
+ try await transport?.send(.response(response))
+ } catch {
+ await sendError(id: request.id, code: JSONRPCError.internalError, message: error.localizedDescription)
+ }
+ }
+
+ private func handleNotification(_ notification: JSONRPCNotification) async {
+ switch notification.method {
+ case "ui/notifications/initialized":
+ isInitialized = true
+ onInitialized?()
+ case "ui/notifications/size-changed":
+ let width = notification.params?["width"]?.value as? Int
+ let height = notification.params?["height"]?.value as? Int
+ onSizeChange?(width, height)
+ case "notifications/message":
+ if let level = notification.params?["level"]?.value as? String,
+ let logLevel = LogLevel(rawValue: level),
+ let data = notification.params?["data"] {
+ let logger = notification.params?["logger"]?.value as? String
+ onLoggingMessage?(logLevel, data, logger)
+ }
+ default:
+ break
+ }
+ }
+
+ private func handleResponse(_ response: JSONRPCResponse) {
+ pendingRequests.removeValue(forKey: response.id)?.resume(returning: response.result)
+ }
+
+ private func handleErrorResponse(_ response: JSONRPCErrorResponse) {
+ if let id = response.id {
+ pendingRequests.removeValue(forKey: id)?.resume(throwing: BridgeError.rpcError(response.error))
+ }
+ }
+
+ // MARK: - Request Handlers
+
+ private func handleInitialize(_ params: [String: AnyCodable]?) async throws -> AnyCodable {
+ let data = try JSONSerialization.data(withJSONObject: params?.mapValues { $0.value } ?? [:])
+ let initParams = try JSONDecoder().decode(McpUiInitializeParams.self, from: data)
+
+ appCapabilities = initParams.appCapabilities
+ appInfo = initParams.appInfo
+
+ let protocolVersion = McpAppsConfig.supportedProtocolVersions.contains(initParams.protocolVersion)
+ ? initParams.protocolVersion
+ : McpAppsConfig.latestProtocolVersion
+
+ let result = McpUiInitializeResult(
+ protocolVersion: protocolVersion,
+ hostInfo: hostInfo,
+ hostCapabilities: hostCapabilities,
+ hostContext: hostContext
+ )
+
+ let resultData = try JSONEncoder().encode(result)
+ let resultDict = try JSONSerialization.jsonObject(with: resultData) as? [String: Any] ?? [:]
+ return AnyCodable(resultDict)
+ }
+
+ private func handleMessageRequest(_ params: [String: AnyCodable]?) async throws -> AnyCodable {
+ let data = try JSONSerialization.data(withJSONObject: params?.mapValues { $0.value } ?? [:])
+ let msgParams = try JSONDecoder().decode(McpUiMessageParams.self, from: data)
+ let result = await onMessage?(msgParams.role, msgParams.content) ?? McpUiMessageResult(isError: true)
+ return AnyCodable(["isError": result.isError ?? false])
+ }
+
+ private func handleOpenLink(_ params: [String: AnyCodable]?) async throws -> AnyCodable {
+ let data = try JSONSerialization.data(withJSONObject: params?.mapValues { $0.value } ?? [:])
+ let linkParams = try JSONDecoder().decode(McpUiOpenLinkParams.self, from: data)
+ let result = await onOpenLink?(linkParams.url) ?? McpUiOpenLinkResult(isError: true)
+ return AnyCodable(["isError": result.isError ?? false])
+ }
+
+ private func handleToolCall(_ params: [String: AnyCodable]?) async throws -> AnyCodable {
+ guard let callback = onToolCall else {
+ throw BridgeError.rpcError(JSONRPCError(code: JSONRPCError.methodNotFound, message: "tools/call not configured"))
+ }
+ guard let name = params?["name"]?.value as? String else {
+ throw BridgeError.rpcError(JSONRPCError(code: JSONRPCError.invalidParams, message: "Missing tool name"))
+ }
+ var arguments: [String: AnyCodable]? = nil
+ if let argsDict = params?["arguments"]?.value as? [String: Any] {
+ arguments = argsDict.mapValues { AnyCodable($0) }
+ }
+ let result = try await callback(name, arguments)
+ return AnyCodable(result.mapValues { $0.value })
+ }
+
+ private func handleResourceRead(_ params: [String: AnyCodable]?) async throws -> AnyCodable {
+ guard let callback = onResourceRead else {
+ throw BridgeError.rpcError(JSONRPCError(code: JSONRPCError.methodNotFound, message: "resources/read not configured"))
+ }
+ guard let uri = params?["uri"]?.value as? String else {
+ throw BridgeError.rpcError(JSONRPCError(code: JSONRPCError.invalidParams, message: "Missing URI"))
+ }
+ let result = try await callback(uri)
+ return AnyCodable(result.mapValues { $0.value })
+ }
+
+ private func sendError(id: JSONRPCId, code: Int, message: String) async {
+ let error = JSONRPCErrorResponse(id: id, error: JSONRPCError(code: code, message: message))
+ try? await transport?.send(.error(error))
+ }
+
+ // MARK: - Public API
+
+ public func getAppCapabilities() -> McpUiAppCapabilities? { appCapabilities }
+ public func getAppVersion() -> Implementation? { appInfo }
+ public func isReady() -> Bool { isInitialized }
+
+ public func sendToolInput(arguments: [String: AnyCodable]?) async throws {
+ try await sendNotification(method: "ui/notifications/tool-input",
+ params: ["arguments": AnyCodable(arguments?.mapValues { $0.value } ?? [:])])
+ }
+
+ public func sendToolInputPartial(arguments: [String: AnyCodable]?) async throws {
+ try await sendNotification(method: "ui/notifications/tool-input-partial",
+ params: ["arguments": AnyCodable(arguments?.mapValues { $0.value } ?? [:])])
+ }
+
+ public func sendToolResult(_ result: [String: AnyCodable]) async throws {
+ try await sendNotification(method: "ui/notifications/tool-result", params: result)
+ }
+
+ public func setHostContext(_ newContext: McpUiHostContext) async throws {
+ guard newContext != hostContext else { return }
+ hostContext = newContext
+ let data = try JSONEncoder().encode(newContext)
+ let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
+ try await sendNotification(method: "ui/notifications/host-context-changed",
+ params: dict.mapValues { AnyCodable($0) })
+ }
+
+ /// Request the App to perform cleanup before the resource is torn down.
+ ///
+ /// - Parameter timeout: Maximum time to wait for the App to respond (default 0.5s)
+ public func sendResourceTeardown(timeout: TimeInterval = 0.5) async throws -> McpUiResourceTeardownResult {
+ _ = try await sendRequest(method: "ui/resource-teardown", params: [:], timeout: timeout)
+ return McpUiResourceTeardownResult()
+ }
+
+ // MARK: - Helpers
+
+ private func sendNotification(method: String, params: [String: AnyCodable]?) async throws {
+ try await transport?.send(.notification(JSONRPCNotification(method: method, params: params)))
+ }
+
+ private func sendRequest(method: String, params: [String: AnyCodable]?, timeout: TimeInterval = 30) async throws -> AnyCodable {
+ let id = JSONRPCId.number(nextRequestId)
+ nextRequestId += 1
+ let request = JSONRPCRequest(id: id, method: method, params: params)
+
+ guard let transport = transport else {
+ throw BridgeError.disconnected
+ }
+
+ // Create the continuation and store it
+ let result: AnyCodable = try await withCheckedThrowingContinuation { continuation in
+ pendingRequests[id] = continuation
+
+ // Start timeout task
+ Task {
+ try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
+ // If still pending after timeout, fail it
+ await self.failPendingRequest(id: id, error: BridgeError.timeout)
+ }
+
+ // Send the request
+ Task {
+ do {
+ try await transport.send(.request(request))
+ } catch {
+ await self.failPendingRequest(id: id, error: error)
+ }
+ }
+ }
+ return result
+ }
+
+ private func failPendingRequest(id: JSONRPCId, error: Error) {
+ pendingRequests.removeValue(forKey: id)?.resume(throwing: error)
+ }
+}
+
+public enum BridgeError: Error {
+ case disconnected
+ case rpcError(JSONRPCError)
+ case timeout
+}
diff --git a/swift/Sources/McpApps/Config.swift b/swift/Sources/McpApps/Config.swift
new file mode 100644
index 00000000..fedf151c
--- /dev/null
+++ b/swift/Sources/McpApps/Config.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+/// MCP Apps SDK configuration and constants.
+public enum McpAppsConfig {
+ /// Current protocol version supported by this SDK.
+ ///
+ /// The SDK automatically handles version negotiation during initialization.
+ /// Apps and hosts don't need to manage protocol versions manually.
+ public static let latestProtocolVersion = "2025-11-21"
+
+ /// Supported protocol versions for negotiation.
+ public static let supportedProtocolVersions = [latestProtocolVersion]
+
+ /// MIME type for MCP UI resources.
+ public static let resourceMimeType = "text/html;profile=mcp-app"
+
+ /// Metadata key for associating a resource URI with a tool call.
+ public static let resourceUriMetaKey = "ui/resourceUri"
+}
diff --git a/swift/Sources/McpApps/Generated/SchemaTypes.swift b/swift/Sources/McpApps/Generated/SchemaTypes.swift
new file mode 100644
index 00000000..d9adf130
--- /dev/null
+++ b/swift/Sources/McpApps/Generated/SchemaTypes.swift
@@ -0,0 +1,1898 @@
+// Generated from src/generated/schema.json
+// DO NOT EDIT - Run: npx tsx scripts/generate-swift-types.ts
+
+import Foundation
+
+// MARK: - Helper Types
+
+/// Empty capability marker (matches TypeScript `{}`)
+public struct EmptyCapability: Codable, Sendable, Equatable {
+ public init() {}
+}
+
+/// Type-erased value for dynamic JSON
+public struct AnyCodable: Codable, Equatable, @unchecked Sendable {
+ public let value: Any
+
+ public init(_ value: Any) { self.value = value }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() { self.value = NSNull() }
+ else if let bool = try? container.decode(Bool.self) { self.value = bool }
+ else if let int = try? container.decode(Int.self) { self.value = int }
+ else if let double = try? container.decode(Double.self) { self.value = double }
+ else if let string = try? container.decode(String.self) { self.value = string }
+ else if let array = try? container.decode([AnyCodable].self) { self.value = array.map { $0.value } }
+ else if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict.mapValues { $0.value } }
+ else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode") }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch value {
+ case is NSNull: try container.encodeNil()
+ case let v as Bool: try container.encode(v)
+ case let v as Int: try container.encode(v)
+ case let v as Double: try container.encode(v)
+ case let v as String: try container.encode(v)
+ case let v as [Any]: try container.encode(v.map { AnyCodable($0) })
+ case let v as [String: Any]: try container.encode(v.mapValues { AnyCodable($0) })
+ default: throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "Cannot encode"))
+ }
+ }
+
+ public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
+ switch (lhs.value, rhs.value) {
+ case is (NSNull, NSNull): return true
+ case let (l as Bool, r as Bool): return l == r
+ case let (l as Int, r as Int): return l == r
+ case let (l as Double, r as Double): return l == r
+ case let (l as String, r as String): return l == r
+ default: return false
+ }
+ }
+}
+
+/// Application/host identification
+public struct Implementation: Codable, Sendable, Equatable {
+ public var name: String
+ public var version: String
+ public var title: String?
+
+ public init(name: String, version: String, title: String? = nil) {
+ self.name = name
+ self.version = version
+ self.title = title
+ }
+}
+
+/// Text content block
+public struct TextContent: Codable, Sendable {
+ public var type: String = "text"
+ public var text: String
+ public init(text: String) { self.text = text }
+}
+
+/// Log level
+public enum LogLevel: String, Codable, Sendable {
+ case debug, info, notice, warning, error, critical, alert, emergency
+}
+
+/// Host options
+public struct HostOptions: Sendable {
+ public var hostContext: McpUiHostContext
+ public init(hostContext: McpUiHostContext = McpUiHostContext()) {
+ self.hostContext = hostContext
+ }
+}
+
+/// CSP configuration
+public struct CspConfig: Codable, Sendable {
+ public var connectDomains: [String]?
+ public var resourceDomains: [String]?
+ public init(connectDomains: [String]? = nil, resourceDomains: [String]? = nil) {
+ self.connectDomains = connectDomains
+ self.resourceDomains = resourceDomains
+ }
+}
+
+// MARK: - Type Aliases for Compatibility
+
+public typealias McpUiInitializeParams = McpUiInitializeRequestParams
+public typealias McpUiMessageParams = McpUiMessageRequestParams
+public typealias McpUiOpenLinkParams = McpUiOpenLinkRequestParams
+public typealias ServerToolsCapability = McpUiHostCapabilitiesServerTools
+public typealias ServerResourcesCapability = McpUiHostCapabilitiesServerResources
+public typealias AppToolsCapability = McpUiAppCapabilitiesTools
+public typealias ContentBlock = McpUiMessageRequestParamsContentItem
+public typealias CallToolResult = McpUiToolResultNotificationParams
+public typealias ResourceContent = McpUiMessageRequestParamsContentItemResourceResource
+
+// MARK: - Generated Types
+
+/// App exposes MCP-style tools that the host can call.
+public struct McpUiAppCapabilitiesTools: Codable, Sendable, Equatable {
+ /// App supports tools/list_changed notifications.
+ public var listChanged: Bool?
+
+ public init(
+ listChanged: Bool? = nil
+ ) {
+ self.listChanged = listChanged
+ }
+}
+
+public struct McpUiAppCapabilities: Codable, Sendable, Equatable {
+ /// Experimental features (structure TBD).
+ public var experimental: EmptyCapability?
+ /// App exposes MCP-style tools that the host can call.
+ public var tools: McpUiAppCapabilitiesTools?
+
+ public init(
+ experimental: EmptyCapability? = nil,
+ tools: McpUiAppCapabilitiesTools? = nil
+ ) {
+ self.experimental = experimental
+ self.tools = tools
+ }
+}
+
+/// Display mode for UI presentation.
+public enum McpUiDisplayMode: String, Codable, Sendable, Equatable {
+ case inline = "inline"
+ case fullscreen = "fullscreen"
+ case pip = "pip"
+}
+
+/// Host can proxy tool calls to the MCP server.
+public struct McpUiHostCapabilitiesServerTools: Codable, Sendable, Equatable {
+ /// Host supports tools/list_changed notifications.
+ public var listChanged: Bool?
+
+ public init(
+ listChanged: Bool? = nil
+ ) {
+ self.listChanged = listChanged
+ }
+}
+
+/// Host can proxy resource reads to the MCP server.
+public struct McpUiHostCapabilitiesServerResources: Codable, Sendable, Equatable {
+ /// Host supports resources/list_changed notifications.
+ public var listChanged: Bool?
+
+ public init(
+ listChanged: Bool? = nil
+ ) {
+ self.listChanged = listChanged
+ }
+}
+
+public struct McpUiHostCapabilities: Codable, Sendable, Equatable {
+ /// Experimental features (structure TBD).
+ public var experimental: EmptyCapability?
+ /// Host supports opening external URLs.
+ public var openLinks: EmptyCapability?
+ /// Host can proxy tool calls to the MCP server.
+ public var serverTools: McpUiHostCapabilitiesServerTools?
+ /// Host can proxy resource reads to the MCP server.
+ public var serverResources: McpUiHostCapabilitiesServerResources?
+ /// Host accepts log messages.
+ public var logging: EmptyCapability?
+
+ public init(
+ experimental: EmptyCapability? = nil,
+ openLinks: EmptyCapability? = nil,
+ serverTools: McpUiHostCapabilitiesServerTools? = nil,
+ serverResources: McpUiHostCapabilitiesServerResources? = nil,
+ logging: EmptyCapability? = nil
+ ) {
+ self.experimental = experimental
+ self.openLinks = openLinks
+ self.serverTools = serverTools
+ self.serverResources = serverResources
+ self.logging = logging
+ }
+}
+
+public struct McpUiHostContextChangedNotificationParamsToolInfoToolIconsItem: Codable, Sendable, Equatable {
+ public var src: String
+ public var mimeType: String?
+ public var sizes: [String]?
+
+ public init(
+ src: String,
+ mimeType: String? = nil,
+ sizes: [String]? = nil
+ ) {
+ self.src = src
+ self.mimeType = mimeType
+ self.sizes = sizes
+ }
+}
+
+public struct McpUiHostContextChangedNotificationParamsToolInfoToolInputSchema: Codable, Sendable, Equatable {
+ public var type: String
+ public var properties: [String: AnyCodable]?
+ public var required: [String]?
+
+ public init(
+ type: String,
+ properties: [String: AnyCodable]? = nil,
+ required: [String]? = nil
+ ) {
+ self.type = type
+ self.properties = properties
+ self.required = required
+ }
+}
+
+public struct McpUiHostContextChangedNotificationParamsToolInfoToolOutputSchema: Codable, Sendable, Equatable {
+ public var type: String
+ public var properties: [String: AnyCodable]?
+ public var required: [String]?
+
+ public init(
+ type: String,
+ properties: [String: AnyCodable]? = nil,
+ required: [String]? = nil
+ ) {
+ self.type = type
+ self.properties = properties
+ self.required = required
+ }
+}
+
+public struct McpUiHostContextChangedNotificationParamsToolInfoToolAnnotations: Codable, Sendable, Equatable {
+ public var title: String?
+ public var readOnlyHint: Bool?
+ public var destructiveHint: Bool?
+ public var idempotentHint: Bool?
+ public var openWorldHint: Bool?
+
+ public init(
+ title: String? = nil,
+ readOnlyHint: Bool? = nil,
+ destructiveHint: Bool? = nil,
+ idempotentHint: Bool? = nil,
+ openWorldHint: Bool? = nil
+ ) {
+ self.title = title
+ self.readOnlyHint = readOnlyHint
+ self.destructiveHint = destructiveHint
+ self.idempotentHint = idempotentHint
+ self.openWorldHint = openWorldHint
+ }
+}
+
+public struct McpUiHostContextChangedNotificationParamsToolInfoToolExecution: Codable, Sendable, Equatable {
+ public var taskSupport: String?
+
+ public init(
+ taskSupport: String? = nil
+ ) {
+ self.taskSupport = taskSupport
+ }
+}
+
+/// Tool definition including name, inputSchema, etc.
+public struct McpUiHostContextChangedNotificationParamsToolInfoTool: Codable, Sendable, Equatable {
+ public var name: String
+ public var title: String?
+ public var icons: [McpUiHostContextChangedNotificationParamsToolInfoToolIconsItem]?
+ public var description: String?
+ public var inputSchema: McpUiHostContextChangedNotificationParamsToolInfoToolInputSchema
+ public var outputSchema: McpUiHostContextChangedNotificationParamsToolInfoToolOutputSchema?
+ public var annotations: McpUiHostContextChangedNotificationParamsToolInfoToolAnnotations?
+ public var execution: McpUiHostContextChangedNotificationParamsToolInfoToolExecution?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case name
+ case title
+ case icons
+ case description
+ case inputSchema
+ case outputSchema
+ case annotations
+ case execution
+ case meta = "_meta"
+ }
+
+ public init(
+ name: String,
+ title: String? = nil,
+ icons: [McpUiHostContextChangedNotificationParamsToolInfoToolIconsItem]? = nil,
+ description: String? = nil,
+ inputSchema: McpUiHostContextChangedNotificationParamsToolInfoToolInputSchema,
+ outputSchema: McpUiHostContextChangedNotificationParamsToolInfoToolOutputSchema? = nil,
+ annotations: McpUiHostContextChangedNotificationParamsToolInfoToolAnnotations? = nil,
+ execution: McpUiHostContextChangedNotificationParamsToolInfoToolExecution? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.name = name
+ self.title = title
+ self.icons = icons
+ self.description = description
+ self.inputSchema = inputSchema
+ self.outputSchema = outputSchema
+ self.annotations = annotations
+ self.execution = execution
+ self.meta = meta
+ }
+}
+
+/// Metadata of the tool call that instantiated this App.
+public struct McpUiHostContextChangedNotificationParamsToolInfo: Codable, Sendable, Equatable {
+ /// JSON-RPC id of the tools/call request.
+ public var id: AnyCodable
+ /// Tool definition including name, inputSchema, etc.
+ public var tool: McpUiHostContextChangedNotificationParamsToolInfoTool
+
+ public init(
+ id: AnyCodable,
+ tool: McpUiHostContextChangedNotificationParamsToolInfoTool
+ ) {
+ self.id = id
+ self.tool = tool
+ }
+}
+
+/// Current color theme preference.
+public enum McpUiTheme: String, Codable, Sendable, Equatable {
+ case light = "light"
+ case dark = "dark"
+}
+
+/// CSS blocks that apps can inject.
+public struct McpUiHostContextChangedNotificationParamsStylesCss: Codable, Sendable, Equatable {
+ /// CSS for font loading (@font-face rules or
+ public var fonts: String?
+
+ public init(
+ fonts: String? = nil
+ ) {
+ self.fonts = fonts
+ }
+}
+
+/// Style configuration for theming the app.
+public struct McpUiHostContextChangedNotificationParamsStyles: Codable, Sendable, Equatable {
+ /// CSS variables for theming the app.
+ public var variables: [String: AnyCodable]?
+ /// CSS blocks that apps can inject.
+ public var css: McpUiHostContextChangedNotificationParamsStylesCss?
+
+ public init(
+ variables: [String: AnyCodable]? = nil,
+ css: McpUiHostContextChangedNotificationParamsStylesCss? = nil
+ ) {
+ self.variables = variables
+ self.css = css
+ }
+}
+
+/// Current and maximum dimensions available to the UI.
+public struct Viewport: Codable, Sendable, Equatable {
+ /// Current viewport width in pixels.
+ public var width: Double
+ /// Current viewport height in pixels.
+ public var height: Double
+ /// Maximum available height in pixels (if constrained).
+ public var maxHeight: Double?
+ /// Maximum available width in pixels (if constrained).
+ public var maxWidth: Double?
+
+ public init(
+ width: Double,
+ height: Double,
+ maxHeight: Double? = nil,
+ maxWidth: Double? = nil
+ ) {
+ self.width = width
+ self.height = height
+ self.maxHeight = maxHeight
+ self.maxWidth = maxWidth
+ }
+}
+
+/// Platform type for responsive design decisions.
+public enum McpUiPlatform: String, Codable, Sendable, Equatable {
+ case web = "web"
+ case desktop = "desktop"
+ case mobile = "mobile"
+}
+
+/// Device input capabilities.
+public struct DeviceCapabilities: Codable, Sendable, Equatable {
+ /// Whether the device supports touch input.
+ public var touch: Bool?
+ /// Whether the device supports hover interactions.
+ public var hover: Bool?
+
+ public init(
+ touch: Bool? = nil,
+ hover: Bool? = nil
+ ) {
+ self.touch = touch
+ self.hover = hover
+ }
+}
+
+/// Mobile safe area boundaries in pixels.
+public struct SafeAreaInsets: Codable, Sendable, Equatable {
+ /// Top safe area inset in pixels.
+ public var top: Double
+ /// Right safe area inset in pixels.
+ public var right: Double
+ /// Bottom safe area inset in pixels.
+ public var bottom: Double
+ /// Left safe area inset in pixels.
+ public var left: Double
+
+ public init(
+ top: Double,
+ right: Double,
+ bottom: Double,
+ left: Double
+ ) {
+ self.top = top
+ self.right = right
+ self.bottom = bottom
+ self.left = left
+ }
+}
+
+/// Partial context update containing only changed fields.
+public struct McpUiHostContext: Codable, Sendable, Equatable {
+ /// Metadata of the tool call that instantiated this App.
+ public var toolInfo: McpUiHostContextChangedNotificationParamsToolInfo?
+ /// Current color theme preference.
+ public var theme: McpUiTheme?
+ /// Style configuration for theming the app.
+ public var styles: McpUiHostContextChangedNotificationParamsStyles?
+ /// How the UI is currently displayed.
+ public var displayMode: McpUiDisplayMode?
+ /// Display modes the host supports.
+ public var availableDisplayModes: [String]?
+ /// Current and maximum dimensions available to the UI.
+ public var viewport: Viewport?
+ /// User's language and region preference in BCP 47 format.
+ public var locale: String?
+ /// User's timezone in IANA format.
+ public var timeZone: String?
+ /// Host application identifier.
+ public var userAgent: String?
+ /// Platform type for responsive design decisions.
+ public var platform: McpUiPlatform?
+ /// Device input capabilities.
+ public var deviceCapabilities: DeviceCapabilities?
+ /// Mobile safe area boundaries in pixels.
+ public var safeAreaInsets: SafeAreaInsets?
+
+ public init(
+ toolInfo: McpUiHostContextChangedNotificationParamsToolInfo? = nil,
+ theme: McpUiTheme? = nil,
+ styles: McpUiHostContextChangedNotificationParamsStyles? = nil,
+ displayMode: McpUiDisplayMode? = nil,
+ availableDisplayModes: [String]? = nil,
+ viewport: Viewport? = nil,
+ locale: String? = nil,
+ timeZone: String? = nil,
+ userAgent: String? = nil,
+ platform: McpUiPlatform? = nil,
+ deviceCapabilities: DeviceCapabilities? = nil,
+ safeAreaInsets: SafeAreaInsets? = nil
+ ) {
+ self.toolInfo = toolInfo
+ self.theme = theme
+ self.styles = styles
+ self.displayMode = displayMode
+ self.availableDisplayModes = availableDisplayModes
+ self.viewport = viewport
+ self.locale = locale
+ self.timeZone = timeZone
+ self.userAgent = userAgent
+ self.platform = platform
+ self.deviceCapabilities = deviceCapabilities
+ self.safeAreaInsets = safeAreaInsets
+ }
+}
+
+public struct McpUiHostContextChangedNotification: Codable, Sendable, Equatable {
+ public var method: String
+ /// Partial context update containing only changed fields.
+ public var params: McpUiHostContext
+
+ public init(
+ method: String,
+ params: McpUiHostContext
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiHostCss: Codable, Sendable, Equatable {
+ /// CSS for font loading (@font-face rules or
+ public var fonts: String?
+
+ public init(
+ fonts: String? = nil
+ ) {
+ self.fonts = fonts
+ }
+}
+
+/// CSS blocks that apps can inject.
+public struct McpUiHostStylesCss: Codable, Sendable, Equatable {
+ /// CSS for font loading (@font-face rules or
+ public var fonts: String?
+
+ public init(
+ fonts: String? = nil
+ ) {
+ self.fonts = fonts
+ }
+}
+
+public struct McpUiHostStyles: Codable, Sendable, Equatable {
+ /// CSS variables for theming the app.
+ public var variables: [String: AnyCodable]?
+ /// CSS blocks that apps can inject.
+ public var css: McpUiHostStylesCss?
+
+ public init(
+ variables: [String: AnyCodable]? = nil,
+ css: McpUiHostStylesCss? = nil
+ ) {
+ self.variables = variables
+ self.css = css
+ }
+}
+
+public struct McpUiInitializeRequestParams: Codable, Sendable, Equatable {
+ /// App identification (name and version).
+ public var appInfo: Implementation
+ /// Features and capabilities this app provides.
+ public var appCapabilities: McpUiAppCapabilities
+ /// Protocol version this app supports.
+ public var protocolVersion: String
+
+ public init(
+ appInfo: Implementation,
+ appCapabilities: McpUiAppCapabilities,
+ protocolVersion: String
+ ) {
+ self.appInfo = appInfo
+ self.appCapabilities = appCapabilities
+ self.protocolVersion = protocolVersion
+ }
+}
+
+public struct McpUiInitializeRequest: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiInitializeRequestParams
+
+ public init(
+ method: String,
+ params: McpUiInitializeRequestParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiInitializeResult: Codable, Sendable, Equatable {
+ /// Negotiated protocol version string (e.g., "2025-11-21").
+ public var protocolVersion: String
+ /// Host application identification and version.
+ public var hostInfo: Implementation
+ /// Features and capabilities provided by the host.
+ public var hostCapabilities: McpUiHostCapabilities
+ /// Rich context about the host environment.
+ public var hostContext: McpUiHostContext
+
+ public init(
+ protocolVersion: String,
+ hostInfo: Implementation,
+ hostCapabilities: McpUiHostCapabilities,
+ hostContext: McpUiHostContext
+ ) {
+ self.protocolVersion = protocolVersion
+ self.hostInfo = hostInfo
+ self.hostCapabilities = hostCapabilities
+ self.hostContext = hostContext
+ }
+}
+
+public struct McpUiInitializedNotification: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: EmptyCapability?
+
+ public init(
+ method: String,
+ params: EmptyCapability? = nil
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemTextAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemText: Codable, Sendable, Equatable {
+ public var type: String
+ public var text: String
+ public var annotations: McpUiMessageRequestParamsContentItemTextAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case text
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ text: String,
+ annotations: McpUiMessageRequestParamsContentItemTextAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.text = text
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemImageAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemImage: Codable, Sendable, Equatable {
+ public var type: String
+ public var data: String
+ public var mimeType: String
+ public var annotations: McpUiMessageRequestParamsContentItemImageAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case data
+ case mimeType
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ data: String,
+ mimeType: String,
+ annotations: McpUiMessageRequestParamsContentItemImageAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.data = data
+ self.mimeType = mimeType
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemAudioAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemAudio: Codable, Sendable, Equatable {
+ public var type: String
+ public var data: String
+ public var mimeType: String
+ public var annotations: McpUiMessageRequestParamsContentItemAudioAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case data
+ case mimeType
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ data: String,
+ mimeType: String,
+ annotations: McpUiMessageRequestParamsContentItemAudioAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.data = data
+ self.mimeType = mimeType
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemResourcelinkIconsItem: Codable, Sendable, Equatable {
+ public var src: String
+ public var mimeType: String?
+ public var sizes: [String]?
+
+ public init(
+ src: String,
+ mimeType: String? = nil,
+ sizes: [String]? = nil
+ ) {
+ self.src = src
+ self.mimeType = mimeType
+ self.sizes = sizes
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemResourcelinkAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemResourcelink: Codable, Sendable, Equatable {
+ public var name: String
+ public var title: String?
+ public var icons: [McpUiMessageRequestParamsContentItemResourcelinkIconsItem]?
+ public var uri: String
+ public var description: String?
+ public var mimeType: String?
+ public var annotations: McpUiMessageRequestParamsContentItemResourcelinkAnnotations?
+ public var meta: [String: AnyCodable]?
+ public var type: String
+
+ private enum CodingKeys: String, CodingKey {
+ case name
+ case title
+ case icons
+ case uri
+ case description
+ case mimeType
+ case annotations
+ case meta = "_meta"
+ case type
+ }
+
+ public init(
+ name: String,
+ title: String? = nil,
+ icons: [McpUiMessageRequestParamsContentItemResourcelinkIconsItem]? = nil,
+ uri: String,
+ description: String? = nil,
+ mimeType: String? = nil,
+ annotations: McpUiMessageRequestParamsContentItemResourcelinkAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil,
+ type: String
+ ) {
+ self.name = name
+ self.title = title
+ self.icons = icons
+ self.uri = uri
+ self.description = description
+ self.mimeType = mimeType
+ self.annotations = annotations
+ self.meta = meta
+ self.type = type
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemResourceResourceText: Codable, Sendable, Equatable {
+ public var uri: String
+ public var mimeType: String?
+ public var meta: [String: AnyCodable]?
+ public var text: String
+
+ private enum CodingKeys: String, CodingKey {
+ case uri
+ case mimeType
+ case meta = "_meta"
+ case text
+ }
+
+ public init(
+ uri: String,
+ mimeType: String? = nil,
+ meta: [String: AnyCodable]? = nil,
+ text: String
+ ) {
+ self.uri = uri
+ self.mimeType = mimeType
+ self.meta = meta
+ self.text = text
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemResourceResourceBlob: Codable, Sendable, Equatable {
+ public var uri: String
+ public var mimeType: String?
+ public var meta: [String: AnyCodable]?
+ public var blob: String
+
+ private enum CodingKeys: String, CodingKey {
+ case uri
+ case mimeType
+ case meta = "_meta"
+ case blob
+ }
+
+ public init(
+ uri: String,
+ mimeType: String? = nil,
+ meta: [String: AnyCodable]? = nil,
+ blob: String
+ ) {
+ self.uri = uri
+ self.mimeType = mimeType
+ self.meta = meta
+ self.blob = blob
+ }
+}
+
+public enum McpUiMessageRequestParamsContentItemResourceResource: Codable, Sendable, Equatable {
+ case text(McpUiMessageRequestParamsContentItemResourceResourceText)
+ case blob(McpUiMessageRequestParamsContentItemResourceResourceBlob)
+
+ public init(from decoder: Decoder) throws {
+ // Try decoding each variant
+ if let v = try? McpUiMessageRequestParamsContentItemResourceResourceText(from: decoder) {
+ self = .text(v)
+ } else if let v = try? McpUiMessageRequestParamsContentItemResourceResourceBlob(from: decoder) {
+ self = .blob(v)
+ } else {
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot decode McpUiMessageRequestParamsContentItemResourceResource"))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .text(let v): try v.encode(to: encoder)
+ case .blob(let v): try v.encode(to: encoder)
+ }
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemResourceAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiMessageRequestParamsContentItemResource: Codable, Sendable, Equatable {
+ public var type: String
+ public var resource: McpUiMessageRequestParamsContentItemResourceResource
+ public var annotations: McpUiMessageRequestParamsContentItemResourceAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case resource
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ resource: McpUiMessageRequestParamsContentItemResourceResource,
+ annotations: McpUiMessageRequestParamsContentItemResourceAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.resource = resource
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public enum McpUiMessageRequestParamsContentItem: Codable, Sendable, Equatable {
+ case text(McpUiMessageRequestParamsContentItemText)
+ case image(McpUiMessageRequestParamsContentItemImage)
+ case audio(McpUiMessageRequestParamsContentItemAudio)
+ case resourcelink(McpUiMessageRequestParamsContentItemResourcelink)
+ case resource(McpUiMessageRequestParamsContentItemResource)
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: .type)
+ switch type {
+ case "text": self = .text(try McpUiMessageRequestParamsContentItemText(from: decoder))
+ case "image": self = .image(try McpUiMessageRequestParamsContentItemImage(from: decoder))
+ case "audio": self = .audio(try McpUiMessageRequestParamsContentItemAudio(from: decoder))
+ case "resource_link": self = .resourcelink(try McpUiMessageRequestParamsContentItemResourcelink(from: decoder))
+ case "resource": self = .resource(try McpUiMessageRequestParamsContentItemResource(from: decoder))
+ default:
+ throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)")
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .text(let v): try v.encode(to: encoder)
+ case .image(let v): try v.encode(to: encoder)
+ case .audio(let v): try v.encode(to: encoder)
+ case .resourcelink(let v): try v.encode(to: encoder)
+ case .resource(let v): try v.encode(to: encoder)
+ }
+ }
+}
+
+public struct McpUiMessageRequestParams: Codable, Sendable, Equatable {
+ /// Message role, currently only "user" is supported.
+ public var role: String
+ /// Message content blocks (text, image, etc.).
+ public var content: [McpUiMessageRequestParamsContentItem]
+
+ public init(
+ role: String,
+ content: [McpUiMessageRequestParamsContentItem]
+ ) {
+ self.role = role
+ self.content = content
+ }
+}
+
+public struct McpUiMessageRequest: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiMessageRequestParams
+
+ public init(
+ method: String,
+ params: McpUiMessageRequestParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiMessageResult: Codable, Sendable, Equatable {
+ /// True if the host rejected or failed to deliver the message.
+ public var isError: Bool?
+
+ public init(
+ isError: Bool? = nil
+ ) {
+ self.isError = isError
+ }
+}
+
+public struct McpUiOpenLinkRequestParams: Codable, Sendable, Equatable {
+ /// URL to open in the host's browser
+ public var url: String
+
+ public init(
+ url: String
+ ) {
+ self.url = url
+ }
+}
+
+public struct McpUiOpenLinkRequest: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiOpenLinkRequestParams
+
+ public init(
+ method: String,
+ params: McpUiOpenLinkRequestParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiOpenLinkResult: Codable, Sendable, Equatable {
+ /// True if the host failed to open the URL (e.g., due to security policy).
+ public var isError: Bool?
+
+ public init(
+ isError: Bool? = nil
+ ) {
+ self.isError = isError
+ }
+}
+
+/// The display mode being requested.
+public enum McpUiRequestDisplayModeRequestParamsMode: String, Codable, Sendable, Equatable {
+ case inline = "inline"
+ case fullscreen = "fullscreen"
+ case pip = "pip"
+}
+
+public struct McpUiRequestDisplayModeRequestParams: Codable, Sendable, Equatable {
+ /// The display mode being requested.
+ public var mode: McpUiRequestDisplayModeRequestParamsMode
+
+ public init(
+ mode: McpUiRequestDisplayModeRequestParamsMode
+ ) {
+ self.mode = mode
+ }
+}
+
+public struct McpUiRequestDisplayModeRequest: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiRequestDisplayModeRequestParams
+
+ public init(
+ method: String,
+ params: McpUiRequestDisplayModeRequestParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+/// The display mode that was actually set. May differ from requested if not supported.
+public enum McpUiRequestDisplayModeResultMode: String, Codable, Sendable, Equatable {
+ case inline = "inline"
+ case fullscreen = "fullscreen"
+ case pip = "pip"
+}
+
+public struct McpUiRequestDisplayModeResult: Codable, Sendable, Equatable {
+ /// The display mode that was actually set. May differ from requested if not supported.
+ public var mode: McpUiRequestDisplayModeResultMode
+
+ public init(
+ mode: McpUiRequestDisplayModeResultMode
+ ) {
+ self.mode = mode
+ }
+}
+
+public struct McpUiResourceCsp: Codable, Sendable, Equatable {
+ /// Origins for network requests (fetch/XHR/WebSocket).
+ public var connectDomains: [String]?
+ /// Origins for static resources (scripts, images, styles, fonts).
+ public var resourceDomains: [String]?
+
+ public init(
+ connectDomains: [String]? = nil,
+ resourceDomains: [String]? = nil
+ ) {
+ self.connectDomains = connectDomains
+ self.resourceDomains = resourceDomains
+ }
+}
+
+/// Content Security Policy configuration.
+public struct McpUiResourceMetaCsp: Codable, Sendable, Equatable {
+ /// Origins for network requests (fetch/XHR/WebSocket).
+ public var connectDomains: [String]?
+ /// Origins for static resources (scripts, images, styles, fonts).
+ public var resourceDomains: [String]?
+
+ public init(
+ connectDomains: [String]? = nil,
+ resourceDomains: [String]? = nil
+ ) {
+ self.connectDomains = connectDomains
+ self.resourceDomains = resourceDomains
+ }
+}
+
+public struct McpUiResourceMeta: Codable, Sendable, Equatable {
+ /// Content Security Policy configuration.
+ public var csp: McpUiResourceMetaCsp?
+ /// Dedicated origin for widget sandbox.
+ public var domain: String?
+ /// Visual boundary preference - true if UI prefers a visible border.
+ public var prefersBorder: Bool?
+
+ public init(
+ csp: McpUiResourceMetaCsp? = nil,
+ domain: String? = nil,
+ prefersBorder: Bool? = nil
+ ) {
+ self.csp = csp
+ self.domain = domain
+ self.prefersBorder = prefersBorder
+ }
+}
+
+public struct McpUiResourceTeardownRequest: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: EmptyCapability
+
+ public init(
+ method: String,
+ params: EmptyCapability
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiResourceTeardownResult: Codable, Sendable, Equatable {
+
+
+ public init(
+
+ ) {
+
+ }
+}
+
+public struct McpUiSandboxProxyReadyNotification: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: EmptyCapability
+
+ public init(
+ method: String,
+ params: EmptyCapability
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+/// CSP configuration from resource metadata.
+public struct McpUiSandboxResourceReadyNotificationParamsCsp: Codable, Sendable, Equatable {
+ /// Origins for network requests (fetch/XHR/WebSocket).
+ public var connectDomains: [String]?
+ /// Origins for static resources (scripts, images, styles, fonts).
+ public var resourceDomains: [String]?
+
+ public init(
+ connectDomains: [String]? = nil,
+ resourceDomains: [String]? = nil
+ ) {
+ self.connectDomains = connectDomains
+ self.resourceDomains = resourceDomains
+ }
+}
+
+public struct McpUiSandboxResourceReadyNotificationParams: Codable, Sendable, Equatable {
+ /// HTML content to load into the inner iframe.
+ public var html: String
+ /// Optional override for the inner iframe's sandbox attribute.
+ public var sandbox: String?
+ /// CSP configuration from resource metadata.
+ public var csp: McpUiSandboxResourceReadyNotificationParamsCsp?
+
+ public init(
+ html: String,
+ sandbox: String? = nil,
+ csp: McpUiSandboxResourceReadyNotificationParamsCsp? = nil
+ ) {
+ self.html = html
+ self.sandbox = sandbox
+ self.csp = csp
+ }
+}
+
+public struct McpUiSandboxResourceReadyNotification: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiSandboxResourceReadyNotificationParams
+
+ public init(
+ method: String,
+ params: McpUiSandboxResourceReadyNotificationParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiSizeChangedNotificationParams: Codable, Sendable, Equatable {
+ /// New width in pixels.
+ public var width: Double?
+ /// New height in pixels.
+ public var height: Double?
+
+ public init(
+ width: Double? = nil,
+ height: Double? = nil
+ ) {
+ self.width = width
+ self.height = height
+ }
+}
+
+public struct McpUiSizeChangedNotification: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiSizeChangedNotificationParams
+
+ public init(
+ method: String,
+ params: McpUiSizeChangedNotificationParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+/// CSS variable keys available to MCP apps for theming.
+public enum McpUiStyleVariableKey: String, Codable, Sendable, Equatable {
+ case colorbackgroundprimary = "--color-background-primary"
+ case colorbackgroundsecondary = "--color-background-secondary"
+ case colorbackgroundtertiary = "--color-background-tertiary"
+ case colorbackgroundinverse = "--color-background-inverse"
+ case colorbackgroundghost = "--color-background-ghost"
+ case colorbackgroundinfo = "--color-background-info"
+ case colorbackgrounddanger = "--color-background-danger"
+ case colorbackgroundsuccess = "--color-background-success"
+ case colorbackgroundwarning = "--color-background-warning"
+ case colorbackgrounddisabled = "--color-background-disabled"
+ case colortextprimary = "--color-text-primary"
+ case colortextsecondary = "--color-text-secondary"
+ case colortexttertiary = "--color-text-tertiary"
+ case colortextinverse = "--color-text-inverse"
+ case colortextinfo = "--color-text-info"
+ case colortextdanger = "--color-text-danger"
+ case colortextsuccess = "--color-text-success"
+ case colortextwarning = "--color-text-warning"
+ case colortextdisabled = "--color-text-disabled"
+ case colortextghost = "--color-text-ghost"
+ case colorborderprimary = "--color-border-primary"
+ case colorbordersecondary = "--color-border-secondary"
+ case colorbordertertiary = "--color-border-tertiary"
+ case colorborderinverse = "--color-border-inverse"
+ case colorborderghost = "--color-border-ghost"
+ case colorborderinfo = "--color-border-info"
+ case colorborderdanger = "--color-border-danger"
+ case colorbordersuccess = "--color-border-success"
+ case colorborderwarning = "--color-border-warning"
+ case colorborderdisabled = "--color-border-disabled"
+ case colorringprimary = "--color-ring-primary"
+ case colorringsecondary = "--color-ring-secondary"
+ case colorringinverse = "--color-ring-inverse"
+ case colorringinfo = "--color-ring-info"
+ case colorringdanger = "--color-ring-danger"
+ case colorringsuccess = "--color-ring-success"
+ case colorringwarning = "--color-ring-warning"
+ case fontsans = "--font-sans"
+ case fontmono = "--font-mono"
+ case fontweightnormal = "--font-weight-normal"
+ case fontweightmedium = "--font-weight-medium"
+ case fontweightsemibold = "--font-weight-semibold"
+ case fontweightbold = "--font-weight-bold"
+ case fonttextxssize = "--font-text-xs-size"
+ case fonttextsmsize = "--font-text-sm-size"
+ case fonttextmdsize = "--font-text-md-size"
+ case fonttextlgsize = "--font-text-lg-size"
+ case fontheadingxssize = "--font-heading-xs-size"
+ case fontheadingsmsize = "--font-heading-sm-size"
+ case fontheadingmdsize = "--font-heading-md-size"
+ case fontheadinglgsize = "--font-heading-lg-size"
+ case fontheadingxlsize = "--font-heading-xl-size"
+ case fontheading2xlsize = "--font-heading-2xl-size"
+ case fontheading3xlsize = "--font-heading-3xl-size"
+ case fonttextxslineheight = "--font-text-xs-line-height"
+ case fonttextsmlineheight = "--font-text-sm-line-height"
+ case fonttextmdlineheight = "--font-text-md-line-height"
+ case fonttextlglineheight = "--font-text-lg-line-height"
+ case fontheadingxslineheight = "--font-heading-xs-line-height"
+ case fontheadingsmlineheight = "--font-heading-sm-line-height"
+ case fontheadingmdlineheight = "--font-heading-md-line-height"
+ case fontheadinglglineheight = "--font-heading-lg-line-height"
+ case fontheadingxllineheight = "--font-heading-xl-line-height"
+ case fontheading2xllineheight = "--font-heading-2xl-line-height"
+ case fontheading3xllineheight = "--font-heading-3xl-line-height"
+ case borderradiusxs = "--border-radius-xs"
+ case borderradiussm = "--border-radius-sm"
+ case borderradiusmd = "--border-radius-md"
+ case borderradiuslg = "--border-radius-lg"
+ case borderradiusxl = "--border-radius-xl"
+ case borderradiusfull = "--border-radius-full"
+ case borderwidthregular = "--border-width-regular"
+ case shadowhairline = "--shadow-hairline"
+ case shadowsm = "--shadow-sm"
+ case shadowmd = "--shadow-md"
+ case shadowlg = "--shadow-lg"
+}
+
+/// Style variables for theming MCP apps.
+///
+/// Individual style keys are optional - hosts may provide any subset of these values.
+/// Values are strings containing CSS values (colors, sizes, font stacks, etc.).
+///
+/// Note: This type uses `Record` rather than `Partial>`
+/// for compatibility with Zod schema generation. Both are functionally equivalent for validation.
+public struct McpUiStyles: Codable, Sendable, Equatable {
+
+
+ public init(
+
+ ) {
+
+ }
+}
+
+public struct McpUiToolCancelledNotificationParams: Codable, Sendable, Equatable {
+ /// Optional reason for the cancellation (e.g., "user action", "timeout").
+ public var reason: String?
+
+ public init(
+ reason: String? = nil
+ ) {
+ self.reason = reason
+ }
+}
+
+public struct McpUiToolCancelledNotification: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiToolCancelledNotificationParams
+
+ public init(
+ method: String,
+ params: McpUiToolCancelledNotificationParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiToolInputNotificationParams: Codable, Sendable, Equatable {
+ /// Complete tool call arguments as key-value pairs.
+ public var arguments: [String: AnyCodable]?
+
+ public init(
+ arguments: [String: AnyCodable]? = nil
+ ) {
+ self.arguments = arguments
+ }
+}
+
+public struct McpUiToolInputNotification: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiToolInputNotificationParams
+
+ public init(
+ method: String,
+ params: McpUiToolInputNotificationParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+public struct McpUiToolInputPartialNotificationParams: Codable, Sendable, Equatable {
+ /// Partial tool call arguments (incomplete, may change).
+ public var arguments: [String: AnyCodable]?
+
+ public init(
+ arguments: [String: AnyCodable]? = nil
+ ) {
+ self.arguments = arguments
+ }
+}
+
+public struct McpUiToolInputPartialNotification: Codable, Sendable, Equatable {
+ public var method: String
+ public var params: McpUiToolInputPartialNotificationParams
+
+ public init(
+ method: String,
+ params: McpUiToolInputPartialNotificationParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+/// Tool visibility scope - who can access the tool.
+public enum McpUiToolMetaVisibilityItem: String, Codable, Sendable, Equatable {
+ case model = "model"
+ case app = "app"
+}
+
+public struct McpUiToolMeta: Codable, Sendable, Equatable {
+ public var resourceUri: String
+ /// Who can access this tool. Default: ["model", "app"]
+ /// - "model": Tool visible to and callable by the agent
+ /// - "app": Tool callable by the app from this server only
+ public var visibility: [McpUiToolMetaVisibilityItem]?
+
+ public init(
+ resourceUri: String,
+ visibility: [McpUiToolMetaVisibilityItem]? = nil
+ ) {
+ self.resourceUri = resourceUri
+ self.visibility = visibility
+ }
+}
+
+public struct McpUiToolResultNotificationParamsMetaIo_modelcontextprotocol_related_task: Codable, Sendable, Equatable {
+ public var taskId: String
+
+ public init(
+ taskId: String
+ ) {
+ self.taskId = taskId
+ }
+}
+
+public struct McpUiToolResultNotificationParamsMeta: Codable, Sendable, Equatable {
+ public var io_modelcontextprotocol_related_task: McpUiToolResultNotificationParamsMetaIo_modelcontextprotocol_related_task?
+
+ private enum CodingKeys: String, CodingKey {
+ case io_modelcontextprotocol_related_task = "io.modelcontextprotocol/related-task"
+ }
+
+ public init(
+ io_modelcontextprotocol_related_task: McpUiToolResultNotificationParamsMetaIo_modelcontextprotocol_related_task? = nil
+ ) {
+ self.io_modelcontextprotocol_related_task = io_modelcontextprotocol_related_task
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemTextAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemText: Codable, Sendable, Equatable {
+ public var type: String
+ public var text: String
+ public var annotations: McpUiToolResultNotificationParamsContentItemTextAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case text
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ text: String,
+ annotations: McpUiToolResultNotificationParamsContentItemTextAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.text = text
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemImageAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemImage: Codable, Sendable, Equatable {
+ public var type: String
+ public var data: String
+ public var mimeType: String
+ public var annotations: McpUiToolResultNotificationParamsContentItemImageAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case data
+ case mimeType
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ data: String,
+ mimeType: String,
+ annotations: McpUiToolResultNotificationParamsContentItemImageAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.data = data
+ self.mimeType = mimeType
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemAudioAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemAudio: Codable, Sendable, Equatable {
+ public var type: String
+ public var data: String
+ public var mimeType: String
+ public var annotations: McpUiToolResultNotificationParamsContentItemAudioAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case data
+ case mimeType
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ data: String,
+ mimeType: String,
+ annotations: McpUiToolResultNotificationParamsContentItemAudioAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.data = data
+ self.mimeType = mimeType
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemResourcelinkIconsItem: Codable, Sendable, Equatable {
+ public var src: String
+ public var mimeType: String?
+ public var sizes: [String]?
+
+ public init(
+ src: String,
+ mimeType: String? = nil,
+ sizes: [String]? = nil
+ ) {
+ self.src = src
+ self.mimeType = mimeType
+ self.sizes = sizes
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemResourcelinkAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemResourcelink: Codable, Sendable, Equatable {
+ public var name: String
+ public var title: String?
+ public var icons: [McpUiToolResultNotificationParamsContentItemResourcelinkIconsItem]?
+ public var uri: String
+ public var description: String?
+ public var mimeType: String?
+ public var annotations: McpUiToolResultNotificationParamsContentItemResourcelinkAnnotations?
+ public var meta: [String: AnyCodable]?
+ public var type: String
+
+ private enum CodingKeys: String, CodingKey {
+ case name
+ case title
+ case icons
+ case uri
+ case description
+ case mimeType
+ case annotations
+ case meta = "_meta"
+ case type
+ }
+
+ public init(
+ name: String,
+ title: String? = nil,
+ icons: [McpUiToolResultNotificationParamsContentItemResourcelinkIconsItem]? = nil,
+ uri: String,
+ description: String? = nil,
+ mimeType: String? = nil,
+ annotations: McpUiToolResultNotificationParamsContentItemResourcelinkAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil,
+ type: String
+ ) {
+ self.name = name
+ self.title = title
+ self.icons = icons
+ self.uri = uri
+ self.description = description
+ self.mimeType = mimeType
+ self.annotations = annotations
+ self.meta = meta
+ self.type = type
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemResourceResourceText: Codable, Sendable, Equatable {
+ public var uri: String
+ public var mimeType: String?
+ public var meta: [String: AnyCodable]?
+ public var text: String
+
+ private enum CodingKeys: String, CodingKey {
+ case uri
+ case mimeType
+ case meta = "_meta"
+ case text
+ }
+
+ public init(
+ uri: String,
+ mimeType: String? = nil,
+ meta: [String: AnyCodable]? = nil,
+ text: String
+ ) {
+ self.uri = uri
+ self.mimeType = mimeType
+ self.meta = meta
+ self.text = text
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemResourceResourceBlob: Codable, Sendable, Equatable {
+ public var uri: String
+ public var mimeType: String?
+ public var meta: [String: AnyCodable]?
+ public var blob: String
+
+ private enum CodingKeys: String, CodingKey {
+ case uri
+ case mimeType
+ case meta = "_meta"
+ case blob
+ }
+
+ public init(
+ uri: String,
+ mimeType: String? = nil,
+ meta: [String: AnyCodable]? = nil,
+ blob: String
+ ) {
+ self.uri = uri
+ self.mimeType = mimeType
+ self.meta = meta
+ self.blob = blob
+ }
+}
+
+public enum McpUiToolResultNotificationParamsContentItemResourceResource: Codable, Sendable, Equatable {
+ case text(McpUiToolResultNotificationParamsContentItemResourceResourceText)
+ case blob(McpUiToolResultNotificationParamsContentItemResourceResourceBlob)
+
+ public init(from decoder: Decoder) throws {
+ // Try decoding each variant
+ if let v = try? McpUiToolResultNotificationParamsContentItemResourceResourceText(from: decoder) {
+ self = .text(v)
+ } else if let v = try? McpUiToolResultNotificationParamsContentItemResourceResourceBlob(from: decoder) {
+ self = .blob(v)
+ } else {
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Cannot decode McpUiToolResultNotificationParamsContentItemResourceResource"))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .text(let v): try v.encode(to: encoder)
+ case .blob(let v): try v.encode(to: encoder)
+ }
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemResourceAnnotations: Codable, Sendable, Equatable {
+ public var audience: [String]?
+ public var priority: Double?
+ public var lastModified: String?
+
+ public init(
+ audience: [String]? = nil,
+ priority: Double? = nil,
+ lastModified: String? = nil
+ ) {
+ self.audience = audience
+ self.priority = priority
+ self.lastModified = lastModified
+ }
+}
+
+public struct McpUiToolResultNotificationParamsContentItemResource: Codable, Sendable, Equatable {
+ public var type: String
+ public var resource: McpUiToolResultNotificationParamsContentItemResourceResource
+ public var annotations: McpUiToolResultNotificationParamsContentItemResourceAnnotations?
+ public var meta: [String: AnyCodable]?
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ case resource
+ case annotations
+ case meta = "_meta"
+ }
+
+ public init(
+ type: String,
+ resource: McpUiToolResultNotificationParamsContentItemResourceResource,
+ annotations: McpUiToolResultNotificationParamsContentItemResourceAnnotations? = nil,
+ meta: [String: AnyCodable]? = nil
+ ) {
+ self.type = type
+ self.resource = resource
+ self.annotations = annotations
+ self.meta = meta
+ }
+}
+
+public enum McpUiToolResultNotificationParamsContentItem: Codable, Sendable, Equatable {
+ case text(McpUiToolResultNotificationParamsContentItemText)
+ case image(McpUiToolResultNotificationParamsContentItemImage)
+ case audio(McpUiToolResultNotificationParamsContentItemAudio)
+ case resourcelink(McpUiToolResultNotificationParamsContentItemResourcelink)
+ case resource(McpUiToolResultNotificationParamsContentItemResource)
+
+ private enum CodingKeys: String, CodingKey {
+ case type
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decode(String.self, forKey: .type)
+ switch type {
+ case "text": self = .text(try McpUiToolResultNotificationParamsContentItemText(from: decoder))
+ case "image": self = .image(try McpUiToolResultNotificationParamsContentItemImage(from: decoder))
+ case "audio": self = .audio(try McpUiToolResultNotificationParamsContentItemAudio(from: decoder))
+ case "resource_link": self = .resourcelink(try McpUiToolResultNotificationParamsContentItemResourcelink(from: decoder))
+ case "resource": self = .resource(try McpUiToolResultNotificationParamsContentItemResource(from: decoder))
+ default:
+ throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)")
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .text(let v): try v.encode(to: encoder)
+ case .image(let v): try v.encode(to: encoder)
+ case .audio(let v): try v.encode(to: encoder)
+ case .resourcelink(let v): try v.encode(to: encoder)
+ case .resource(let v): try v.encode(to: encoder)
+ }
+ }
+}
+
+/// Standard MCP tool execution result.
+public struct McpUiToolResultNotificationParams: Codable, Sendable, Equatable {
+ public var meta: McpUiToolResultNotificationParamsMeta?
+ public var content: [McpUiToolResultNotificationParamsContentItem]
+ public var structuredContent: [String: AnyCodable]?
+ public var isError: Bool?
+
+ private enum CodingKeys: String, CodingKey {
+ case meta = "_meta"
+ case content
+ case structuredContent
+ case isError
+ }
+
+ public init(
+ meta: McpUiToolResultNotificationParamsMeta? = nil,
+ content: [McpUiToolResultNotificationParamsContentItem],
+ structuredContent: [String: AnyCodable]? = nil,
+ isError: Bool? = nil
+ ) {
+ self.meta = meta
+ self.content = content
+ self.structuredContent = structuredContent
+ self.isError = isError
+ }
+}
+
+public struct McpUiToolResultNotification: Codable, Sendable, Equatable {
+ public var method: String
+ /// Standard MCP tool execution result.
+ public var params: McpUiToolResultNotificationParams
+
+ public init(
+ method: String,
+ params: McpUiToolResultNotificationParams
+ ) {
+ self.method = method
+ self.params = params
+ }
+}
+
+/// Tool visibility scope - who can access the tool.
+public enum McpUiToolVisibility: String, Codable, Sendable, Equatable {
+ case model = "model"
+ case app = "app"
+}
diff --git a/swift/Sources/McpApps/Transport/Transport.swift b/swift/Sources/McpApps/Transport/Transport.swift
new file mode 100644
index 00000000..0c62c0a3
--- /dev/null
+++ b/swift/Sources/McpApps/Transport/Transport.swift
@@ -0,0 +1,237 @@
+import Foundation
+
+/// JSON-RPC message types for MCP Apps communication.
+public enum JSONRPCMessage: Codable, Sendable {
+ case request(JSONRPCRequest)
+ case notification(JSONRPCNotification)
+ case response(JSONRPCResponse)
+ case error(JSONRPCErrorResponse)
+
+ enum CodingKeys: String, CodingKey {
+ case jsonrpc, id, method, params, result, error
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ // Check for error response first
+ if container.contains(.error) {
+ self = .error(try JSONRPCErrorResponse(from: decoder))
+ return
+ }
+
+ // Check for result (response)
+ if container.contains(.result) {
+ self = .response(try JSONRPCResponse(from: decoder))
+ return
+ }
+
+ // Check for id (request vs notification)
+ if container.contains(.id) {
+ self = .request(try JSONRPCRequest(from: decoder))
+ } else {
+ self = .notification(try JSONRPCNotification(from: decoder))
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ switch self {
+ case .request(let request):
+ try request.encode(to: encoder)
+ case .notification(let notification):
+ try notification.encode(to: encoder)
+ case .response(let response):
+ try response.encode(to: encoder)
+ case .error(let error):
+ try error.encode(to: encoder)
+ }
+ }
+}
+
+/// JSON-RPC request message.
+public struct JSONRPCRequest: Codable, Sendable {
+ public var jsonrpc: String = "2.0"
+ /// Unique identifier for this request
+ public var id: JSONRPCId
+ /// Method name to invoke
+ public var method: String
+ /// Optional parameters for the method
+ public var params: [String: AnyCodable]?
+
+ public init(id: JSONRPCId, method: String, params: [String: AnyCodable]? = nil) {
+ self.id = id
+ self.method = method
+ self.params = params
+ }
+}
+
+/// JSON-RPC notification message.
+public struct JSONRPCNotification: Codable, Sendable {
+ public var jsonrpc: String = "2.0"
+ /// Method name for this notification
+ public var method: String
+ /// Optional parameters for the notification
+ public var params: [String: AnyCodable]?
+
+ public init(method: String, params: [String: AnyCodable]? = nil) {
+ self.method = method
+ self.params = params
+ }
+}
+
+/// JSON-RPC success response message.
+public struct JSONRPCResponse: Codable, Sendable {
+ public var jsonrpc: String = "2.0"
+ /// ID matching the original request
+ public var id: JSONRPCId
+ /// Result of the method invocation
+ public var result: AnyCodable
+
+ public init(id: JSONRPCId, result: AnyCodable) {
+ self.id = id
+ self.result = result
+ }
+}
+
+/// JSON-RPC error response message.
+public struct JSONRPCErrorResponse: Codable, Sendable {
+ public var jsonrpc: String = "2.0"
+ /// ID matching the original request
+ public var id: JSONRPCId?
+ /// Error details
+ public var error: JSONRPCError
+
+ public init(id: JSONRPCId?, error: JSONRPCError) {
+ self.id = id
+ self.error = error
+ }
+}
+
+/// JSON-RPC error object.
+public struct JSONRPCError: Codable, Sendable {
+ /// Error code
+ public var code: Int
+ /// Human-readable error message
+ public var message: String
+ /// Optional additional error data
+ public var data: AnyCodable?
+
+ public init(code: Int, message: String, data: AnyCodable? = nil) {
+ self.code = code
+ self.message = message
+ self.data = data
+ }
+
+ // Standard JSON-RPC error codes
+ public static let parseError = -32700
+ public static let invalidRequest = -32600
+ public static let methodNotFound = -32601
+ public static let invalidParams = -32602
+ public static let internalError = -32603
+ public static let mcpError = -32000
+}
+
+/// JSON-RPC request ID.
+public enum JSONRPCId: Codable, Sendable, Hashable {
+ case string(String)
+ case number(Int)
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if let intValue = try? container.decode(Int.self) {
+ self = .number(intValue)
+ } else if let stringValue = try? container.decode(String.self) {
+ self = .string(stringValue)
+ } else {
+ throw DecodingError.typeMismatch(
+ JSONRPCId.self,
+ DecodingError.Context(
+ codingPath: decoder.codingPath,
+ debugDescription: "Expected string or number"
+ )
+ )
+ }
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let value):
+ try container.encode(value)
+ case .number(let value):
+ try container.encode(value)
+ }
+ }
+}
+
+/// Transport protocol for MCP Apps communication.
+///
+/// This protocol abstracts the underlying message transport mechanism,
+/// allowing different implementations for various platforms.
+public protocol McpAppsTransport: Actor {
+ /// Start the transport and begin listening for messages.
+ func start() async throws
+
+ /// Send a JSON-RPC message to the peer.
+ func send(_ message: JSONRPCMessage) async throws
+
+ /// Close the transport and cleanup resources.
+ func close() async
+
+ /// Stream of incoming JSON-RPC messages from the peer.
+ var incoming: AsyncThrowingStream { get }
+}
+
+/// In-memory transport for testing.
+public actor InMemoryTransport: McpAppsTransport {
+ private var peer: InMemoryTransport?
+ private var continuation: AsyncThrowingStream.Continuation?
+
+ public let incoming: AsyncThrowingStream
+
+ private init(peer: InMemoryTransport?) {
+ var continuation: AsyncThrowingStream.Continuation?
+ self.incoming = AsyncThrowingStream { continuation = $0 }
+ self.continuation = continuation
+ self.peer = peer
+ }
+
+ public func start() async throws {
+ // Nothing to do for in-memory transport
+ }
+
+ public func send(_ message: JSONRPCMessage) async throws {
+ guard let peer = peer else {
+ throw TransportError.notConnected
+ }
+ await peer.receiveFromPeer(message)
+ }
+
+ public func close() async {
+ continuation?.finish()
+ peer = nil
+ }
+
+ func receiveFromPeer(_ message: JSONRPCMessage) {
+ continuation?.yield(message)
+ }
+
+ func setPeer(_ peer: InMemoryTransport) {
+ self.peer = peer
+ }
+
+ /// Create a linked pair of transports for testing.
+ public static func createLinkedPair() async -> (InMemoryTransport, InMemoryTransport) {
+ let first = InMemoryTransport(peer: nil)
+ let second = InMemoryTransport(peer: first)
+ await first.setPeer(second)
+ return (first, second)
+ }
+}
+
+/// Transport errors.
+public enum TransportError: Error {
+ case notConnected
+ case serializationFailed
+ case invalidMessage
+}
diff --git a/swift/Sources/McpApps/Transport/WKWebViewTransport.swift b/swift/Sources/McpApps/Transport/WKWebViewTransport.swift
new file mode 100644
index 00000000..b69d1d28
--- /dev/null
+++ b/swift/Sources/McpApps/Transport/WKWebViewTransport.swift
@@ -0,0 +1,167 @@
+import Foundation
+import WebKit
+
+/// Transport for MCP Apps communication using WKWebView.
+///
+/// This transport enables bidirectional communication between a Swift host
+/// application and a Guest UI running in a WKWebView.
+///
+/// ## Usage
+///
+/// ```swift
+/// let webView = WKWebView()
+/// let transport = WKWebViewTransport(webView: webView)
+///
+/// let bridge = AppBridge(hostInfo: hostInfo, hostCapabilities: capabilities)
+/// try await bridge.connect(transport)
+///
+/// webView.loadHTMLString(htmlContent, baseURL: nil)
+/// ```
+@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
+public actor WKWebViewTransport: McpAppsTransport {
+ private let handlerName: String
+ private var continuation: AsyncThrowingStream.Continuation?
+ private var isStarted: Bool = false
+ private var messageHandler: MessageHandlerProxy?
+
+ // Store webView reference for MainActor access
+ // Note: Using strong reference to ensure webView stays alive for teardown messages
+ @MainActor private var webView: WKWebView?
+
+ public let incoming: AsyncThrowingStream
+
+ /// Create a new WKWebView transport.
+ @MainActor
+ public init(webView: WKWebView, handlerName: String = "mcpBridge") {
+ self.webView = webView
+ self.handlerName = handlerName
+
+ var continuation: AsyncThrowingStream.Continuation?
+ self.incoming = AsyncThrowingStream { continuation = $0 }
+ self.continuation = continuation
+ }
+
+ /// Start the transport and inject the JavaScript bridge.
+ public func start() async throws {
+ guard !isStarted else { return }
+
+ let script = createBridgeScript()
+ let name = handlerName
+
+ await MainActor.run { [weak self] in
+ guard let self else { return }
+ let handler = MessageHandlerProxy { body in
+ // Serialize on main thread to make sendable
+ if let data = try? JSONSerialization.data(withJSONObject: body) {
+ Task { await self.handleIncomingData(data) }
+ }
+ }
+ webView?.configuration.userContentController.add(handler, name: name)
+
+ let userScript = WKUserScript(
+ source: script,
+ injectionTime: .atDocumentStart,
+ forMainFrameOnly: true
+ )
+ webView?.configuration.userContentController.addUserScript(userScript)
+ }
+
+ isStarted = true
+ }
+
+ /// Send a JSON-RPC message to the Guest UI.
+ public func send(_ message: JSONRPCMessage) async throws {
+ let encoder = JSONEncoder()
+ let data = try encoder.encode(message)
+
+ guard let jsonString = String(data: data, encoding: .utf8) else {
+ throw TransportError.serializationFailed
+ }
+
+ // Dispatch MessageEvent with parsed object as data
+ let script = """
+ (function() {
+ try {
+ const messageObj = \(jsonString);
+ window.dispatchEvent(new MessageEvent('message', {
+ data: messageObj,
+ origin: window.location.origin,
+ source: window
+ }));
+ } catch (error) {
+ console.error('Failed to dispatch message:', error);
+ }
+ })();
+ """
+
+ try await MainActor.run {
+ guard let wv = webView else {
+ throw TransportError.notConnected
+ }
+ wv.evaluateJavaScript(script, completionHandler: nil)
+ }
+ }
+
+ /// Close the transport and cleanup resources.
+ public func close() async {
+ continuation?.finish()
+
+ let name = handlerName
+ await MainActor.run {
+ webView?.configuration.userContentController.removeScriptMessageHandler(forName: name)
+ webView?.configuration.userContentController.removeAllUserScripts()
+ webView = nil // Release strong reference
+ }
+
+ messageHandler = nil
+ isStarted = false
+ }
+
+ // MARK: - Private Methods
+
+ private func createBridgeScript() -> String {
+ """
+ (function() {
+ window.parent = window.parent || {};
+ window.parent.postMessage = function(message, targetOrigin) {
+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.\(handlerName)) {
+ window.webkit.messageHandlers.\(handlerName).postMessage(message);
+ } else {
+ console.error('WKWebView message handler not available');
+ }
+ };
+ window.dispatchEvent(new Event('mcp-bridge-ready'));
+ console.log('MCP Apps WKWebView bridge initialized');
+ })();
+ """
+ }
+
+ private func handleIncomingData(_ data: Data) async {
+ do {
+ let message = try JSONDecoder().decode(JSONRPCMessage.self, from: data)
+ continuation?.yield(message)
+ } catch {
+ continuation?.yield(with: .failure(error))
+ }
+ }
+}
+
+/// Proxy class to bridge between WKScriptMessageHandler and the actor.
+@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
+@MainActor
+private class MessageHandlerProxy: NSObject, WKScriptMessageHandler {
+ private let onMessage: @Sendable (Any) -> Void
+
+ init(onMessage: @escaping @Sendable (Any) -> Void) {
+ self.onMessage = onMessage
+ super.init()
+ }
+
+ func userContentController(
+ _ userContentController: WKUserContentController,
+ didReceive message: WKScriptMessage
+ ) {
+ let body = message.body
+ onMessage(body)
+ }
+}
diff --git a/swift/Tests/McpAppsTests/AppBridgeTests.swift b/swift/Tests/McpAppsTests/AppBridgeTests.swift
new file mode 100644
index 00000000..96e1cae2
--- /dev/null
+++ b/swift/Tests/McpAppsTests/AppBridgeTests.swift
@@ -0,0 +1,372 @@
+import XCTest
+@testable import McpApps
+
+final class AppBridgeTests: XCTestCase {
+
+ let testHostInfo = Implementation(name: "TestHost", version: "1.0.0")
+ let testAppInfo = Implementation(name: "TestApp", version: "1.0.0")
+ let testHostCapabilities = McpUiHostCapabilities(
+ openLinks: EmptyCapability(),
+ serverTools: ServerToolsCapability(),
+ logging: EmptyCapability()
+ )
+
+ func testMessageTypes() throws {
+ // Test that all message types encode/decode correctly
+ let initParams = McpUiInitializeParams(
+ appInfo: Implementation(name: "TestApp", version: "1.0.0"),
+ appCapabilities: McpUiAppCapabilities(
+ tools: AppToolsCapability(listChanged: true)
+ ),
+ protocolVersion: "2025-11-21"
+ )
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(initParams)
+ let decoded = try decoder.decode(McpUiInitializeParams.self, from: encoded)
+
+ XCTAssertEqual(decoded.appInfo.name, initParams.appInfo.name)
+ XCTAssertEqual(decoded.protocolVersion, initParams.protocolVersion)
+ }
+
+ func testHostContext() throws {
+ let context = McpUiHostContext(
+ theme: .dark,
+ displayMode: .inline,
+ viewport: Viewport(width: 800, height: 600, maxHeight: 1000),
+ locale: "en-US",
+ timeZone: "America/New_York",
+ platform: .mobile,
+ deviceCapabilities: DeviceCapabilities(touch: true, hover: false),
+ safeAreaInsets: SafeAreaInsets(top: 44, right: 0, bottom: 34, left: 0)
+ )
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(context)
+ let decoded = try decoder.decode(McpUiHostContext.self, from: encoded)
+
+ XCTAssertEqual(decoded.theme, .dark)
+ XCTAssertEqual(decoded.displayMode, .inline)
+ XCTAssertEqual(decoded.viewport?.width, 800)
+ XCTAssertEqual(decoded.locale, "en-US")
+ XCTAssertEqual(decoded.platform, .mobile)
+ XCTAssertEqual(decoded.deviceCapabilities?.touch, true)
+ }
+
+ func testToolInputParams() throws {
+ let params = McpUiToolInputNotificationParams(
+ arguments: [
+ "query": AnyCodable("weather in NYC"),
+ "count": AnyCodable(5)
+ ]
+ )
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(params)
+ let decoded = try decoder.decode(McpUiToolInputNotificationParams.self, from: encoded)
+
+ XCTAssertEqual(decoded.arguments?["query"]?.value as? String, "weather in NYC")
+ XCTAssertEqual(decoded.arguments?["count"]?.value as? Int, 5)
+ }
+
+ func testJSONRPCRequest() throws {
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "ui/initialize",
+ params: ["test": AnyCodable("value")]
+ )
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(request)
+ let decoded = try decoder.decode(JSONRPCRequest.self, from: encoded)
+
+ XCTAssertEqual(decoded.id, .number(1))
+ XCTAssertEqual(decoded.method, "ui/initialize")
+ XCTAssertEqual(decoded.jsonrpc, "2.0")
+ }
+
+ func testJSONRPCNotification() throws {
+ let notification = JSONRPCNotification(
+ method: "ui/notifications/initialized",
+ params: nil
+ )
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(notification)
+ let decoded = try decoder.decode(JSONRPCNotification.self, from: encoded)
+
+ XCTAssertEqual(decoded.method, "ui/notifications/initialized")
+ XCTAssertEqual(decoded.jsonrpc, "2.0")
+ }
+
+ func testAnyCodable() throws {
+ let values: [String: AnyCodable] = [
+ "string": AnyCodable("hello"),
+ "int": AnyCodable(42),
+ "double": AnyCodable(3.14),
+ "bool": AnyCodable(true),
+ "array": AnyCodable([1, 2, 3]),
+ "dict": AnyCodable(["nested": "value"])
+ ]
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(values)
+ let decoded = try decoder.decode([String: AnyCodable].self, from: encoded)
+
+ XCTAssertEqual(decoded["string"]?.value as? String, "hello")
+ XCTAssertEqual(decoded["int"]?.value as? Int, 42)
+ XCTAssertEqual(decoded["bool"]?.value as? Bool, true)
+ }
+
+ func testAppBridgeCreation() async throws {
+ let bridge = AppBridge(
+ hostInfo: testHostInfo,
+ hostCapabilities: testHostCapabilities
+ )
+
+ let isReady = await bridge.isReady()
+ let capabilities = await bridge.getAppCapabilities()
+ let version = await bridge.getAppVersion()
+
+ XCTAssertFalse(isReady)
+ XCTAssertNil(capabilities)
+ XCTAssertNil(version)
+ }
+
+ func testInMemoryTransportCreation() async throws {
+ let (transport1, transport2) = await InMemoryTransport.createLinkedPair()
+
+ try await transport1.start()
+ try await transport2.start()
+
+ // Test that we can create transports without error
+ await transport1.close()
+ await transport2.close()
+ }
+
+ func testInitializeResult() throws {
+ let result = McpUiInitializeResult(
+ protocolVersion: McpAppsConfig.latestProtocolVersion,
+ hostInfo: testHostInfo,
+ hostCapabilities: testHostCapabilities,
+ hostContext: McpUiHostContext(theme: .light)
+ )
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(result)
+ let decoded = try decoder.decode(McpUiInitializeResult.self, from: encoded)
+
+ XCTAssertEqual(decoded.protocolVersion, McpAppsConfig.latestProtocolVersion)
+ XCTAssertEqual(decoded.hostInfo.name, testHostInfo.name)
+ XCTAssertEqual(decoded.hostContext.theme, McpUiTheme.light)
+ }
+
+ func testLogLevel() throws {
+ let levels: [LogLevel] = [.debug, .info, .notice, .warning, .error, .critical, .alert, .emergency]
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ for level in levels {
+ let encoded = try encoder.encode(level)
+ let decoded = try decoder.decode(LogLevel.self, from: encoded)
+ XCTAssertEqual(decoded, level)
+ }
+ }
+
+ func testResourceMeta() throws {
+ let meta = McpUiResourceMeta(
+ csp: McpUiResourceMetaCsp(
+ connectDomains: ["https://api.example.com"],
+ resourceDomains: ["https://cdn.example.com"]
+ ),
+ domain: "https://widget.example.com",
+ prefersBorder: true
+ )
+
+ let encoder = JSONEncoder()
+ let decoder = JSONDecoder()
+
+ let encoded = try encoder.encode(meta)
+ let decoded = try decoder.decode(McpUiResourceMeta.self, from: encoded)
+
+ XCTAssertEqual(decoded.csp?.connectDomains?.first, "https://api.example.com")
+ XCTAssertEqual(decoded.domain, "https://widget.example.com")
+ XCTAssertEqual(decoded.prefersBorder, true)
+ }
+
+ func testToolCallForwarding() async throws {
+ let bridge = AppBridge(
+ hostInfo: testHostInfo,
+ hostCapabilities: testHostCapabilities
+ )
+
+ // Use actors for thread-safe state capture
+ actor CallState {
+ var toolName: String?
+ var arguments: [String: AnyCodable]?
+ func set(name: String, args: [String: AnyCodable]?) {
+ self.toolName = name
+ self.arguments = args
+ }
+ }
+ let state = CallState()
+
+ await bridge.setOnToolCall { name, arguments in
+ await state.set(name: name, args: arguments)
+ return [
+ "content": AnyCodable([
+ ["type": "text", "text": "Tool result"]
+ ])
+ ]
+ }
+
+ // Create linked transport pair
+ let (hostTransport, guestTransport) = await InMemoryTransport.createLinkedPair()
+
+ // Connect bridge
+ try await bridge.connect(hostTransport)
+
+ // Send tools/call request from guest
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "tools/call",
+ params: [
+ "name": AnyCodable("test_tool"),
+ "arguments": AnyCodable([
+ "param1": "value1",
+ "param2": 42
+ ])
+ ]
+ )
+ try await guestTransport.send(.request(request))
+
+ // Wait a bit for message processing
+ try await Task.sleep(nanoseconds: 100_000_000) // 100ms
+
+ // Verify callback was called
+ let receivedToolName = await state.toolName
+ let receivedArguments = await state.arguments
+ XCTAssertEqual(receivedToolName, "test_tool")
+ XCTAssertNotNil(receivedArguments)
+ XCTAssertEqual(receivedArguments?["param1"]?.value as? String, "value1")
+ XCTAssertEqual(receivedArguments?["param2"]?.value as? Int, 42)
+
+ await bridge.close()
+ await guestTransport.close()
+ }
+
+ func testResourceReadForwarding() async throws {
+ let bridge = AppBridge(
+ hostInfo: testHostInfo,
+ hostCapabilities: testHostCapabilities
+ )
+
+ // Use actor for thread-safe state capture
+ actor UriState {
+ var uri: String?
+ func set(_ value: String) { uri = value }
+ }
+ let state = UriState()
+
+ await bridge.setOnResourceRead { uri in
+ await state.set(uri)
+ return [
+ "contents": AnyCodable([
+ [
+ "uri": uri,
+ "mimeType": "text/html",
+ "text": "Resource content"
+ ]
+ ])
+ ]
+ }
+
+ // Create linked transport pair
+ let (hostTransport, guestTransport) = await InMemoryTransport.createLinkedPair()
+
+ // Connect bridge
+ try await bridge.connect(hostTransport)
+
+ // Send resources/read request from guest
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "resources/read",
+ params: [
+ "uri": AnyCodable("ui://test-app")
+ ]
+ )
+ try await guestTransport.send(.request(request))
+
+ // Wait a bit for message processing
+ try await Task.sleep(nanoseconds: 100_000_000) // 100ms
+
+ // Verify callback was called
+ let receivedUri = await state.uri
+ XCTAssertEqual(receivedUri, "ui://test-app")
+
+ await bridge.close()
+ await guestTransport.close()
+ }
+
+ func testToolCallWithoutCallback() async throws {
+ let bridge = AppBridge(
+ hostInfo: testHostInfo,
+ hostCapabilities: testHostCapabilities
+ )
+
+ // Don't set up callback - should result in error
+
+ // Create linked transport pair
+ let (hostTransport, guestTransport) = await InMemoryTransport.createLinkedPair()
+
+ // Connect bridge
+ try await bridge.connect(hostTransport)
+
+ // Send tools/call request from guest
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "tools/call",
+ params: [
+ "name": AnyCodable("test_tool")
+ ]
+ )
+ try await guestTransport.send(.request(request))
+
+ // Wait a bit for message processing
+ try await Task.sleep(nanoseconds: 100_000_000) // 100ms
+
+ // Verify error response was sent (we can't easily check the response without more infrastructure)
+ // Just verify the bridge is still functional
+ let isReady = await bridge.isReady()
+ XCTAssertFalse(isReady)
+
+ await bridge.close()
+ await guestTransport.close()
+ }
+}
+
+// Helper extension for tests
+extension AppBridge {
+ func setOnToolCall(_ callback: @escaping @Sendable (String, [String: AnyCodable]?) async throws -> [String: AnyCodable]) {
+ self.onToolCall = callback
+ }
+
+ func setOnResourceRead(_ callback: @escaping @Sendable (String) async throws -> [String: AnyCodable]) {
+ self.onResourceRead = callback
+ }
+}
diff --git a/swift/Tests/McpAppsTests/WKWebViewTransportTests.swift b/swift/Tests/McpAppsTests/WKWebViewTransportTests.swift
new file mode 100644
index 00000000..a3d4c9b1
--- /dev/null
+++ b/swift/Tests/McpAppsTests/WKWebViewTransportTests.swift
@@ -0,0 +1,442 @@
+import XCTest
+import WebKit
+@testable import McpApps
+
+@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)
+final class WKWebViewTransportTests: XCTestCase {
+
+ @MainActor
+ func testTransportCreation() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ // Transport should be created successfully
+ XCTAssertNotNil(transport)
+ }
+
+ @MainActor
+ func testTransportStartAndClose() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ // Start transport
+ try await transport.start()
+
+ // Close transport
+ await transport.close()
+ }
+
+ @MainActor
+ func testTransportSendRequest() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Create a test request
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "ui/initialize",
+ params: ["test": AnyCodable("value")]
+ )
+
+ // Send request - this should not throw
+ try await transport.send(.request(request))
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testTransportSendNotification() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Create a test notification
+ let notification = JSONRPCNotification(
+ method: "ui/notifications/initialized",
+ params: nil
+ )
+
+ // Send notification - this should not throw
+ try await transport.send(.notification(notification))
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testTransportSendResponse() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Create a test response
+ let response = JSONRPCResponse(
+ id: .number(1),
+ result: AnyCodable(["success": true])
+ )
+
+ // Send response - this should not throw
+ try await transport.send(.response(response))
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testTransportSendError() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Create a test error response
+ let errorResponse = JSONRPCErrorResponse(
+ id: .number(1),
+ error: JSONRPCError(
+ code: JSONRPCError.internalError,
+ message: "Test error"
+ )
+ )
+
+ // Send error - this should not throw
+ try await transport.send(.error(errorResponse))
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testCustomHandlerName() async throws {
+ let webView = WKWebView()
+ let customHandlerName = "customBridge"
+ let transport = WKWebViewTransport(webView: webView, handlerName: customHandlerName)
+
+ try await transport.start()
+
+ // Verify the handler is registered with the custom name
+ // Note: We can't directly verify this without accessing private members,
+ // but we can ensure start() completes without error
+ XCTAssertNotNil(transport)
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testMultipleStartCallsAreIdempotent() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ // Start multiple times should not cause issues
+ try await transport.start()
+ try await transport.start()
+ try await transport.start()
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testSendWithoutStartThrows() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "test",
+ params: nil
+ )
+
+ // Sending without start should still work (no explicit check in implementation)
+ // but might fail due to missing script injection
+ // This test documents the current behavior
+ do {
+ try await transport.send(.request(request))
+ // If it doesn't throw, that's fine too
+ } catch {
+ // Expected to potentially fail
+ XCTAssertTrue(error is Error)
+ }
+ }
+
+ @MainActor
+ func testJSONEncodingWithSpecialCharacters() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Test with special characters that need escaping
+ let request = JSONRPCRequest(
+ id: .string("test-id"),
+ method: "test/method",
+ params: [
+ "message": AnyCodable("Line 1\nLine 2\r\nWith \"quotes\" and \\backslash\\"),
+ "nested": AnyCodable(["key": "value with spaces"])
+ ]
+ )
+
+ // Should handle special characters without throwing
+ try await transport.send(.request(request))
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testMessageReception() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Create a task to collect incoming messages
+ var receivedMessages: [JSONRPCMessage] = []
+ let expectation = expectation(description: "Message received")
+ expectation.isInverted = true // We don't expect messages in this test
+
+ Task {
+ for try await message in await transport.incoming {
+ receivedMessages.append(message)
+ expectation.fulfill()
+ break
+ }
+ }
+
+ // Wait a bit to ensure no messages are received
+ await fulfillment(of: [expectation], timeout: 0.5)
+
+ // Should have received no messages (no JavaScript execution)
+ XCTAssertEqual(receivedMessages.count, 0)
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testTransportWithNilWebView() async throws {
+ // Create a weak reference to test behavior with deallocated webView
+ var webView: WKWebView? = WKWebView()
+ let transport = WKWebViewTransport(webView: webView!)
+
+ try await transport.start()
+
+ // Deallocate webView
+ webView = nil
+
+ // Sending should throw notConnected error
+ let request = JSONRPCRequest(id: .number(1), method: "test", params: nil)
+
+ do {
+ try await transport.send(.request(request))
+ XCTFail("Should have thrown notConnected error")
+ } catch TransportError.notConnected {
+ // Expected
+ } catch {
+ XCTFail("Wrong error type: \(error)")
+ }
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testConcurrentSends() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Send multiple messages concurrently
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for i in 0..<10 {
+ group.addTask {
+ let request = JSONRPCRequest(
+ id: .number(i),
+ method: "test/method",
+ params: ["index": AnyCodable(i)]
+ )
+ try await transport.send(.request(request))
+ }
+ }
+
+ try await group.waitForAll()
+ }
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testMessageWithDifferentIdTypes() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Test with string ID
+ let requestString = JSONRPCRequest(
+ id: .string("test-id-123"),
+ method: "test/method",
+ params: nil
+ )
+ try await transport.send(.request(requestString))
+
+ // Test with number ID
+ let requestNumber = JSONRPCRequest(
+ id: .number(42),
+ method: "test/method",
+ params: nil
+ )
+ try await transport.send(.request(requestNumber))
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testComplexNestedParams() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Create complex nested structure
+ let params: [String: AnyCodable] = [
+ "string": AnyCodable("test"),
+ "number": AnyCodable(42),
+ "bool": AnyCodable(true),
+ "null": AnyCodable(nil as String?),
+ "array": AnyCodable([1, 2, 3]),
+ "nested": AnyCodable([
+ "level2": [
+ "level3": "deep value"
+ ]
+ ])
+ ]
+
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "test/complex",
+ params: params
+ )
+
+ try await transport.send(.request(request))
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testMessageEventFormat() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Load an HTML page that captures MessageEvents
+ let html = """
+
+
+
+ Test
+
+
+ Test Page
+
+ """
+
+ webView.loadHTMLString(html, baseURL: nil)
+
+ // Wait for page to load
+ try await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
+
+ // Send a message
+ let request = JSONRPCRequest(
+ id: .number(1),
+ method: "test/method",
+ params: ["key": AnyCodable("value")]
+ )
+
+ try await transport.send(.request(request))
+
+ // Wait for event to be captured
+ try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
+
+ // Verify the event was captured correctly
+ let result = try await webView.evaluateJavaScript("window.capturedEvents.length")
+
+ // Should have captured at least one event
+ if let count = result as? Int {
+ XCTAssertGreaterThan(count, 0, "Should have captured at least one message event")
+
+ // Check the format of the first event
+ let firstEvent = try await webView.evaluateJavaScript("JSON.stringify(window.capturedEvents[0])")
+
+ if let eventJson = firstEvent as? String {
+ // Parse and verify the event structure
+ let data = eventJson.data(using: .utf8)!
+ let event = try JSONSerialization.jsonObject(with: data) as! [String: Any]
+
+ // CRITICAL: event.data must be an object, not a string
+ XCTAssertEqual(event["dataType"] as? String, "object", "event.data must be an object")
+ XCTAssertEqual(event["isObject"] as? Bool, true, "event.data must be an object")
+ XCTAssertEqual(event["hasJsonRpc"] as? Bool, true, "event.data must have jsonrpc property")
+ }
+ }
+
+ await transport.close()
+ }
+
+ @MainActor
+ func testBridgeScriptPostMessage() async throws {
+ let webView = WKWebView()
+ let transport = WKWebViewTransport(webView: webView)
+
+ try await transport.start()
+
+ // Load HTML that uses window.parent.postMessage (like TypeScript SDK)
+ let html = """
+
+
+
+ Test
+
+
+ Test Page
+
+ """
+
+ // Set up expectation to receive message
+ let expectation = expectation(description: "Receive message from JS")
+
+ Task {
+ for try await message in await transport.incoming {
+ if case .notification(let notif) = message, notif.method == "test/fromJs" {
+ expectation.fulfill()
+ break
+ }
+ }
+ }
+
+ webView.loadHTMLString(html, baseURL: nil)
+
+ await fulfillment(of: [expectation], timeout: 2.0)
+
+ await transport.close()
+ }
+}