diff --git a/MuxdMobile/MuxdMobile/MuxdMobile/Services/MuxdClient.swift b/MuxdMobile/MuxdMobile/MuxdMobile/Services/MuxdClient.swift index dfd3070..271fddd 100644 --- a/MuxdMobile/MuxdMobile/MuxdMobile/Services/MuxdClient.swift +++ b/MuxdMobile/MuxdMobile/MuxdMobile/Services/MuxdClient.swift @@ -317,6 +317,17 @@ actor MuxdClient { } } + func getMCPTools() async throws -> MCPToolsResponse { + let request = try makeRequest(method: "GET", path: "/api/mcp/tools") + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw MuxdError.serverError("Failed to get MCP tools") + } + + return try JSONDecoder().decode(MCPToolsResponse.self, from: data) + } + func getMobileConfig() async throws -> MobileConfig { let request = try makeRequest(method: "GET", path: "/api/mobile/config") let (data, response) = try await session.data(for: request) diff --git a/MuxdMobile/MuxdMobile/MuxdMobile/Views/Config/ConfigView.swift b/MuxdMobile/MuxdMobile/MuxdMobile/Views/Config/ConfigView.swift index eda8ef8..ad5dda5 100644 --- a/MuxdMobile/MuxdMobile/MuxdMobile/Views/Config/ConfigView.swift +++ b/MuxdMobile/MuxdMobile/MuxdMobile/Views/Config/ConfigView.swift @@ -100,6 +100,14 @@ struct ConfigView: View { } } + Section("Server") { + NavigationLink { + MCPToolsView() + } label: { + Label("MCP Tools", systemImage: "puzzlepiece.extension") + } + } + Section("About") { LabeledContent("Version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0") Link(destination: URL(string: "https://www.muxd.sh/support")!) { diff --git a/MuxdMobile/MuxdMobile/MuxdMobile/Views/Config/MCPToolsView.swift b/MuxdMobile/MuxdMobile/MuxdMobile/Views/Config/MCPToolsView.swift new file mode 100644 index 0000000..111b96d --- /dev/null +++ b/MuxdMobile/MuxdMobile/MuxdMobile/Views/Config/MCPToolsView.swift @@ -0,0 +1,155 @@ +// Copyright (c) 2026 muxd.sh by Bata Labs +// SPDX-License-Identifier: MIT + +import SwiftUI + +struct MCPToolsResponse: Decodable { + let tools: [String] + let statuses: [String: String] + + // Group tool names by server prefix (e.g. "github__create_issue" → server "github") + var groupedByServer: [(server: String, status: String, tools: [String])] { + var serverTools: [String: [String]] = [:] + for tool in tools { + let server = tool.components(separatedBy: "__").first ?? tool + serverTools[server, default: []].append(tool) + } + return serverTools + .map { server, tools in + let status = statuses[server] ?? "unknown" + return (server: server, status: status, tools: tools.sorted()) + } + .sorted { $0.server < $1.server } + } + + // Servers that appear in statuses but have no connected tools + var disconnectedServers: [(server: String, status: String)] { + let serversWithTools = Set(tools.compactMap { $0.components(separatedBy: "__").first }) + return statuses + .filter { !serversWithTools.contains($0.key) } + .map { (server: $0.key, status: $0.value) } + .sorted { $0.server < $1.server } + } +} + +struct MCPToolsView: View { + @EnvironmentObject var appState: AppState + @State private var response: MCPToolsResponse? = nil + @State private var isLoading = true + @State private var error: String? = nil + + var body: some View { + List { + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + .listRowBackground(Color.clear) + } else if let error { + Section { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundColor(.secondary) + .font(.subheadline) + } + } else if let response { + if response.tools.isEmpty && response.statuses.isEmpty { + Section { + VStack(spacing: 8) { + Image(systemName: "puzzlepiece.extension") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("No MCP servers configured") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + .listRowBackground(Color.clear) + } else { + // Connected servers with tools + ForEach(response.groupedByServer, id: \.server) { group in + Section { + ForEach(group.tools, id: \.self) { tool in + // Strip server prefix for display + let displayName = tool.components(separatedBy: "__").dropFirst().joined(separator: "__") + Label(displayName.isEmpty ? tool : displayName, systemImage: "wrench") + .font(.subheadline) + .foregroundColor(.primary) + } + } header: { + HStack(spacing: 6) { + Text(group.server) + StatusDot(status: group.status) + Spacer() + Text("\(group.tools.count)") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // Disconnected/errored servers + ForEach(response.disconnectedServers, id: \.server) { item in + Section { + Text(item.status) + .font(.caption) + .foregroundColor(.secondary) + } header: { + HStack(spacing: 6) { + Text(item.server) + StatusDot(status: item.status) + } + } + } + } + } + } + .navigationTitle("MCP Tools") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(isLoading) + } + } + .task { await load() } + } + + private func load() async { + isLoading = true + error = nil + defer { isLoading = false } + guard let client = appState.getClient() else { + error = "Not connected" + return + } + do { + response = try await client.getMCPTools() + } catch { + self.error = error.localizedDescription + } + } +} + +private struct StatusDot: View { + let status: String + + private var color: Color { + if status == "connected" { return .green } + if status.hasPrefix("error") { return .red } + return .orange + } + + var body: some View { + Circle() + .fill(color) + .frame(width: 7, height: 7) + } +}