├── 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 |
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 | 
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------