Skip to content

Commit df066d4

Browse files
authored
feat(macos): cross-registry server browse + multiselect registry filter (#583)
Ports the Web UI R1 experience into the macOS tray's Registries tab (MCP-996 parity for macOS, tracked under the v0.36.0 feedback batch): - APIClient: searchRegistryServers (GET /registries/{id}/servers) and addServerFromRegistry (POST .../servers/{serverId}/add), encodeURIComponent- equivalent path encoding for ids with slashes. - RegistryModels: RepositoryServer (+ neutral transport classifier mirroring the Web UI: remote / stdio:npm / stdio:python / stdio:docker / stdio), SearchRegistryServersResponse (+ per-registry 'unavailable' marker), AddServerResult / error decode with missing_inputs. - ServerBrowseView: multiselect registry filter (Menu w/ check toggles + All/Clear), cross-registry search (fan out + merge + dedupe), registry- attributed result cards with transport label + Add button, and a non-fatal notice for registries that need a key / are unreachable. Embedded atop the Registries tab; the configured-registries list stays below. - MainWindow: hidden Cmd-1..5 shortcuts to jump between sidebar sections (keyboard nav + enables UI-test automation to reach a section). Verified: swiftc -O clean; 23 SwiftPM unit tests pass (transport/decode/encode); data layer bash-checked (reference+docker return, pulse/smithery report unavailable); ui-test MCP confirmed the app runs this build and the Cmd-3 navigation works (pixel screenshots blocked by missing Screen Recording permission on the ui-test helper). Refs MCP-996
1 parent c3adae7 commit df066d4

6 files changed

Lines changed: 541 additions & 0 deletions

File tree

native/macos/MCPProxy/MCPProxy/API/APIClient.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,4 +726,37 @@ actor APIClient {
726726
return .failure(code: nil, error: error.localizedDescription)
727727
}
728728
}
729+
730+
/// Search a single registry's servers via
731+
/// `GET /api/v1/registries/{id}/servers?q=&limit=`. Throws on transport/HTTP
732+
/// errors; a 200 with an `unavailable` marker (e.g. key required) is a
733+
/// normal, non-throwing result that the browse view surfaces per-registry.
734+
func searchRegistryServers(registryID: String, query: String, limit: Int = 20) async throws -> SearchRegistryServersResponse {
735+
var params: [String] = ["limit=\(limit)"]
736+
if !query.isEmpty { params.insert("q=\(query.uriComponentEncoded)", at: 0) }
737+
let path = "/api/v1/registries/\(registryID.uriComponentEncoded)/servers?\(params.joined(separator: "&"))"
738+
return try await fetchWrapped(path: path)
739+
}
740+
741+
/// Add a server discovered through a registry via
742+
/// `POST /api/v1/registries/{id}/servers/{serverId}/add`. Returns a
743+
/// structured result (does not throw) carrying `missingInputs` when the
744+
/// server needs env values the caller hasn't supplied yet.
745+
func addServerFromRegistry(registryID: String, serverID: String, env: [String: String]? = nil) async -> AddServerResult {
746+
var body: [String: Any] = [:]
747+
if let env, !env.isEmpty { body["env"] = env }
748+
let path = "/api/v1/registries/\(registryID.uriComponentEncoded)/servers/\(serverID.uriComponentEncoded)/add"
749+
do {
750+
let bodyData = try JSONSerialization.data(withJSONObject: body)
751+
let (data, response) = try await rawRequest(path: path, method: "POST", body: bodyData)
752+
if (200...299).contains(response.statusCode) { return .ok() }
753+
let err = try? JSONDecoder().decode(RegistryAddServerErrorBody.self, from: data)
754+
return .failure(
755+
message: err?.message ?? "HTTP \(response.statusCode): \(HTTPURLResponse.localizedString(forStatusCode: response.statusCode))",
756+
missingInputs: err?.missingInputs
757+
)
758+
} catch {
759+
return .failure(message: error.localizedDescription)
760+
}
761+
}
729762
}

