Skip to content

Commit 3a34c8e

Browse files
authored
feat(connect): one-click Claude Desktop via mcp-remote stdio bridge (MCP-2479) (#682)
* feat(connect): one-click Claude Desktop via mcp-remote stdio bridge Claude Desktop was marked unsupported in the Connect wizard because it only speaks stdio, even though mcpproxy can be reached from it via an `npx -y mcp-remote <mcpURL>` bridge (already documented in docs/setup.md). - Flip claude-desktop to Supported=true and add a stdio-bridge branch to buildServerEntry that writes `command: npx`, `args: [-y, mcp-remote, <mcpURL>]`. - Add a Note field (ClientDef/ClientStatus) surfacing the bridge requirement; render it in ConnectModal so users know the path uses mcp-remote (needs Node.js). - Connected-state detection already matches the canonical `mcpproxy` entry name, so bridge connections are tracked without a URL field. - Docs: note the one-click wizard path in docs/setup.md; widen connect POST/DELETE client-ID examples. Related MCP-2479 * test(connect): isolate claude-desktop config path on Windows On Windows, ConfigPath ignores the homeDir override for claude-desktop and vscode (it reads %APPDATA%), so the no-key and with-key Claude Desktop bridge tests both wrote to the same real APPDATA\Claude path. The no-key test ran first and created the entry; the with-key test then hit `already_exists` (force=false) and never wrote the apikey, failing the assertion on the windows-amd64 runner. Pin %APPDATA% under the per-test temp dir in the test-service helpers, mirroring the existing %LOCALAPPDATA% line, so every client's config path is isolated per-test on Windows. No-op on macOS/Linux. Related MCP-2479 * 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 bacd807 commit 3a34c8e

10 files changed

Lines changed: 314 additions & 28 deletions

File tree

docs/setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,8 @@ Add mcpproxy as a remote MCP server via Settings → Connectors → Add Custom C
254254

255255
#### Option A: Free Plan — JSON Configuration
256256

257+
> **💡 One-click:** mcpproxy's built-in **Connect** wizard (Web UI / tray) can write this bridge configuration for you automatically — pick **Claude Desktop** and click **Connect**. It registers the `npx -y mcp-remote` bridge shown below (Node.js required). The manual steps remain available if you prefer to edit the file yourself.
258+
257259
1. Create the config file if it doesn't exist:
258260

259261
**macOS:**

frontend/src/components/ConnectModal.vue

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@
3333
<div class="min-w-0 flex-1">
3434
<div class="font-medium text-sm truncate">{{ client.name }}</div>
3535
<div class="text-xs opacity-50 truncate" :title="client.config_path">{{ client.config_path }}</div>
36+
<div v-if="client.note" class="text-xs opacity-60 italic mt-0.5" :title="client.note">{{ client.note }}</div>
3637
</div>
3738
</div>
3839
<div class="shrink-0 ml-2">
3940
<span v-if="!client.supported" class="badge badge-ghost badge-sm">{{ client.reason || 'Not supported' }}</span>
40-
<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>
4142
<button
4243
v-else-if="client.connected"
4344
@click="disconnect(client.id)"
@@ -114,7 +115,9 @@ const loading = reactive({
114115
})
115116
116117
const connectableClients = computed(() =>
117-
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)
118121
)
119122
120123
const allConnected = computed(() =>

frontend/src/types/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,8 @@ export interface ClientStatus {
795795
connected: boolean
796796
supported: boolean
797797
reason?: string
798+
note?: string
799+
bridge?: boolean
798800
icon: string
799801
server_name?: string
800802
}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,69 @@ describe('ConnectModal', () => {
9696
expect(wrapper.text()).toContain('⌘')
9797
})
9898

99+
it('renders a Connect button and bridge note for Claude Desktop', async () => {
100+
;(api.getConnectStatus as any).mockResolvedValue({
101+
success: true,
102+
data: [{
103+
id: 'claude-desktop',
104+
name: 'Claude Desktop',
105+
config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json',
106+
exists: true,
107+
connected: false,
108+
supported: true,
109+
note: 'Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.',
110+
icon: 'claude-desktop',
111+
}],
112+
})
113+
114+
const wrapper = mount(ConnectModal, {
115+
props: { show: false },
116+
global: { plugins: [pinia] },
117+
})
118+
119+
await wrapper.setProps({ show: true })
120+
await flushPromises()
121+
122+
// A real one-click Connect button must be offered (not greyed out).
123+
const connectButton = wrapper.find('button.btn-primary.btn-xs')
124+
expect(connectButton.exists()).toBe(true)
125+
expect(connectButton.text()).toContain('Connect')
126+
127+
// The bridge note must be surfaced to the user.
128+
expect(wrapper.text()).toContain('mcp-remote stdio bridge')
129+
})
130+
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+
99162
it('disconnect uses server_name alias when OpenCode status is adopted', async () => {
100163
;(api.getConnectStatus as any).mockResolvedValue({
101164
success: true,

internal/connect/clients.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ type ClientDef struct {
1414
Name string // Human-readable name, e.g. "Claude Code"
1515
Format string // File format: "json" or "toml"
1616
ServerKey string // Top-level key for server entries: "mcpServers" or "servers"
17-
Supported bool // Whether this client supports HTTP/SSE transport
17+
Supported bool // Whether this client can be connected (directly or via a bridge)
1818
Reason string // Explanation when Supported is false
19+
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
1921
Icon string // Icon identifier for frontend use
2022
}
2123

@@ -34,8 +36,9 @@ var allClients = []ClientDef{
3436
Name: "Claude Desktop",
3537
Format: "json",
3638
ServerKey: "mcpServers",
37-
Supported: false,
38-
Reason: "Claude Desktop only supports stdio transport; HTTP/SSE not available",
39+
Supported: true,
40+
Note: "Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.",
41+
Bridge: true,
3942
Icon: "claude-desktop",
4043
},
4144
{
@@ -186,6 +189,13 @@ func buildServerEntry(clientID, mcpURL string) map[string]interface{} {
186189
"type": "http",
187190
"url": mcpURL,
188191
}
192+
case "claude-desktop":
193+
// Claude Desktop only speaks stdio, so bridge to mcpproxy's HTTP
194+
// endpoint with mcp-remote run via npx.
195+
return map[string]interface{}{
196+
"command": "npx",
197+
"args": []string{"-y", "mcp-remote", mcpURL},
198+
}
189199
case "cursor":
190200
return map[string]interface{}{
191201
"url": mcpURL,

internal/connect/connect.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ type ClientStatus struct {
3131
ConfigPath string `json:"config_path"`
3232
Exists bool `json:"exists"` // config file exists on disk
3333
Connected bool `json:"connected"` // mcpproxy entry present in config
34-
Supported bool `json:"supported"` // client supports HTTP/SSE
34+
Supported bool `json:"supported"` // client can be connected (directly or via a bridge)
3535
Reason string `json:"reason,omitempty"` // why not supported
36+
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
3638
Icon string `json:"icon"`
3739
ServerName string `json:"server_name,omitempty"` // name under which mcpproxy is registered
3840
}
@@ -119,6 +121,8 @@ func (s *Service) GetAllStatus() []ClientStatus {
119121
ConfigPath: cfgPath,
120122
Supported: c.Supported,
121123
Reason: c.Reason,
124+
Note: c.Note,
125+
Bridge: c.Bridge,
122126
Icon: c.Icon,
123127
}
124128

@@ -670,6 +674,13 @@ func (s *Service) findEntryJSON(client ClientDef, cfgPath string) (string, bool)
670674
}
671675
}
672676

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+
673684
// Also match by server name
674685
if name == defaultServerName {
675686
return name, true
@@ -679,6 +690,30 @@ func (s *Service) findEntryJSON(client ClientDef, cfgPath string) (string, bool)
679690
return "", false
680691
}
681692

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+
682717
var trailingCommaPattern = regexp.MustCompile(`,\s*([}\]])`)
683718

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

0 commit comments

Comments
 (0)