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
2 changes: 2 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,8 @@ Add mcpproxy as a remote MCP server via Settings → Connectors → Add Custom C

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

> **💡 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.

1. Create the config file if it doesn't exist:

**macOS:**
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/components/ConnectModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
<div class="min-w-0 flex-1">
<div class="font-medium text-sm truncate">{{ client.name }}</div>
<div class="text-xs opacity-50 truncate" :title="client.config_path">{{ client.config_path }}</div>
<div v-if="client.note" class="text-xs opacity-60 italic mt-0.5" :title="client.note">{{ client.note }}</div>
</div>
</div>
<div class="shrink-0 ml-2">
<span v-if="!client.supported" class="badge badge-ghost badge-sm">{{ client.reason || 'Not supported' }}</span>
<span v-else-if="!client.exists" class="text-xs opacity-40">Config not found</span>
<span v-else-if="!client.exists && !client.bridge" class="text-xs opacity-40">Config not found</span>
<button
v-else-if="client.connected"
@click="disconnect(client.id)"
Expand Down Expand Up @@ -114,7 +115,9 @@ const loading = reactive({
})

const connectableClients = computed(() =>
clients.value.filter(c => c.supported && c.exists && !c.connected)
// Bridge clients (e.g. Claude Desktop) can be connected even without an
// existing config file — Connect creates it.
clients.value.filter(c => c.supported && (c.exists || c.bridge) && !c.connected)
)

