├── Assets ├── companion-macos-add-server.png └── companion-macos-tool-detail.png ├── Companion ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── Icon-macOS-256x256.png │ │ ├── Icon-macOS-512x512.png │ │ ├── Icon-macOS-512x512 1.png │ │ ├── Icon-macOS-512x512@2x.png │ │ ├── Icon-macOS-512x512@2x 1.png │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── mcp.fill.symbolset │ │ ├── Contents.json │ │ └── mcp.fill.svg ├── Extensions │ ├── String+Nonempty.swift │ ├── String+Hash.swift │ └── Bundle+Icon.swift ├── Companion.entitlements ├── CompanionApp.swift ├── Views │ ├── Utility │ │ ├── WidthPassthroughView.swift │ │ └── FilterToolbar.swift │ ├── Tools │ │ ├── ToolDetailView.swift │ │ ├── ToolSchemaView.swift │ │ ├── ToolHintsView.swift │ │ └── ToolListView.swift │ ├── Onboarding │ │ └── WelcomeView.swift │ ├── DetailView.swift │ ├── Resources │ │ ├── ResourceTemplateView.swift │ │ └── ResourceListView.swift │ ├── Server │ │ ├── ServerInformationView.swift │ │ ├── EditServerSheet.swift │ │ ├── AddServerSheet.swift │ │ ├── ServerFormComponents.swift │ │ └── ServerDetailView.swift │ ├── Prompts │ │ └── PromptDetailView.swift │ └── Common │ │ └── ContentDisplayHelpers.swift ├── Features │ ├── AddServerFeature.swift │ ├── EditServerFeature.swift │ ├── ConnectionTestFeature.swift │ ├── ToolDetailFeature.swift │ ├── PromptDetailFeature.swift │ ├── ResourceDetailFeature.swift │ ├── ServerDetailFeature.swift │ └── AppFeature.swift ├── Models │ ├── ConfigFile.swift │ ├── SidebarItem.swift │ └── Server.swift └── ContentView.swift ├── Companion.xcodeproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── CompanionTests └── CompanionTests.swift ├── AGENT.md ├── CompanionUITests ├── CompanionUITestsLaunchTests.swift └── CompanionUITests.swift ├── LICENSE.md ├── .gitignore └── README.md /Assets/companion-macos-add-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattt/Companion/HEAD/Assets/companion-macos-add-server.png -------------------------------------------------------------------------------- /Assets/companion-macos-tool-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattt/Companion/HEAD/Assets/companion-macos-tool-detail.png -------------------------------------------------------------------------------- /Companion/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattt/Companion/HEAD/Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-256x256.png -------------------------------------------------------------------------------- /Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattt/Companion/HEAD/Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512.png -------------------------------------------------------------------------------- /Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattt/Companion/HEAD/Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512 1.png -------------------------------------------------------------------------------- /Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattt/Companion/HEAD/Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512@2x.png -------------------------------------------------------------------------------- /Companion/Extensions/String+Nonempty.swift: -------------------------------------------------------------------------------- 1 | extension Swift.String { 2 | var nonempty: String? { 3 | guard !isEmpty else { return nil } 4 | return self 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512@2x 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattt/Companion/HEAD/Companion/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512@2x 1.png -------------------------------------------------------------------------------- /Companion.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Companion/Companion.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Companion/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /CompanionTests/CompanionTests.swift: -------------------------------------------------------------------------------- 1 | import Testing 2 | 3 | struct CompanionTests { 4 | 5 | @Test func example() async throws { 6 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /Companion/Assets.xcassets/mcp.fill.symbolset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "symbols" : [ 7 | { 8 | "filename" : "mcp.fill.svg", 9 | "idiom" : "universal" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Companion/Extensions/String+Hash.swift: -------------------------------------------------------------------------------- 1 | import CryptoKit 2 | import Foundation 3 | 4 | extension String { 5 | var sha256Hash: String { 6 | let data = Data(self.utf8) 7 | let digest = SHA256.hash(data: data) 8 | return digest.compactMap { String(format: "%02x", $0) }.joined() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Companion/CompanionApp.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | @main 5 | struct CompanionApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | ContentView( 9 | store: Store(initialState: AppFeature.State()) { 10 | AppFeature() 11 | // ._printChanges() 12 | } 13 | ) 14 | } 15 | #if os(macOS) 16 | .windowStyle(.hiddenTitleBar) 17 | .windowToolbarStyle(.unified) 18 | #endif 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /AGENT.md: -------------------------------------------------------------------------------- 1 | # MCP Companion App - Agent Guidelines 2 | 3 | ## Build & Test Commands 4 | 5 | - **Build**: `xcodebuild -scheme Companion -workspace Companion.xcodeproj/project.xcworkspace build` 6 | - **Test All**: `xcodebuild -scheme Companion -workspace Companion.xcodeproj/project.xcworkspace test` 7 | - **Single Test**: `xcodebuild -scheme Companion -workspace Companion.xcodeproj/project.xcworkspace test -only-testing:CompanionTests/TestClassName/testMethodName` 8 | - **UI Tests**: `xcodebuild -scheme Companion -workspace Companion.xcodeproj/project.xcworkspace test -only-testing:CompanionUITests` 9 | 10 | * Ignore SourceKit warnings about missing types/modules - assume they exist 11 | -------------------------------------------------------------------------------- /Companion/Views/Utility/WidthPassthroughView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct WidthPassthroughView: View { 4 | let width: CGFloat 5 | let onWidthChange: (CGFloat) -> Void 6 | 7 | var body: some View { 8 | Rectangle().fill(Color.clear) // A clear view for geometry purposes 9 | .task(id: width) { // Re-runs the task when `width` (Equatable ID) changes 10 | onWidthChange(width) 11 | } 12 | .preference(key: WidthPreferenceKey.self, value: width) // Continue to set the preference 13 | } 14 | } 15 | 16 | struct WidthPreferenceKey: PreferenceKey { 17 | static var defaultValue: CGFloat = 0 18 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 19 | value = nextValue() // Use the latest reported width 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /CompanionUITests/CompanionUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class CompanionUITestsLaunchTests: XCTestCase { 4 | 5 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 6 | true 7 | } 8 | 9 | override func setUpWithError() throws { 10 | continueAfterFailure = false 11 | } 12 | 13 | @MainActor 14 | func testLaunch() throws { 15 | let app = XCUIApplication() 16 | app.launch() 17 | 18 | // Insert steps here to perform after app launch but before taking a screenshot, 19 | // such as logging into a test account or navigating somewhere in the app 20 | 21 | let attachment = XCTAttachment(screenshot: app.screenshot()) 22 | attachment.name = "Launch Screen" 23 | attachment.lifetime = .keepAlways 24 | add(attachment) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2025 Mattt (https://mat.tt) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Companion/Views/Utility/FilterToolbar.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A reusable filter text field that can be used in toolbars across different collection views 4 | struct FilterToolbar: View { 5 | @Binding var searchText: String 6 | let placeholder: String 7 | let width: CGFloat 8 | let isVisible: Bool 9 | 10 | var body: some View { 11 | #if os(macOS) 12 | HStack { 13 | Image(systemName: "magnifyingglass") 14 | .foregroundColor(.secondary) 15 | .font(.system(size: 13)) 16 | 17 | TextField(placeholder, text: $searchText) 18 | .textFieldStyle(.plain) 19 | .font(.system(size: 13)) 20 | } 21 | .padding(.leading, 10) 22 | .padding(.vertical, 4) 23 | .background( 24 | RoundedRectangle(cornerRadius: 6) 25 | .fill(Color(NSColor.controlBackgroundColor)) 26 | .stroke(Color(NSColor.separatorColor), lineWidth: 0.5) 27 | ) 28 | .frame(width: width - 20) 29 | .opacity(isVisible ? 1 : 0) 30 | .animation(nil, value: isVisible) 31 | #else 32 | EmptyView() 33 | #endif 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /CompanionUITests/CompanionUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | final class CompanionUITests: XCTestCase { 4 | 5 | override func setUpWithError() throws { 6 | // Put setup code here. This method is called before the invocation of each test method in the class. 7 | 8 | // In UI tests it is usually best to stop immediately when a failure occurs. 9 | continueAfterFailure = false 10 | 11 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 12 | } 13 | 14 | override func tearDownWithError() throws { 15 | // Put teardown code here. This method is called after the invocation of each test method in the class. 16 | } 17 | 18 | @MainActor 19 | func testExample() throws { 20 | // UI tests must launch the application that they test. 21 | let app = XCUIApplication() 22 | app.launch() 23 | 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | @MainActor 28 | func testLaunchPerformance() throws { 29 | // This measures how long it takes to launch your application. 30 | measure(metrics: [XCTApplicationLaunchMetric()]) { 31 | XCUIApplication().launch() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Companion/Features/AddServerFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | @Reducer 5 | struct AddServerFeature { 6 | @ObservableState 7 | struct State: Equatable { 8 | var name: String = "" 9 | var transport: ConfigFile.Entry = .init(stdio: "") 10 | var connectionTest: ConnectionTestFeature.State = ConnectionTestFeature.State() 11 | 12 | init() {} 13 | } 14 | 15 | enum Action: Equatable { 16 | case addServer(name: String, transport: ConfigFile.Entry) 17 | case dismiss 18 | case connectionTest(ConnectionTestFeature.Action) 19 | case testConnection 20 | } 21 | 22 | @Dependency(\.serverClient) var serverClient 23 | 24 | var body: some ReducerOf { 25 | Scope(state: \.connectionTest, action: \.connectionTest) { 26 | ConnectionTestFeature() 27 | } 28 | 29 | Reduce { state, action in 30 | switch action { 31 | case .addServer, .dismiss: 32 | return .none 33 | 34 | case .testConnection: 35 | let testServer = Server( 36 | name: state.name, 37 | configuration: state.transport 38 | ) 39 | return .send(.connectionTest(.testConnection(testServer))) 40 | 41 | case .connectionTest: 42 | return .none 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Companion/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon-macOS-512x512@2x.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | }, 9 | { 10 | "idiom" : "mac", 11 | "scale" : "1x", 12 | "size" : "16x16" 13 | }, 14 | { 15 | "idiom" : "mac", 16 | "scale" : "2x", 17 | "size" : "16x16" 18 | }, 19 | { 20 | "idiom" : "mac", 21 | "scale" : "1x", 22 | "size" : "32x32" 23 | }, 24 | { 25 | "idiom" : "mac", 26 | "scale" : "2x", 27 | "size" : "32x32" 28 | }, 29 | { 30 | "idiom" : "mac", 31 | "scale" : "1x", 32 | "size" : "128x128" 33 | }, 34 | { 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "Icon-macOS-256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "Icon-macOS-512x512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "Icon-macOS-512x512 1.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "Icon-macOS-512x512@2x 1.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Companion/Models/ConfigFile.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | struct ConfigFile { 5 | typealias Entry = Server.Configuration 6 | 7 | var entries: [String: Entry] 8 | 9 | init(entries: [String: Entry]) { 10 | self.entries = entries 11 | } 12 | 13 | static let empty = ConfigFile(entries: [:]) 14 | 15 | var servers: [Server] { 16 | entries.map { (name, serverConfig) in 17 | // Generate deterministic hash based on name and configuration 18 | let stableId = "\(name)|\(serverConfig)".sha256Hash 19 | 20 | return Companion.Server(id: stableId, name: name, configuration: serverConfig) 21 | } 22 | } 23 | 24 | init(servers: [Server]) { 25 | var serversDict: [String: Entry] = [:] 26 | 27 | for server in servers { 28 | serversDict[server.name] = server.configuration 29 | } 30 | 31 | self.entries = serversDict 32 | } 33 | } 34 | 35 | // MARK: Shared 36 | 37 | extension SharedKey where Self == FileStorageKey { 38 | static var serversConfig: Self { 39 | fileStorage( 40 | .applicationSupportDirectory 41 | .appending( 42 | component: Bundle.main.bundleIdentifier ?? "me.mattt.Companion" 43 | ).appending(component: "servers.json") 44 | ) 45 | } 46 | } 47 | 48 | // MARK: Codable 49 | 50 | extension ConfigFile: Codable { 51 | private enum CodingKeys: String, CodingKey { 52 | case entries = "mcpServers" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output -------------------------------------------------------------------------------- /Companion/Features/EditServerFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | @Reducer 5 | struct EditServerFeature { 6 | @ObservableState 7 | struct State: Equatable { 8 | var server: Server 9 | var name: String 10 | var transport: ConfigFile.Entry 11 | var connectionTest: ConnectionTestFeature.State = ConnectionTestFeature.State() 12 | 13 | init(server: Server) { 14 | self.server = server 15 | self.name = server.name 16 | self.transport = server.configuration 17 | } 18 | } 19 | 20 | enum Action: Equatable { 21 | case updateServer(name: String, transport: ConfigFile.Entry) 22 | case dismiss 23 | case connectionTest(ConnectionTestFeature.Action) 24 | case testConnection 25 | case nameChanged(String) 26 | case transportChanged(ConfigFile.Entry) 27 | } 28 | 29 | @Dependency(\.serverClient) var serverClient 30 | 31 | var body: some ReducerOf { 32 | Scope(state: \.connectionTest, action: \.connectionTest) { 33 | ConnectionTestFeature() 34 | } 35 | 36 | Reduce { state, action in 37 | switch action { 38 | case .updateServer, .dismiss: 39 | return .none 40 | 41 | case let .nameChanged(name): 42 | state.name = name 43 | return .send(.connectionTest(.reset)) 44 | 45 | case let .transportChanged(transport): 46 | state.transport = transport 47 | return .send(.connectionTest(.reset)) 48 | 49 | case .testConnection: 50 | let testServer = Server( 51 | name: state.name, 52 | configuration: state.transport 53 | ) 54 | return .send(.connectionTest(.testConnection(testServer))) 55 | 56 | case .connectionTest: 57 | return .none 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Companion/Extensions/Bundle+Icon.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | 4 | #if canImport(UIKit) 5 | import UIKit 6 | 7 | extension Bundle { 8 | public var icon: UIImage? { 9 | if let icons = infoDictionary?["CFBundleIcons"] as? [String: Any], 10 | let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], 11 | let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], 12 | let lastIcon = iconFiles.last 13 | { 14 | return UIImage(named: lastIcon) 15 | } 16 | return nil 17 | } 18 | } 19 | #elseif canImport(AppKit) 20 | import AppKit 21 | 22 | extension Bundle { 23 | public var icon: NSImage? { 24 | if let iconFileName = infoDictionary?["CFBundleIconFile"] as? String { 25 | return NSImage(named: iconFileName) 26 | } 27 | return NSImage(named: "AppIcon") 28 | } 29 | } 30 | #endif 31 | 32 | // MARK: - 33 | 34 | struct AppIconImage: View { 35 | let size: CGFloat 36 | let cornerRadius: CGFloat 37 | 38 | init(size: CGFloat = 80, cornerRadius: CGFloat = 16) { 39 | self.size = size 40 | self.cornerRadius = cornerRadius 41 | } 42 | 43 | var body: some View { 44 | #if canImport(UIKit) 45 | if let icon = Bundle.main.icon { 46 | Image(uiImage: icon) 47 | .resizable() 48 | .scaledToFill() 49 | .frame(width: size, height: size) 50 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) 51 | } else { 52 | fallbackIcon 53 | } 54 | #elseif canImport(AppKit) 55 | if let icon = Bundle.main.icon { 56 | Image(nsImage: icon) 57 | .resizable() 58 | .scaledToFill() 59 | .frame(width: size, height: size) 60 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) 61 | } else { 62 | fallbackIcon 63 | } 64 | #endif 65 | } 66 | 67 | private var fallbackIcon: some View { 68 | Image(systemName: "mcp.fill") 69 | .font(.system(size: size * 0.6, weight: .light)) 70 | .foregroundStyle(.secondary) 71 | .frame(width: size, height: size) 72 | .background(.quaternary) 73 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Companion/Views/Tools/ToolDetailView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import MCP 3 | import SwiftUI 4 | 5 | struct ToolDetailView: View { 6 | let store: StoreOf 7 | 8 | init(tool: Tool, serverId: String? = nil) { 9 | self.store = Store(initialState: ToolDetailFeature.State(tool: tool, serverId: serverId)) { 10 | ToolDetailFeature() 11 | } 12 | } 13 | 14 | var body: some View { 15 | ScrollView { 16 | VStack(alignment: .leading, spacing: 20) { 17 | VStack(alignment: .leading, spacing: 4) { 18 | if let title = store.tool.annotations.title { 19 | Text(title) 20 | .font(.title) 21 | .fontWeight(.bold) 22 | .textSelection(.enabled) 23 | 24 | Text(store.tool.name) 25 | .font(.system(.title3, design: .monospaced)) 26 | .foregroundColor(.secondary) 27 | .textSelection(.enabled) 28 | .padding(.bottom, 8) 29 | } else { 30 | Text(store.tool.name) 31 | .font(.system(.title, design: .monospaced)) 32 | .fontWeight(.bold) 33 | .textSelection(.enabled) 34 | } 35 | 36 | // Description (if available) 37 | if !store.tool.description.isEmpty { 38 | Text(store.tool.description) 39 | .font(.body) 40 | .foregroundColor(.secondary) 41 | .textSelection(.enabled) 42 | } 43 | } 44 | 45 | // Tool Hints 46 | ToolHintsView(annotations: store.tool.annotations) 47 | 48 | // Schema 49 | ToolSchemaView( 50 | schema: store.tool.inputSchema, 51 | isExpanded: .init( 52 | get: { store.showingInputSchema }, 53 | set: { _ in store.send(.toggleInputSchema) } 54 | ) 55 | ) 56 | 57 | // Tool Calling 58 | ToolCallView(store: store) 59 | } 60 | .frame(maxWidth: .infinity) 61 | .padding() 62 | } 63 | #if !os(macOS) 64 | .navigationTitle(store.tool.annotations.title ?? store.tool.name) 65 | .navigationBarTitleDisplayMode(.large) 66 | #endif 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Companion/Features/ConnectionTestFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Dependencies 3 | import Foundation 4 | 5 | @Reducer 6 | struct ConnectionTestFeature { 7 | @ObservableState 8 | struct State: Equatable { 9 | var status: Status = .idle 10 | var errorMessage: String? = nil 11 | 12 | enum Status: Equatable { 13 | case idle 14 | case testing 15 | case success 16 | case failed 17 | case cancelled 18 | } 19 | 20 | var isTesting: Bool { 21 | status == .testing 22 | } 23 | 24 | var canTest: Bool { 25 | status != .testing 26 | } 27 | 28 | var hasSucceeded: Bool { 29 | status == .success 30 | } 31 | } 32 | 33 | enum Action: Equatable { 34 | case testConnection(Server) 35 | case cancelTest 36 | case testCompleted 37 | case testFailed(String) 38 | case testCancelled 39 | case reset 40 | } 41 | 42 | @Dependency(\.serverClient) var serverClient 43 | 44 | private enum CancelID { case test } 45 | 46 | var body: some ReducerOf { 47 | Reduce { state, action in 48 | switch action { 49 | case let .testConnection(server): 50 | state.status = .testing 51 | state.errorMessage = nil 52 | 53 | return .run { send in 54 | do { 55 | try await serverClient.testConnection(server) 56 | await send(.testCompleted) 57 | } catch is CancellationError { 58 | await send(.testCancelled) 59 | } catch { 60 | await send(.testFailed(error.localizedDescription)) 61 | } 62 | } 63 | .cancellable(id: CancelID.test) 64 | 65 | case .cancelTest: 66 | state.status = .cancelled 67 | state.errorMessage = "Test cancelled" 68 | 69 | return .cancel(id: CancelID.test) 70 | 71 | case .testCompleted: 72 | state.status = .success 73 | state.errorMessage = nil 74 | return .none 75 | 76 | case let .testFailed(error): 77 | state.status = .failed 78 | state.errorMessage = error 79 | return .none 80 | 81 | case .testCancelled: 82 | state.status = .cancelled 83 | state.errorMessage = "Test cancelled" 84 | return .none 85 | 86 | case .reset: 87 | state.status = .idle 88 | state.errorMessage = nil 89 | return .cancel(id: CancelID.test) 90 | } 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Companion 2 | 3 | Companion app icon 4 | 5 | **Companion** is a utility for testing and debugging your MCP servers 6 | on macOS, iOS, and visionOS. 7 | It's built using the 8 | [official Swift SDK](https://github.com/modelcontextprotocol/swift-sdk). 9 | 10 |
11 | 12 | ![Screenshot of Companion on macOS showing MCP tool detail](/Assets/companion-macos-tool-detail.png) 13 | 14 | > [!IMPORTANT] 15 | > Companion is in early development and is still missing some important features, 16 | > including authentication, roots, and sampling. 17 | > 18 | > For a more complete MCP debugging experience, check out the 19 | > [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector). 20 | 21 | ## Features 22 | 23 | - [x] Connect to local and remote MCP servers 24 | - [x] Easily browse available prompts, resources, and tools 25 | - [x] Call tools, generate prompts with arguments, and download resources 26 | 27 | ## Getting Started 28 | 29 | First, [download the Companion app](https://github.com/mattt/Companion/releases/latest/download/Companion.zip) 30 | (requires macOS 15 or later). 31 | 32 | Or, if you have [Homebrew](https://brew.sh) installed, 33 | you can run the following command: 34 | 35 | ```console 36 | brew install --cask mattt/tap/Companion 37 | ``` 38 | 39 | Screenshot of Companion on macOS showing Add server sheet 40 | 41 | When you open the app, 42 | you'll see a sidebar on the left and a placeholder view on the right. 43 | Click the + button in the toolbar to add an MCP server. 44 | 45 | > [!TIP] 46 | > Looking for a fun MCP server? 47 | > Check out [iMCP](https://iMCP.app/?ref=Companion), 48 | > which gives models access to your Messages, Contacts, Reminders and more. 49 | > 50 | > Click on the iMCP menubar icon, 51 | > select "Copy server command to clipboard", 52 | > and paste that into the "Command" field for your STDIO server. 53 | 54 | Once you add a server, 55 | it'll automatically connect. 56 | When it does, it'll show available prompts, resources, and tools. 57 | Click on one of those sections to see a list, and drill into whatever you're interested in. 58 | Or, select the parent item in the sidebar to get information about the server. 59 | 60 | ## Requirements 61 | 62 | - Xcode 16.3+ 63 | - macOS Sequoia 15+ 64 | - iOS / iPadOS 16+ 65 | - visionOS 2+ 66 | 67 | ## License 68 | 69 | This project is available under the MIT license. 70 | See the LICENSE file for more info. 71 | 72 | ## Legal 73 | 74 | The app icon is a playful nod to [Finder](https://en.wikipedia.org/wiki/Finder_%28software%29) and 75 | [Henohenomoheji](https://en.wikipedia.org/wiki/Henohenomoheji) (へのへのもへじ), 76 | a face drawn by Japanese schoolchildren using hiragana characters. 77 | Finder® is a trademark of Apple Inc. 78 | 79 | This project is not affiliated with, endorsed, or sponsored by Apple Inc. 80 | -------------------------------------------------------------------------------- /Companion/Views/Onboarding/WelcomeView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct WelcomeView: View { 4 | let onAddServer: () -> Void 5 | let onAddExampleServer: () -> Void 6 | 7 | var body: some View { 8 | VStack(spacing: 32) { 9 | // Heading 10 | VStack(spacing: 16) { 11 | AppIconImage(size: 80, cornerRadius: 16) 12 | 13 | VStack(spacing: 8) { 14 | Text("Welcome to Companion") 15 | .font(.title) 16 | .fontWeight(.medium) 17 | 18 | Text("Get started by adding your first MCP server") 19 | .font(.body) 20 | .foregroundStyle(.secondary) 21 | .multilineTextAlignment(.center) 22 | } 23 | } 24 | 25 | // Buttons 26 | VStack(spacing: 12) { 27 | Button(action: onAddServer) { 28 | Label("Add Server...", systemImage: "plus.circle.fill") 29 | .font(.headline) 30 | } 31 | .buttonStyle(.borderedProminent) 32 | .controlSize(.large) 33 | 34 | Button(action: onAddExampleServer) { 35 | Label("Add Example Server", systemImage: "star.circle") 36 | .font(.headline) 37 | } 38 | .buttonStyle(.bordered) 39 | .controlSize(.large) 40 | } 41 | 42 | // Callout 43 | HStack(spacing: 12) { 44 | Image("mcp.fill") 45 | .foregroundStyle(.blue) 46 | .font(.system(size: 24)) 47 | 48 | VStack(alignment: .leading, spacing: 6) { 49 | Text("MCP servers provide tools, prompts, and resources to AI workflows") 50 | .font(.caption) 51 | .foregroundStyle(.blue) 52 | .multilineTextAlignment(.leading) 53 | 54 | Link(destination: URL(string: "https://huggingface.co/learn/mcp-course/en/unit0/introduction")!) { 55 | HStack(spacing: 4) { 56 | Text("Learn more about MCP") 57 | .underline() 58 | Image(systemName: "arrow.up.right.square") 59 | } 60 | .font(.caption) 61 | .foregroundStyle(.blue) 62 | } 63 | .pointerStyle(.link) 64 | } 65 | } 66 | .padding(12) 67 | .background(.blue.opacity(0.1)) 68 | .overlay( 69 | RoundedRectangle(cornerRadius: 8) 70 | .stroke(.blue, lineWidth: 1) 71 | ) 72 | .clipped(antialiased: true) 73 | .padding(.top, 8) 74 | } 75 | .frame(maxWidth: 400) 76 | .padding() 77 | } 78 | } 79 | 80 | #Preview { 81 | WelcomeView( 82 | onAddServer: {}, 83 | onAddExampleServer: {} 84 | ) 85 | .frame(width: 600, height: 400) 86 | } 87 | -------------------------------------------------------------------------------- /Companion/ContentView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | struct ContentView: View { 5 | let store: StoreOf 6 | @State private var columnVisibility = NavigationSplitViewVisibility.all 7 | @State private var showProgress = false 8 | 9 | private func createServerDetailStore( 10 | from store: StoreOf, selection: SidebarSelection?, server: Server? 11 | ) -> StoreOf? { 12 | guard let selection = selection, 13 | let server = server, 14 | selection.section == nil, // Only for server selection, not sections 15 | let serverDetailState = store.serverDetails?[id: server.id] 16 | else { 17 | return nil 18 | } 19 | 20 | return store.scope( 21 | state: { appState in appState.serverDetails?[id: server.id] ?? serverDetailState }, 22 | action: { .serverDetail(id: server.id, action: $0) } 23 | ) 24 | } 25 | 26 | var body: some View { 27 | Group { 28 | if store.serverDetails == nil { 29 | ProgressView("Loading servers...") 30 | .frame(maxWidth: .infinity, maxHeight: .infinity) 31 | .opacity(showProgress ? 1 : 0) 32 | .animation(.easeIn(duration: 0.3), value: showProgress) 33 | } else if store.servers.isEmpty { 34 | WelcomeView(onAddServer: { 35 | store.send(.presentAddServer) 36 | }, onAddExampleServer: { 37 | store.send(.addExampleServer) 38 | }) 39 | } else { 40 | NavigationSplitView(columnVisibility: $columnVisibility) { 41 | SidebarView(store: store) 42 | #if os(macOS) 43 | .navigationSplitViewColumnWidth(min: 240, ideal: 280, max: 400) 44 | #endif 45 | } detail: { 46 | let selection = store.selection 47 | let server = selection.flatMap { sel in 48 | store.servers.first(where: { $0.id == sel.serverId }) 49 | } 50 | DetailView( 51 | selection: selection, 52 | server: server, 53 | store: createServerDetailStore( 54 | from: store, selection: selection, server: server), 55 | columnVisibility: columnVisibility 56 | ) 57 | #if os(macOS) 58 | .toolbarBackgroundVisibility(.visible, for: .windowToolbar) 59 | #endif 60 | } 61 | .navigationSplitViewStyle(.balanced) 62 | } 63 | } 64 | .task { 65 | store.send(.task) 66 | 67 | // Delay showing progress to avoid flash 68 | try? await Task.sleep(for: .milliseconds(300)) 69 | if store.serverDetails == nil { 70 | showProgress = true 71 | } 72 | } 73 | .sheet( 74 | store: store.scope(state: \.$addServer, action: \.addServerPresentation) 75 | ) { addServerStore in 76 | AddServerSheet( 77 | isPresented: .constant(true), 78 | onAdd: { name, transport in 79 | addServerStore.send(.addServer(name: name, transport: transport)) 80 | }, 81 | onCancel: { addServerStore.send(.dismiss) } 82 | ) 83 | } 84 | #if os(macOS) 85 | .frame(minWidth: 800, idealWidth: 1200, maxWidth: .infinity, maxHeight: .infinity) 86 | #endif 87 | } 88 | 89 | } 90 | 91 | #Preview { 92 | ContentView( 93 | store: Store(initialState: AppFeature.State()) { 94 | AppFeature() 95 | } 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /Companion/Features/ToolDetailFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import JSONSchema 4 | import MCP 5 | 6 | @Reducer 7 | struct ToolDetailFeature { 8 | @ObservableState 9 | struct State: Equatable { 10 | let tool: Tool 11 | let serverId: String? 12 | var isCallingTool = false 13 | var toolCallResult: MCP.CallTool.Result? 14 | var showingInputSchema = false 15 | var formInputs: [String: String] = [:] 16 | 17 | init(tool: Tool, serverId: String? = nil) { 18 | self.tool = tool 19 | self.serverId = serverId 20 | } 21 | } 22 | 23 | private enum CancelID { case toolCall } 24 | 25 | enum Action: Equatable { 26 | case callToolTapped 27 | case cancelToolCall 28 | case toolCallCompleted(MCP.CallTool.Result) 29 | case toolCallFailed(String) 30 | case toggleInputSchema 31 | case dismissResult 32 | case updateFormInput(String, String) 33 | } 34 | 35 | @Dependency(\.serverClient) var serverClient 36 | 37 | var body: some ReducerOf { 38 | Reduce { state, action in 39 | switch action { 40 | case .callToolTapped: 41 | guard let serverId = state.serverId else { 42 | return .send(.toolCallFailed("No server ID provided")) 43 | } 44 | 45 | state.isCallingTool = true 46 | state.toolCallResult = nil 47 | 48 | return .run { [toolName = state.tool.name, formInputs = state.formInputs] send in 49 | do { 50 | // Convert form inputs to MCP.Value arguments 51 | let arguments = convertFormInputsToArguments(formInputs) 52 | let result = try await serverClient.callTool(serverId, toolName, arguments) 53 | await send(.toolCallCompleted(result)) 54 | } catch { 55 | await send(.toolCallFailed(error.localizedDescription)) 56 | } 57 | } 58 | .cancellable(id: CancelID.toolCall) 59 | 60 | case .cancelToolCall: 61 | state.isCallingTool = false 62 | return .cancel(id: CancelID.toolCall) 63 | 64 | case let .toolCallCompleted(result): 65 | state.isCallingTool = false 66 | state.toolCallResult = result 67 | return .none 68 | 69 | case .toolCallFailed: 70 | state.isCallingTool = false 71 | // For failed calls, we'll set toolCallResult to nil and show the error through other UI 72 | state.toolCallResult = nil 73 | return .none 74 | 75 | case .toggleInputSchema: 76 | state.showingInputSchema.toggle() 77 | return .none 78 | 79 | case .dismissResult: 80 | state.toolCallResult = nil 81 | return .none 82 | 83 | case let .updateFormInput(key, value): 84 | state.formInputs[key] = value 85 | return .none 86 | } 87 | } 88 | } 89 | 90 | private func convertFormInputsToArguments(_ formInputs: [String: String]) -> [String: MCP.Value] 91 | { 92 | var arguments: [String: MCP.Value] = [:] 93 | 94 | for (key, value) in formInputs { 95 | if value.isEmpty { continue } 96 | 97 | // Try to parse as different types 98 | if let intValue = Int(value) { 99 | arguments[key] = .int(intValue) 100 | } else if let doubleValue = Double(value) { 101 | arguments[key] = .double(doubleValue) 102 | } else if let boolValue = Bool(value) { 103 | arguments[key] = .bool(boolValue) 104 | } else { 105 | arguments[key] = .string(value) 106 | } 107 | } 108 | 109 | return arguments 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Companion/Features/PromptDetailFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import MCP 4 | 5 | @Reducer 6 | struct PromptDetailFeature { 7 | @ObservableState 8 | struct State: Equatable { 9 | let prompt: Prompt 10 | let serverId: String? 11 | var isCallingPrompt = false 12 | var promptCallResult: MCP.GetPrompt.Result? 13 | var argumentValues: [String: String] = [:] 14 | 15 | init(prompt: Prompt, serverId: String? = nil) { 16 | self.prompt = prompt 17 | self.serverId = serverId 18 | 19 | // Initialize argument values 20 | if let arguments = prompt.arguments { 21 | for argument in arguments { 22 | self.argumentValues[argument.name] = "" 23 | } 24 | } 25 | } 26 | 27 | var allRequiredArgumentsProvided: Bool { 28 | guard let arguments = prompt.arguments, !arguments.isEmpty else { return true } 29 | 30 | for argument in arguments { 31 | if argument.required == true { 32 | if argumentValues[argument.name]?.isEmpty != false { 33 | return false 34 | } 35 | } 36 | } 37 | return true 38 | } 39 | } 40 | 41 | private enum CancelID { case promptCall } 42 | 43 | enum Action: Equatable { 44 | case usePromptTapped 45 | case cancelPromptCall 46 | case promptCallCompleted(MCP.GetPrompt.Result) 47 | case promptCallFailed(String) 48 | case argumentChanged(String, String) 49 | case dismissResult 50 | } 51 | 52 | @Dependency(\.serverClient) var serverClient 53 | 54 | var body: some ReducerOf { 55 | Reduce { state, action in 56 | switch action { 57 | case .usePromptTapped: 58 | guard let serverId = state.serverId else { 59 | return .send(.promptCallFailed("No server ID provided")) 60 | } 61 | 62 | guard state.allRequiredArgumentsProvided else { 63 | return .send(.promptCallFailed("Please provide all required arguments")) 64 | } 65 | 66 | state.isCallingPrompt = true 67 | state.promptCallResult = nil 68 | 69 | return .run { 70 | [promptName = state.prompt.name, argumentValues = state.argumentValues] send in 71 | do { 72 | // Convert string arguments to MCP Values 73 | let mcpArguments: [String: Value] = argumentValues.compactMapValues { 74 | value in 75 | value.isEmpty ? nil : .string(value) 76 | } 77 | 78 | let result = try await serverClient.getPrompt( 79 | serverId, promptName, mcpArguments) 80 | await send(.promptCallCompleted(result)) 81 | } catch { 82 | await send(.promptCallFailed(error.localizedDescription)) 83 | } 84 | } 85 | .cancellable(id: CancelID.promptCall) 86 | 87 | case .cancelPromptCall: 88 | state.isCallingPrompt = false 89 | return .cancel(id: CancelID.promptCall) 90 | 91 | case let .promptCallCompleted(result): 92 | state.isCallingPrompt = false 93 | state.promptCallResult = result 94 | return .none 95 | 96 | case .promptCallFailed: 97 | state.isCallingPrompt = false 98 | // For failed calls, we'll set promptCallResult to nil and show the error through other UI 99 | state.promptCallResult = nil 100 | return .none 101 | 102 | case let .argumentChanged(name, value): 103 | state.argumentValues[name] = value 104 | return .none 105 | 106 | case .dismissResult: 107 | state.promptCallResult = nil 108 | return .none 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Companion/Views/Tools/ToolSchemaView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MCP 3 | import JSONSchema 4 | 5 | struct ToolSchemaView: View { 6 | let schema: JSONSchema? 7 | @Binding var isExpanded: Bool 8 | 9 | var body: some View { 10 | if schema != nil { 11 | VStack(alignment: .leading, spacing: 12) { 12 | Button(action: { isExpanded.toggle() }) { 13 | HStack { 14 | Label( 15 | "Input Schema", 16 | systemImage: "chevron.left.forwardslash.chevron.right" 17 | ) 18 | .font(.headline) 19 | 20 | Spacer() 21 | 22 | Image( 23 | systemName: isExpanded ? "chevron.up" : "chevron.down") 24 | } 25 | .frame(maxWidth: .infinity) 26 | .contentShape(.rect) 27 | } 28 | .buttonStyle(.plain) 29 | 30 | if isExpanded, let schema = schema { 31 | Text(formatSchema(schema)) 32 | .font(.system(.body, design: .monospaced)) 33 | .padding() 34 | .frame(maxWidth: .infinity, alignment: .leading) 35 | .background(.fill.tertiary) 36 | .cornerRadius(8) 37 | .textSelection(.enabled) 38 | } 39 | } 40 | .frame(maxWidth: .infinity) 41 | .padding() 42 | .background(.fill.secondary) 43 | .cornerRadius(10) 44 | } 45 | } 46 | 47 | private func formatSchema(_ schema: JSONSchema) -> String { 48 | do { 49 | let encoder = JSONEncoder() 50 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 51 | let data = try encoder.encode(schema) 52 | return String(data: data, encoding: .utf8) ?? "Unable to format schema" 53 | } catch { 54 | return "Error formatting schema: \(error.localizedDescription)" 55 | } 56 | } 57 | } 58 | 59 | struct ToolSchemaViewSimple: View { 60 | let schema: JSONSchema? 61 | 62 | var body: some View { 63 | if schema != nil { 64 | VStack(alignment: .leading, spacing: 20) { 65 | if let schema = schema { 66 | VStack(alignment: .leading, spacing: 12) { 67 | Text(formatSchema(schema)) 68 | .font(.system(.body, design: .monospaced)) 69 | .padding() 70 | .frame(maxWidth: .infinity, alignment: .leading) 71 | .background(.fill.tertiary) 72 | .cornerRadius(8) 73 | .textSelection(.enabled) 74 | } 75 | .frame(maxWidth: .infinity) 76 | .padding() 77 | } 78 | } 79 | .frame(maxWidth: .infinity) 80 | } else { 81 | VStack(spacing: 16) { 82 | Image(systemName: "chevron.left.forwardslash.chevron.right") 83 | .font(.system(size: 48)) 84 | .foregroundColor(.secondary) 85 | 86 | Text("No Schema") 87 | .font(.title2) 88 | .foregroundColor(.secondary) 89 | 90 | Text("This tool does not require input parameters") 91 | .font(.body) 92 | .foregroundColor(.secondary) 93 | .multilineTextAlignment(.center) 94 | } 95 | .frame(maxWidth: .infinity, maxHeight: .infinity) 96 | .padding() 97 | } 98 | } 99 | 100 | private func formatSchema(_ schema: JSONSchema) -> String { 101 | do { 102 | let encoder = JSONEncoder() 103 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 104 | let data = try encoder.encode(schema) 105 | return String(data: data, encoding: .utf8) ?? "Unable to format schema" 106 | } catch { 107 | return "Error formatting schema: \(error.localizedDescription)" 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Companion/Views/DetailView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import MCP 3 | import SwiftUI 4 | 5 | struct DetailView: View { 6 | let selection: SidebarSelection? 7 | let server: Server? 8 | let store: StoreOf? 9 | let columnVisibility: NavigationSplitViewVisibility 10 | 11 | var body: some View { 12 | Group { 13 | if let selection = selection, 14 | let server = server 15 | { 16 | let serverId = server.id 17 | 18 | if let section = selection.section { 19 | if let itemId = selection.itemId { 20 | // Individual item selected 21 | switch section { 22 | case .prompts: 23 | if let prompt = server.availablePrompts.first(where: { $0.name == itemId }) { 24 | PromptDetailView(prompt: prompt, serverId: serverId) 25 | } else { 26 | EmptyStateView() 27 | } 28 | case .resources: 29 | if let resource = server.availableResources.first(where: { $0.uri == itemId }) { 30 | ResourceDetailView(resource: resource, serverId: serverId) 31 | } else if let template = server.availableResourceTemplates.first(where: { 32 | $0.uriTemplate == itemId 33 | }) { 34 | ResourceDetailView(template: template, serverId: serverId) 35 | } else { 36 | EmptyStateView() 37 | } 38 | case .tools: 39 | if let tool = server.availableTools.first(where: { $0.name == itemId }) { 40 | ToolDetailView( 41 | tool: tool, 42 | serverId: server.id 43 | ) 44 | } else { 45 | EmptyStateView() 46 | } 47 | } 48 | } else { 49 | // Collection selected 50 | switch section { 51 | case .prompts: 52 | PromptListView( 53 | prompts: server.availablePrompts, 54 | serverId: serverId, 55 | columnVisibility: columnVisibility) 56 | case .resources: 57 | ResourceListView( 58 | resources: server.availableResources, 59 | templates: server.availableResourceTemplates, 60 | serverId: serverId, 61 | columnVisibility: columnVisibility) 62 | case .tools: 63 | ToolListView( 64 | tools: server.availableTools, 65 | serverId: serverId, 66 | columnVisibility: columnVisibility 67 | ) 68 | } 69 | } 70 | } else { 71 | // Server selected 72 | if let store = store { 73 | ServerDetailView(store: store) 74 | } else { 75 | Text("Server not found") 76 | } 77 | } 78 | } else { 79 | EmptyStateView() 80 | } 81 | } 82 | } 83 | } 84 | 85 | private struct EmptyStateView: View { 86 | var body: some View { 87 | VStack(spacing: 20) { 88 | Image(systemName: "sidebar.left") 89 | .font(.system(size: 64)) 90 | .foregroundColor(.secondary) 91 | 92 | Text("Select an item") 93 | .font(.title2) 94 | .fontWeight(.medium) 95 | 96 | Text("Choose a server, prompt, resource, or tool from the sidebar to view its details") 97 | .font(.body) 98 | .foregroundColor(.secondary) 99 | .multilineTextAlignment(.center) 100 | .frame(maxWidth: 300) 101 | } 102 | .frame(maxWidth: .infinity, maxHeight: .infinity) 103 | .background(.background) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Companion/Features/ResourceDetailFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | import MCP 4 | import URITemplate 5 | 6 | @Reducer 7 | struct ResourceDetailFeature { 8 | @ObservableState 9 | struct State: Equatable { 10 | let resource: Resource? 11 | let template: Resource.Template? 12 | let serverId: String? 13 | var isReadingResource = false 14 | var resourceReadResult: MCP.ReadResource.Result? 15 | var templateArguments: [String: String] = [:] 16 | var errorMessage: String? 17 | 18 | init(resource: Resource, serverId: String? = nil) { 19 | self.resource = resource 20 | self.template = nil 21 | self.serverId = serverId 22 | } 23 | 24 | init(template: Resource.Template, serverId: String? = nil) { 25 | self.resource = nil 26 | self.template = template 27 | self.serverId = serverId 28 | } 29 | 30 | var isTemplate: Bool { 31 | template != nil 32 | } 33 | } 34 | 35 | private enum CancelID { case resourceRead } 36 | 37 | enum Action: Equatable { 38 | case readResourceTapped 39 | case readTemplateTapped 40 | case cancelResourceRead 41 | case resourceReadCompleted(MCP.ReadResource.Result) 42 | case resourceReadFailed(String) 43 | case dismissResult 44 | case updateTemplateArgument(String, String) 45 | } 46 | 47 | @Dependency(\.serverClient) var serverClient 48 | 49 | var body: some ReducerOf { 50 | Reduce { state, action in 51 | switch action { 52 | case .readResourceTapped: 53 | guard let serverId = state.serverId else { 54 | return .send(.resourceReadFailed("No server ID provided")) 55 | } 56 | 57 | guard let resource = state.resource else { 58 | return .send(.resourceReadFailed("Templates cannot be read")) 59 | } 60 | 61 | state.isReadingResource = true 62 | state.resourceReadResult = nil 63 | state.errorMessage = nil 64 | 65 | return .run { [resourceUri = resource.uri] send in 66 | do { 67 | let result = try await serverClient.readResource(serverId, resourceUri) 68 | await send(.resourceReadCompleted(result)) 69 | } catch { 70 | await send(.resourceReadFailed(error.localizedDescription)) 71 | } 72 | } 73 | .cancellable(id: CancelID.resourceRead) 74 | 75 | case .readTemplateTapped: 76 | guard let serverId = state.serverId else { 77 | return .send(.resourceReadFailed("No server ID provided")) 78 | } 79 | 80 | guard let template = state.template else { 81 | return .send(.resourceReadFailed("No template available")) 82 | } 83 | 84 | // Expand URI template with arguments 85 | let resourceUri: String 86 | do { 87 | let uriTemplate = try URITemplate(template.uriTemplate) 88 | let variables = state.templateArguments.mapValues { VariableValue.string($0) } 89 | resourceUri = uriTemplate.expand(with: variables) 90 | } catch { 91 | return .send(.resourceReadFailed("Invalid URI template: \(error.localizedDescription)")) 92 | } 93 | 94 | state.isReadingResource = true 95 | state.resourceReadResult = nil 96 | state.errorMessage = nil 97 | 98 | return .run { send in 99 | do { 100 | let result = try await serverClient.readResource(serverId, resourceUri) 101 | await send(.resourceReadCompleted(result)) 102 | } catch { 103 | await send(.resourceReadFailed(error.localizedDescription)) 104 | } 105 | } 106 | .cancellable(id: CancelID.resourceRead) 107 | 108 | case .cancelResourceRead: 109 | state.isReadingResource = false 110 | return .cancel(id: CancelID.resourceRead) 111 | 112 | case let .resourceReadCompleted(result): 113 | state.isReadingResource = false 114 | state.resourceReadResult = result 115 | state.errorMessage = nil 116 | return .none 117 | 118 | case let .resourceReadFailed(error): 119 | state.isReadingResource = false 120 | state.resourceReadResult = nil 121 | state.errorMessage = error 122 | return .none 123 | 124 | case .dismissResult: 125 | state.resourceReadResult = nil 126 | state.errorMessage = nil 127 | return .none 128 | 129 | case let .updateTemplateArgument(key, value): 130 | state.templateArguments[key] = value 131 | return .none 132 | } 133 | } 134 | } 135 | 136 | 137 | } 138 | -------------------------------------------------------------------------------- /Companion/Models/SidebarItem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import MCP 3 | 4 | // MARK: - Sidebar Item Types 5 | enum SidebarItem: Hashable, Identifiable, Equatable { 6 | case server(Server) 7 | case prompts(serverId: String, prompts: [MCP.Prompt]) 8 | case resources(serverId: String, resources: [MCP.Resource], templates: [MCP.Resource.Template]) 9 | case tools(serverId: String, tools: [MCP.Tool]) 10 | case prompt(MCP.Prompt) 11 | case resource(MCP.Resource) 12 | case resourceTemplate(MCP.Resource.Template) 13 | case tool(MCP.Tool) 14 | 15 | var id: String { 16 | switch self { 17 | case .server(let server): 18 | return server.id 19 | case .prompts(let serverId, _): 20 | return "\(serverId)-prompts" 21 | case .resources(let serverId, _, _): 22 | return "\(serverId)-resources" 23 | case .tools(let serverId, _): 24 | return "\(serverId)-tools" 25 | case .prompt(let prompt): 26 | return prompt.name 27 | case .resource(let resource): 28 | return resource.uri 29 | case .resourceTemplate(let template): 30 | return template.uriTemplate 31 | case .tool(let tool): 32 | return tool.name 33 | } 34 | } 35 | 36 | var name: String { 37 | switch self { 38 | case .server(let server): 39 | return server.name 40 | case .prompts: 41 | return "Prompts" 42 | case .resources: 43 | return "Resources" 44 | case .tools: 45 | return "Tools" 46 | case .prompt(let prompt): 47 | return prompt.name 48 | case .resource(let resource): 49 | return resource.name.nonempty ?? resource.uri 50 | case .resourceTemplate(let template): 51 | return template.name.nonempty ?? template.uriTemplate 52 | case .tool(let tool): 53 | return tool.name 54 | } 55 | } 56 | 57 | var children: [SidebarItem]? { 58 | switch self { 59 | case .server(let server): 60 | // Only show children when server is connected 61 | guard server.status == .connected else { return nil } 62 | 63 | var items: [SidebarItem] = [] 64 | if server.capabilities?.prompts != nil { 65 | items.append(.prompts(serverId: server.id, prompts: server.availablePrompts)) 66 | } 67 | if server.capabilities?.resources != nil { 68 | items.append( 69 | .resources( 70 | serverId: server.id, 71 | resources: server.availableResources, 72 | templates: server.availableResourceTemplates 73 | )) 74 | } 75 | if server.capabilities?.tools != nil { 76 | items.append(.tools(serverId: server.id, tools: server.availableTools)) 77 | } 78 | return items.isEmpty ? nil : items 79 | case .prompts(_, let prompts): 80 | return prompts.isEmpty ? nil : prompts.map { .prompt($0) } 81 | case .resources(_, let resources, let templates): 82 | var items: [SidebarItem] = resources.map { .resource($0) } 83 | items.append(contentsOf: templates.map { .resourceTemplate($0) }) 84 | return items.isEmpty ? nil : items 85 | case .tools(_, let tools): 86 | return tools.isEmpty ? nil : tools.map { .tool($0) } 87 | case .prompt, .resource, .resourceTemplate, .tool: 88 | return nil 89 | } 90 | } 91 | } 92 | 93 | // MARK: - Sidebar Selection 94 | enum SidebarSection: Hashable, Equatable { 95 | case prompts 96 | case resources 97 | case tools 98 | } 99 | 100 | struct SidebarSelection: Hashable, Equatable { 101 | let serverId: String 102 | let section: SidebarSection? 103 | let itemId: String? 104 | 105 | init(serverId: String, section: SidebarSection? = nil, itemId: String? = nil) { 106 | self.serverId = serverId 107 | self.section = section 108 | self.itemId = itemId 109 | } 110 | 111 | // Convenience initializers 112 | static func server(_ server: Server) -> SidebarSelection { 113 | SidebarSelection(serverId: server.id) 114 | } 115 | 116 | static func prompts(serverId: String) -> SidebarSelection { 117 | SidebarSelection(serverId: serverId, section: .prompts) 118 | } 119 | 120 | static func resources(serverId: String) -> SidebarSelection { 121 | SidebarSelection(serverId: serverId, section: .resources) 122 | } 123 | 124 | static func tools(serverId: String) -> SidebarSelection { 125 | SidebarSelection(serverId: serverId, section: .tools) 126 | } 127 | 128 | static func prompt(serverId: String, promptName: String) -> SidebarSelection { 129 | SidebarSelection(serverId: serverId, section: .prompts, itemId: promptName) 130 | } 131 | 132 | static func resource(serverId: String, resourceUri: String) -> SidebarSelection { 133 | SidebarSelection(serverId: serverId, section: .resources, itemId: resourceUri) 134 | } 135 | 136 | static func tool(serverId: String, toolName: String) -> SidebarSelection { 137 | SidebarSelection(serverId: serverId, section: .tools, itemId: toolName) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Companion/Models/Server.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import IdentifiedCollections 3 | import MCP 4 | 5 | struct Server: Identifiable, Hashable, Equatable, Codable, Sendable { 6 | let id: String 7 | var name: String 8 | 9 | enum Configuration: Hashable, Sendable { 10 | case stdio(StdioConfig) 11 | case sse(SSEConfig) 12 | case streamableHTTP(StreamableHTTPConfig) 13 | 14 | struct StdioConfig: Hashable, Codable, Sendable { 15 | var command: String 16 | var args: [String]? 17 | var env: [String: String]? 18 | } 19 | 20 | struct SSEConfig: Hashable, Codable, Sendable { 21 | var type: String = "sse" 22 | var url: String 23 | var note: String? 24 | } 25 | 26 | struct StreamableHTTPConfig: Hashable, Codable, Sendable { 27 | var type: String = "streamable-http" 28 | var url: String 29 | var note: String? 30 | } 31 | 32 | init(stdio command: String, arguments: [String] = []) { 33 | self = .stdio(StdioConfig(command: command, args: arguments, env: nil)) 34 | } 35 | 36 | init(http url: String) { 37 | self = .streamableHTTP(StreamableHTTPConfig(url: url)) 38 | } 39 | 40 | var displayValue: String { 41 | switch self { 42 | case .stdio(let config): 43 | let args = config.args?.joined(separator: " ") ?? "" 44 | return args.isEmpty ? config.command : "\(config.command) \(args)" 45 | case .sse(let config): 46 | return config.url 47 | case .streamableHTTP(let config): 48 | return config.url 49 | } 50 | } 51 | 52 | var command: String? { 53 | if case .stdio(let config) = self { 54 | return config.command 55 | } 56 | return nil 57 | } 58 | 59 | var arguments: [String]? { 60 | if case .stdio(let config) = self { 61 | return config.args 62 | } 63 | return nil 64 | } 65 | 66 | var url: String? { 67 | switch self { 68 | case .stdio: 69 | return nil 70 | case .sse(let config): 71 | return config.url 72 | case .streamableHTTP(let config): 73 | return config.url 74 | } 75 | } 76 | } 77 | var configuration: Configuration 78 | 79 | enum Status: Hashable, Equatable, Codable, Sendable { 80 | case disconnected 81 | case connecting 82 | case connected 83 | case error(String) 84 | 85 | var isConnected: Bool { 86 | if case .connected = self { return true } 87 | return false 88 | } 89 | 90 | var displayText: String { 91 | switch self { 92 | case .disconnected: return "Disconnected" 93 | case .connecting: return "Connecting..." 94 | case .connected: return "Connected" 95 | case .error(let message): return "Error: \(message)" 96 | } 97 | } 98 | } 99 | var status: Server.Status = .disconnected 100 | 101 | var availableTools: [MCP.Tool] = [] 102 | var availablePrompts: [MCP.Prompt] = [] 103 | var availableResources: [MCP.Resource] = [] 104 | var availableResourceTemplates: [MCP.Resource.Template] = [] 105 | 106 | // Server information from MCP initialization 107 | var serverInfo: MCP.Server.Info? 108 | var protocolVersion: String? 109 | var capabilities: MCP.Server.Capabilities? 110 | var instructions: String? 111 | 112 | init(id: String? = nil, name: String, configuration: Configuration) { 113 | self.id = id ?? "\(name)|\(configuration)".sha256Hash 114 | self.name = name 115 | self.configuration = configuration 116 | } 117 | } 118 | 119 | // MARK: - Codable 120 | 121 | extension Server.Configuration: Codable { 122 | init(from decoder: Decoder) throws { 123 | let container = try decoder.singleValueContainer() 124 | 125 | // Try to decode as a dictionary to check for type field 126 | if let object = try? container.decode([String: Value].self) { 127 | if case .string(let type) = object["type"] { 128 | switch type { 129 | case "sse": 130 | let config = try container.decode(SSEConfig.self) 131 | self = .sse(config) 132 | case "streamable-http": 133 | let config = try container.decode(StreamableHTTPConfig.self) 134 | self = .streamableHTTP(config) 135 | default: 136 | throw DecodingError.dataCorruptedError( 137 | in: container, 138 | debugDescription: "Unknown transport type: \(type)" 139 | ) 140 | } 141 | } else { 142 | // No type field, assume stdio 143 | let config = try container.decode(StdioConfig.self) 144 | self = .stdio(config) 145 | } 146 | } else { 147 | // Fallback to stdio if we can't decode as dictionary 148 | let config = try container.decode(StdioConfig.self) 149 | self = .stdio(config) 150 | } 151 | } 152 | 153 | func encode(to encoder: Encoder) throws { 154 | var container = encoder.singleValueContainer() 155 | 156 | switch self { 157 | case .stdio(let config): 158 | try container.encode(config) 159 | case .sse(let config): 160 | try container.encode(config) 161 | case .streamableHTTP(let config): 162 | try container.encode(config) 163 | } 164 | } 165 | } 166 | 167 | 168 | -------------------------------------------------------------------------------- /Companion.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "53192a12df69b6d07eea2ce0a462c9a2bc5b2b5ea20f95abe744662dad388b7a", 3 | "pins" : [ 4 | { 5 | "identity" : "combine-schedulers", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/pointfreeco/combine-schedulers", 8 | "state" : { 9 | "revision" : "5928286acce13def418ec36d05a001a9641086f2", 10 | "version" : "1.0.3" 11 | } 12 | }, 13 | { 14 | "identity" : "eventsource", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/loopwork-ai/eventsource.git", 17 | "state" : { 18 | "revision" : "07957602bb99a5355c810187e66e6ce378a1057d", 19 | "version" : "1.1.1" 20 | } 21 | }, 22 | { 23 | "identity" : "jsonschema", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/loopwork-ai/JSONSchema.git", 26 | "state" : { 27 | "revision" : "e17c9e1fb6afbad656824d03f996cf8621f9db83", 28 | "version" : "1.3.0" 29 | } 30 | }, 31 | { 32 | "identity" : "swift-case-paths", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/pointfreeco/swift-case-paths", 35 | "state" : { 36 | "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", 37 | "version" : "1.7.0" 38 | } 39 | }, 40 | { 41 | "identity" : "swift-clocks", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/pointfreeco/swift-clocks", 44 | "state" : { 45 | "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", 46 | "version" : "1.0.6" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-collections", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-collections", 53 | "state" : { 54 | "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", 55 | "version" : "1.2.0" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-composable-architecture", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/pointfreeco/swift-composable-architecture.git", 62 | "state" : { 63 | "revision" : "6574de2396319a58e86e2178577268cb4aeccc30", 64 | "version" : "1.20.2" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-concurrency-extras", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/pointfreeco/swift-concurrency-extras", 71 | "state" : { 72 | "revision" : "82a4ae7170d98d8538ec77238b7eb8e7199ef2e8", 73 | "version" : "1.3.1" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-custom-dump", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/pointfreeco/swift-custom-dump", 80 | "state" : { 81 | "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", 82 | "version" : "1.3.3" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-dependencies", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/pointfreeco/swift-dependencies", 89 | "state" : { 90 | "revision" : "4c90d6b2b9bf0911af87b103bb40f41771891596", 91 | "version" : "1.9.2" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-identified-collections", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/pointfreeco/swift-identified-collections", 98 | "state" : { 99 | "revision" : "322d9ffeeba85c9f7c4984b39422ec7cc3c56597", 100 | "version" : "1.1.1" 101 | } 102 | }, 103 | { 104 | "identity" : "swift-log", 105 | "kind" : "remoteSourceControl", 106 | "location" : "https://github.com/apple/swift-log.git", 107 | "state" : { 108 | "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", 109 | "version" : "1.6.3" 110 | } 111 | }, 112 | { 113 | "identity" : "swift-navigation", 114 | "kind" : "remoteSourceControl", 115 | "location" : "https://github.com/pointfreeco/swift-navigation", 116 | "state" : { 117 | "revision" : "ae208d1a5cf33aee1d43734ea780a09ada6e2a21", 118 | "version" : "2.3.1" 119 | } 120 | }, 121 | { 122 | "identity" : "swift-perception", 123 | "kind" : "remoteSourceControl", 124 | "location" : "https://github.com/pointfreeco/swift-perception", 125 | "state" : { 126 | "revision" : "d924c62a70fca5f43872f286dbd7cef0957f1c01", 127 | "version" : "1.6.0" 128 | } 129 | }, 130 | { 131 | "identity" : "swift-sdk", 132 | "kind" : "remoteSourceControl", 133 | "location" : "https://github.com/modelcontextprotocol/swift-sdk", 134 | "state" : { 135 | "revision" : "106167bad12cd8d004b0cbfcec8211c5408794d8" 136 | } 137 | }, 138 | { 139 | "identity" : "swift-sharing", 140 | "kind" : "remoteSourceControl", 141 | "location" : "https://github.com/pointfreeco/swift-sharing", 142 | "state" : { 143 | "revision" : "75e846ee3159dc75b3a29bfc24b6ce5a557ddca9", 144 | "version" : "2.5.2" 145 | } 146 | }, 147 | { 148 | "identity" : "swift-syntax", 149 | "kind" : "remoteSourceControl", 150 | "location" : "https://github.com/swiftlang/swift-syntax", 151 | "state" : { 152 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 153 | "version" : "601.0.1" 154 | } 155 | }, 156 | { 157 | "identity" : "swift-system", 158 | "kind" : "remoteSourceControl", 159 | "location" : "https://github.com/apple/swift-system.git", 160 | "state" : { 161 | "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6", 162 | "version" : "1.5.0" 163 | } 164 | }, 165 | { 166 | "identity" : "uritemplate", 167 | "kind" : "remoteSourceControl", 168 | "location" : "https://github.com/loopwork/URITemplate", 169 | "state" : { 170 | "revision" : "8e2101f43a31584ccac08b359c3c5f985904c7a0", 171 | "version" : "1.1.0" 172 | } 173 | }, 174 | { 175 | "identity" : "xctest-dynamic-overlay", 176 | "kind" : "remoteSourceControl", 177 | "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", 178 | "state" : { 179 | "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", 180 | "version" : "1.5.2" 181 | } 182 | } 183 | ], 184 | "version" : 3 185 | } 186 | -------------------------------------------------------------------------------- /Companion/Features/ServerDetailFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | import enum MCP.Value 5 | 6 | @Reducer 7 | struct ServerDetailFeature { 8 | @ObservableState 9 | struct State: Equatable, Identifiable { 10 | var server: Server 11 | var isConnecting: Bool = false 12 | var isLoadingData: Bool = false 13 | var error: String? 14 | 15 | init(server: Server) { 16 | self.server = server 17 | } 18 | 19 | var id: String { 20 | server.id 21 | } 22 | 23 | var isConnected: Bool { 24 | server.status.isConnected 25 | } 26 | 27 | var serverStatus: Server.Status { 28 | server.status 29 | } 30 | 31 | // Add computed properties for easier access 32 | var name: String { 33 | server.name 34 | } 35 | 36 | var transport: ConfigFile.Entry { 37 | server.configuration 38 | } 39 | } 40 | 41 | enum Action: Equatable { 42 | case connect 43 | case disconnect 44 | case cancel 45 | case restart 46 | case edit 47 | case refresh 48 | case deselectServer 49 | 50 | // Internal actions for state updates 51 | case connectionStarted 52 | case connectionCompleted 53 | case connectionFailed(String) 54 | case connectionCancelled 55 | case dataLoadStarted 56 | case dataLoadCompleted 57 | case dataLoadFailed(String) 58 | case serverUpdated(Server) 59 | case errorCleared 60 | } 61 | 62 | @Dependency(\.serverClient) var serverClient 63 | 64 | private enum CancelID: Sendable, Hashable { 65 | case connect 66 | case loadData 67 | } 68 | 69 | var body: some ReducerOf { 70 | Reduce { state, action in 71 | switch action { 72 | case .connect: 73 | guard !state.isConnected && !state.isConnecting else { return .none } 74 | state.isConnecting = true 75 | state.error = nil 76 | return .run { [server = state.server] send in 77 | do { 78 | try await serverClient.connect(server) 79 | await send(.connectionCompleted) 80 | } catch is CancellationError { 81 | await send(.connectionCancelled) 82 | } catch { 83 | await send(.connectionFailed(error.localizedDescription)) 84 | } 85 | } 86 | .cancellable(id: CancelID.connect) 87 | 88 | case .disconnect: 89 | guard state.isConnected else { return .none } 90 | state.isConnecting = false 91 | state.isLoadingData = false 92 | state.error = nil 93 | return .run { [id = state.server.id] send in 94 | do { 95 | try await serverClient.disconnect(id, true) 96 | await send(.deselectServer) 97 | } catch { 98 | await send(.connectionFailed(error.localizedDescription)) 99 | } 100 | } 101 | .cancellable(id: CancelID.connect) 102 | 103 | case .cancel: 104 | guard state.isConnecting else { return .none } 105 | state.isConnecting = false 106 | state.error = nil 107 | return .cancel(id: CancelID.connect) 108 | 109 | case .restart: 110 | return .run { send in 111 | await send(.disconnect) 112 | try await Task.sleep(for: .milliseconds(100)) 113 | await send(.connect) 114 | } 115 | 116 | case .edit: 117 | // This will be handled by parent to present edit sheet 118 | return .none 119 | 120 | case .refresh: 121 | guard state.isConnected else { return .none } 122 | state.isLoadingData = true 123 | state.error = nil 124 | return .run { [id = state.server.id] send in 125 | await send(.dataLoadStarted) 126 | do { 127 | async let tools = serverClient.fetchTools(id) 128 | async let prompts = serverClient.fetchPrompts(id) 129 | async let resources = serverClient.fetchResources(id) 130 | async let templates = serverClient.fetchResourceTemplates(id) 131 | 132 | let _ = try await (tools, prompts, resources, templates) 133 | await send(.dataLoadCompleted) 134 | } catch { 135 | await send(.dataLoadFailed(error.localizedDescription)) 136 | } 137 | } 138 | .cancellable(id: CancelID.loadData) 139 | 140 | case .connectionStarted: 141 | state.isConnecting = true 142 | state.error = nil 143 | return .none 144 | 145 | case .connectionCompleted: 146 | state.isConnecting = false 147 | state.error = nil 148 | // Start loading data after successful connection 149 | return .send(.refresh) 150 | 151 | case let .connectionFailed(error): 152 | state.isConnecting = false 153 | state.error = error 154 | return .none 155 | 156 | case .connectionCancelled: 157 | state.isConnecting = false 158 | state.error = nil 159 | return .none 160 | 161 | case .dataLoadStarted: 162 | state.isLoadingData = true 163 | return .none 164 | 165 | case .dataLoadCompleted: 166 | state.isLoadingData = false 167 | return .none 168 | 169 | case let .dataLoadFailed(error): 170 | state.isLoadingData = false 171 | state.error = error 172 | return .none 173 | 174 | case let .serverUpdated(server): 175 | state.server = server 176 | return .none 177 | 178 | case .errorCleared: 179 | state.error = nil 180 | return .none 181 | 182 | case .deselectServer: 183 | // This will be handled by parent to deselect server 184 | return .none 185 | } 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Companion/Views/Resources/ResourceTemplateView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import MCP 3 | import SwiftUI 4 | import URITemplate 5 | 6 | struct ResourceTemplateView: View { 7 | let store: StoreOf 8 | 9 | var body: some View { 10 | VStack(alignment: .leading, spacing: 12) { 11 | Label("Read Templated Resource", systemImage: "doc.text") 12 | .font(.headline) 13 | 14 | // Template arguments form 15 | templateArgumentsForm() 16 | 17 | // Preview section 18 | if store.isReadingResource { 19 | HStack { 20 | ProgressView() 21 | .scaleEffect(0.8) 22 | Text("Loading content...") 23 | .foregroundColor(.secondary) 24 | } 25 | .frame(maxWidth: .infinity, minHeight: 200) 26 | .background(.fill.tertiary) 27 | .cornerRadius(8) 28 | } else if let result = store.resourceReadResult { 29 | VStack(alignment: .leading, spacing: 8) { 30 | HStack { 31 | Label( 32 | "Success", 33 | systemImage: "checkmark.circle.fill" 34 | ) 35 | .foregroundColor(.green) 36 | .font(.headline) 37 | 38 | Spacer() 39 | 40 | Button("Clear") { 41 | store.send(.dismissResult) 42 | } 43 | .font(.caption) 44 | } 45 | 46 | // Show resolved URI 47 | if let template = store.template { 48 | VStack(alignment: .leading, spacing: 4) { 49 | Text("Resolved URI") 50 | .font(.caption) 51 | .fontWeight(.medium) 52 | 53 | let resolvedUri: String = { 54 | do { 55 | let uriTemplate = try URITemplate(template.uriTemplate) 56 | let variables = store.templateArguments.mapValues { VariableValue.string($0) } 57 | return uriTemplate.expand(with: variables) 58 | } catch { 59 | return "Invalid template: \(error.localizedDescription)" 60 | } 61 | }() 62 | 63 | Text(resolvedUri) 64 | .font(.system(.caption, design: .monospaced)) 65 | .foregroundColor(.secondary) 66 | .padding(8) 67 | .frame(maxWidth: .infinity, alignment: .leading) 68 | .background(.fill.tertiary) 69 | .cornerRadius(6) 70 | .textSelection(.enabled) 71 | } 72 | } 73 | 74 | // Show content 75 | let content = ResourceContent( 76 | text: extractTextContent(from: result.contents), 77 | data: extractBinaryContent(from: result.contents) 78 | ) 79 | ContentPreviewView(content: content, mimeType: store.template?.mimeType) 80 | } 81 | .padding() 82 | #if os(visionOS) 83 | .background(.thickMaterial, in: RoundedRectangle(cornerRadius: 8)) 84 | #else 85 | .background(.fill.quaternary) 86 | .cornerRadius(8) 87 | #endif 88 | } 89 | } 90 | .frame(maxWidth: .infinity, alignment: .leading) 91 | .padding() 92 | #if os(visionOS) 93 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 94 | #else 95 | .background(.fill.secondary) 96 | .cornerRadius(10) 97 | #endif 98 | } 99 | 100 | @ViewBuilder 101 | private func templateArgumentsForm() -> some View { 102 | VStack(alignment: .leading, spacing: 12) { 103 | // Parse template parameters from URI template 104 | let parameters = extractTemplateParameters(from: store.template?.uriTemplate ?? "") 105 | 106 | if !parameters.isEmpty { 107 | VStack(alignment: .leading, spacing: 8) { 108 | Text("Template Parameters") 109 | .font(.subheadline) 110 | .fontWeight(.medium) 111 | 112 | ForEach(parameters, id: \.self) { parameter in 113 | VStack(alignment: .leading, spacing: 4) { 114 | Text(parameter) 115 | .font(.caption) 116 | .fontWeight(.medium) 117 | 118 | TextField( 119 | "Enter \(parameter)", 120 | text: Binding( 121 | get: { store.templateArguments[parameter] ?? "" }, 122 | set: { store.send(.updateTemplateArgument(parameter, $0)) } 123 | ) 124 | ) 125 | .textFieldStyle(.roundedBorder) 126 | } 127 | } 128 | } 129 | .padding() 130 | .background(.fill.quaternary) 131 | .cornerRadius(8) 132 | } 133 | 134 | // Submit button 135 | if store.isReadingResource { 136 | Button(action: { store.send(.cancelResourceRead) }) { 137 | Label("Cancel", systemImage: "xmark.circle.fill") 138 | .frame(maxWidth: .infinity) 139 | } 140 | .buttonStyle(.bordered) 141 | .controlSize(.large) 142 | } else { 143 | Button(action: { store.send(.readTemplateTapped) }) { 144 | Text("Submit") 145 | .frame(maxWidth: .infinity) 146 | } 147 | .buttonStyle(.bordered) 148 | .controlSize(.large) 149 | .disabled(store.serverId == nil || !allParametersHaveValues) 150 | } 151 | } 152 | } 153 | 154 | private var allParametersHaveValues: Bool { 155 | let parameters = extractTemplateParameters(from: store.template?.uriTemplate ?? "") 156 | 157 | for parameter in parameters { 158 | let value = store.templateArguments[parameter] ?? "" 159 | if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 160 | return false 161 | } 162 | } 163 | 164 | return true 165 | } 166 | 167 | private func extractTemplateParameters(from template: String) -> [String] { 168 | let uriTemplate = try? URITemplate(template) 169 | return uriTemplate?.variables ?? [] 170 | } 171 | 172 | private func extractTextContent(from contents: [Resource.Content]) -> String? { 173 | let textContents: [String] = contents.compactMap { content in 174 | if let text = content.text { 175 | return text 176 | } else if content.blob != nil { 177 | return "[Binary Resource: \(content.uri)]" 178 | } 179 | return nil 180 | } 181 | 182 | return textContents.isEmpty ? nil : textContents.joined(separator: "\n\n") 183 | } 184 | 185 | private func extractBinaryContent(from contents: [Resource.Content]) -> Data? { 186 | for content in contents { 187 | if let blob = content.blob { 188 | return Data(base64Encoded: blob) 189 | } 190 | } 191 | return nil 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Companion/Assets.xcassets/mcp.fill.symbolset/mcp.fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | Small 8 | Medium 9 | Large 10 | 11 | 12 | Ultralight 13 | Regular 14 | Black 15 | Template 16 | v.3.0 17 | 18 | Mattt 19 | 20 | 21 | 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 43 | 44 | 45 | 47 | 48 | 49 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Companion/Views/Server/ServerInformationView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ServerInformationView: View { 4 | let server: Server 5 | let serverStatus: Server.Status 6 | let isConnecting: Bool 7 | 8 | private var isConnected: Bool { 9 | serverStatus.isConnected 10 | } 11 | 12 | var body: some View { 13 | VStack(alignment: .leading, spacing: 20) { 14 | #if os(iOS) 15 | // Connection Status Card 16 | VStack(alignment: .leading, spacing: 12) { 17 | Label("Connection", systemImage: "network") 18 | .font(.headline) 19 | 20 | HStack { 21 | Image( 22 | systemName: isConnecting 23 | ? "clock.circle.fill" : (isConnected ? "circle.fill" : "circle") 24 | ) 25 | .foregroundColor(isConnecting ? .orange : (isConnected ? .green : .red)) 26 | .font(.system(size: 12)) 27 | Text( 28 | isConnecting 29 | ? "Connecting..." : (isConnected ? "Connected" : "Disconnected") 30 | ) 31 | .fontWeight(.medium) 32 | Spacer() 33 | } 34 | 35 | VStack(alignment: .leading, spacing: 4) { 36 | Text( 37 | server.configuration.displayValue.hasPrefix("http") 38 | ? "URL" : "Command" 39 | ) 40 | .font(.caption) 41 | .foregroundColor(.secondary) 42 | Text(server.configuration.displayValue) 43 | .font(.system(.body, design: .monospaced)) 44 | .foregroundColor(.primary) 45 | .textSelection(.enabled) 46 | } 47 | } 48 | .frame(maxWidth: .infinity, alignment: .leading) 49 | .padding() 50 | #if os(visionOS) 51 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 52 | #else 53 | .background(.fill.secondary) 54 | .cornerRadius(10) 55 | #endif 56 | #endif 57 | 58 | // Server Info 59 | VStack(alignment: .leading, spacing: 12) { 60 | Label("Server Information", systemImage: "info.circle") 61 | .font(.headline) 62 | 63 | VStack(alignment: .leading, spacing: 8) { 64 | HStack { 65 | Text("Name:") 66 | .foregroundColor(.secondary) 67 | if let name = server.serverInfo?.name, 68 | !name.isEmpty 69 | { 70 | Text(name) 71 | .fontWeight(.medium) 72 | .textSelection(.enabled) 73 | } else { 74 | Text("Unknown") 75 | .italic() 76 | } 77 | } 78 | 79 | HStack { 80 | Text("Version:") 81 | .foregroundColor(.secondary) 82 | if let version = server.serverInfo?.version, 83 | !version.isEmpty 84 | { 85 | Text(version) 86 | .fontWeight(.medium) 87 | .monospacedDigit() 88 | .textSelection(.enabled) 89 | } else { 90 | Text("Unspecified") 91 | .italic() 92 | } 93 | } 94 | 95 | HStack { 96 | Text("Protocol:") 97 | .foregroundColor(.secondary) 98 | if let protocolVersion = server.protocolVersion { 99 | Text(protocolVersion) 100 | .font(.system(.body, design: .monospaced)) 101 | .fontWeight(.medium) 102 | .monospacedDigit() 103 | .textSelection(.enabled) 104 | } else { 105 | Text("Unspecified") 106 | .italic() 107 | } 108 | } 109 | 110 | #if !os(iOS) 111 | HStack { 112 | Text( 113 | server.configuration.displayValue.hasPrefix("http") 114 | ? "URL:" : "Command:" 115 | ) 116 | .foregroundColor(.secondary) 117 | Text(server.configuration.displayValue) 118 | .font(.system(.body, design: .monospaced)) 119 | .fontWeight(.medium) 120 | .lineLimit(1) 121 | .truncationMode( 122 | server.configuration.displayValue.hasPrefix("http") 123 | ? .tail : .middle 124 | ) 125 | .textSelection(.enabled) 126 | } 127 | #endif 128 | } 129 | .frame(maxWidth: .infinity, alignment: .leading) 130 | } 131 | .frame(maxWidth: .infinity, alignment: .leading) 132 | .padding() 133 | #if os(visionOS) 134 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 135 | #else 136 | .background(.fill.secondary) 137 | .cornerRadius(10) 138 | #endif 139 | 140 | // Capabilities 141 | VStack(alignment: .leading, spacing: 12) { 142 | Label("Capabilities", systemImage: "gear.badge") 143 | .font(.headline) 144 | 145 | if let capabilities = server.capabilities { 146 | ServerCapabilitiesView(capabilities: capabilities) 147 | } else { 148 | Text("No capabilities information available") 149 | .foregroundColor(.secondary) 150 | .italic() 151 | } 152 | } 153 | .frame(maxWidth: .infinity, alignment: .leading) 154 | .padding() 155 | #if os(visionOS) 156 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 157 | #else 158 | .background(.fill.secondary) 159 | .cornerRadius(10) 160 | #endif 161 | 162 | // Server Instructions 163 | VStack(alignment: .leading, spacing: 12) { 164 | Label("Instructions", systemImage: "doc.text") 165 | .font(.headline) 166 | 167 | if let instructions = server.instructions { 168 | Text(instructions) 169 | .foregroundColor(.secondary) 170 | .multilineTextAlignment(.leading) 171 | .textSelection(.enabled) 172 | } else { 173 | Text("No instructions provided by the server.") 174 | .foregroundColor(.secondary) 175 | .italic() 176 | } 177 | } 178 | .frame(maxWidth: .infinity, alignment: .leading) 179 | .padding() 180 | #if os(visionOS) 181 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 182 | #else 183 | .background(.fill.secondary) 184 | .cornerRadius(10) 185 | #endif 186 | 187 | Spacer() 188 | } 189 | .frame(maxWidth: .infinity, alignment: .leading) 190 | .padding() 191 | } 192 | } -------------------------------------------------------------------------------- /Companion/Views/Tools/ToolHintsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import MCP 3 | 4 | struct ToolHintsView: View { 5 | let annotations: Tool.Annotations 6 | 7 | var body: some View { 8 | if !annotations.isEmpty { 9 | VStack(alignment: .leading, spacing: 12) { 10 | Label("Behavior Hints", systemImage: "lightbulb") 11 | .font(.headline) 12 | .frame(maxWidth: .infinity, alignment: .leading) 13 | 14 | HintsLayout { 15 | if let readOnly = annotations.readOnlyHint { 16 | AnnotationRow( 17 | icon: readOnly ? "lock.fill" : "lock.open.fill", 18 | title: readOnly ? "Read-only" : "Can modify", 19 | description: readOnly 20 | ? "This tool does not modify its environment" 21 | : "This tool can modify its environment", 22 | color: readOnly ? .green : .orange 23 | ) 24 | } 25 | 26 | if let destructive = annotations.destructiveHint { 27 | AnnotationRow( 28 | icon: destructive 29 | ? "exclamationmark.triangle.fill" 30 | : "checkmark.shield.fill", 31 | title: destructive ? "Destructive" : "Non-destructive", 32 | description: destructive 33 | ? "May perform destructive updates" 34 | : "Performs only additive updates", 35 | color: destructive ? .red : .green 36 | ) 37 | } 38 | 39 | if let idempotent = annotations.idempotentHint { 40 | AnnotationRow( 41 | icon: idempotent 42 | ? "circle.lefthalf.filled" : "arrow.clockwise", 43 | title: idempotent ? "Idempotent" : "Not idempotent", 44 | description: idempotent 45 | ? "Repeated calls have no additional effect" 46 | : "Each call may have different effects", 47 | color: idempotent ? .blue : .gray 48 | ) 49 | } 50 | 51 | if let openWorld = annotations.openWorldHint { 52 | AnnotationRow( 53 | icon: openWorld ? "globe" : "cube.box", 54 | title: openWorld ? "Open world" : "Closed world", 55 | description: openWorld 56 | ? "Interacts with external entities" 57 | : "Limited to a closed domain", 58 | color: openWorld ? .purple : .brown 59 | ) 60 | } 61 | } 62 | } 63 | .padding() 64 | #if os(visionOS) 65 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 66 | #else 67 | .background(.fill.secondary) 68 | .cornerRadius(10) 69 | #endif 70 | } 71 | } 72 | } 73 | 74 | struct AnnotationRow: View { 75 | let icon: String 76 | let title: String 77 | let description: String 78 | let color: Color 79 | 80 | var body: some View { 81 | HStack(spacing: 12) { 82 | Image(systemName: icon) 83 | .font(.title2) 84 | .foregroundColor(color) 85 | .frame(width: 30) 86 | 87 | VStack(alignment: .leading, spacing: 2) { 88 | Text(title) 89 | .font(.subheadline) 90 | .fontWeight(.medium) 91 | 92 | Text(description) 93 | .font(.caption) 94 | .foregroundColor(.secondary) 95 | } 96 | 97 | Spacer() 98 | } 99 | .padding(.vertical, 4) 100 | } 101 | } 102 | 103 | // MARK: - Custom Layout for Hints 104 | private struct HintsLayout: Layout { 105 | private let spacing: CGFloat = 8 106 | private let minColumnWidth: CGFloat = 200 107 | private let maxColumns = 2 108 | 109 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { 110 | let width = proposal.width ?? 0 111 | let columnCount = calculateColumnCount(for: subviews.count, availableWidth: width) 112 | let columnWidth = (width - CGFloat(columnCount - 1) * spacing) / CGFloat(columnCount) 113 | let isSingleColumn = columnCount == 1 114 | 115 | // Calculate the height needed for each cell 116 | let cellHeights: [CGFloat] = subviews.map { 117 | $0.sizeThatFits(ProposedViewSize(width: columnWidth, height: nil)).height 118 | } 119 | let maxCellHeight = cellHeights.max() ?? 0 120 | 121 | // Calculate row heights 122 | var rowHeights: [CGFloat] = [] 123 | let itemsPerRow = columnCount 124 | for rowIndex in 0.. Int { 186 | // Handle edge cases 187 | guard availableWidth > 0 && availableWidth.isFinite else { 188 | return 1 189 | } 190 | 191 | // Single column for narrow screens 192 | if availableWidth < minColumnWidth * 1.5 { 193 | return 1 194 | } 195 | 196 | // Calculate optimal columns based on width and item count 197 | let maxPossibleColumns = min(Int(availableWidth / minColumnWidth), maxColumns) 198 | 199 | switch itemCount { 200 | case 1: 201 | return 1 202 | case 2: 203 | return min(2, maxPossibleColumns) 204 | case 3: 205 | return min(2, maxPossibleColumns) // Better than 3x1 for hints 206 | case 4: 207 | return min(2, maxPossibleColumns) // Perfect 2x2 208 | default: 209 | return min(maxColumns, maxPossibleColumns) 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Companion/Views/Tools/ToolListView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | import MCP 4 | 5 | struct ToolListView: View { 6 | let tools: [Tool] 7 | let serverId: String? 8 | let columnVisibility: NavigationSplitViewVisibility 9 | @State private var selectedTool: Tool? 10 | @State private var leftPaneWidth: CGFloat = 350 11 | @State private var searchText: String = "" 12 | 13 | private var filteredTools: [Tool] { 14 | if searchText.isEmpty { 15 | return tools.sorted(by: { $0.name < $1.name }) 16 | } else { 17 | return tools.filter { tool in 18 | tool.name.localizedCaseInsensitiveContains(searchText) 19 | || tool.description.localizedCaseInsensitiveContains(searchText) == true 20 | || tool.annotations.title?.localizedCaseInsensitiveContains(searchText) 21 | == true 22 | }.sorted(by: { $0.name < $1.name }) 23 | } 24 | } 25 | 26 | private var placeholderView: some View { 27 | VStack(spacing: 16) { 28 | Image(systemName: "wrench.and.screwdriver") 29 | .font(.system(size: 48)) 30 | .foregroundColor(.secondary) 31 | 32 | Text("Select a tool") 33 | .font(.title2) 34 | .foregroundColor(.secondary) 35 | 36 | Text("Choose a tool from the list to view its details and input schema") 37 | .font(.body) 38 | .foregroundColor(.secondary) 39 | .multilineTextAlignment(.center) 40 | .frame(maxWidth: 300) 41 | } 42 | .frame(maxWidth: .infinity, maxHeight: .infinity) 43 | .background(.fill.secondary) 44 | } 45 | 46 | var body: some View { 47 | Group { 48 | #if os(macOS) 49 | HSplitView { 50 | VStack(alignment: .leading, spacing: 0) { 51 | if filteredTools.isEmpty { 52 | VStack(spacing: 16) { 53 | Image(systemName: "wrench.and.screwdriver") 54 | .font(.system(size: 48)) 55 | .foregroundColor(.secondary) 56 | 57 | Text("No tools") 58 | .font(.title2) 59 | .foregroundColor(.secondary) 60 | 61 | if searchText.isEmpty { 62 | Text("This server doesn't provide any tools") 63 | .font(.body) 64 | .foregroundColor(.secondary) 65 | .multilineTextAlignment(.center) 66 | } else { 67 | Text("No tools match your search") 68 | .font(.body) 69 | .foregroundColor(.secondary) 70 | .multilineTextAlignment(.center) 71 | } 72 | } 73 | .frame(maxWidth: .infinity, maxHeight: .infinity) 74 | } else { 75 | List(selection: $selectedTool) { 76 | ForEach(filteredTools, id: \.name) { tool in 77 | ToolRowView(tool: tool, selectedTool: $selectedTool) 78 | .tag(tool) 79 | } 80 | } 81 | .listStyle(.sidebar) 82 | .background( 83 | GeometryReader { geo in 84 | // Use WidthPassthroughView to update leftPaneWidth directly via .task 85 | WidthPassthroughView(width: geo.size.width) { 86 | newCalculatedWidth in 87 | if newCalculatedWidth > 0 88 | && self.leftPaneWidth != newCalculatedWidth 89 | { 90 | self.leftPaneWidth = newCalculatedWidth 91 | } 92 | } 93 | } 94 | ) 95 | } 96 | } 97 | .frame(minWidth: 250, idealWidth: 350, maxWidth: 400) 98 | 99 | if let selectedTool = selectedTool { 100 | ToolDetailView( 101 | tool: selectedTool, 102 | serverId: serverId 103 | ) 104 | } else if !filteredTools.isEmpty { 105 | placeholderView 106 | } 107 | }.toolbar { 108 | if !tools.isEmpty { 109 | #if os(macOS) 110 | // if #available(macOS 26.0, *) { 111 | // ToolbarItemGroup(placement: .navigation) { 112 | // FilterToolbar( 113 | // searchText: $searchText, 114 | // placeholder: "Filter tools", 115 | // width: leftPaneWidth, 116 | // isVisible: columnVisibility == .all 117 | // ) 118 | // } 119 | // .sharedBackgroundVisibility(Visibility.hidden) 120 | // } else { 121 | ToolbarItemGroup(placement: .navigation) { 122 | FilterToolbar( 123 | searchText: $searchText, 124 | placeholder: "Filter tools", 125 | width: leftPaneWidth, 126 | isVisible: columnVisibility == .all 127 | ) 128 | } 129 | // } 130 | #endif 131 | } 132 | } 133 | #else 134 | NavigationStack { 135 | List { 136 | ForEach(filteredTools, id: \.name) { tool in 137 | NavigationLink( 138 | destination: ToolDetailView( 139 | tool: tool, 140 | serverId: serverId 141 | ) 142 | ) { 143 | ToolRowView(tool: tool, selectedTool: $selectedTool) 144 | } 145 | } 146 | } 147 | .navigationTitle("Tools") 148 | .navigationBarTitleDisplayMode(.large) 149 | } 150 | #endif 151 | } 152 | .onAppear { 153 | if selectedTool == nil, let firstTool = tools.first { 154 | selectedTool = firstTool 155 | } 156 | } 157 | .onChange(of: searchText, initial: true) { _, newValue in 158 | // When search changes, ensure selected tool is still visible 159 | if let selected = selectedTool, 160 | !filteredTools.contains(where: { $0.name == selected.name }) 161 | { 162 | selectedTool = filteredTools.first 163 | } else if selectedTool == nil { 164 | selectedTool = filteredTools.first 165 | } 166 | } 167 | } 168 | } 169 | 170 | private struct ToolRowView: View { 171 | let tool: Tool 172 | @Binding var selectedTool: Tool? 173 | 174 | var body: some View { 175 | VStack(alignment: .leading, spacing: 4) { 176 | HStack { 177 | VStack(alignment: .leading, spacing: 2) { 178 | if let title = tool.annotations.title { 179 | Text(title) 180 | .font(.subheadline) 181 | .fontWeight(.medium) 182 | 183 | Text(tool.name) 184 | .font(.system(.caption, design: .monospaced)) 185 | .foregroundColor(.secondary) 186 | } else { 187 | Text(tool.name) 188 | .font(.system(.subheadline, design: .monospaced)) 189 | .fontWeight(.medium) 190 | } 191 | 192 | if !tool.description.isEmpty { 193 | Text(tool.description) 194 | .font(.caption) 195 | .foregroundColor(.secondary) 196 | .lineLimit(2) 197 | } 198 | } 199 | 200 | Spacer() 201 | 202 | HStack(spacing: 4) { 203 | if let destructive = tool.annotations.destructiveHint, destructive { 204 | Image(systemName: "exclamationmark.triangle.fill") 205 | .foregroundColor(selectedTool?.name == tool.name ? .primary : .red) 206 | .font(.caption) 207 | } 208 | } 209 | } 210 | } 211 | .padding(.vertical, 4) 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Companion/Views/Prompts/PromptDetailView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import MCP 3 | import SwiftUI 4 | 5 | struct PromptDetailView: View { 6 | let store: StoreOf 7 | 8 | init(prompt: Prompt, serverId: String? = nil) { 9 | self.store = Store( 10 | initialState: PromptDetailFeature.State(prompt: prompt, serverId: serverId) 11 | ) { 12 | PromptDetailFeature() 13 | } 14 | } 15 | 16 | var body: some View { 17 | ScrollView { 18 | VStack(alignment: .leading, spacing: 20) { 19 | VStack(alignment: .leading, spacing: 4) { 20 | // Title 21 | #if os(macOS) 22 | Text(store.prompt.name) 23 | .font(.title) 24 | .fontWeight(.bold) 25 | .textSelection(.enabled) 26 | #endif 27 | 28 | // Description (if available) 29 | if let description = store.prompt.description, !description.isEmpty { 30 | Text(description) 31 | .font(.body) 32 | .foregroundColor(.secondary) 33 | .textSelection(.enabled) 34 | } 35 | } 36 | 37 | // Interactive Form 38 | VStack(alignment: .leading, spacing: 12) { 39 | Label("Use Prompt", systemImage: "play.rectangle") 40 | .font(.headline) 41 | 42 | if let arguments = store.prompt.arguments, !arguments.isEmpty { 43 | ForEach(arguments, id: \.name) { argument in 44 | ArgumentInputView( 45 | argument: argument, 46 | value: store.argumentValues[argument.name] ?? "", 47 | onValueChange: { newValue in 48 | store.send(.argumentChanged(argument.name, newValue)) 49 | } 50 | ) 51 | } 52 | } else { 53 | Text("This prompt has no arguments") 54 | .font(.body) 55 | .foregroundColor(.secondary) 56 | } 57 | 58 | if store.isCallingPrompt { 59 | Button(action: { store.send(.cancelPromptCall) }) { 60 | Label("Cancel", systemImage: "xmark.circle.fill") 61 | .frame(maxWidth: .infinity) 62 | } 63 | .buttonStyle(.bordered) 64 | .controlSize(.large) 65 | } else { 66 | Button(action: { store.send(.usePromptTapped) }) { 67 | Text("Submit") 68 | .frame(maxWidth: .infinity) 69 | } 70 | .buttonStyle(.bordered) 71 | .controlSize(.large) 72 | .disabled(store.serverId == nil) 73 | } 74 | 75 | // Prompt Result 76 | if let result = store.promptCallResult { 77 | VStack(alignment: .leading, spacing: 12) { 78 | HStack { 79 | Label("Success", systemImage: "checkmark.circle.fill") 80 | .font(.headline) 81 | .foregroundColor(.green) 82 | 83 | Spacer() 84 | 85 | Button("Clear") { 86 | store.send(.dismissResult) 87 | } 88 | .font(.caption) 89 | } 90 | 91 | if let description = result.description { 92 | VStack(alignment: .leading, spacing: 8) { 93 | Text("Description:") 94 | .font(.subheadline) 95 | .fontWeight(.medium) 96 | Text(description) 97 | .font(.body) 98 | .frame(maxWidth: .infinity, alignment: .leading) 99 | .padding() 100 | .background(.fill.tertiary) 101 | .cornerRadius(8) 102 | .textSelection(.enabled) 103 | } 104 | } 105 | 106 | if !result.messages.isEmpty { 107 | VStack(alignment: .leading, spacing: 8) { 108 | Text("Messages (\(result.messages.count)):") 109 | .font(.subheadline) 110 | .fontWeight(.medium) 111 | 112 | ForEach(Array(result.messages.enumerated()), id: \.offset) { 113 | index, message in 114 | VStack(alignment: .leading, spacing: 4) { 115 | Text("Message \(index + 1) - \(message.role.rawValue)") 116 | .font(.caption) 117 | .fontWeight(.medium) 118 | .foregroundColor(.secondary) 119 | 120 | Text(formatMessageContent(message.content)) 121 | .font(.system(.body, design: .monospaced)) 122 | .frame(maxWidth: .infinity, alignment: .leading) 123 | .padding() 124 | .background(.fill.tertiary) 125 | .cornerRadius(8) 126 | .textSelection(.enabled) 127 | } 128 | } 129 | } 130 | } 131 | } 132 | .frame(maxWidth: .infinity, alignment: .leading) 133 | .padding() 134 | #if os(visionOS) 135 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 136 | #else 137 | .background(.fill.secondary) 138 | .cornerRadius(10) 139 | #endif 140 | } 141 | } 142 | .frame(maxWidth: .infinity, alignment: .leading) 143 | .padding() 144 | #if os(visionOS) 145 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10)) 146 | #else 147 | .background(.fill.secondary) 148 | .cornerRadius(10) 149 | #endif 150 | } 151 | .frame(maxWidth: .infinity, alignment: .leading) 152 | .padding() 153 | } 154 | .navigationTitle(store.prompt.name) 155 | } 156 | 157 | private func formatMessageContent(_ content: Prompt.Message.Content) -> String { 158 | switch content { 159 | case .text(let text): 160 | return text 161 | case .image(let data, let mimeType): 162 | return "[Image: \(mimeType), \(data.count) bytes]" 163 | case .audio(let data, let mimeType): 164 | return "[Audio: \(mimeType), \(data.count) bytes]" 165 | case .resource(let uri, _, let text, _): 166 | if let text = text { 167 | return "[Resource: \(uri)]\n\(text)" 168 | } else { 169 | return "[Resource: \(uri)]" 170 | } 171 | } 172 | } 173 | } 174 | 175 | struct ArgumentInputView: View { 176 | let argument: Prompt.Argument 177 | let value: String 178 | let onValueChange: (String) -> Void 179 | 180 | var body: some View { 181 | VStack(alignment: .leading, spacing: 8) { 182 | HStack { 183 | Text(argument.name) 184 | .font(.subheadline) 185 | .fontWeight(.medium) 186 | 187 | if argument.required == true { 188 | Text("*") 189 | .foregroundColor(.red) 190 | .fontWeight(.bold) 191 | } 192 | 193 | Spacer() 194 | 195 | Text(argument.required == true ? "Required" : "Optional") 196 | .font(.caption) 197 | .foregroundColor( 198 | argument.required == true ? .red : .secondary) 199 | } 200 | 201 | if let description = argument.description { 202 | Text(description) 203 | .font(.caption) 204 | .foregroundColor(.secondary) 205 | } 206 | 207 | TextField( 208 | "Enter \(argument.name)", 209 | text: Binding( 210 | get: { value }, 211 | set: onValueChange 212 | ) 213 | ) 214 | .textFieldStyle(.roundedBorder) 215 | } 216 | .frame(maxWidth: .infinity, alignment: .leading) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Companion/Views/Server/EditServerSheet.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import SwiftUI 3 | 4 | struct EditServerSheet: View { 5 | @Binding var isPresented: Bool 6 | let server: Server 7 | let onSave: (String, ConfigFile.Entry) -> Void 8 | let onCancel: (() -> Void)? 9 | 10 | @State private var serverName: String 11 | @State private var transportType: TransportType 12 | @State private var command: String 13 | @State private var arguments: String 14 | @State private var url: String 15 | @FocusState private var isNameFieldFocused: Bool 16 | 17 | let store: StoreOf 18 | 19 | init( 20 | isPresented: Binding, server: Server, 21 | onSave: @escaping (String, ConfigFile.Entry) -> Void, 22 | onCancel: (() -> Void)?, 23 | store: StoreOf 24 | ) { 25 | self._isPresented = isPresented 26 | self.server = server 27 | self.onSave = onSave 28 | self.onCancel = onCancel 29 | self.store = store 30 | 31 | // Initialize state from server 32 | let config = server.configuration 33 | self._serverName = State(initialValue: server.name) 34 | 35 | // Convert ConfigFile.Entry to TransportType 36 | let transportType: TransportType = { 37 | switch config { 38 | case .stdio: return .stdio 39 | case .sse, .streamableHTTP: return .http 40 | } 41 | }() 42 | 43 | self._transportType = State(initialValue: transportType) 44 | self._command = State(initialValue: config.command ?? "") 45 | self._arguments = State( 46 | initialValue: config.arguments?.joined(separator: " ") ?? "") 47 | self._url = State(initialValue: config.url ?? "") 48 | } 49 | 50 | private var isValid: Bool { 51 | let trimmedName = serverName.trimmingCharacters(in: .whitespacesAndNewlines) 52 | guard !trimmedName.isEmpty else { return false } 53 | 54 | #if os(macOS) 55 | switch transportType { 56 | case .stdio: 57 | return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 58 | case .http: 59 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 60 | } 61 | #else 62 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 63 | #endif 64 | } 65 | 66 | private var canTestConnection: Bool { 67 | #if os(macOS) 68 | switch transportType { 69 | case .stdio: 70 | return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 71 | case .http: 72 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 73 | } 74 | #else 75 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 76 | #endif 77 | } 78 | 79 | var body: some View { 80 | VStack(spacing: 0) { 81 | // Header 82 | ServerFormHeader( 83 | icon: "mcp.fill", 84 | title: "Edit Server", 85 | subtitle: "Update the details of your MCP server" 86 | ) 87 | 88 | // Form 89 | #if os(macOS) 90 | VStack(alignment: .leading, spacing: 20) { 91 | VStack(alignment: .leading, spacing: 8) { 92 | Text("Server Name") 93 | .font(.headline) 94 | TextField("Server Name", text: $serverName) 95 | .focused($isNameFieldFocused) 96 | .textFieldStyle(.roundedBorder) 97 | .disabled(store.connectionTest.isTesting) 98 | .onSubmit { 99 | if isValid { 100 | saveServer() 101 | } 102 | } 103 | .onChange(of: serverName, initial: true) { _, _ in store.send(.connectionTest(.reset)) } 104 | } 105 | 106 | VStack(alignment: .leading, spacing: 12) { 107 | Text("Transport Type") 108 | .font(.headline) 109 | 110 | TransportTypeSelector( 111 | transportType: $transportType, 112 | isDisabled: store.connectionTest.isTesting 113 | ) 114 | .onChange(of: transportType, initial: true) { _, _ in store.send(.connectionTest(.reset)) } 115 | 116 | TransportConfigurationFields( 117 | transportType: transportType, 118 | command: $command, 119 | arguments: $arguments, 120 | url: $url, 121 | isDisabled: store.connectionTest.isTesting, 122 | onSubmit: { 123 | if isValid { 124 | saveServer() 125 | } 126 | }, 127 | onChange: { store.send(.connectionTest(.reset)) } 128 | ) 129 | } 130 | 131 | TestConnectionSection( 132 | isTesting: store.connectionTest.isTesting, 133 | hasSucceeded: store.connectionTest.hasSucceeded, 134 | errorMessage: store.connectionTest.errorMessage, 135 | testAction: { 136 | // Update store state and trigger test 137 | store.send(.testConnection) 138 | }, 139 | cancelAction: { store.send(.connectionTest(.cancelTest)) } 140 | ) 141 | .disabled(!canTestConnection && !store.connectionTest.isTesting) 142 | } 143 | .padding(.horizontal) 144 | .padding(.vertical, 20) 145 | 146 | Spacer(minLength: 20) 147 | #else 148 | Form { 149 | Section { 150 | TextField("Server Name", text: $serverName) 151 | .focused($isNameFieldFocused) 152 | .onSubmit { 153 | if isValid { 154 | saveServer() 155 | } 156 | } 157 | } 158 | 159 | Section( 160 | header: Text("Server URL"), 161 | footer: Text( 162 | "STDIO transport (local command execution) is only supported on macOS. On iOS, use HTTP transport to connect to remote MCP servers." 163 | ) 164 | .font(.caption) 165 | ) { 166 | TextField( 167 | "Server URL", text: $url, 168 | prompt: Text(verbatim: "https://example.com/mcp") 169 | ) 170 | .keyboardType(.URL) 171 | .textContentType(.URL) 172 | .autocapitalization(.none) 173 | .textInputAutocapitalization(.never) 174 | .disableAutocorrection(true) 175 | .onSubmit { 176 | if isValid { 177 | saveServer() 178 | } 179 | } 180 | } 181 | } 182 | .formStyle(.grouped) 183 | #endif 184 | 185 | // Buttons 186 | ServerFormButtons( 187 | cancelAction: { onCancel?() ?? { isPresented = false }() }, 188 | saveAction: saveServer, 189 | saveTitle: "Save Changes", 190 | isValid: isValid 191 | ) 192 | } 193 | #if os(macOS) 194 | .frame(width: 500, height: 600) 195 | #else 196 | .frame(maxWidth: .infinity, maxHeight: .infinity) 197 | #endif 198 | .background(.background) 199 | .onAppear { 200 | isNameFieldFocused = true 201 | } 202 | .onDisappear { 203 | store.send(.connectionTest(.reset)) 204 | } 205 | } 206 | 207 | private func saveServer() { 208 | let trimmedName = serverName.trimmingCharacters(in: .whitespacesAndNewlines) 209 | guard !trimmedName.isEmpty else { return } 210 | 211 | let configuration: ConfigFile.Entry 212 | 213 | #if os(macOS) 214 | switch transportType { 215 | case .stdio: 216 | let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) 217 | guard !trimmedCommand.isEmpty else { return } 218 | 219 | let trimmedArgs = arguments.trimmingCharacters(in: .whitespacesAndNewlines) 220 | let argArray = 221 | trimmedArgs.isEmpty ? [] : trimmedArgs.split(separator: " ").map(String.init) 222 | 223 | configuration = ConfigFile.Entry(stdio: trimmedCommand, arguments: argArray) 224 | 225 | case .http: 226 | let trimmedUrl = url.trimmingCharacters(in: .whitespacesAndNewlines) 227 | guard !trimmedUrl.isEmpty else { return } 228 | 229 | configuration = ConfigFile.Entry(http: trimmedUrl) 230 | } 231 | #else 232 | let trimmedUrl = url.trimmingCharacters(in: .whitespacesAndNewlines) 233 | guard !trimmedUrl.isEmpty else { return } 234 | 235 | configuration = ConfigFile.Entry(http: trimmedUrl) 236 | #endif 237 | 238 | onSave(trimmedName, configuration) 239 | } 240 | 241 | 242 | } 243 | -------------------------------------------------------------------------------- /Companion/Views/Server/AddServerSheet.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Dependencies 3 | import SwiftUI 4 | 5 | struct AddServerSheet: View { 6 | @Binding var isPresented: Bool 7 | let onAdd: (String, ConfigFile.Entry) -> Void 8 | let onCancel: (() -> Void)? 9 | 10 | @State private var serverName = "" 11 | #if os(macOS) 12 | @State private var transportType: TransportType = .stdio 13 | #else 14 | @State private var transportType: TransportType = .http 15 | #endif 16 | @State private var command = "" 17 | @State private var arguments = "" 18 | @State private var url = "" 19 | @FocusState private var isNameFieldFocused: Bool 20 | 21 | @State private var store = Store(initialState: ConnectionTestFeature.State()) { 22 | ConnectionTestFeature() 23 | } 24 | 25 | private var isValid: Bool { 26 | let trimmedName = serverName.trimmingCharacters(in: .whitespacesAndNewlines) 27 | guard !trimmedName.isEmpty else { return false } 28 | 29 | #if os(macOS) 30 | switch transportType { 31 | case .stdio: 32 | return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 33 | case .http: 34 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 35 | } 36 | #else 37 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 38 | #endif 39 | } 40 | 41 | private var canTestConnection: Bool { 42 | #if os(macOS) 43 | switch transportType { 44 | case .stdio: 45 | return !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 46 | case .http: 47 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 48 | } 49 | #else 50 | return !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 51 | #endif 52 | } 53 | 54 | var body: some View { 55 | VStack(spacing: 0) { 56 | // Header 57 | ServerFormHeader( 58 | icon: "mcp.fill", 59 | title: "Add Server", 60 | subtitle: "Enter the details of the MCP server you want to connect to" 61 | ) 62 | 63 | // Form 64 | #if os(macOS) 65 | VStack(alignment: .leading, spacing: 20) { 66 | // Server Name 67 | VStack(alignment: .leading, spacing: 8) { 68 | Text("Server Name") 69 | .font(.headline) 70 | TextField("Server Name", text: $serverName) 71 | .focused($isNameFieldFocused) 72 | .textFieldStyle(.roundedBorder) 73 | .disabled(store.isTesting) 74 | .onSubmit { 75 | if isValid { 76 | addServer() 77 | } 78 | } 79 | .onChange(of: serverName, initial: true) { _, _ in store.send(.reset) } 80 | } 81 | 82 | // Transport Configuration 83 | VStack(alignment: .leading, spacing: 12) { 84 | Text("Transport Type") 85 | .font(.headline) 86 | 87 | TransportTypeSelector( 88 | transportType: $transportType, 89 | isDisabled: store.isTesting 90 | ) 91 | .onChange(of: transportType, initial: true) { _, _ in store.send(.reset) } 92 | 93 | TransportConfigurationFields( 94 | transportType: transportType, 95 | command: $command, 96 | arguments: $arguments, 97 | url: $url, 98 | isDisabled: store.isTesting, 99 | onSubmit: { 100 | if isValid { 101 | addServer() 102 | } 103 | }, 104 | onChange: { store.send(.reset) } 105 | ) 106 | } 107 | 108 | // Connection Test Section 109 | TestConnectionSection( 110 | isTesting: store.isTesting, 111 | hasSucceeded: store.hasSucceeded, 112 | errorMessage: store.errorMessage, 113 | testAction: { testConnection() }, 114 | cancelAction: { store.send(.cancelTest) } 115 | ) 116 | .disabled(!canTestConnection && !store.isTesting) 117 | } 118 | .padding(.horizontal) 119 | .padding(.vertical, 20) 120 | 121 | Spacer(minLength: 20) 122 | #else 123 | Form { 124 | Section { 125 | TextField("Server Name", text: $serverName) 126 | .focused($isNameFieldFocused) 127 | .disabled(store.isTesting) 128 | .onSubmit { 129 | if isValid { 130 | addServer() 131 | } 132 | } 133 | .onChange(of: serverName) { _, _ in store.send(.reset) } 134 | } 135 | 136 | Section( 137 | header: Text("Server URL"), 138 | footer: Text( 139 | "STDIO transport (local command execution) is only supported on macOS. On iOS, use HTTP transport to connect to remote MCP servers." 140 | ) 141 | .font(.caption) 142 | ) { 143 | TextField( 144 | "Server URL", text: $url, 145 | prompt: Text(verbatim: "https://example.com/mcp") 146 | ) 147 | .keyboardType(.URL) 148 | .textContentType(.URL) 149 | .autocapitalization(.none) 150 | .textInputAutocapitalization(.never) 151 | .disableAutocorrection(true) 152 | .disabled(store.isTesting) 153 | .onSubmit { 154 | if isValid { 155 | addServer() 156 | } 157 | } 158 | .onChange(of: url) { _, _ in store.send(.reset) } 159 | } 160 | } 161 | .formStyle(.grouped) 162 | #endif 163 | 164 | // Buttons 165 | ServerFormButtons( 166 | cancelAction: { onCancel?() ?? { isPresented = false }() }, 167 | saveAction: addServer, 168 | saveTitle: "Add Server", 169 | isValid: isValid 170 | ) 171 | } 172 | #if os(macOS) 173 | .frame(width: 500, height: 600) 174 | #else 175 | .frame(maxWidth: .infinity, maxHeight: .infinity) 176 | #endif 177 | .background(.background) 178 | .onAppear { 179 | isNameFieldFocused = true 180 | } 181 | .onDisappear { 182 | store.send(.reset) 183 | } 184 | } 185 | 186 | private func addServer() { 187 | let trimmedName = serverName.trimmingCharacters(in: .whitespacesAndNewlines) 188 | guard !trimmedName.isEmpty else { return } 189 | 190 | let configuration: ConfigFile.Entry 191 | 192 | #if os(macOS) 193 | switch transportType { 194 | case .stdio: 195 | let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) 196 | guard !trimmedCommand.isEmpty else { return } 197 | 198 | let trimmedArgs = arguments.trimmingCharacters(in: .whitespacesAndNewlines) 199 | let argArray = 200 | trimmedArgs.isEmpty ? [] : trimmedArgs.split(separator: " ").map(String.init) 201 | 202 | configuration = ConfigFile.Entry(stdio: trimmedCommand, arguments: argArray) 203 | 204 | case .http: 205 | let trimmedUrl = url.trimmingCharacters(in: .whitespacesAndNewlines) 206 | 207 | 208 | configuration = ConfigFile.Entry(http: trimmedUrl) 209 | } 210 | #else 211 | let trimmedUrl = url.trimmingCharacters(in: .whitespacesAndNewlines) 212 | guard !trimmedUrl.isEmpty else { return } 213 | 214 | configuration = ConfigFile.Entry(http: trimmedUrl) 215 | #endif 216 | 217 | onAdd(trimmedName, configuration) 218 | } 219 | 220 | private func testConnection() { 221 | let trimmedName = serverName.trimmingCharacters(in: .whitespacesAndNewlines) 222 | 223 | let configuration: ConfigFile.Entry 224 | 225 | #if os(macOS) 226 | switch transportType { 227 | case .stdio: 228 | let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) 229 | 230 | let trimmedArgs = arguments.trimmingCharacters(in: .whitespacesAndNewlines) 231 | let argArray = 232 | trimmedArgs.isEmpty ? [] : trimmedArgs.split(separator: " ").map(String.init) 233 | 234 | configuration = ConfigFile.Entry(stdio: trimmedCommand, arguments: argArray) 235 | 236 | case .http: 237 | let trimmedUrl = url.trimmingCharacters(in: .whitespacesAndNewlines) 238 | 239 | 240 | configuration = ConfigFile.Entry(http: trimmedUrl) 241 | } 242 | #else 243 | let trimmedUrl = url.trimmingCharacters(in: .whitespacesAndNewlines) 244 | guard !trimmedUrl.isEmpty else { return } 245 | 246 | configuration = ConfigFile.Entry(http: trimmedUrl) 247 | #endif 248 | 249 | let testServer = Server( 250 | name: trimmedName.isEmpty ? "Test Server" : trimmedName, 251 | configuration: configuration 252 | ) 253 | 254 | store.send(.testConnection(testServer)) 255 | } 256 | } 257 | 258 | #Preview { 259 | AddServerSheet(isPresented: .constant(true), onAdd: { _, _ in }, onCancel: { }) 260 | } 261 | -------------------------------------------------------------------------------- /Companion/Views/Server/ServerFormComponents.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // MARK: - Transport Type (View Model) 4 | 5 | enum TransportType: String, CaseIterable, Codable { 6 | case stdio = "stdio" 7 | case http = "http" 8 | 9 | var displayName: String { 10 | switch self { 11 | case .stdio: return "STDIO" 12 | case .http: return "HTTP" 13 | } 14 | } 15 | 16 | var icon: String { 17 | switch self { 18 | case .stdio: return "terminal" 19 | case .http: return "globe" 20 | } 21 | } 22 | 23 | var description: String { 24 | switch self { 25 | case .stdio: return "Local command execution" 26 | case .http: return "Remote server connection" 27 | } 28 | } 29 | } 30 | 31 | // MARK: - Server Form Header 32 | struct ServerFormHeader: View { 33 | let icon: String 34 | let title: String 35 | let subtitle: String 36 | 37 | var body: some View { 38 | HStack(alignment: .top, spacing: 12) { 39 | Image(icon) 40 | .font(.largeTitle) 41 | .foregroundColor(.accentColor) 42 | .padding(.top, 2) 43 | VStack(alignment: .leading, spacing: 4) { 44 | Text(title) 45 | .font(.title2) 46 | .fontWeight(.semibold) 47 | Text(subtitle) 48 | .font(.callout) 49 | .foregroundColor(.secondary) 50 | .multilineTextAlignment(.leading) 51 | } 52 | Spacer() 53 | } 54 | .padding(.top, 20) 55 | .padding(.bottom, 20) 56 | .padding(.horizontal) 57 | } 58 | } 59 | 60 | // MARK: - Transport Type Selector 61 | struct TransportTypeSelector: View { 62 | @Binding var transportType: TransportType 63 | let isDisabled: Bool 64 | 65 | var body: some View { 66 | HStack(spacing: 12) { 67 | ForEach(TransportType.allCases, id: \.self) { type in 68 | TransportTypeCard( 69 | type: type, 70 | isSelected: transportType == type, 71 | isDisabled: isDisabled 72 | ) { 73 | transportType = type 74 | } 75 | } 76 | } 77 | } 78 | } 79 | 80 | // MARK: - Transport Type Card 81 | struct TransportTypeCard: View { 82 | let type: TransportType 83 | let isSelected: Bool 84 | let isDisabled: Bool 85 | let action: () -> Void 86 | 87 | var body: some View { 88 | HStack(spacing: 12) { 89 | Image(systemName: type.icon) 90 | .font(.system(size: 16)) 91 | .foregroundColor(.accentColor) 92 | .frame(width: 20) 93 | 94 | VStack(alignment: .leading, spacing: 2) { 95 | Text(type.displayName) 96 | .font(.system(size: 15, weight: .semibold)) 97 | .foregroundColor(.primary) 98 | .lineLimit(1) 99 | 100 | Text(type.description) 101 | .font(.system(size: 13)) 102 | .foregroundColor(.secondary) 103 | .lineLimit(3) 104 | .multilineTextAlignment(.leading) 105 | .fixedSize(horizontal: false, vertical: true) 106 | } 107 | .frame(minWidth: 120) 108 | 109 | Spacer() 110 | 111 | Image( 112 | systemName: isSelected 113 | ? "checkmark.circle.fill" : "circle" 114 | ) 115 | .font(.system(size: 16)) 116 | .foregroundColor( 117 | isSelected 118 | ? .accentColor : .secondary) 119 | } 120 | .padding(.horizontal, 12) 121 | .padding(.vertical, 10) 122 | .background( 123 | RoundedRectangle(cornerRadius: 8) 124 | .fill( 125 | isSelected 126 | ? Color.accentColor.opacity(0.1) 127 | : Color.clear) 128 | ) 129 | .overlay( 130 | RoundedRectangle(cornerRadius: 8) 131 | .stroke( 132 | isSelected 133 | ? Color.accentColor 134 | : Color.secondary.opacity(0.3), 135 | lineWidth: 1) 136 | ) 137 | .contentShape(Rectangle()) 138 | .onTapGesture { 139 | if !isDisabled { 140 | action() 141 | } 142 | } 143 | .opacity(isDisabled ? 0.6 : 1) 144 | } 145 | } 146 | 147 | // MARK: - Transport Configuration Fields 148 | struct TransportConfigurationFields: View { 149 | let transportType: TransportType 150 | @Binding var command: String 151 | @Binding var arguments: String 152 | @Binding var url: String 153 | let isDisabled: Bool 154 | let onSubmit: () -> Void 155 | let onChange: (() -> Void)? 156 | 157 | var body: some View { 158 | ZStack(alignment: .top) { 159 | // STDIO fields 160 | VStack(alignment: .leading, spacing: 8) { 161 | TextField("Command", text: $command) 162 | .textFieldStyle(.roundedBorder) 163 | .disabled(isDisabled) 164 | .help("e.g., python, node, /usr/local/bin/my-server") 165 | .onSubmit(onSubmit) 166 | .onChange(of: command, initial: true) { _, _ in onChange?() } 167 | 168 | TextField("Arguments (optional)", text: $arguments) 169 | .textFieldStyle(.roundedBorder) 170 | .disabled(isDisabled) 171 | .help("Space-separated arguments, e.g., --port 3000 --verbose") 172 | .onSubmit(onSubmit) 173 | .onChange(of: arguments, initial: true) { _, _ in onChange?() } 174 | } 175 | .opacity(transportType == .stdio ? 1 : 0) 176 | .allowsHitTesting(transportType == .stdio) 177 | 178 | // HTTP fields 179 | VStack(alignment: .leading, spacing: 8) { 180 | TextField("URL", text: $url) 181 | .textFieldStyle(.roundedBorder) 182 | .disabled(isDisabled) 183 | .help(Text(verbatim: "e.g., http://localhost:3000 or https://api.example.com")) 184 | .onSubmit(onSubmit) 185 | .onChange(of: url, initial: true) { _, _ in onChange?() } 186 | 187 | // Add spacer to match STDIO height (2 fields) 188 | Color.clear 189 | .frame(height: 28) 190 | } 191 | .opacity(transportType == .http ? 1 : 0) 192 | .allowsHitTesting(transportType == .http) 193 | } 194 | } 195 | } 196 | 197 | // MARK: - Server Form Buttons 198 | struct ServerFormButtons: View { 199 | let cancelAction: () -> Void 200 | let saveAction: () -> Void 201 | let saveTitle: String 202 | let isValid: Bool 203 | 204 | var body: some View { 205 | HStack { 206 | Button("Cancel", action: cancelAction) 207 | .keyboardShortcut(.cancelAction) 208 | 209 | Spacer() 210 | 211 | Button(saveTitle, action: saveAction) 212 | .keyboardShortcut(.defaultAction) 213 | .disabled(!isValid) 214 | } 215 | .padding() 216 | #if os(macOS) 217 | .background(Color(NSColor.controlBackgroundColor)) 218 | #else 219 | .background(Color(UIColor.secondarySystemBackground)) 220 | #endif 221 | } 222 | } 223 | 224 | // MARK: - ConfigFile.Entry Extensions for UI 225 | 226 | extension ConfigFile.Entry { 227 | var transportType: TransportType { 228 | switch self { 229 | case .stdio: 230 | return .stdio 231 | case .sse, .streamableHTTP: 232 | return .http 233 | } 234 | } 235 | } 236 | 237 | // MARK: - Test Connection Section 238 | struct TestConnectionSection: View { 239 | let isTesting: Bool 240 | let hasSucceeded: Bool 241 | let errorMessage: String? 242 | let testAction: () -> Void 243 | let cancelAction: () -> Void 244 | 245 | var body: some View { 246 | VStack(alignment: .leading, spacing: 12) { 247 | // Test button and status 248 | HStack { 249 | if isTesting { 250 | Button("Cancel Test", action: cancelAction) 251 | .foregroundColor(.red) 252 | } else { 253 | Button(action: testAction) { 254 | HStack { 255 | Image(systemName: "bolt.circle") 256 | Text("Test Connection") 257 | } 258 | } 259 | } 260 | 261 | Spacer() 262 | 263 | if isTesting { 264 | HStack(spacing: 8) { 265 | ProgressView() 266 | .scaleEffect(0.4) 267 | .frame(width: 16, height: 16) 268 | Text("Testing connection...") 269 | .foregroundColor(.secondary) 270 | } 271 | } else if hasSucceeded { 272 | HStack(spacing: 8) { 273 | Image(systemName: "checkmark.circle.fill") 274 | .foregroundColor(.green) 275 | .frame(width: 16, height: 16) 276 | Text("Connection successful") 277 | .foregroundColor(.secondary) 278 | } 279 | } else if errorMessage != nil { 280 | HStack(spacing: 8) { 281 | Image(systemName: "exclamationmark.triangle.fill") 282 | .foregroundColor(.red) 283 | .frame(width: 16, height: 16) 284 | Text("Connection failed") 285 | .foregroundColor(.secondary) 286 | } 287 | } 288 | } 289 | 290 | // Error details (if any) 291 | if let error = errorMessage { 292 | VStack(alignment: .leading, spacing: 8) { 293 | Text("Error Details") 294 | .font(.caption) 295 | .foregroundColor(.primary) 296 | 297 | ScrollView { 298 | Text(error) 299 | .font(.system(.caption, design: .monospaced)) 300 | .foregroundColor(.red) 301 | .textSelection(.enabled) 302 | .frame(maxWidth: .infinity, alignment: .leading) 303 | .padding(8) 304 | .background(Color.red.opacity(0.1)) 305 | .cornerRadius(6) 306 | } 307 | } 308 | } 309 | 310 | // Help text 311 | if hasSucceeded { 312 | Text("The server responded successfully and is ready to use.") 313 | .font(.caption) 314 | .foregroundColor(.secondary) 315 | } 316 | } 317 | .padding() 318 | #if os(macOS) 319 | .background(Color(NSColor.controlBackgroundColor)) 320 | #else 321 | .background(Color(UIColor.secondarySystemBackground)) 322 | #endif 323 | .cornerRadius(8) 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /Companion/Views/Resources/ResourceListView.swift: -------------------------------------------------------------------------------- 1 | import MCP 2 | import QuickLook 3 | import SwiftUI 4 | 5 | private enum ResourceItem: Hashable { 6 | case resource(Resource) 7 | case template(Resource.Template) 8 | 9 | var id: String { 10 | switch self { 11 | case .resource(let res): 12 | return res.uri 13 | case .template(let temp): 14 | return temp.uriTemplate 15 | } 16 | } 17 | 18 | var name: String { 19 | switch self { 20 | case .resource(let res): 21 | return res.name 22 | case .template(let temp): 23 | return temp.name 24 | } 25 | } 26 | 27 | var displayName: String { 28 | name 29 | } 30 | 31 | var uri: String { 32 | switch self { 33 | case .resource(let res): 34 | return res.uri 35 | case .template(let temp): 36 | return temp.uriTemplate 37 | } 38 | } 39 | 40 | var description: String? { 41 | switch self { 42 | case .resource(let res): 43 | return res.description 44 | case .template(let temp): 45 | return temp.description 46 | } 47 | } 48 | 49 | var mimeType: String? { 50 | switch self { 51 | case .resource(let res): 52 | return res.mimeType 53 | case .template(let temp): 54 | return temp.mimeType 55 | } 56 | } 57 | } 58 | 59 | struct ResourceListView: View { 60 | let resources: [Resource] 61 | let templates: [Resource.Template] 62 | let serverId: String 63 | let columnVisibility: NavigationSplitViewVisibility 64 | @State private var selectedResourceID: String? 65 | @State private var leftPaneWidth: CGFloat = 350 66 | @State private var searchText: String = "" 67 | 68 | private var allResources: [ResourceItem] { 69 | let resourceItems = resources.map { ResourceItem.resource($0) } 70 | let templateItems = templates.map { ResourceItem.template($0) } 71 | return resourceItems + templateItems 72 | } 73 | 74 | private var filteredResources: [ResourceItem] { 75 | if searchText.isEmpty { 76 | return allResources.sorted(by: { $0.name < $1.name }) 77 | } else { 78 | return allResources.filter { item in 79 | item.name.localizedCaseInsensitiveContains(searchText) 80 | || item.uri.localizedCaseInsensitiveContains(searchText) 81 | || (item.description?.localizedCaseInsensitiveContains(searchText) ?? false) 82 | || (item.mimeType?.localizedCaseInsensitiveContains(searchText) ?? false) 83 | }.sorted(by: { $0.name < $1.name }) 84 | } 85 | } 86 | 87 | private var selectedResource: ResourceItem? { 88 | allResources.first { $0.id == selectedResourceID } 89 | } 90 | 91 | private var placeholderView: some View { 92 | VStack(spacing: 16) { 93 | Image(systemName: "doc.text") 94 | .font(.system(size: 48)) 95 | .foregroundColor(.secondary) 96 | 97 | Text("Select a resource") 98 | .font(.title2) 99 | .foregroundColor(.secondary) 100 | 101 | Text("Choose a resource from the list to view its content and metadata") 102 | .font(.body) 103 | .foregroundColor(.secondary) 104 | .multilineTextAlignment(.center) 105 | .frame(maxWidth: 300) 106 | } 107 | .frame(maxWidth: .infinity, maxHeight: .infinity) 108 | .background(.fill.secondary) 109 | } 110 | 111 | var body: some View { 112 | Group { 113 | #if os(macOS) 114 | HSplitView { 115 | VStack(alignment: .leading, spacing: 0) { 116 | if filteredResources.isEmpty { 117 | VStack(spacing: 16) { 118 | Image(systemName: "doc.text") 119 | .font(.system(size: 48)) 120 | .foregroundColor(.secondary) 121 | 122 | Text("No resources") 123 | .font(.title2) 124 | .foregroundColor(.secondary) 125 | 126 | if searchText.isEmpty { 127 | Text("This server doesn't provide any resources") 128 | .font(.body) 129 | .foregroundColor(.secondary) 130 | .multilineTextAlignment(.center) 131 | } else { 132 | Text("No resources match your search") 133 | .font(.body) 134 | .foregroundColor(.secondary) 135 | .multilineTextAlignment(.center) 136 | } 137 | } 138 | .frame(maxWidth: .infinity, maxHeight: .infinity) 139 | } else { 140 | List( 141 | filteredResources, 142 | id: \.id, 143 | selection: $selectedResourceID 144 | ) { resourceItem in 145 | ResourceRowView(resource: resourceItem) 146 | .tag(resourceItem.id) 147 | } 148 | .listStyle(.sidebar) 149 | .background( 150 | GeometryReader { geo in 151 | WidthPassthroughView(width: geo.size.width) { 152 | newCalculatedWidth in 153 | if newCalculatedWidth > 0 154 | && self.leftPaneWidth != newCalculatedWidth 155 | { 156 | self.leftPaneWidth = newCalculatedWidth 157 | } 158 | } 159 | } 160 | ) 161 | } 162 | } 163 | .frame(minWidth: 250, idealWidth: 350, maxWidth: 400) 164 | 165 | if let selectedResource = selectedResource { 166 | switch selectedResource { 167 | case .resource(let resource): 168 | ResourceDetailView(resource: resource, serverId: serverId) 169 | case .template(let template): 170 | ResourceDetailView(template: template, serverId: serverId) 171 | } 172 | } else if !filteredResources.isEmpty { 173 | placeholderView 174 | } 175 | }.toolbar { 176 | if !(resources.isEmpty && templates.isEmpty) { 177 | #if os(macOS) 178 | // if #available(macOS 26.0, *) { 179 | // ToolbarItemGroup(placement: .navigation) { 180 | // FilterToolbar( 181 | // searchText: $searchText, 182 | // placeholder: "Filter resources", 183 | // width: leftPaneWidth, 184 | // isVisible: columnVisibility == .all 185 | // ) 186 | // } 187 | // .sharedBackgroundVisibility(Visibility.hidden) 188 | // } else { 189 | ToolbarItemGroup(placement: .navigation) { 190 | FilterToolbar( 191 | searchText: $searchText, 192 | placeholder: "Filter resources", 193 | width: leftPaneWidth, 194 | isVisible: columnVisibility == .all 195 | ) 196 | } 197 | // } 198 | #endif 199 | } 200 | } 201 | #else 202 | NavigationStack { 203 | List(filteredResources, id: \.id) { resourceItem in 204 | NavigationLink( 205 | destination: Group { 206 | switch resourceItem { 207 | case .resource(let resource): 208 | ResourceDetailView(resource: resource, serverId: serverId) 209 | case .template(let template): 210 | ResourceDetailView(template: template, serverId: serverId) 211 | } 212 | } 213 | ) { 214 | ResourceRowView(resource: resourceItem) 215 | } 216 | } 217 | .navigationTitle("Resources") 218 | .navigationBarTitleDisplayMode(.large) 219 | } 220 | #endif 221 | } 222 | .onAppear { 223 | if selectedResourceID == nil, let firstResource = allResources.first { 224 | selectedResourceID = firstResource.id 225 | } 226 | } 227 | .onChange(of: searchText) { _, _ in 228 | // When search changes, ensure selected resource is still visible 229 | if let selected = selectedResource, 230 | !filteredResources.contains(where: { $0.id == selected.id }) 231 | { 232 | selectedResourceID = filteredResources.first?.id 233 | } else if selectedResourceID == nil { 234 | selectedResourceID = filteredResources.first?.id 235 | } 236 | } 237 | } 238 | } 239 | 240 | private struct ResourceRowView: View { 241 | let resource: ResourceItem 242 | 243 | var body: some View { 244 | VStack(alignment: .leading, spacing: 6) { 245 | Text(resource.name) 246 | .font(.subheadline) 247 | .fontWeight(.medium) 248 | 249 | Text(resource.uri) 250 | .font(.system(.caption, design: .monospaced)) 251 | .foregroundColor(.secondary) 252 | .lineLimit(1) 253 | 254 | if let description = resource.description { 255 | Text(description) 256 | .font(.caption) 257 | .foregroundColor(.secondary) 258 | .lineLimit(3) 259 | } 260 | 261 | HStack(spacing: 8) { 262 | // Resource type indicator 263 | if case .template = resource { 264 | Text("Template") 265 | .font(.caption2) 266 | .padding(.horizontal, 4) 267 | .padding(.vertical, 1) 268 | .background(Color.orange.opacity(0.2)) 269 | .foregroundColor(.orange) 270 | .cornerRadius(3) 271 | } 272 | 273 | // MIME type badge 274 | if let mimeType = resource.mimeType { 275 | Text(mimeType) 276 | .font(.caption2) 277 | .padding(.horizontal, 4) 278 | .padding(.vertical, 1) 279 | .background(Color.blue.opacity(0.2)) 280 | .foregroundColor(.blue) 281 | .cornerRadius(3) 282 | } 283 | 284 | Spacer() 285 | } 286 | } 287 | .padding(.vertical, 4) 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /Companion/Views/Server/ServerDetailView.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import MCP 3 | import SwiftUI 4 | 5 | struct ServerDetailView: View { 6 | let store: StoreOf 7 | 8 | @State private var showingActionSheet = false 9 | 10 | private var server: Server { store.server } 11 | private var isConnecting: Bool { store.isConnecting } 12 | private var isConnected: Bool { store.isConnected } 13 | 14 | var body: some View { 15 | #if os(iOS) 16 | ScrollView { 17 | ServerInformationView( 18 | server: server, 19 | serverStatus: server.status, 20 | isConnecting: isConnecting 21 | ) 22 | } 23 | .navigationTitle(server.name) 24 | .navigationBarTitleDisplayMode(.inline) 25 | .toolbar { 26 | ToolbarItem(placement: .navigationBarTrailing) { 27 | Menu { 28 | Button { 29 | store.send(.edit) 30 | } label: { 31 | Label("Edit Server", systemImage: "square.and.pencil") 32 | } 33 | 34 | Button { 35 | copyServerInfo() 36 | } label: { 37 | Label("Copy Server Info", systemImage: "doc.on.doc") 38 | } 39 | 40 | if isConnected { 41 | Button { 42 | store.send(.restart) 43 | } label: { 44 | Label("Restart", systemImage: "arrow.clockwise") 45 | } 46 | } 47 | 48 | Button { 49 | if isConnecting { 50 | store.send(.cancel) 51 | } else if isConnected { 52 | store.send(.disconnect) 53 | } else { 54 | store.send(.connect) 55 | } 56 | } label: { 57 | Label( 58 | isConnecting ? "Cancel" : (isConnected ? "Disconnect" : "Connect"), 59 | systemImage: isConnected ? "bolt.fill" : "bolt") 60 | } 61 | } label: { 62 | Image(systemName: "ellipsis.circle") 63 | } 64 | } 65 | } 66 | .confirmationDialog("Server Actions", isPresented: $showingActionSheet) { 67 | Button("Edit Server") { 68 | store.send(.edit) 69 | } 70 | 71 | if isConnected { 72 | Button("Restart") { 73 | store.send(.restart) 74 | } 75 | } 76 | 77 | Button(isConnected ? "Disconnect" : "Connect") { 78 | if isConnected { 79 | store.send(.disconnect) 80 | } else { 81 | store.send(.connect) 82 | } 83 | } 84 | 85 | Button("Cancel", role: .cancel) {} 86 | } 87 | #else 88 | VStack(spacing: 0) { 89 | if isConnected { 90 | ScrollView { 91 | ServerInformationView( 92 | server: server, 93 | serverStatus: server.status, 94 | isConnecting: isConnecting 95 | ) 96 | } 97 | .frame(maxWidth: .infinity, maxHeight: .infinity) 98 | } else { 99 | ServerConnectionStateView( 100 | isConnecting: isConnecting, 101 | onConnect: { store.send(.connect) }, 102 | onCancel: { store.send(.cancel) }, 103 | onEdit: { store.send(.edit) } 104 | ) 105 | } 106 | } 107 | .toolbar { 108 | #if os(macOS) 109 | // if #available(macOS 26.0, *) { 110 | // ToolbarItemGroup(placement: .navigation) { 111 | // ServerInfoToolbarContent(server: server) 112 | // .padding(.horizontal, 8) 113 | // } 114 | // .sharedBackgroundVisibility(Visibility.hidden) 115 | // } else { 116 | ToolbarItemGroup(placement: .navigation) { 117 | ServerInfoToolbarContent(server: server) 118 | Spacer() 119 | } 120 | // } 121 | #else 122 | ToolbarItemGroup(placement: .navigation) { 123 | ServerInfoToolbarContent(server: server) 124 | Spacer() 125 | } 126 | #endif 127 | 128 | // Connection status as separate toolbar item 129 | ToolbarItem(placement: .automatic) { 130 | ServerConnectionStatus( 131 | isConnected: isConnected, 132 | isConnecting: isConnecting 133 | ) 134 | } 135 | 136 | // Action menu as separate toolbar item 137 | ToolbarItem(placement: .primaryAction) { 138 | ServerActionMenu( 139 | isConnected: isConnected, 140 | isConnecting: isConnecting, 141 | onConnect: { store.send(.connect) }, 142 | onDisconnect: { store.send(.disconnect) }, 143 | onCancel: { store.send(.cancel) }, 144 | onEdit: { store.send(.edit) } 145 | ) 146 | } 147 | } 148 | #endif 149 | } 150 | 151 | private func copyServerInfo() { 152 | let serverInfo = "{}" 153 | 154 | #if os(iOS) 155 | UIPasteboard.general.string = serverInfo 156 | #elseif os(macOS) 157 | NSPasteboard.general.clearContents() 158 | NSPasteboard.general.setString(serverInfo, forType: .string) 159 | #endif 160 | } 161 | } 162 | 163 | private struct ServerInfoToolbarContent: View { 164 | let server: Server 165 | 166 | var body: some View { 167 | VStack(alignment: .leading, spacing: 2) { 168 | Text(server.name) 169 | .font(.headline) 170 | .fontWeight(.semibold) 171 | .lineLimit(1) 172 | 173 | Text(server.configuration.displayValue) 174 | .font(.system(.caption, design: .monospaced)) 175 | .foregroundColor(.secondary) 176 | .lineLimit(1) 177 | .truncationMode( 178 | server.configuration.displayValue.hasPrefix("http") 179 | ? .tail : .middle) 180 | } 181 | } 182 | } 183 | 184 | private struct ServerConnectionStatus: View { 185 | let isConnected: Bool 186 | let isConnecting: Bool 187 | 188 | var body: some View { 189 | HStack(spacing: 4) { 190 | Image( 191 | systemName: isConnecting 192 | ? "clock.circle.fill" : (isConnected ? "circle.fill" : "circle") 193 | ) 194 | .foregroundColor(isConnecting ? .orange : (isConnected ? .green : .red)) 195 | .font(.system(size: 10)) 196 | Text( 197 | isConnecting 198 | ? "Connecting..." : (isConnected ? "Connected" : "Disconnected") 199 | ) 200 | .font(.caption) 201 | .foregroundColor(.secondary) 202 | } 203 | } 204 | } 205 | 206 | private struct ServerActionMenu: View { 207 | let isConnected: Bool 208 | let isConnecting: Bool 209 | let onConnect: () -> Void 210 | let onDisconnect: () -> Void 211 | let onCancel: () -> Void 212 | let onEdit: () -> Void 213 | 214 | var body: some View { 215 | Menu { 216 | Button { 217 | onEdit() 218 | } label: { 219 | Label("Edit Server", systemImage: "square.and.pencil") 220 | } 221 | 222 | if isConnected { 223 | Divider() 224 | 225 | Button { 226 | Task { 227 | onDisconnect() 228 | try await Task.sleep(for: .milliseconds(100)) 229 | onConnect() 230 | } 231 | } label: { 232 | Label("Restart", systemImage: "arrow.clockwise") 233 | } 234 | 235 | Button { 236 | onDisconnect() 237 | } label: { 238 | Label("Disconnect", systemImage: "bolt.slash") 239 | } 240 | } else { 241 | Divider() 242 | 243 | if isConnecting { 244 | Button { 245 | onCancel() 246 | } label: { 247 | Label("Cancel", systemImage: "xmark.circle") 248 | } 249 | .buttonStyle(.borderedProminent) 250 | } else { 251 | Button { 252 | onConnect() 253 | } label: { 254 | Label("Connect", systemImage: "bolt") 255 | } 256 | .buttonStyle(.borderedProminent) 257 | } 258 | } 259 | } label: { 260 | Image(systemName: "ellipsis.circle") 261 | } 262 | .buttonStyle(.bordered) 263 | .controlSize(.small) 264 | } 265 | } 266 | 267 | private struct ServerConnectionStateView: View { 268 | let isConnecting: Bool 269 | let onConnect: () -> Void 270 | let onCancel: () -> Void 271 | let onEdit: () -> Void 272 | 273 | var body: some View { 274 | VStack(spacing: 48) { 275 | VStack(spacing: 16) { 276 | if isConnecting { 277 | Image(systemName: "bolt.badge.clock.fill") 278 | .font(.system(size: 48)) 279 | .foregroundColor(.orange) 280 | .symbolEffect(.pulse, options: .repeat(.continuous)) 281 | 282 | Text("Connecting...") 283 | .font(.title2) 284 | .foregroundColor(.secondary) 285 | } else { 286 | Image(systemName: "bolt") 287 | .font(.system(size: 48)) 288 | .foregroundColor(.secondary) 289 | 290 | Text("Disconnected") 291 | .font(.title2) 292 | .foregroundColor(.secondary) 293 | } 294 | } 295 | 296 | HStack(spacing: 12) { 297 | if isConnecting { 298 | Button { 299 | onCancel() 300 | } label: { 301 | Text("Cancel") 302 | } 303 | .buttonStyle(.borderedProminent) 304 | } else { 305 | Button { 306 | onConnect() 307 | } label: { 308 | Text("Connect") 309 | } 310 | .buttonStyle(.borderedProminent) 311 | 312 | Button { 313 | onEdit() 314 | } label: { 315 | Text("Edit") 316 | } 317 | .buttonStyle(.bordered) 318 | } 319 | } 320 | } 321 | .frame(maxWidth: .infinity, maxHeight: .infinity) 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /Companion/Features/AppFeature.swift: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Foundation 3 | 4 | import enum MCP.Value 5 | 6 | @Reducer 7 | struct AppFeature { 8 | @ObservableState 9 | struct State: Equatable { 10 | var serverDetails: IdentifiedArrayOf? 11 | var selection: SidebarSelection? 12 | var error: String? 13 | @Presents var editServer: EditServerFeature.State? 14 | @Presents var addServer: AddServerFeature.State? 15 | 16 | init() { 17 | // Initialize with nil - will be loaded from ServerClient 18 | } 19 | 20 | var servers: IdentifiedArrayOf { 21 | guard let serverDetails else { return [] } 22 | return IdentifiedArrayOf(uniqueElements: serverDetails.map { $0.server }) 23 | } 24 | 25 | func serverDetail(_ serverId: String) -> ServerDetailFeature.State? { 26 | serverDetails?[id: serverId] 27 | } 28 | } 29 | 30 | enum Action: Equatable { 31 | case task 32 | case serversUpdated(IdentifiedArrayOf) 33 | case selectionChanged(SidebarSelection?) 34 | 35 | case removeServer(id: String) 36 | case errorOccurred(String?) 37 | case presentAddServer 38 | case addExampleServer 39 | case addServerPresentation(PresentationAction) 40 | case presentEditServer(Server) 41 | case editServer(PresentationAction) 42 | case serverDetail(id: String, action: ServerDetailFeature.Action) 43 | } 44 | 45 | @Dependency(\.serverClient) var serverClient 46 | 47 | var body: some ReducerOf { 48 | Reduce { state, action in 49 | switch action { 50 | case .task: 51 | return .run { send in 52 | // Load existing servers 53 | let servers = await serverClient.getServers() 54 | await send(.serversUpdated(servers)) 55 | 56 | // Observe server changes 57 | for await updatedServers in await serverClient.observeServers() { 58 | await send(.serversUpdated(updatedServers)) 59 | } 60 | } 61 | 62 | case let .serversUpdated(servers): 63 | print("AppFeature: Received server update. \(servers.count) servers:") 64 | for server in servers { 65 | print( 66 | " - \(server.name): \(server.status) (\(server.availableTools.count) tools)" 67 | ) 68 | } 69 | 70 | // Preserve current selection if it still exists 71 | let currentSelection = state.selection 72 | 73 | // Update or create ServerDetailFeature.State for each server 74 | var newServerDetails: IdentifiedArrayOf = [] 75 | for server in servers { 76 | if let existingDetail = state.serverDetails?[id: server.id] { 77 | // Update existing server detail with new server data 78 | var updatedDetail = existingDetail 79 | updatedDetail.server = server 80 | newServerDetails.append(updatedDetail) 81 | } else { 82 | // Create new server detail 83 | newServerDetails.append(ServerDetailFeature.State(server: server)) 84 | } 85 | } 86 | state.serverDetails = newServerDetails 87 | 88 | // Re-apply selection if the selected item still exists 89 | if let currentSelection = currentSelection { 90 | // Find the server that matches the current selection 91 | if let server = servers.first(where: { 92 | $0.id == currentSelection.serverId 93 | }) { 94 | // Update selection with current server data 95 | if let section = currentSelection.section { 96 | switch section { 97 | case .prompts: 98 | if !server.availablePrompts.isEmpty { 99 | state.selection = .prompts(serverId: currentSelection.serverId) 100 | } 101 | case .resources: 102 | if !server.availableResources.isEmpty { 103 | state.selection = .resources( 104 | serverId: currentSelection.serverId) 105 | } 106 | case .tools: 107 | if !server.availableTools.isEmpty { 108 | state.selection = .tools(serverId: currentSelection.serverId) 109 | } 110 | } 111 | } else { 112 | // Server selection 113 | state.selection = .server(server) 114 | } 115 | } else { 116 | // Server no longer exists, clear selection 117 | state.selection = nil 118 | } 119 | } 120 | 121 | return .none 122 | 123 | case let .selectionChanged(selection): 124 | state.selection = selection 125 | return .none 126 | 127 | case let .removeServer(id): 128 | // Clear selection if removed server was selected 129 | if let selection = state.selection, 130 | selection.serverId == id 131 | { 132 | state.selection = nil 133 | } 134 | // Remove from server details 135 | state.serverDetails?.remove(id: id) 136 | return .run { _ in 137 | await serverClient.removeServer(id) 138 | } 139 | 140 | case let .errorOccurred(error): 141 | state.error = error 142 | return .none 143 | 144 | case let .presentEditServer(server): 145 | state.editServer = EditServerFeature.State(server: server) 146 | return .none 147 | 148 | case .editServer(.presented(.updateServer(let name, let transport))): 149 | guard let editServerState = state.editServer else { 150 | return .none 151 | } 152 | let serverId = editServerState.server.id 153 | guard state.serverDetails?[id: serverId] != nil else { 154 | print("AppFeature: Server \(serverId) not found for update") 155 | return .none 156 | } 157 | let updatedServer = Server( 158 | id: serverId, 159 | name: name, 160 | configuration: transport 161 | ) 162 | print("AppFeature: Updating server '\(name)' with status: \(updatedServer.status)") 163 | state.editServer = nil 164 | return .run { send in 165 | await serverClient.updateServer(updatedServer) 166 | print("AppFeature: Server '\(name)' updated, now auto-connecting...") 167 | await send(.serverDetail(id: serverId, action: .connect)) 168 | } 169 | 170 | case .editServer(.presented(.testConnection)): 171 | // Test connection actions are handled internally by EditServerFeature 172 | return .none 173 | 174 | case .editServer(.presented(.connectionTest)): 175 | // Connection test actions are handled internally by EditServerFeature 176 | return .none 177 | 178 | case .editServer(.presented(.dismiss)): 179 | state.editServer = nil 180 | return .none 181 | 182 | case .editServer: 183 | return .none 184 | 185 | case .presentAddServer: 186 | state.addServer = AddServerFeature.State() 187 | return .none 188 | 189 | case .addExampleServer: 190 | let exampleServer = Server( 191 | name: "Everything", 192 | configuration: .init(stdio: "npx", arguments: ["-y", "@modelcontextprotocol/server-everything"]) 193 | ) 194 | print("AppFeature: Adding example server '\(exampleServer.name)'") 195 | return .run { send in 196 | await serverClient.addServer(exampleServer) 197 | print("AppFeature: Example server added, now auto-connecting...") 198 | // Wait a brief moment for the server to be processed and state updated 199 | try? await Task.sleep(for: .milliseconds(100)) 200 | await send(.selectionChanged(.server(exampleServer))) 201 | await send(.serverDetail(id: exampleServer.id, action: .connect)) 202 | } 203 | 204 | case .addServerPresentation(.presented(.addServer(let name, let transport))): 205 | let newServer = Server( 206 | name: name, 207 | configuration: transport 208 | ) 209 | print("AppFeature: Adding new server '\(name)' with status: \(newServer.status)") 210 | state.addServer = nil 211 | return .run { send in 212 | await serverClient.addServer(newServer) 213 | print( 214 | "AppFeature: Server '\(name)' added to ServerClient, now auto-connecting..." 215 | ) 216 | await send(.selectionChanged(.server(newServer))) 217 | await send(.serverDetail(id: newServer.id, action: .connect)) 218 | } 219 | 220 | case .addServerPresentation(.presented(.testConnection)): 221 | // Test connection actions are handled internally by AddServerFeature 222 | return .none 223 | 224 | case .addServerPresentation(.presented(.connectionTest)): 225 | // Connection test actions are handled internally by AddServerFeature 226 | return .none 227 | 228 | case .addServerPresentation(.presented(.dismiss)): 229 | state.addServer = nil 230 | return .none 231 | 232 | case .addServerPresentation: 233 | return .none 234 | 235 | case let .serverDetail(id, action): 236 | // Handle special actions that require AppFeature coordination 237 | switch action { 238 | case .edit: 239 | guard let serverDetail = state.serverDetails?[id: id] else { return .none } 240 | return .send(.presentEditServer(serverDetail.server)) 241 | case .deselectServer: 242 | return .send(.selectionChanged(nil)) 243 | default: 244 | // Handle the action in the focused ServerDetailFeature 245 | guard var serverDetail = state.serverDetails?[id: id] else { return .none } 246 | 247 | // Create a mini-store to run the ServerDetailFeature reducer 248 | let serverFeature = ServerDetailFeature() 249 | let effect = serverFeature.reduce(into: &serverDetail, action: action) 250 | 251 | // Update the state with the modified server detail 252 | state.serverDetails?[id: id] = serverDetail 253 | 254 | // Map any effects to include the server ID 255 | return effect.map { .serverDetail(id: id, action: $0) } 256 | } 257 | } 258 | } 259 | .ifLet(\.$editServer, action: \.editServer) { 260 | EditServerFeature() 261 | } 262 | .ifLet(\.$addServer, action: \.addServerPresentation) { 263 | AddServerFeature() 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /Companion/Views/Common/ContentDisplayHelpers.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import SwiftUI 3 | 4 | // MARK: - Shared Content Display Views 5 | 6 | struct ContentDisplayHelpers { 7 | 8 | @ViewBuilder 9 | static func textContentView(_ text: String) -> some View { 10 | if !text.isEmpty { 11 | Text(text) 12 | .font(.system(.body, design: .monospaced)) 13 | .padding() 14 | .frame(maxWidth: .infinity, alignment: .leading) 15 | #if os(visionOS) 16 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) 17 | #else 18 | .background(.fill.tertiary) 19 | .cornerRadius(8) 20 | #endif 21 | .textSelection(.enabled) 22 | } 23 | } 24 | 25 | @ViewBuilder 26 | static func imageContentView(data: String, mimeType: String, metadata: [String: String]?) 27 | -> some View 28 | { 29 | VStack(alignment: .leading, spacing: 8) { 30 | HStack { 31 | Label("Image", systemImage: "photo") 32 | .font(.caption) 33 | .foregroundColor(.secondary) 34 | Spacer() 35 | Text(mimeType) 36 | .font(.caption) 37 | .foregroundColor(.secondary) 38 | } 39 | 40 | if let imageData = Data(base64Encoded: data) { 41 | #if os(macOS) 42 | if let nsImage = NSImage(data: imageData) { 43 | Image(nsImage: nsImage) 44 | .resizable() 45 | .aspectRatio(contentMode: .fit) 46 | .frame(maxHeight: 300) 47 | .cornerRadius(8) 48 | } else { 49 | imageDecodeErrorView() 50 | } 51 | #else 52 | if let uiImage = UIImage(data: imageData) { 53 | Image(uiImage: uiImage) 54 | .resizable() 55 | .aspectRatio(contentMode: .fit) 56 | .frame(maxHeight: 300) 57 | .cornerRadius(8) 58 | } else { 59 | imageDecodeErrorView() 60 | } 61 | #endif 62 | } else { 63 | imageDecodeErrorView() 64 | } 65 | 66 | if let metadata = metadata, !metadata.isEmpty { 67 | metadataView(metadata) 68 | } 69 | } 70 | .padding() 71 | #if os(visionOS) 72 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) 73 | #else 74 | .background(.fill.tertiary) 75 | .cornerRadius(8) 76 | #endif 77 | } 78 | 79 | @ViewBuilder 80 | static func audioContentView(data: String, mimeType: String) -> some View { 81 | if let audioData = Data(base64Encoded: data) { 82 | AudioPlayerView(audioData: audioData, mimeType: mimeType) 83 | } else { 84 | audioDecodeErrorView() 85 | } 86 | } 87 | 88 | @ViewBuilder 89 | static func resourceContentView(uri: String, mimeType: String, text: String?) -> some View { 90 | VStack(alignment: .leading, spacing: 8) { 91 | HStack { 92 | Label("Resource", systemImage: "doc") 93 | .font(.caption) 94 | .foregroundColor(.secondary) 95 | Spacer() 96 | if !mimeType.isEmpty { 97 | Text(mimeType) 98 | .font(.caption) 99 | .foregroundColor(.secondary) 100 | } 101 | } 102 | 103 | Text(uri) 104 | .font(.system(.caption, design: .monospaced)) 105 | .foregroundColor(.accentColor) 106 | .textSelection(.enabled) 107 | 108 | if let text = text, !text.isEmpty { 109 | Text(text) 110 | .font(.system(.body, design: .monospaced)) 111 | .textSelection(.enabled) 112 | } 113 | } 114 | .padding() 115 | #if os(visionOS) 116 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) 117 | #else 118 | .background(.fill.tertiary) 119 | .cornerRadius(8) 120 | #endif 121 | } 122 | 123 | @ViewBuilder 124 | private static func imageDecodeErrorView() -> some View { 125 | Text("Failed to decode image data") 126 | .font(.caption) 127 | .foregroundColor(.red) 128 | .padding() 129 | .frame(maxWidth: .infinity) 130 | #if os(visionOS) 131 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) 132 | #else 133 | .background(.fill.tertiary) 134 | .cornerRadius(8) 135 | #endif 136 | } 137 | 138 | @ViewBuilder 139 | private static func audioDecodeErrorView() -> some View { 140 | VStack(spacing: 8) { 141 | Image(systemName: "exclamationmark.triangle.fill") 142 | .foregroundColor(.orange) 143 | .font(.title2) 144 | Text("Unable to decode audio data") 145 | .font(.caption) 146 | .foregroundColor(.red) 147 | } 148 | .padding() 149 | .frame(maxWidth: .infinity) 150 | #if os(visionOS) 151 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) 152 | #else 153 | .background(.fill.tertiary) 154 | .cornerRadius(8) 155 | #endif 156 | } 157 | 158 | @ViewBuilder 159 | private static func metadataView(_ metadata: [String: String]) -> some View { 160 | VStack(alignment: .leading, spacing: 4) { 161 | Text("Metadata:") 162 | .font(.caption2) 163 | .foregroundColor(.secondary) 164 | ForEach(Array(metadata.keys.sorted()), id: \.self) { key in 165 | HStack { 166 | Text(key) 167 | .font(.caption2) 168 | .foregroundColor(.secondary) 169 | Spacer() 170 | Text(metadata[key] ?? "") 171 | .font(.caption2) 172 | .foregroundColor(.primary) 173 | } 174 | } 175 | } 176 | .padding(.horizontal, 8) 177 | .padding(.vertical, 4) 178 | #if os(visionOS) 179 | .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6)) 180 | #else 181 | .background(.fill.quaternary) 182 | .cornerRadius(6) 183 | #endif 184 | } 185 | } 186 | 187 | // MARK: - Audio Player View 188 | 189 | private struct AudioPlayerView: View { 190 | let audioData: Data 191 | let mimeType: String 192 | 193 | @StateObject private var player = AudioPlayer() 194 | 195 | var body: some View { 196 | VStack(alignment: .leading, spacing: 12) { 197 | HStack { 198 | Label("Audio", systemImage: "waveform") 199 | .font(.caption) 200 | .foregroundColor(.secondary) 201 | Spacer() 202 | Text(mimeType) 203 | .font(.caption) 204 | .foregroundColor(.secondary) 205 | } 206 | 207 | HStack(spacing: 12) { 208 | Button(action: { 209 | if player.isPlaying { 210 | player.pause() 211 | } else { 212 | player.play() 213 | } 214 | }) { 215 | Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") 216 | .font(.title2) 217 | .foregroundColor(.accentColor) 218 | } 219 | .disabled(!player.isReady) 220 | 221 | if let errorMessage = player.errorMessage { 222 | Text(errorMessage) 223 | .font(.caption) 224 | .foregroundColor(.red) 225 | .multilineTextAlignment(.leading) 226 | } 227 | 228 | VStack(alignment: .leading, spacing: 4) { 229 | HStack { 230 | Text(formatTime(player.currentTime)) 231 | .font(.caption) 232 | .foregroundColor(.secondary) 233 | Spacer() 234 | Text(formatTime(player.duration)) 235 | .font(.caption) 236 | .foregroundColor(.secondary) 237 | } 238 | 239 | ProgressView(value: player.progress) 240 | .progressViewStyle(LinearProgressViewStyle()) 241 | .frame(height: 4) 242 | } 243 | } 244 | .padding() 245 | #if os(visionOS) 246 | .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 6)) 247 | #else 248 | .background(.fill.quaternary) 249 | .cornerRadius(6) 250 | #endif 251 | } 252 | .padding() 253 | #if os(visionOS) 254 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 8)) 255 | #else 256 | .background(.fill.tertiary) 257 | .cornerRadius(8) 258 | #endif 259 | .onAppear { 260 | player.setupAudio(from: audioData) 261 | } 262 | .onDisappear { 263 | player.stop() 264 | } 265 | } 266 | 267 | private func formatTime(_ time: TimeInterval) -> String { 268 | let minutes = Int(time) / 60 269 | let seconds = Int(time) % 60 270 | return String(format: "%d:%02d", minutes, seconds) 271 | } 272 | } 273 | 274 | // MARK: - Audio Player 275 | 276 | private class AudioPlayer: NSObject, ObservableObject { 277 | @Published var isPlaying = false 278 | @Published var isReady = false 279 | @Published var currentTime: TimeInterval = 0 280 | @Published var duration: TimeInterval = 0 281 | @Published var progress: Double = 0 282 | @Published var errorMessage: String? 283 | 284 | private var audioPlayer: AVAudioPlayer? 285 | private var timer: Timer? 286 | 287 | private static let progressUpdateInterval: TimeInterval = 0.1 288 | 289 | func setupAudio(from data: Data) { 290 | // Clean up existing resources before setting up new ones 291 | stop() 292 | 293 | do { 294 | audioPlayer = try AVAudioPlayer(data: data) 295 | audioPlayer?.delegate = self 296 | audioPlayer?.prepareToPlay() 297 | 298 | duration = audioPlayer?.duration ?? 0 299 | isReady = true 300 | errorMessage = nil 301 | 302 | startTimer() 303 | } catch { 304 | let errorDescription = error.localizedDescription 305 | errorMessage = "Failed to load audio: \(errorDescription)" 306 | print("Audio setup failed: \(error)") 307 | isReady = false 308 | } 309 | } 310 | 311 | func play() { 312 | audioPlayer?.play() 313 | isPlaying = true 314 | } 315 | 316 | func pause() { 317 | audioPlayer?.pause() 318 | isPlaying = false 319 | } 320 | 321 | func stop() { 322 | audioPlayer?.stop() 323 | isPlaying = false 324 | currentTime = 0 325 | progress = 0 326 | timer?.invalidate() 327 | } 328 | 329 | private func startTimer() { 330 | timer = Timer.scheduledTimer(withTimeInterval: Self.progressUpdateInterval, repeats: true) { _ in 331 | guard let player = self.audioPlayer else { return } 332 | self.currentTime = player.currentTime 333 | self.progress = self.duration > 0 ? player.currentTime / self.duration : 0 334 | } 335 | } 336 | } 337 | 338 | // MARK: - AVAudioPlayerDelegate 339 | 340 | extension AudioPlayer: AVAudioPlayerDelegate { 341 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 342 | isPlaying = false 343 | currentTime = 0 344 | progress = 0 345 | } 346 | } 347 | --------------------------------------------------------------------------------