Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ Equivalent surfaces:
protocol/name). Each registry in the selector is flagged **Official · trusted** or
**Third-party · unverified** from its `provenance`, and the first custom add shows a
one-time third-party-registry warning (the acknowledgement is remembered locally).
- **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).
Expand Down
64 changes: 62 additions & 2 deletions native/macos/MCPProxy/MCPProxy/API/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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<AddRegistrySourceData>.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)
}
}
}
148 changes: 148 additions & 0 deletions native/macos/MCPProxy/MCPProxy/API/RegistryModels.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions native/macos/MCPProxy/MCPProxy/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
}
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading