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 frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,9 @@ export interface ClientStatus {
// privacy fix including the exact tccutil reset command).
access_state?: AccessState
remediation?: string
// Every config location the existence check consults, highest precedence
// first (e.g. OpenCode's opencode.jsonc then opencode.json).
checked_paths?: string[]
}

export interface ConnectResult {
Expand Down
100 changes: 100 additions & 0 deletions internal/connect/checked_paths_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package connect

import (
"os"
"path/filepath"
"runtime"
"testing"
)

// A "No config found" row must be able to name the exact files that were
// looked for — otherwise a user whose opencode.jsonc exists cannot tell
// whether mcpproxy checked the wrong file or could not see it.

func statusFor(t *testing.T, statuses []ClientStatus, id string) ClientStatus {
t.Helper()
for _, st := range statuses {
if st.ID == id {
return st
}
}
t.Fatalf("client %q not in status list", id)
return ClientStatus{}
}

func TestGetAllStatusReportsCheckedPaths(t *testing.T) {
if runtime.GOOS == "windows" {
t.Setenv("LOCALAPPDATA", "")
}
home := t.TempDir()
s := NewServiceWithHome("127.0.0.1:8080", "key", home)
statuses := s.GetAllStatus()

// OpenCode consults both candidates, .jsonc first (it shadows .json), even
// when neither exists — the row must say so instead of naming only the
// create-new default.
oc := statusFor(t, statuses, "opencode")
want := opencodeConfigCandidates(home)
if len(oc.CheckedPaths) != len(want) {
t.Fatalf("opencode checked_paths = %v, want %v", oc.CheckedPaths, want)
}
for i := range want {
if oc.CheckedPaths[i] != want[i] {
t.Fatalf("opencode checked_paths[%d] = %s, want %s", i, oc.CheckedPaths[i], want[i])
}
}
if filepath.Base(oc.CheckedPaths[0]) != "opencode.jsonc" {
t.Fatalf("opencode checked_paths[0] = %s, want opencode.jsonc first", oc.CheckedPaths[0])
}

// A single-file client reports exactly its one path.
cursor := statusFor(t, statuses, "cursor")
if len(cursor.CheckedPaths) != 1 || cursor.CheckedPaths[0] != ConfigPath("cursor", home) {
t.Fatalf("cursor checked_paths = %v, want [%s]", cursor.CheckedPaths, ConfigPath("cursor", home))
}
}

// Production Services are built without a homeDir (NewService); the opencode
// candidates must still resolve to absolute paths, or both the jsonc
// preference and checked_paths silently degrade to CWD-relative stats.
func TestOpencodePathsAreAbsoluteWithoutAnExplicitHome(t *testing.T) {
if _, err := os.UserHomeDir(); err != nil {
t.Skip("no resolvable home directory")
}
s := NewService("127.0.0.1:8080", "key")

paths := s.checkedPaths("opencode")
if len(paths) != 2 {
t.Fatalf("checked paths = %v, want both opencode candidates", paths)
}
for _, p := range paths {
if !filepath.IsAbs(p) {
t.Fatalf("checked path %q is CWD-relative — empty homeDir must resolve via os.UserHomeDir", p)
}
}
if p := s.configPath("opencode"); !filepath.IsAbs(p) {
t.Fatalf("configPath %q is CWD-relative — the jsonc preference never fires in production", p)
}
}

func TestGetStatusReportsCheckedPaths(t *testing.T) {
if runtime.GOOS == "windows" {
t.Setenv("LOCALAPPDATA", "")
}
home := t.TempDir()
s := NewServiceWithHome("127.0.0.1:8080", "key", home)

st, err := s.GetStatus("opencode")
if err != nil {
t.Fatal(err)
}
want := opencodeConfigCandidates(home)
if len(st.CheckedPaths) != len(want) {
t.Fatalf("opencode checked_paths = %v, want %v", st.CheckedPaths, want)
}
for i := range want {
if st.CheckedPaths[i] != want[i] {
t.Fatalf("opencode checked_paths[%d] = %s, want %s", i, st.CheckedPaths[i], want[i])
}
}
}
24 changes: 24 additions & 0 deletions internal/connect/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,18 @@ func opencodeConfigDir(homeDir string) string {
// opencodeConfigCandidates lists the global config files OpenCode itself loads,
// highest precedence first: opencode.jsonc shadows opencode.json for the same
// keys, and recent OpenCode versions bootstrap the .jsonc variant (#922).
//
// An empty homeDir resolves through os.UserHomeDir, exactly like ConfigPath —
// production Services are built without one (NewService), and joining "" would
// yield CWD-relative candidates that stat against the wrong directory.
func opencodeConfigCandidates(homeDir string) []string {
if homeDir == "" {
var err error
homeDir, err = os.UserHomeDir()
if err != nil {
return nil
}
}
dir := opencodeConfigDir(homeDir)
return []string{
filepath.Join(dir, "opencode.jsonc"),
Expand Down Expand Up @@ -217,6 +228,19 @@ func (s *Service) configPath(clientID string) string {
return ConfigPath(clientID, s.homeDir)
}

// checkedPaths lists the config files the existence check consults for a
// client, highest precedence first — the paths a "no config found" UI should
// name. Static: no stat calls, so it is safe on the content-read-free path.
func (s *Service) checkedPaths(clientID string) []string {
if clientID == "opencode" {
return opencodeConfigCandidates(s.homeDir)
}
if p := ConfigPath(clientID, s.homeDir); p != "" {
return []string{p}
}
return nil
}

// buildServerEntry returns the JSON/TOML-serializable map inserted into the
// client's config for the mcpproxy endpoint. When p.credential is set (only when
// require_mcp_auth is on), it is written via the carrier each client actually
Expand Down
43 changes: 25 additions & 18 deletions internal/connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ type ClientStatus struct {
// Empty/"unknown" in the content-read-free overall status; resolved to
// "accessible"/"absent"/"malformed" (and "denied" in US2) by on-demand reads.
AccessState string `json:"access_state"`
// CheckedPaths lists every config location the existence check consults,
// highest precedence first. For most clients this is just ConfigPath; for
// OpenCode it names both opencode.jsonc and opencode.json (#922), so a
// "no config found" UI can say exactly which files were looked for.
CheckedPaths []string `json:"checked_paths,omitempty"`
// Remediation carries actionable fix text, populated only when access is denied.
Remediation string `json:"remediation,omitempty"`
}
Expand Down Expand Up @@ -298,15 +303,16 @@ func (s *Service) GetAllStatus() []ClientStatus {
for _, c := range clients {
cfgPath := s.configPath(c.ID)
status := ClientStatus{
ID: c.ID,
Name: c.Name,
ConfigPath: cfgPath,
Supported: c.Supported,
Reason: c.Reason,
Note: c.Note,
Bridge: c.Bridge,
Icon: c.Icon,
AccessState: accessUnknown,
ID: c.ID,
Name: c.Name,
ConfigPath: cfgPath,
CheckedPaths: s.checkedPaths(c.ID),
Supported: c.Supported,
Reason: c.Reason,
Note: c.Note,
Bridge: c.Bridge,
Icon: c.Icon,
AccessState: accessUnknown,
}

// Metadata-only existence check (no content read).
Expand Down Expand Up @@ -338,15 +344,16 @@ func (s *Service) GetStatus(clientID string) (ClientStatus, error) {

cfgPath := s.configPath(c.ID)
status := ClientStatus{
ID: c.ID,
Name: c.Name,
ConfigPath: cfgPath,
Supported: c.Supported,
Reason: c.Reason,
Note: c.Note,
Bridge: c.Bridge,
Icon: c.Icon,
AccessState: accessUnknown,
ID: c.ID,
Name: c.Name,
ConfigPath: cfgPath,
CheckedPaths: s.checkedPaths(c.ID),
Supported: c.Supported,
Reason: c.Reason,
Note: c.Note,
Bridge: c.Bridge,
Icon: c.Icon,
AccessState: accessUnknown,
}

if _, err := s.stat(cfgPath); err == nil {
Expand Down
4 changes: 4 additions & 0 deletions native/macos/MCPProxy/MCPProxy/API/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,9 @@ actor APIClient {
let note: String?
/// Connects through a stdio bridge; connectable without an existing config.
let bridge: Bool?
/// Every config location the core's existence check consults, highest
/// precedence first (e.g. OpenCode's opencode.jsonc then opencode.json).
let checkedPaths: [String]?

enum CodingKeys: String, CodingKey {
case clientId = "id"
Expand All @@ -322,6 +325,7 @@ actor APIClient {
case serverName = "server_name"
case accessState = "access_state"
case remediation
case checkedPaths = "checked_paths"
}

/// Name to render; a core newer than the app may report a client this
Expand Down
51 changes: 47 additions & 4 deletions native/macos/MCPProxy/MCPProxy/Views/ConnectClientModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ final class ConnectClientModel: ObservableObject {
/// Extra guidance for the row: the core's remediation when access is
/// denied, or its caveat for a supported client.
let note: String?
/// Whether the note is cautionary (remediation, a core caveat) or
/// merely informational (the looked-for paths). Drives which visual
/// channel renders it — the warning channel must not be diluted by
/// path hints on every not-installed row.
let noteIsWarning: Bool
let connected: Bool

var id: String { clientId }
Expand Down Expand Up @@ -387,13 +392,15 @@ final class ConnectClientModel: ObservableObject {

private func row(for client: APIClient.ClientStatus) -> ClientRow {
let resolved = resolvedDetails[client.clientId] ?? client
let note = Self.note(for: resolved)
return ClientRow(
clientId: client.clientId,
displayName: client.displayName,
symbolName: client.symbolName,
stateLabel: Self.stateLabel(for: resolved),
isSelectable: client.supported,
note: Self.note(for: resolved),
note: note?.text,
noteIsWarning: note?.isWarning ?? false,
connected: resolved.connected
)
}
Expand Down Expand Up @@ -422,15 +429,51 @@ final class ConnectClientModel: ObservableObject {
return client.exists ? "Config present" : "No config found"
}

private static func note(for client: APIClient.ClientStatus) -> String? {
private static func note(for client: APIClient.ClientStatus) -> (text: String, isWarning: Bool)? {
if client.accessState == .denied, let remediation = client.remediation,
!remediation.isEmpty {
return remediation
return (remediation, true)
}
if let note = client.note, !note.isEmpty { return (note, true) }
if client.supported, !client.connected, !client.exists {
switch client.accessState {
case .none, .unknown, .absent:
if let looked = lookedForDescription(for: client) { return (looked, false) }
case .accessible, .denied, .malformed:
// A denied stat is not evidence of absence: the aggregate list
// sets "denied" WITHOUT remediation, and saying "Looked for …"
// there would claim the files were checked when the check was
// forbidden — the exact wrong-problem label the core refuses
// to produce.
break
}
}
if let note = client.note, !note.isEmpty { return note }
return nil
}

/// Names the exact files behind a "No config found" verdict, so a user
/// whose config lives at a path this build never checks (say, an
/// opencode.jsonc against a pre-#923 core) can see the mismatch instead of
/// guessing.
private static func lookedForDescription(for client: APIClient.ClientStatus) -> String? {
var paths = client.checkedPaths ?? []
if paths.isEmpty, !client.configPath.isEmpty { paths = [client.configPath] }
guard !paths.isEmpty else { return nil }
let abbreviated = paths.map(abbreviatingHome)
let dirs = Set(abbreviated.map { ($0 as NSString).deletingLastPathComponent })
if abbreviated.count > 1, dirs.count == 1, let dir = dirs.first, !dir.isEmpty {
let names = abbreviated.map { ($0 as NSString).lastPathComponent }
return "Looked for \(names.joined(separator: " or ")) in \(dir)"
}
return "Looked for \(abbreviated.joined(separator: ", "))"
}

private static func abbreviatingHome(_ path: String) -> String {
let home = NSHomeDirectory()
guard home.count > 1, path.hasPrefix(home + "/") else { return path }
return "~" + path.dropFirst(home.count)
}

// MARK: - Derived

/// The currently rendered preview, or nil when none is bound to the current
Expand Down
6 changes: 4 additions & 2 deletions native/macos/MCPProxy/MCPProxy/Views/ConnectClientView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,17 @@ struct ConnectClientView: View {
if let note = row.note {
Text(note)
.font(.caption2)
.foregroundStyle(.orange)
.foregroundStyle(row.noteIsWarning ? Color.orange : Color.secondary)
}
}
Spacer()
}
.padding(.vertical, 2)
.opacity(row.isSelectable ? 1 : 0.5)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(row.displayName), \(row.stateLabel)")
.accessibilityLabel(
row.note.map { "\(row.displayName), \(row.stateLabel), \($0)" }
?? "\(row.displayName), \(row.stateLabel)")
}

// MARK: Detail + preview
Expand Down
Loading
Loading