Skip to content

Commit 06e23d4

Browse files
authored
feat(macos): add-registry affordance + provenance surfacing in tray (MCP-902) (#578)
Mirror the MCP-867 Web UI registry surface in the macOS tray (native/macos/): - New "Registries" sidebar tab listing every configured registry with an Official · trusted / Third-party · unverified provenance badge derived from the provenance/trusted fields (older payloads without the field default to official). Custom registries carry an always-quarantined note. - "Add Registry" sheet: https URL + optional name, protocol fixed to modelcontextprotocol/registry; POSTs /api/v1/registries via the new APIClient.addRegistrySource(), mapping the stable error codes (invalid_registry_url / registries_locked / registry_shadows_builtin / duplicate_registry) to actionable messages. - One-time third-party warning gating the first custom add; the acknowledgement is persisted in UserDefaults. APIClient gains registries() + addRegistrySource(); performRequest is refactored onto a non-validating rawRequest() so the add flow can read the error code from the body. Tests: RegistryModelsTests covers provenance/trust derivation, list/summary decode, error-code to message mapping, and ack persistence. Docs updated in docs/registries.md.
1 parent b30c6fd commit 06e23d4

6 files changed

Lines changed: 771 additions & 2 deletions

File tree

docs/registries.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ Equivalent surfaces:
7171
protocol/name). Each registry in the selector is flagged **Official · trusted** or
7272
**Third-party · unverified** from its `provenance`, and the first custom add shows a
7373
one-time third-party-registry warning (the acknowledgement is remembered locally).
74+
- **macOS tray:** the **Registries** sidebar tab lists every configured registry
75+
with its provenance/trust badge, offers an **Add Registry** affordance,
76+
and shows a one-time third-party warning before the first custom add.
7477

7578
Errors share a stable code across surfaces: `invalid_registry_url` (400),
7679
`registries_locked` (403), `registry_shadows_builtin` / `duplicate_registry` (409).

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

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,11 @@ actor APIClient {
618618
return data
619619
}
620620