const allConnected = computed(() =>
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,8 @@ export interface ClientStatus {
connected: boolean
supported: boolean
reason?: string
note?: string
bridge?: boolean
icon: string
server_name?: string
}
Expand Down
63 changes: 63 additions & 0 deletions frontend/tests/unit/connect-modal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,69 @@ describe('ConnectModal', () => {
expect(wrapper.text()).toContain('⌘')
})

it('renders a Connect button and bridge note for Claude Desktop', async () => {
;(api.getConnectStatus as any).mockResolvedValue({
success: true,
data: [{
id: 'claude-desktop',
name: 'Claude Desktop',
config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json',
exists: true,
connected: false,
supported: true,
note: 'Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.',
icon: 'claude-desktop',
}],
})

const wrapper = mount(ConnectModal, {
props: { show: false },
global: { plugins: [pinia] },
})

await wrapper.setProps({ show: true })
await flushPromises()

// A real one-click Connect button must be offered (not greyed out).
const connectButton = wrapper.find('button.btn-primary.btn-xs')
expect(connectButton.exists()).toBe(true)
expect(connectButton.text()).toContain('Connect')

// The bridge note must be surfaced to the user.
expect(wrapper.text()).toContain('mcp-remote stdio bridge')
})

it('shows Connect for a bridge client even when its config file does not exist', async () => {
;(api.getConnectStatus as any).mockResolvedValue({
success: true,
data: [{
id: 'claude-desktop',
name: 'Claude Desktop',
config_path: '/Users/test/Library/Application Support/Claude/claude_desktop_config.json',
exists: false,
connected: false,
supported: true,
bridge: true,
note: 'Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.',
icon: 'claude-desktop',
}],
})

const wrapper = mount(ConnectModal, {
props: { show: false },
global: { plugins: [pinia] },
})

await wrapper.setProps({ show: true })
await flushPromises()

// Fresh install: no config file yet, but the bridge Connect must still appear.
const connectButton = wrapper.find('button.btn-primary.btn-xs')
expect(connectButton.exists()).toBe(true)
expect(connectButton.text()).toContain('Connect')
expect(wrapper.text()).not.toContain('Config not found')
})

it('disconnect uses server_name alias when OpenCode status is adopted', async () => {
;(api.getConnectStatus as any).mockResolvedValue({
success: true,
Expand Down
16 changes: 13 additions & 3 deletions internal/connect/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ type ClientDef struct {
Name string // Human-readable name, e.g. "Claude Code"
Format string // File format: "json" or "toml"
ServerKey string // Top-level key for server entries: "mcpServers" or "servers"
Supported bool // Whether this client supports HTTP/SSE transport
Supported bool // Whether this client can be connected (directly or via a bridge)
Reason string // Explanation when Supported is false
Note string // Optional caveat shown for supported clients (e.g. bridge requirement)
Bridge bool // Connects via a stdio bridge; Connect can create the config when absent
Icon string // Icon identifier for frontend use
}

Expand All @@ -34,8 +36,9 @@ var allClients = []ClientDef{
Name: "Claude Desktop",
Format: "json",
ServerKey: "mcpServers",
Supported: false,
Reason: "Claude Desktop only supports stdio transport; HTTP/SSE not available",
Supported: true,
Note: "Connects via an mcp-remote stdio bridge (npx -y mcp-remote). Requires Node.js.",
Bridge: true,
Icon: "claude-desktop",
},
{
Expand Down Expand Up @@ -186,6 +189,13 @@ func buildServerEntry(clientID, mcpURL string) map[string]interface{} {
"type": "http",
"url": mcpURL,
}
case "claude-desktop":
// Claude Desktop only speaks stdio, so bridge to mcpproxy's HTTP
// endpoint with mcp-remote run via npx.
return map[string]interface{}{
"command": "npx",
"args": []string{"-y", "mcp-remote", mcpURL},
}
case "cursor":
return map[string]interface{}{
"url": mcpURL,
Expand Down
37 changes: 36 additions & 1 deletion internal/connect/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ type ClientStatus struct {
ConfigPath string `json:"config_path"`
Exists bool `json:"exists"` // config file exists on disk
Connected bool `json:"connected"` // mcpproxy entry present in config
Supported bool `json:"supported"` // client supports HTTP/SSE
Supported bool `json:"supported"` // client can be connected (directly or via a bridge)
Reason string `json:"reason,omitempty"` // why not supported
Note string `json:"note,omitempty"` // caveat for supported clients (e.g. bridge requirement)
Bridge bool `json:"bridge,omitempty"` // connects via a stdio bridge; connectable even without an existing config
Icon string `json:"icon"`
ServerName string `json:"server_name,omitempty"` // name under which mcpproxy is registered
}
Expand Down Expand Up @@ -119,6 +121,8 @@ func (s *Service) GetAllStatus() []ClientStatus {
ConfigPath: cfgPath,
Supported: c.Supported,
Reason: c.Reason,
Note: c.Note,
Bridge: c.Bridge,
Icon: c.Icon,
}

Expand Down Expand Up @@ -670,6 +674,13 @@ func (s *Service) findEntryJSON(client ClientDef, cfgPath string) (string, bool)
}
}

// Stdio-bridge clients (e.g. Claude Desktop) have no URL field; the
// mcpproxy endpoint lives in the command args. Detect by inspecting
// args so a bridge written under a custom server name is still found.
if entryPointsToBridge(entry, mcpURL, baseURL) {
return name, true
}

// Also match by server name
if name == defaultServerName {
return name, true
Expand All @@ -679,6 +690,30 @@ func (s *Service) findEntryJSON(client ClientDef, cfgPath string) (string, bool)
return "", false
}

// entryPointsToBridge reports whether a JSON config entry is an mcp-remote
// stdio bridge targeting our MCP endpoint, regardless of the entry key.
func entryPointsToBridge(entry map[string]interface{}, mcpURL, baseURL string) bool {
rawArgs, ok := entry["args"].([]interface{})
if !ok {
return false
}
hasBridgePkg := false
pointsToUs := false
for _, a := range rawArgs {
s, ok := a.(string)
if !ok {
continue
}
if s == "mcp-remote" {
hasBridgePkg = true
}
if s == mcpURL || s == baseURL || strings.HasPrefix(s, baseURL+"?") {
pointsToUs = true
}
}
return hasBridgePkg && pointsToUs
}

var trailingCommaPattern = regexp.MustCompile(`,\s*([}\]])`)

func unmarshalLenientJSON(raw []byte, out interface{}) error {
Expand Down
Loading
Loading