native/macos/MCPProxy/MCPProxy/API/RegistryModels.swift

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,109 @@ struct ThirdPartyRegistryAck {
146146
defaults.set(true, forKey: Self.key)
147147
}
148148
}
149+
150+
// MARK: - Server discovery / browse (macOS mirror of Web UI R1)
151+
//
152+
// Models `GET /api/v1/registries/{id}/servers` (per-registry search) and
153+
// `POST /api/v1/registries/{id}/servers/{serverId}/add`. The browse view fans
154+
// these out across several selected registries and merges the results.
155+
156+
/// A server returned by a registry search. Mirrors `contracts.RepositoryServer`
157+
/// (only the fields the tray browse UI needs are decoded).
158+
struct RepositoryServer: Codable, Identifiable, Equatable {
159+
let id: String
160+
let name: String
161+
let description: String?
162+
let url: String?
163+
let sourceCodeURL: String?
164+
let installCmd: String?
165+
let connectURL: String?
166+
/// Which registry this result came from (used for per-card attribution and
167+
/// as the registry id passed to the add endpoint).
168+
let registry: String?
169+
let requiredInputs: [RequiredInput]?
170+
171+
enum CodingKeys: String, CodingKey {
172+
case id, name, description, url, registry
173+
case sourceCodeURL = "source_code_url"
174+
case installCmd = "install_cmd"
175+
case connectURL = "connect_url"
176+
case requiredInputs = "required_inputs"
177+
}
178+
179+
struct RequiredInput: Codable, Equatable {
180+
let name: String
181+
let description: String?
182+
let secret: Bool?
183+
}
184+
185+
/// Neutral transport label mirroring the Web UI's `serverTransport` (R2):
186+
/// remote / stdio:npm / stdio:python / stdio:docker / stdio.
187+
var transport: String {
188+
let cmd = (installCmd ?? "").trimmingCharacters(in: .whitespaces).lowercased()
189+
if !cmd.isEmpty {
190+
if cmd.hasPrefix("docker") { return "stdio:docker" }
191+
if cmd.hasPrefix("npx") || cmd.range(of: #"(^|\s)(npm|node)(\s|$)"#, options: .regularExpression) != nil { return "stdio:npm" }
192+
if cmd.hasPrefix("uvx") || cmd.hasPrefix("uv ") || cmd.range(of: #"(^|\s)(pipx?|python3?)(\s|$)"#, options: .regularExpression) != nil { return "stdio:python" }
193+
return "stdio"
194+
}
195+
if let url, !url.isEmpty { return "remote" }
196+
return "stdio"
197+
}
198+
}
199+
200+
/// Per-registry "unavailable" marker (e.g. key required). Mirrors
201+
/// `contracts.RegistryUnavailable`.
202+
struct RegistryUnavailable: Codable, Equatable {
203+
let reason: String?
204+
}
205+
206+
/// Response of `GET /api/v1/registries/{id}/servers`. Mirrors
207+
/// `contracts.SearchRegistryServersResponse`.
208+
struct SearchRegistryServersResponse: Codable {
209+
let registryID: String?
210+
let servers: [RepositoryServer]?
211+
let total: Int?
212+
let unavailable: RegistryUnavailable?
213+
214+
enum CodingKeys: String, CodingKey {
215+
case registryID = "registry_id"
216+
case servers, total, unavailable
217+
}
218+
}
219+
220+
/// Result of adding a server from a registry. Carries `missingInputs` when the
221+
/// backend rejects with `missing_required_input` so the UI can tell the user
222+
/// which env vars are needed (the full prompt flow is a follow-up).
223+
struct AddServerResult: Equatable {
224+
let success: Bool
225+
let message: String?
226+
let missingInputs: [String]?
227+
228+
static func ok() -> AddServerResult { AddServerResult(success: true, message: nil, missingInputs: nil) }
229+
static func failure(message: String?, missingInputs: [String]? = nil) -> AddServerResult {
230+
AddServerResult(success: false, message: message, missingInputs: missingInputs)
231+
}
232+
}
233+
234+
/// Structured error body of a failed add-from-registry. Mirrors
235+
/// `contracts.RegistryAddError`.
236+
struct RegistryAddServerErrorBody: Decodable {
237+
let code: String?
238+
let message: String?
239+
let missingInputs: [String]?
240+
enum CodingKeys: String, CodingKey {
241+
case code, message
242+
case missingInputs = "missing_inputs"
243+
}
244+
}
245+
246+
/// JS `encodeURIComponent` equivalent for safe path-segment encoding (the Web
247+
/// UI uses encodeURIComponent on the registry id and server id).
248+
extension String {
249+
var uriComponentEncoded: String {
250+
let allowed = CharacterSet(charactersIn:
251+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()")
252+
return addingPercentEncoding(withAllowedCharacters: allowed) ?? self
253+
}
254+
}

native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ struct MainWindow: View {
7575
.accessibilityIdentifier("detail-view")
7676
}
7777
.frame(minWidth: 800, minHeight: 500)
78+
.background(sidebarShortcuts)
7879
.onReceive(NotificationCenter.default.publisher(for: .switchToActivity)) { _ in
7980
selectedItem = .activity
8081
}
@@ -83,6 +84,24 @@ struct MainWindow: View {
8384
}
8485
}
8586

87+
/// Hidden ⌘1…⌘5 shortcuts to jump straight to each sidebar section. Keeps
88+
/// keyboard navigation fast for users and lets UI-test automation reach a
89+
/// section (the sidebar List rows aren't directly clickable via the
90+
/// accessibility menu API).
91+
@ViewBuilder
92+
private var sidebarShortcuts: some View {
93+
VStack {
94+
ForEach(Array(SidebarItem.allCases.enumerated()), id: \.element) { index, item in
95+
Button("") { selectedItem = item }
96+
.keyboardShortcut(KeyEquivalent(Character(String(index + 1))), modifiers: .command)
97+
.accessibilityIdentifier("sidebar-shortcut-\(item.rawValue)")
98+
}
99+
}
100+
.opacity(0)
101+
.frame(width: 0, height: 0)
102+
.accessibilityHidden(true)
103+
}
104+
86105
// MARK: - Core Status Banner
87106

88107
@ViewBuilder

native/macos/MCPProxy/MCPProxy/Views/RegistriesView.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ struct RegistriesView: View {
4949
banner(icon: "checkmark.circle.fill", tint: .green, text: success)
5050
}
5151

52+
// Browse + add servers across one or more registries (R1 parity).
53+
ServerBrowseView(appState: appState, registries: registries)
54+
55+
HStack {
56+
Text("Configured registries")
57+
.font(.scaled(.caption, scale: fontScale).bold())
58+
.foregroundStyle(.secondary)
59+
Spacer()
60+
}
61+
.padding(.horizontal)
62+
.padding(.top, 4)
63+
Divider()
64+
5265
content
5366
}
5467
.sheet(isPresented: $showAddRegistry) {

0 commit comments

Comments
 (0)