621-
/// Low-level request execution with HTTP status validation.
622-
private func performRequest(
621+
/// Low-level request execution WITHOUT HTTP status validation. Returns the
622+
/// raw body and response for any status. Callers that need to inspect error
623+
/// bodies (e.g. the registry add-source flow, which reads a stable `code`)
624+
/// use this directly; most callers use `performRequest`, which validates.
625+
private func rawRequest(
623626
path: String,
624627
method: String,
625628
body: Data? = nil
@@ -653,6 +656,17 @@ actor APIClient {
653656
throw APIClientError.noData
654657
}
655658

659+
return (data, httpResponse)
660+
}
661+
662+
/// Low-level request execution with HTTP status validation.
663+
private func performRequest(
664+
path: String,
665+
method: String,
666+
body: Data? = nil
667+
) async throws -> (Data, HTTPURLResponse) {
668+
let (data, httpResponse) = try await rawRequest(path: path, method: method, body: body)
669+
656670
// 2xx is success; for readiness we also treat the response as-is
657671
guard (200...299).contains(httpResponse.statusCode) else {
658672
// Try to extract error message from body
@@ -666,4 +680,50 @@ actor APIClient {
666680

667681
return (data, httpResponse)
668682
}
683+
684+
// MARK: - Registries (MCP-866 / MCP-902)
685+
686+
/// List configured registries from `GET /api/v1/registries`, each tagged
687+
/// with provenance/trust so the UI can flag official vs custom sources.
688+
func registries() async throws -> [Registry] {
689+
let response: GetRegistriesResponse = try await fetchWrapped(path: "/api/v1/registries")
690+
return response.registries
691+
}
692+
693+
/// Add a user-supplied registry source via `POST /api/v1/registries`. The
694+
/// server always tags an added source custom/unverified (provenance is NOT
695+
/// part of the request), so every server later discovered through it lands
696+
/// quarantined. Returns a structured result carrying the stable error
697+
/// `code` instead of throwing, mirroring the Web UI's `addRegistrySource`.
698+
func addRegistrySource(
699+
url: String,
700+
protocol proto: String? = nil,
701+
id: String? = nil,
702+
name: String? = nil
703+
) async -> AddRegistrySourceResult {
704+
var body: [String: Any] = ["url": url]
705+
if let proto, !proto.isEmpty { body["protocol"] = proto }
706+
if let id, !id.isEmpty { body["id"] = id }
707+
if let name, !name.isEmpty { body["name"] = name }
708+
709+
do {
710+
let bodyData = try JSONSerialization.data(withJSONObject: body)
711+
let (data, response) = try await rawRequest(path: "/api/v1/registries", method: "POST", body: bodyData)
712+
let decoder = JSONDecoder()
713+
714+
if (200...299).contains(response.statusCode),
715+
let wrapper = try? decoder.decode(APIResponse<AddRegistrySourceData>.self, from: data),
716+
wrapper.success {
717+
return .ok(wrapper.data?.registry)
718+
}
719+
720+
let errBody = try? decoder.decode(RegistryAddErrorBody.self, from: data)
721+
return .failure(
722+
code: errBody?.code,
723+
error: errBody?.error ?? "HTTP \(response.statusCode): \(HTTPURLResponse.localizedString(forStatusCode: response.statusCode))"
724+
)
725+
} catch {
726+
return .failure(code: nil, error: error.localizedDescription)
727+
}
728+
}
669729
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import Foundation
2+
3+
// MARK: - Registries (MCP-866 / MCP-902)
4+
//
5+
// macOS-tray mirror of the MCP-867 Web UI registry surface. Models the
6+
// `GET /api/v1/registries` list (with provenance/trust) and the
7+
// `POST /api/v1/registries` add-source flow (with stable error codes), plus the
8+
// one-time third-party-registry warning acknowledgement.
9+
10+
/// Trust-tag constants mirroring `config.RegistryProvenance*` on the Go side
11+
/// (and `REGISTRY_PROVENANCE_*` in the Web UI). Trust is derived server-side
12+
/// from membership in the shipped default set — never self-asserted.
13+
enum RegistryProvenance {
14+
static let official = "official/trusted"
15+
static let custom = "custom/unverified"
16+
}
17+
18+
/// A registry as listed by `GET /api/v1/registries`. Mirrors `contracts.Registry`.
19+
/// Unknown fields (`count`, `tags`) are intentionally ignored — the tray view
20+
/// only needs identity, description, and provenance/trust.
21+
struct Registry: Codable, Identifiable, Equatable {
22+
let id: String
23+
let name: String
24+
let description: String?
25+
let url: String?
26+
let serversURL: String?
27+
let `protocol`: String?
28+
/// "official/trusted" for built-in defaults, "custom/unverified" for
29+
/// user-added sources.
30+
let provenance: String?
31+
/// Convenience boolean mirror of `provenance == "official/trusted"`.
32+
let trusted: Bool?
33+
34+
enum CodingKeys: String, CodingKey {
35+
case id, name, description, url
36+
case serversURL = "servers_url"
37+
case `protocol`
38+
case provenance, trusted
39+
}
40+
41+
/// A registry is "custom/unverified" (third-party) when its provenance says
42+
/// so, or — defensively — when `trusted` is explicitly false. Anything else
43+
/// (including older payloads without the field) is treated as
44+
/// official/trusted. Mirrors the Web UI's `isCustomRegistry`.
45+
var isCustom: Bool {
46+
provenance == RegistryProvenance.custom || trusted == false
47+
}
48+
}
49+
50+
/// Slim projection returned by `POST /api/v1/registries` (add-source).
51+
/// Mirrors `contracts.RegistrySummary`.
52+
struct RegistrySummary: Codable, Equatable {
53+
let id: String
54+
let name: String
55+
let url: String?
56+
let serversURL: String?
57+
let `protocol`: String?
58+
let provenance: String?
59+
let trusted: Bool?
60+
61+
enum CodingKeys: String, CodingKey {
62+
case id, name, url
63+
case serversURL = "servers_url"
64+
case `protocol`
65+
case provenance, trusted
66+
}
67+
}
68+
69+
/// Response wrapper for `GET /api/v1/registries`.
70+
struct GetRegistriesResponse: Codable {
71+
let registries: [Registry]
72+
let total: Int?
73+
}
74+
75+
/// `data` payload of a successful `POST /api/v1/registries`.
76+
struct AddRegistrySourceData: Codable {
77+
let registry: RegistrySummary?
78+
}
79+
80+
/// Structured error body of a failed `POST /api/v1/registries`, carrying the
81+
/// stable cross-surface `code` (see `writeRegistryAddError` on the Go side).
82+
struct RegistryAddErrorBody: Decodable {
83+
let error: String?
84+
let code: String?
85+
}
86+
87+
/// Result of adding a *registry source*. Carries the stable error `code`
88+
/// (`invalid_registry_url` | `registries_locked` | `registry_shadows_builtin` |
89+
/// `duplicate_registry`) so the UI renders an actionable message instead of a
90+
/// generic string. Mirrors the Web UI's `AddRegistrySourceResult`.
91+
struct AddRegistrySourceResult: Equatable {
92+
let success: Bool
93+
let registry: RegistrySummary?
94+
let error: String?
95+
let code: String?
96+
97+
static func ok(_ registry: RegistrySummary?) -> AddRegistrySourceResult {
98+
AddRegistrySourceResult(success: true, registry: registry, error: nil, code: nil)
99+
}
100+
101+
static func failure(code: String?, error: String?) -> AddRegistrySourceResult {
102+
AddRegistrySourceResult(success: false, registry: nil, error: error, code: code)
103+
}
104+
105+
/// Actionable message for this result, derived from its `code`.
106+
var userMessage: String {
107+
Self.message(code: code, fallback: error)
108+
}
109+
110+
/// Map the backend's stable error code to an actionable message.
111+
/// Mirrors the Web UI's `addRegistryErrorMessage`.
112+
static func message(code: String?, fallback: String?) -> String {
113+
switch code {
114+
case "invalid_registry_url":
115+
return fallback ?? "That URL is not a valid HTTPS registry endpoint."
116+
case "registries_locked":
117+
return "Adding registries is locked by an administrator on this instance."
118+
case "registry_shadows_builtin":
119+
return "That id/host collides with a built-in registry. Try a different id."
120+
case "duplicate_registry":
121+
return "A registry with that id is already configured."
122+
default:
123+
return fallback ?? "Failed to add registry."
124+
}
125+
}
126+
}
127+
128+
/// Persists the one-time acknowledgement of the third-party-registry warning
129+
/// (MCP-867 parity). Backed by UserDefaults so the warning only shows until the
130+
/// user acknowledges it once. `defaults` is injectable for testing.
131+
struct ThirdPartyRegistryAck {
132+
/// Key mirrors the Web UI's localStorage key for cross-surface consistency.
133+
static let key = "mcpproxy-thirdparty-registry-ack"
134+
135+
let defaults: UserDefaults
136+
137+
init(defaults: UserDefaults = .standard) {
138+
self.defaults = defaults
139+
}
140+
141+
var hasAcknowledged: Bool {
142+
defaults.bool(forKey: Self.key)
143+
}
144+
145+
func acknowledge() {
146+
defaults.set(true, forKey: Self.key)
147+
}
148+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import SwiftUI
66
enum SidebarItem: String, CaseIterable, Identifiable {
77
case dashboard = "Dashboard"
88
case servers = "Servers"
9+
case registries = "Registries"
910
case activity = "Activity Log"
1011
case secrets = "Secrets"
1112

@@ -15,6 +16,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
1516
switch self {
1617
case .dashboard: return "rectangle.3.group"
1718
case .servers: return "server.rack"
19+
case .registries: return "books.vertical"
1820
case .activity: return "clock.arrow.circlepath"
1921
case .secrets: return "key.fill"
2022
}
@@ -59,6 +61,8 @@ struct MainWindow: View {
5961
DashboardView(appState: appState)
6062
case .servers:
6163
ServersView(appState: appState)
64+
case .registries:
65+
RegistriesView(appState: appState)
6266
case .activity:
6367
ActivityView(appState: appState)
6468
case .secrets:

0 commit comments

Comments
 (0)