Skip to content

Commit 0df7d44

Browse files
committed
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
1 parent 9794dfe commit 0df7d44

10 files changed

Lines changed: 199 additions & 22 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
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">

frontend/src/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ export interface ClientStatus {
795795
connected: boolean
796796
supported: boolean
797797
reason?: string
798+
note?: string
798799
icon: string
799800
server_name?: string
800801
}

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,38 @@ 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+
99131
it('disconnect uses server_name alias when OpenCode status is adopted', async () => {
100132
;(api.getConnectStatus as any).mockResolvedValue({
101133
success: true,

internal/connect/clients.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ 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)
1920
Icon string // Icon identifier for frontend use
2021
}
2122

@@ -34,8 +35,8 @@ var allClients = []ClientDef{
3435
Name: "Claude Desktop",
3536
Format: "json",
3637
ServerKey: "mcpServers",
37-
Supported: false,
38-
Reason: "Claude Desktop only supports stdio transport; HTTP/SSE not available",
38+
Supported: true,
39+
Note: "Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.",
3940
Icon: "claude-desktop",
4041
},
4142
{
@@ -186,6 +187,13 @@ func buildServerEntry(clientID, mcpURL string) map[string]interface{} {
186187
"type": "http",
187188
"url": mcpURL,
188189
}
190+
case "claude-desktop":
191+
// Claude Desktop only speaks stdio, so bridge to mcpproxy's HTTP
192+
// endpoint with mcp-remote run via npx.
193+
return map[string]interface{}{
194+
"command": "npx",
195+
"args": []string{"-y", "mcp-remote", mcpURL},
196+
}
189197
case "cursor":
190198
return map[string]interface{}{
191199
"url": mcpURL,

internal/connect/connect.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ 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)
3637
Icon string `json:"icon"`
3738
ServerName string `json:"server_name,omitempty"` // name under which mcpproxy is registered
3839
}
@@ -119,6 +120,7 @@ func (s *Service) GetAllStatus() []ClientStatus {
119120
ConfigPath: cfgPath,
120121
Supported: c.Supported,
121122
Reason: c.Reason,
123+
Note: c.Note,
122124
Icon: c.Icon,
123125
}
124126

internal/connect/connect_test.go

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -735,18 +735,146 @@ func TestDisconnect_TOML(t *testing.T) {
735735
}
736736
}
737737

738-
// ---------- Unsupported client tests ----------
738+
// ---------- Claude Desktop stdio-bridge tests (MCP-2479) ----------
739739

740-
func TestConnect_UnsupportedClient(t *testing.T) {
741-
svc, _ := testService(t)
740+
// Claude Desktop only speaks stdio, so mcpproxy connects via an mcp-remote
741+
// stdio bridge instead of a direct HTTP/SSE URL. It must be a supported,
742+
// one-click client.
743+
func TestClaudeDesktop_SupportedWithBridgeNote(t *testing.T) {
744+
client := FindClient("claude-desktop")
745+
if client == nil {
746+
t.Fatal("expected claude-desktop client definition")
747+
}
748+
if !client.Supported {
749+
t.Error("claude-desktop should be supported via the mcp-remote stdio bridge")
750+
}
751+
if client.Note == "" {
752+
t.Error("claude-desktop should carry a note explaining the mcp-remote bridge")
753+
}
754+
if !strings.Contains(strings.ToLower(client.Note), "mcp-remote") {
755+
t.Errorf("claude-desktop note should mention mcp-remote, got: %q", client.Note)
756+
}
757+
}
742758

743-
_, err := svc.Connect("claude-desktop", "", false)
744-
if err == nil {
745-
t.Fatal("Expected error for unsupported client")
759+
func TestBuildServerEntry_ClaudeDesktop_StdioBridge(t *testing.T) {
760+
entry := buildServerEntry("claude-desktop", "http://127.0.0.1:8080/mcp")
761+
762+
if entry["command"] != "npx" {
763+
t.Errorf("expected command=npx, got %v", entry["command"])
764+
}
765+
args, ok := entry["args"].([]string)
766+
if !ok {
767+
t.Fatalf("expected args []string, got %T", entry["args"])
768+
}
769+
want := []string{"-y", "mcp-remote", "http://127.0.0.1:8080/mcp"}
770+
if len(args) != len(want) {
771+
t.Fatalf("expected args %v, got %v", want, args)
772+
}
773+
for i := range want {
774+
if args[i] != want[i] {
775+
t.Errorf("args[%d]=%q, want %q", i, args[i], want[i])
776+
}
777+
}
778+
// A stdio bridge has no direct URL/type fields.
779+
if _, ok := entry["url"]; ok {
780+
t.Error("stdio-bridge entry should not contain a url field")
781+
}
782+
}
783+
784+
func TestConnect_ClaudeDesktop_WritesStdioBridge(t *testing.T) {
785+
svc, homeDir := testService(t)
786+
787+
cfgPath := ConfigPath("claude-desktop", homeDir)
788+
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
789+
t.Fatal(err)
790+
}
791+
792+
result, err := svc.Connect("claude-desktop", "", false)
793+
if err != nil {
794+
t.Fatalf("Connect failed: %v", err)
795+
}
796+
if !result.Success {
797+
t.Fatalf("Expected success, got: %s", result.Message)
798+
}
799+
800+
raw, _ := os.ReadFile(result.ConfigPath)
801+
var data map[string]interface{}
802+
if err := json.Unmarshal(raw, &data); err != nil {
803+
t.Fatalf("Parse config failed: %v", err)
804+
}
805+
806+
servers, ok := data["mcpServers"].(map[string]interface{})
807+
if !ok {
808+
t.Fatal("Missing mcpServers key")
809+
}
810+
entry, ok := servers["mcpproxy"].(map[string]interface{})
811+
if !ok {
812+
t.Fatal("Missing mcpproxy entry")
813+
}
814+
if entry["command"] != "npx" {
815+
t.Errorf("Expected command=npx, got %v", entry["command"])
816+
}
817+
args, ok := entry["args"].([]interface{})
818+
if !ok {
819+
t.Fatalf("Expected args array, got %T", entry["args"])
746820
}
747-
if !strings.Contains(err.Error(), "not supported") {
748-
t.Errorf("Expected 'not supported' in error, got: %v", err)
821+
want := []string{"-y", "mcp-remote", "http://127.0.0.1:8080/mcp"}
822+
if len(args) != len(want) {
823+
t.Fatalf("Expected args %v, got %v", want, args)
824+
}
825+
for i := range want {
826+
if args[i] != want[i] {
827+
t.Errorf("args[%d]=%v, want %q", i, args[i], want[i])
828+
}
829+
}
830+
}
831+
832+
func TestConnect_ClaudeDesktop_BridgeIncludesAPIKey(t *testing.T) {
833+
svc, homeDir := testServiceWithKey(t)
834+
835+
cfgPath := ConfigPath("claude-desktop", homeDir)
836+
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
837+
t.Fatal(err)
838+
}
839+
840+
result, err := svc.Connect("claude-desktop", "", false)
841+
if err != nil {
842+
t.Fatalf("Connect failed: %v", err)
843+
}
844+
845+
raw, _ := os.ReadFile(result.ConfigPath)
846+
var data map[string]interface{}
847+
json.Unmarshal(raw, &data)
848+
849+
servers := data["mcpServers"].(map[string]interface{})
850+
entry := servers["mcpproxy"].(map[string]interface{})
851+
args := entry["args"].([]interface{})
852+
lastArg, _ := args[len(args)-1].(string)
853+
if lastArg != "http://127.0.0.1:8080/mcp?apikey=test-key-123" {
854+
t.Errorf("Expected bridge URL with apikey, got %v", lastArg)
855+
}
856+
}
857+
858+
func TestGetAllStatus_ClaudeDesktop_DetectsBridgeConnection(t *testing.T) {
859+
svc, homeDir := testService(t)
860+
861+
cfgPath := ConfigPath("claude-desktop", homeDir)
862+
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
863+
t.Fatal(err)
864+
}
865+
if _, err := svc.Connect("claude-desktop", "", false); err != nil {
866+
t.Fatal(err)
867+
}
868+
869+
for _, s := range svc.GetAllStatus() {
870+
if s.ID == "claude-desktop" {
871+
if !s.Connected {
872+
t.Error("expected claude-desktop connected=true after bridge connect")
873+
}
874+
return
875+
}
749876
}
877+
t.Fatal("claude-desktop status not found")
750878
}
751879

752880
func TestConnect_UnknownClient(t *testing.T) {
@@ -797,14 +925,15 @@ func TestGetAllStatus(t *testing.T) {
797925
t.Errorf("Expected %d statuses, got %d", len(allClients), len(statuses))
798926
}
799927

800-
// Verify claude-desktop is not supported
928+
// Verify claude-desktop is supported via the mcp-remote stdio bridge and
929+
// surfaces a note explaining the bridge.
801930
for _, s := range statuses {
802931
if s.ID == "claude-desktop" {
803-
if s.Supported {
804-
t.Error("claude-desktop should not be supported")
932+
if !s.Supported {
933+
t.Error("claude-desktop should be supported via the mcp-remote bridge")
805934
}
806-
if s.Reason == "" {
807-
t.Error("claude-desktop should have a reason")
935+
if s.Note == "" {
936+
t.Error("claude-desktop should expose a bridge note")
808937
}
809938
}
810939
}

internal/httpapi/connect.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func (s *Server) handleGetConnectStatus(w http.ResponseWriter, r *http.Request)
4646
// @Produce json
4747
// @Security ApiKeyAuth
4848
// @Security ApiKeyQuery
49-
// @Param client path string true "Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)"
49+
// @Param client path string true "Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)"
5050
// @Param body body ConnectRequest false "Optional connection parameters"
5151
// @Success 200 {object} contracts.APIResponse "ConnectResult"
5252
// @Failure 400 {object} contracts.ErrorResponse "Bad request"
@@ -108,7 +108,7 @@ func (s *Server) handleConnectClient(w http.ResponseWriter, r *http.Request) {
108108
// @Produce json
109109
// @Security ApiKeyAuth
110110
// @Security ApiKeyQuery
111-
// @Param client path string true "Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)"
111+
// @Param client path string true "Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)"
112112
// @Param body body ConnectRequest false "Optional parameters (server_name)"
113113
// @Success 200 {object} contracts.APIResponse "ConnectResult"
114114
// @Failure 400 {object} contracts.ErrorResponse "Bad request"

oas/docs.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

oas/swagger.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,7 +3279,8 @@ paths:
32793279
Remove the MCPProxy entry from the specified client's configuration file.
32803280
Creates a backup of the existing config before modifying.
32813281
parameters:
3282-
- description: Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)
3282+
- description: Client ID (claude-code, claude-desktop, cursor, windsurf, vscode,
3283+
codex, gemini, opencode)
32833284
in: path
32843285
name: client
32853286
required: true
@@ -3327,7 +3328,8 @@ paths:
33273328
Register MCPProxy as an MCP server in the specified client's configuration file.
33283329
Creates a backup of the existing config before modifying.
33293330
parameters:
3330-
- description: Client ID (claude-code, cursor, windsurf, vscode, codex, gemini)
3331+
- description: Client ID (claude-code, claude-desktop, cursor, windsurf, vscode,
3332+
codex, gemini, opencode)
33313333
in: path
33323334
name: client
33333335
required: true

0 commit comments

Comments
 (0)