Skip to content

Commit 77a1b6a

Browse files
committed
fix(connect): bridge connectable on fresh install + detect custom-name bridge
Addresses Codex REQUEST_CHANGES on PR #682 (MCP-2479): Gap 1 — Connect button hidden on fresh installs. A fresh Claude Desktop has no config file yet (exists=false), so the modal showed "Config not found" instead of a Connect button even though Connect can create the file. Add a `Bridge` flag to ClientDef/ClientStatus (true for claude-desktop) and relax the frontend gate to `supported && (exists || bridge)` for the button and the Connect-All set. Gap 2 — Status detection only matched the `mcpproxy` key. A bridge written under a custom server_name has no URL field and a non-default key, so it showed disconnected. Detect the bridge by inspecting the entry args (mcp-remote + mcpURL) via entryPointsToBridge, regardless of the key. Tests: custom-name bridge detection, bridge flag on ClientDef/status, and a fresh-install (exists=false) Connect-button render. go test ./internal/connect/... green; full vitest + vue-tsc green. Related MCP-2479
1 parent b7ac5c7 commit 77a1b6a

6 files changed

Lines changed: 108 additions & 2 deletions

File tree

frontend/src/components/ConnectModal.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
</div>
3939
<div class="shrink-0 ml-2">
4040
<span v-if="!client.supported" class="badge badge-ghost badge-sm">{{ client.reason || 'Not supported' }}</span>
41-
<span v-else-if="!client.exists" class="text-xs opacity-40">Config not found</span>
41+
<span v-else-if="!client.exists && !client.bridge" class="text-xs opacity-40">Config not found</span>
4242
<button
4343
v-else-if="client.connected"
4444
@click="disconnect(client.id)"
@@ -115,7 +115,9 @@ const loading = reactive({
115115
})
116116
117117
const connectableClients = computed(() =>
118-
clients.value.filter(c => c.supported && c.exists && !c.connected)
118+
// Bridge clients (e.g. Claude Desktop) can be connected even without an
119+
// existing config file — Connect creates it.
120+
clients.value.filter(c => c.supported && (c.exists || c.bridge) && !c.connected)
119121
)
120122
121123
const allConnected = computed(() =>

frontend/src/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ export interface ClientStatus {
796796
supported: boolean
797797
reason?: string
798798
note?: string
799+
bridge?: boolean
799800
icon: string
800801
server_name?: string
801802
}

frontend/tests/unit/connect-modal.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,37 @@ describe('ConnectModal', () => {
128128
expect(wrapper.text()).toContain('mcp-remote stdio bridge')
129129
})
130130

131+
it('shows Connect for a bridge client even when its config file does not exist', async () => {
132+
;(api.getConnectStatus as any).mockResolvedValue({
133+
success: true,
134+
data: [{
135+
id: 'claude-desktop',
136+
name: 'Claude Desktop',
137+
config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json',
138+
exists: false,
139+
connected: false,
140+
supported: true,
141+
bridge: true,
142+
note: 'Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.',
143+
icon: 'claude-desktop',
144+
}],
145+
})
146+
147+
const wrapper = mount(ConnectModal, {
148+
props: { show: false },
149+
global: { plugins: [pinia] },
150+
})
151+
152+
await wrapper.setProps({ show: true })
153+
await flushPromises()
154+
155+
// Fresh install: no config file yet, but the bridge Connect must still appear.
156+
const connectButton = wrapper.find('button.btn-primary.btn-xs')
157+
expect(connectButton.exists()).toBe(true)
158+
expect(connectButton.text()).toContain('Connect')
159+
expect(wrapper.text()).not.toContain('Config not found')
160+
})
161+
131162
it('disconnect uses server_name alias when OpenCode status is adopted', async () => {
132163
;(api.getConnectStatus as any).mockResolvedValue({
133164
success: true,

internal/connect/clients.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type ClientDef struct {
1717
Supported bool // Whether this client can be connected (directly or via a bridge)
1818
Reason string // Explanation when Supported is false
1919
Note string // Optional caveat shown for supported clients (e.g. bridge requirement)
20+
Bridge bool // Connects via a stdio bridge; Connect can create the config when absent
2021
Icon string // Icon identifier for frontend use
2122
}
2223

@@ -37,6 +38,7 @@ var allClients = []ClientDef{
3738
ServerKey: "mcpServers",
3839
Supported: true,
3940
Note: "Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.",
41+
Bridge: true,
4042
Icon: "claude-desktop",
4143
},
4244
{

internal/connect/connect.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type ClientStatus struct {
3434
Supported bool `json:"supported"` // client can be connected (directly or via a bridge)
3535
Reason string `json:"reason,omitempty"` // why not supported
3636
Note string `json:"note,omitempty"` // caveat for supported clients (e.g. bridge requirement)
37+
Bridge bool `json:"bridge,omitempty"` // connects via a stdio bridge; connectable even without an existing config
3738
Icon string `json:"icon"`
3839
ServerName string `json:"server_name,omitempty"` // name under which mcpproxy is registered
3940
}
@@ -121,6 +122,7 @@ func (s *Service) GetAllStatus() []ClientStatus {
121122
Supported: c.Supported,
122123
Reason: c.Reason,
123124
Note: c.Note,
125+
Bridge: c.Bridge,
124126
Icon: c.Icon,
125127
}
126128

@@ -672,6 +674,13 @@ func (s *Service) findEntryJSON(client ClientDef, cfgPath string) (string, bool)
672674
}
673675
}
674676

677+
// Stdio-bridge clients (e.g. Claude Desktop) have no URL field; the
678+
// mcpproxy endpoint lives in the command args. Detect by inspecting
679+
// args so a bridge written under a custom server name is still found.
680+
if entryPointsToBridge(entry, mcpURL, baseURL) {
681+
return name, true
682+
}
683+
675684
// Also match by server name
676685
if name == defaultServerName {
677686
return name, true
@@ -681,6 +690,30 @@ func (s *Service) findEntryJSON(client ClientDef, cfgPath string) (string, bool)
681690
return "", false
682691
}
683692

693+
// entryPointsToBridge reports whether a JSON config entry is an mcp-remote
694+
// stdio bridge targeting our MCP endpoint, regardless of the entry key.
695+
func entryPointsToBridge(entry map[string]interface{}, mcpURL, baseURL string) bool {
696+
rawArgs, ok := entry["args"].([]interface{})
697+
if !ok {
698+
return false
699+
}
700+
hasBridgePkg := false
701+
pointsToUs := false
702+
for _, a := range rawArgs {
703+
s, ok := a.(string)
704+
if !ok {
705+
continue
706+
}
707+
if s == "mcp-remote" {
708+
hasBridgePkg = true
709+
}
710+
if s == mcpURL || s == baseURL || strings.HasPrefix(s, baseURL+"?") {
711+
pointsToUs = true
712+
}
713+
}
714+
return hasBridgePkg && pointsToUs
715+
}
716+
684717
var trailingCommaPattern = regexp.MustCompile(`,\s*([}\]])`)
685718

686719
func unmarshalLenientJSON(raw []byte, out interface{}) error {

internal/connect/connect_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,12 @@ func TestClaudeDesktop_SupportedWithBridgeNote(t *testing.T) {
757757
if !strings.Contains(strings.ToLower(client.Note), "mcp-remote") {
758758
t.Errorf("claude-desktop note should mention mcp-remote, got: %q", client.Note)
759759
}
760+
// Bridge clients can be connected even when no config file exists yet
761+
// (Connect creates it), so the frontend must offer the button on fresh
762+
// installs.
763+
if !client.Bridge {
764+
t.Error("claude-desktop should be flagged as a bridge client")
765+
}
760766
}
761767

762768
func TestBuildServerEntry_ClaudeDesktop_StdioBridge(t *testing.T) {
@@ -880,6 +886,34 @@ func TestGetAllStatus_ClaudeDesktop_DetectsBridgeConnection(t *testing.T) {
880886
t.Fatal("claude-desktop status not found")
881887
}
882888

889+
// Gap 2 (Codex review): a bridge written under a custom server_name has no
890+
// URL field and a non-"mcpproxy" key, so it must be detected by inspecting
891+
// the entry's args (mcp-remote + mcpURL).
892+
func TestGetAllStatus_ClaudeDesktop_DetectsBridgeUnderCustomName(t *testing.T) {
893+
svc, homeDir := testService(t)
894+
895+
cfgPath := ConfigPath("claude-desktop", homeDir)
896+
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
897+
t.Fatal(err)
898+
}
899+
if _, err := svc.Connect("claude-desktop", "my-bridge", false); err != nil {
900+
t.Fatal(err)
901+
}
902+
903+
for _, s := range svc.GetAllStatus() {
904+
if s.ID == "claude-desktop" {
905+
if !s.Connected {
906+
t.Error("expected claude-desktop connected via bridge args under a custom name")
907+
}
908+
if s.ServerName != "my-bridge" {
909+
t.Errorf("expected server_name=my-bridge, got %q", s.ServerName)
910+
}
911+
return
912+
}
913+
}
914+
t.Fatal("claude-desktop status not found")
915+
}
916+
883917
func TestConnect_UnknownClient(t *testing.T) {
884918
svc, _ := testService(t)
885919

@@ -938,6 +972,9 @@ func TestGetAllStatus(t *testing.T) {
938972
if s.Note == "" {
939973
t.Error("claude-desktop should expose a bridge note")
940974
}
975+
if !s.Bridge {
976+
t.Error("claude-desktop status should expose bridge=true")
977+
}
941978
}
942979
}
943980
}

0 commit comments

Comments
 (0)