From d3aa661240d33b05ee57d984684a4603e89dfaa5 Mon Sep 17 00:00:00 2001 From: Algis Dumbris Date: Tue, 2 Jun 2026 22:25:14 +0300 Subject: [PATCH] feat(macos): add-registry affordance + provenance surfacing in tray (MCP-902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/registries.md | 3 + .../MCPProxy/MCPProxy/API/APIClient.swift | 64 ++- .../MCPProxy/API/RegistryModels.swift | 148 +++++++ .../MCPProxy/MCPProxy/Views/MainWindow.swift | 4 + .../MCPProxy/Views/RegistriesView.swift | 395 ++++++++++++++++++ .../MCPProxyTests/RegistryModelsTests.swift | 159 +++++++ 6 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 native/macos/MCPProxy/MCPProxy/API/RegistryModels.swift create mode 100644 native/macos/MCPProxy/MCPProxy/Views/RegistriesView.swift create mode 100644 native/macos/MCPProxy/MCPProxyTests/RegistryModelsTests.swift diff --git a/docs/registries.md b/docs/registries.md index 7ec0ccca..8153697a 100644 --- a/docs/registries.md +++ b/docs/registries.md @@ -67,6 +67,9 @@ Equivalent surfaces: - **REST:** `POST /api/v1/registries` with `{ "url": "https://…", "protocol": "…", "id": "…", "name": "…" }`. - **CLI:** `mcpproxy registry add-source `. +- **macOS tray:** the **Registries** sidebar tab lists every configured registry + with its provenance/trust badge, offers an **Add Registry** affordance, + and shows a one-time third-party warning before the first custom add. Errors share a stable code across surfaces: `invalid_registry_url` (400), `registries_locked` (403), `registry_shadows_builtin` / `duplicate_registry` (409). diff --git a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift index 0946b57f..49e8e2f5 100644 --- a/native/macos/MCPProxy/MCPProxy/API/APIClient.swift +++ b/native/macos/MCPProxy/MCPProxy/API/APIClient.swift @@ -618,8 +618,11 @@ actor APIClient { return data } - /// Low-level request execution with HTTP status validation. - private func performRequest( + /// Low-level request execution WITHOUT HTTP status validation. Returns the + /// raw body and response for any status. Callers that need to inspect error + /// bodies (e.g. the registry add-source flow, which reads a stable `code`) + /// use this directly; most callers use `performRequest`, which validates. + private func rawRequest( path: String, method: String, body: Data? = nil @@ -653,6 +656,17 @@ actor APIClient { throw APIClientError.noData } + return (data, httpResponse) + } + + /// Low-level request execution with HTTP status validation. + private func performRequest( + path: String, + method: String, + body: Data? = nil + ) async throws -> (Data, HTTPURLResponse) { + let (data, httpResponse) = try await rawRequest(path: path, method: method, body: body) + // 2xx is success; for readiness we also treat the response as-is guard (200...299).contains(httpResponse.statusCode) else { // Try to extract error message from body @@ -666,4 +680,50 @@ actor APIClient { return (data, httpResponse) } + + // MARK: - Registries (MCP-866 / MCP-902) + + /// List configured registries from `GET /api/v1/registries`, each tagged + /// with provenance/trust so the UI can flag official vs custom sources. + func registries() async throws -> [Registry] { + let response: GetRegistriesResponse = try await fetchWrapped(path: "/api/v1/registries") + return response.registries + } + + /// Add a user-supplied registry source via `POST /api/v1/registries`. The + /// server always tags an added source custom/unverified (provenance is NOT + /// part of the request), so every server later discovered through it lands + /// quarantined. Returns a structured result carrying the stable error + /// `code` instead of throwing, mirroring the Web UI's `addRegistrySource`. + func addRegistrySource( + url: String, + protocol proto: String? = nil, + id: String? = nil, + name: String? = nil + ) async -> AddRegistrySourceResult { + var body: [String: Any] = ["url": url] + if let proto, !proto.isEmpty { body["protocol"] = proto } + if let id, !id.isEmpty { body["id"] = id } + if let name, !name.isEmpty { body["name"] = name } + + do { + let bodyData = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await rawRequest(path: "/api/v1/registries", method: "POST", body: bodyData) + let decoder = JSONDecoder() + + if (200...299).contains(response.statusCode), + let wrapper = try? decoder.decode(APIResponse.self, from: data), + wrapper.success { + return .ok(wrapper.data?.registry) + } + + let errBody = try? decoder.decode(RegistryAddErrorBody.self, from: data) + return .failure( + code: errBody?.code, + error: errBody?.error ?? "HTTP \(response.statusCode): \(HTTPURLResponse.localizedString(forStatusCode: response.statusCode))" + ) + } catch { + return .failure(code: nil, error: error.localizedDescription) + } + } } diff --git a/native/macos/MCPProxy/MCPProxy/API/RegistryModels.swift b/native/macos/MCPProxy/MCPProxy/API/RegistryModels.swift new file mode 100644 index 00000000..3719f9eb --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/API/RegistryModels.swift @@ -0,0 +1,148 @@ +import Foundation + +// MARK: - Registries (MCP-866 / MCP-902) +// +// macOS-tray mirror of the MCP-867 Web UI registry surface. Models the +// `GET /api/v1/registries` list (with provenance/trust) and the +// `POST /api/v1/registries` add-source flow (with stable error codes), plus the +// one-time third-party-registry warning acknowledgement. + +/// Trust-tag constants mirroring `config.RegistryProvenance*` on the Go side +/// (and `REGISTRY_PROVENANCE_*` in the Web UI). Trust is derived server-side +/// from membership in the shipped default set — never self-asserted. +enum RegistryProvenance { + static let official = "official/trusted" + static let custom = "custom/unverified" +} + +/// A registry as listed by `GET /api/v1/registries`. Mirrors `contracts.Registry`. +/// Unknown fields (`count`, `tags`) are intentionally ignored — the tray view +/// only needs identity, description, and provenance/trust. +struct Registry: Codable, Identifiable, Equatable { + let id: String + let name: String + let description: String? + let url: String? + let serversURL: String? + let `protocol`: String? + /// "official/trusted" for built-in defaults, "custom/unverified" for + /// user-added sources. + let provenance: String? + /// Convenience boolean mirror of `provenance == "official/trusted"`. + let trusted: Bool? + + enum CodingKeys: String, CodingKey { + case id, name, description, url + case serversURL = "servers_url" + case `protocol` + case provenance, trusted + } + + /// A registry is "custom/unverified" (third-party) when its provenance says + /// so, or — defensively — when `trusted` is explicitly false. Anything else + /// (including older payloads without the field) is treated as + /// official/trusted. Mirrors the Web UI's `isCustomRegistry`. + var isCustom: Bool { + provenance == RegistryProvenance.custom || trusted == false + } +} + +/// Slim projection returned by `POST /api/v1/registries` (add-source). +/// Mirrors `contracts.RegistrySummary`. +struct RegistrySummary: Codable, Equatable { + let id: String + let name: String + let url: String? + let serversURL: String? + let `protocol`: String? + let provenance: String? + let trusted: Bool? + + enum CodingKeys: String, CodingKey { + case id, name, url + case serversURL = "servers_url" + case `protocol` + case provenance, trusted + } +} + +/// Response wrapper for `GET /api/v1/registries`. +struct GetRegistriesResponse: Codable { + let registries: [Registry] + let total: Int? +} + +/// `data` payload of a successful `POST /api/v1/registries`. +struct AddRegistrySourceData: Codable { + let registry: RegistrySummary? +} + +/// Structured error body of a failed `POST /api/v1/registries`, carrying the +/// stable cross-surface `code` (see `writeRegistryAddError` on the Go side). +struct RegistryAddErrorBody: Decodable { + let error: String? + let code: String? +} + +/// Result of adding a *registry source*. Carries the stable error `code` +/// (`invalid_registry_url` | `registries_locked` | `registry_shadows_builtin` | +/// `duplicate_registry`) so the UI renders an actionable message instead of a +/// generic string. Mirrors the Web UI's `AddRegistrySourceResult`. +struct AddRegistrySourceResult: Equatable { + let success: Bool + let registry: RegistrySummary? + let error: String? + let code: String? + + static func ok(_ registry: RegistrySummary?) -> AddRegistrySourceResult { + AddRegistrySourceResult(success: true, registry: registry, error: nil, code: nil) + } + + static func failure(code: String?, error: String?) -> AddRegistrySourceResult { + AddRegistrySourceResult(success: false, registry: nil, error: error, code: code) + } + + /// Actionable message for this result, derived from its `code`. + var userMessage: String { + Self.message(code: code, fallback: error) + } + + /// Map the backend's stable error code to an actionable message. + /// Mirrors the Web UI's `addRegistryErrorMessage`. + static func message(code: String?, fallback: String?) -> String { + switch code { + case "invalid_registry_url": + return fallback ?? "That URL is not a valid HTTPS registry endpoint." + case "registries_locked": + return "Adding registries is locked by an administrator on this instance." + case "registry_shadows_builtin": + return "That id/host collides with a built-in registry. Try a different id." + case "duplicate_registry": + return "A registry with that id is already configured." + default: + return fallback ?? "Failed to add registry." + } + } +} + +/// Persists the one-time acknowledgement of the third-party-registry warning +/// (MCP-867 parity). Backed by UserDefaults so the warning only shows until the +/// user acknowledges it once. `defaults` is injectable for testing. +struct ThirdPartyRegistryAck { + /// Key mirrors the Web UI's localStorage key for cross-surface consistency. + static let key = "mcpproxy-thirdparty-registry-ack" + + let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + var hasAcknowledged: Bool { + defaults.bool(forKey: Self.key) + } + + func acknowledge() { + defaults.set(true, forKey: Self.key) + } +} diff --git a/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift b/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift index 6f0652d9..adeaef6d 100644 --- a/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift +++ b/native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift @@ -6,6 +6,7 @@ import SwiftUI enum SidebarItem: String, CaseIterable, Identifiable { case dashboard = "Dashboard" case servers = "Servers" + case registries = "Registries" case activity = "Activity Log" case secrets = "Secrets" @@ -15,6 +16,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { switch self { case .dashboard: return "rectangle.3.group" case .servers: return "server.rack" + case .registries: return "books.vertical" case .activity: return "clock.arrow.circlepath" case .secrets: return "key.fill" } @@ -59,6 +61,8 @@ struct MainWindow: View { DashboardView(appState: appState) case .servers: ServersView(appState: appState) + case .registries: + RegistriesView(appState: appState) case .activity: ActivityView(appState: appState) case .secrets: diff --git a/native/macos/MCPProxy/MCPProxy/Views/RegistriesView.swift b/native/macos/MCPProxy/MCPProxy/Views/RegistriesView.swift new file mode 100644 index 00000000..0172a958 --- /dev/null +++ b/native/macos/MCPProxy/MCPProxy/Views/RegistriesView.swift @@ -0,0 +1,395 @@ +// RegistriesView.swift +// MCPProxy +// +// macOS-tray mirror of the MCP-867 Web UI registry surface (MCP-902): +// - lists configured registries with provenance/trust badges +// - an "Add Registry" affordance (POST /api/v1/registries) +// - a one-time third-party warning gating the first custom add +// +// Servers discovered through a custom (third-party) registry are always +// quarantined and can never skip security review; the UI surfaces that. + +import SwiftUI + +// MARK: - Registries View + +struct RegistriesView: View { + @ObservedObject var appState: AppState + @Environment(\.fontScale) var fontScale + + @State private var registries: [Registry] = [] + @State private var isLoading = false + @State private var loadError: String? + @State private var showAddRegistry = false + @State private var successMessage: String? + + private var apiClient: APIClient? { appState.apiClient } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + + // Prominent "Add Registry" button bar (mirrors ServersView). + HStack { + Button { + showAddRegistry = true + } label: { + Label("Add Registry", systemImage: "plus.circle.fill") + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("registry-add-source-button") + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + if let success = successMessage { + banner(icon: "checkmark.circle.fill", tint: .green, text: success) + } + + content + } + .sheet(isPresented: $showAddRegistry) { + AddRegistryView(appState: appState, isPresented: $showAddRegistry) { added in + successMessage = "Added registry \u{201C}\(added.name.isEmpty ? added.id : added.name)\u{201D} — third-party \u{00B7} unverified." + Task { await load() } + } + } + .task { await load() } + } + + // MARK: Header + + @ViewBuilder + private var header: some View { + HStack { + Text("Registries") + .font(.scaled(.title2, scale: fontScale).bold()) + Spacer() + Text("\(registries.count) configured") + .foregroundStyle(.secondary) + + if isLoading { + ProgressView().controlSize(.small) + } else { + Button { + Task { await load() } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .accessibilityIdentifier("registries-refresh") + } + } + .padding() + .accessibilityIdentifier("registries-header") + } + + // MARK: Content + + @ViewBuilder + private var content: some View { + if let err = loadError, registries.isEmpty { + VStack(spacing: 12) { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 40 * fontScale)) + .foregroundStyle(.orange) + Text("Couldn't load registries") + .font(.scaled(.title3, scale: fontScale)) + .foregroundStyle(.secondary) + Text(err) + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.tertiary) + .multilineTextAlignment(.center) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else if registries.isEmpty && !isLoading { + VStack(spacing: 12) { + Spacer() + Image(systemName: "books.vertical") + .font(.system(size: 40 * fontScale)) + .foregroundStyle(.tertiary) + Text("No registries") + .font(.scaled(.title3, scale: fontScale)) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(registries) { registry in + registryRow(registry) + } + } + .padding() + } + } + } + + @ViewBuilder + private func registryRow(_ registry: Registry) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(registry.name.isEmpty ? registry.id : registry.name) + .font(.scaled(.headline, scale: fontScale)) + provenanceBadge(registry) + Spacer() + } + + if let desc = registry.description, !desc.isEmpty { + Text(desc) + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.secondary) + } + + if let url = registry.serversURL ?? registry.url, !url.isEmpty { + Text(url) + .font(.scaledMonospaced(.caption, scale: fontScale)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + + if registry.isCustom { + Text("Servers added from this third-party registry are always quarantined and cannot skip security review.") + .font(.scaled(.caption2, scale: fontScale)) + .foregroundStyle(.orange) + .accessibilityIdentifier("registry-custom-quarantine-note") + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(8) + .accessibilityIdentifier("registry-row-\(registry.id)") + } + + @ViewBuilder + private func provenanceBadge(_ registry: Registry) -> some View { + if registry.isCustom { + badge(text: "Third-party \u{00B7} unverified", tint: .orange) + .accessibilityIdentifier("registry-provenance-badge-custom") + } else { + badge(text: "Official \u{00B7} trusted", tint: .green) + .accessibilityIdentifier("registry-provenance-badge-official") + } + } + + @ViewBuilder + private func badge(text: String, tint: Color) -> some View { + Text(text) + .font(.scaled(.caption2, scale: fontScale).weight(.medium)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(tint.opacity(0.18)) + .foregroundStyle(tint) + .clipShape(Capsule()) + } + + @ViewBuilder + private func banner(icon: String, tint: Color, text: String) -> some View { + HStack { + Image(systemName: icon).foregroundStyle(tint) + Text(text) + .font(.scaled(.caption, scale: fontScale)) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 6) + .background(tint.opacity(0.1)) + } + + // MARK: Data + + private func load() async { + guard let client = apiClient else { + loadError = "Not connected to MCPProxy core" + return + } + isLoading = true + loadError = nil + defer { isLoading = false } + do { + registries = try await client.registries() + } catch { + loadError = error.localizedDescription + } + } +} + +// MARK: - Add Registry Sheet + +struct AddRegistryView: View { + @ObservedObject var appState: AppState + @Binding var isPresented: Bool + let onAdded: (RegistrySummary) -> Void + @Environment(\.fontScale) var fontScale + + @State private var url = "" + @State private var name = "" + /// Only the official protocol is offered (mirrors the Web UI's single + /// option); the field exists so the contract stays explicit. + private let registryProtocol = "modelcontextprotocol/registry" + + @State private var addError: String? + @State private var isAdding = false + @State private var showThirdPartyWarning = false + + private var apiClient: APIClient? { appState.apiClient } + private let ack = ThirdPartyRegistryAck() + + private var isValid: Bool { + !url.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("Add a Registry") + .font(.scaled(.title2, scale: fontScale).bold()) + Spacer() + Button { + if !isAdding { isPresented = false } + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + .padding() + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Add a custom **modelcontextprotocol/registry** v0.1 source by its HTTPS URL. Added registries are marked **third-party \u{00B7} unverified**; their servers are always quarantined.") + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.secondary) + + formField(label: "Registry URL (required)") { + TextField("https://registry.example.com/", text: $url) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("registry-add-url-input") + } + + formField(label: "Protocol") { + Text(registryProtocol) + .font(.scaledMonospaced(.body, scale: fontScale)) + .foregroundStyle(.secondary) + } + + formField(label: "Name (optional)") { + TextField("Derived from the URL host when empty", text: $name) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("registry-add-name-input") + } + + if let err = addError { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(err) + .font(.scaled(.caption, scale: fontScale)) + .foregroundStyle(.red) + .textSelection(.enabled) + Spacer() + } + .padding(8) + .background(Color.red.opacity(0.1)) + .cornerRadius(6) + .accessibilityIdentifier("registry-add-error") + } + } + .padding() + } + + Divider() + + HStack { + Spacer() + Button("Cancel") { + if !isAdding { isPresented = false } + } + .buttonStyle(.bordered) + + Button { + submit() + } label: { + if isAdding { + ProgressView().controlSize(.small) + } else { + Text("Add Registry") + } + } + .buttonStyle(.borderedProminent) + .disabled(!isValid || isAdding) + .accessibilityIdentifier("registry-add-submit") + } + .padding() + } + .frame(width: 520, height: 420) + // One-time third-party warning (MCP-867 parity). Gates the first custom + // add until the user acknowledges it once. + .alert("Adding a third-party registry", isPresented: $showThirdPartyWarning) { + Button("Cancel", role: .cancel) {} + Button("I understand, continue") { + ack.acknowledge() + Task { await doAdd() } + } + } message: { + Text("You're about to add a registry that is not shipped with MCPProxy. Custom registries are unverified — MCPProxy cannot vouch for the servers they list.\n\nFor your safety, every server you add from a custom registry is always quarantined and can never skip security review. Only add registries operated by parties you trust.") + } + } + + @ViewBuilder + private func formField(label: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.scaled(.subheadline, scale: fontScale)) + .foregroundStyle(.secondary) + content() + } + } + + /// Gate the first-ever custom add behind the one-time warning; every + /// user-added source is custom/unverified server-side, so the warning + /// applies to all adds until acknowledged once. + private func submit() { + guard isValid, !isAdding else { return } + addError = nil + if ack.hasAcknowledged { + Task { await doAdd() } + } else { + showThirdPartyWarning = true + } + } + + private func doAdd() async { + guard let client = apiClient else { + addError = "Not connected to MCPProxy core" + return + } + isAdding = true + addError = nil + defer { isAdding = false } + + let result = await client.addRegistrySource( + url: url.trimmingCharacters(in: .whitespaces), + protocol: registryProtocol, + name: name.trimmingCharacters(in: .whitespaces).isEmpty ? nil : name.trimmingCharacters(in: .whitespaces) + ) + + if result.success, let registry = result.registry { + isPresented = false + onAdded(registry) + return + } + addError = result.userMessage + } +} diff --git a/native/macos/MCPProxy/MCPProxyTests/RegistryModelsTests.swift b/native/macos/MCPProxy/MCPProxyTests/RegistryModelsTests.swift new file mode 100644 index 00000000..af142fcc --- /dev/null +++ b/native/macos/MCPProxy/MCPProxyTests/RegistryModelsTests.swift @@ -0,0 +1,159 @@ +import XCTest +@testable import MCPProxy + +/// MCP-902 — macOS mirror of the MCP-867 Web UI registry surface. +/// Covers the pure, testable units the view relies on: provenance/trust +/// derivation, add-source error-code → message mapping, JSON decode of the +/// list + add-source payloads, and the one-time third-party warning ack. +final class RegistryModelsTests: XCTestCase { + + private func decode(_ type: T.Type, from jsonString: String) throws -> T { + let data = jsonString.data(using: .utf8)! + return try JSONDecoder().decode(T.self, from: data) + } + + // MARK: - Provenance / trust derivation (mirrors Web UI isCustomRegistry) + + func testRegistryProvenanceConstants() { + XCTAssertEqual(RegistryProvenance.official, "official/trusted") + XCTAssertEqual(RegistryProvenance.custom, "custom/unverified") + } + + func testOfficialRegistryIsNotCustom() throws { + let json = """ + {"id": "official", "name": "Official MCP Registry", + "provenance": "official/trusted", "trusted": true} + """ + let reg = try decode(Registry.self, from: json) + XCTAssertFalse(reg.isCustom) + } + + func testCustomProvenanceIsCustom() throws { + let json = """ + {"id": "acme", "name": "Acme Corp", + "provenance": "custom/unverified", "trusted": false} + """ + let reg = try decode(Registry.self, from: json) + XCTAssertTrue(reg.isCustom) + } + + func testTrustedFalseAloneIsCustom() throws { + // Defensive: trusted=false with no provenance still reads as custom. + let json = """ + {"id": "weird", "name": "Weird", "trusted": false} + """ + let reg = try decode(Registry.self, from: json) + XCTAssertTrue(reg.isCustom) + } + + func testMissingProvenanceTreatedAsOfficial() throws { + // Older payloads without the field are treated as official/trusted. + let json = """ + {"id": "legacy", "name": "Legacy"} + """ + let reg = try decode(Registry.self, from: json) + XCTAssertFalse(reg.isCustom) + } + + // MARK: - List decode + + func testDecodeGetRegistriesResponse() throws { + let json = """ + { + "registries": [ + {"id": "official", "name": "Official MCP Registry", + "description": "Primary aggregator", "url": "https://registry.modelcontextprotocol.io", + "servers_url": "https://registry.modelcontextprotocol.io/v0.1/servers", + "protocol": "modelcontextprotocol/registry", + "provenance": "official/trusted", "trusted": true, "count": 1234, "tags": ["x"]}, + {"id": "acme", "name": "Acme", "protocol": "modelcontextprotocol/registry", + "provenance": "custom/unverified", "trusted": false} + ], + "total": 2 + } + """ + let resp = try decode(GetRegistriesResponse.self, from: json) + XCTAssertEqual(resp.registries.count, 2) + XCTAssertEqual(resp.registries[0].id, "official") + XCTAssertEqual(resp.registries[0].serversURL, "https://registry.modelcontextprotocol.io/v0.1/servers") + XCTAssertFalse(resp.registries[0].isCustom) + XCTAssertTrue(resp.registries[1].isCustom) + } + + // MARK: - Add-source result decode + error mapping + + func testDecodeRegistrySummary() throws { + let json = """ + {"id": "acme", "name": "Acme Corp", "url": "https://registry.acme.com", + "servers_url": "https://registry.acme.com/v0.1/servers", + "protocol": "modelcontextprotocol/registry", + "provenance": "custom/unverified", "trusted": false} + """ + let summary = try decode(RegistrySummary.self, from: json) + XCTAssertEqual(summary.id, "acme") + XCTAssertEqual(summary.provenance, "custom/unverified") + XCTAssertEqual(summary.trusted, false) + } + + func testErrorMessageInvalidURL() { + XCTAssertEqual( + AddRegistrySourceResult.message(code: "invalid_registry_url", fallback: nil), + "That URL is not a valid HTTPS registry endpoint." + ) + // A server-supplied fallback for invalid_registry_url is preferred. + XCTAssertEqual( + AddRegistrySourceResult.message(code: "invalid_registry_url", fallback: "must be https"), + "must be https" + ) + } + + func testErrorMessageRegistriesLocked() { + XCTAssertEqual( + AddRegistrySourceResult.message(code: "registries_locked", fallback: "ignored"), + "Adding registries is locked by an administrator on this instance." + ) + } + + func testErrorMessageShadowsBuiltin() { + XCTAssertEqual( + AddRegistrySourceResult.message(code: "registry_shadows_builtin", fallback: nil), + "That id/host collides with a built-in registry. Try a different id." + ) + } + + func testErrorMessageDuplicate() { + XCTAssertEqual( + AddRegistrySourceResult.message(code: "duplicate_registry", fallback: nil), + "A registry with that id is already configured." + ) + } + + func testErrorMessageUnknownFallsBack() { + XCTAssertEqual( + AddRegistrySourceResult.message(code: nil, fallback: "boom"), + "boom" + ) + XCTAssertEqual( + AddRegistrySourceResult.message(code: "totally_unknown", fallback: nil), + "Failed to add registry." + ) + } + + // MARK: - One-time third-party warning ack persistence (mirrors localStorage) + + func testThirdPartyAckPersistence() throws { + let suiteName = "test-thirdparty-ack-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let ack = ThirdPartyRegistryAck(defaults: defaults) + XCTAssertFalse(ack.hasAcknowledged, "fresh defaults should not be acknowledged") + + ack.acknowledge() + XCTAssertTrue(ack.hasAcknowledged, "acknowledge() must persist") + + // A new instance over the same defaults sees the persisted ack. + let ack2 = ThirdPartyRegistryAck(defaults: defaults) + XCTAssertTrue(ack2.hasAcknowledged) + } +}