Skip to content

Commit 5997a11

Browse files
committed
feat(gateway): remember workspace pins per mcp-remote process
Cursor leaves ${workspaceFolder} unsubstituted on many mcp-remote spawns, so a session arrives with an empty header and no way to resolve a route. A manual mcpmux_set_workspace_root fixed only that one session, so every Reload MCP cost another call. Pins now promote to a window keyed on the owning process, letting later sessions from the same mcp-remote inherit the claim. Inheritance is re-validated against that session's own open folder set, and pins for dead processes are evicted on read. Pin logs name their source so a trace can tell a working header from a manual recovery, and window-pin writes log once per claim instead of once per request. The bridge snippet and probe recipe inline the access key, dropping a second substitution that could silently expand to empty. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 934b0f2 commit 5997a11

17 files changed

Lines changed: 1136 additions & 79 deletions

Cargo.lock

Lines changed: 144 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
11
/** Default API-key client name for the global Cursor mcp-remote bridge. */
22
export const CURSOR_BRIDGE_CLIENT_NAME = 'cursor-global-bridge';
33

4+
const DEFAULT_LOOPBACK_PORT = '45818';
5+
6+
/**
7+
* Resolve the MCP URL a local `mcp-remote` child should hit.
8+
*
9+
* The Cursor bridge is stdio on the same machine as the gateway. An advertised
10+
* public/tunnel URL (Cloudflare Access) 403s because `mcp-remote` has no Access
11+
* cookies. Non-loopback inputs fall back to `127.0.0.1:45818`; a loopback input
12+
* keeps its port so a custom local bind still pastes correctly.
13+
*/
14+
export function localMcpUrlForCursorBridge(gatewayUrl: string): string {
15+
const trimmed = gatewayUrl.trim().replace(/\/$/, '');
16+
try {
17+
const parsed = new URL(trimmed.includes('://') ? trimmed : `http://${trimmed}`);
18+
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
19+
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
20+
if (isLoopback) {
21+
const port = parsed.port || DEFAULT_LOOPBACK_PORT;
22+
return `http://127.0.0.1:${port}/mcp`;
23+
}
24+
} catch {
25+
// advertised URL or junk — use the default local bind
26+
}
27+
return `http://127.0.0.1:${DEFAULT_LOOPBACK_PORT}/mcp`;
28+
}
29+
430
/**
531
* Build the `~/.cursor/mcp.json` snippet for the global mcp-remote bridge.
632
*
33+
* Always targets loopback (see `localMcpUrlForCursorBridge`) so the snippet is
34+
* paste-ready even when the UI is advertising a Cloudflare tunnel.
35+
*
736
* Cursor resolves `${workspaceFolder}` in `args` at spawn time, so one global
837
* entry routes each window to the correct workspace header. That substitution
938
* is unreliable (measured at ~21% failure across 282 spawns), and when it
@@ -12,9 +41,17 @@ export const CURSOR_BRIDGE_CLIENT_NAME = 'cursor-global-bridge';
1241
* `mcp-remote`, which expands it from the child environment — giving the
1342
* gateway the window's full folder set even when the active folder is missing.
1443
* The set constrains which root the session may claim; it does not pick one.
44+
*
45+
* The key is inlined rather than referenced through `env.MCPMUX_API_KEY`. Both
46+
* live in this one file at the same permissions, so the indirection bought no
47+
* secrecy — only exposure to the same substitution flake: a Cursor MCP respawn
48+
* was observed sending the literal `${MCPMUX_API_KEY}`, which the gateway
49+
* correctly 401s while the client sits on a connection timeout. The workspace
50+
* variables above have to stay variables because they differ per window; a
51+
* constant does not.
1552
*/
1653
export function buildCursorBridgeMcpJson(apiKey: string, gatewayUrl: string): string {
17-
const mcpUrl = `${gatewayUrl.replace(/\/$/, '')}/mcp`;
54+
const mcpUrl = localMcpUrlForCursorBridge(gatewayUrl);
1855
const config = {
1956
mcpServers: {
2057
mcpmux: {
@@ -29,9 +66,8 @@ export function buildCursorBridgeMcpJson(apiKey: string, gatewayUrl: string): st
2966
'--header',
3067
'X-Mcpmux-Workspace-Set:${WORKSPACE_FOLDER_PATHS}',
3168
'--header',
32-
'Authorization:Bearer ${MCPMUX_API_KEY}',
69+
`Authorization:Bearer ${apiKey}`,
3370
],
34-
env: { MCPMUX_API_KEY: apiKey },
3571
},
3672
},
3773
};

apps/desktop/src/locales/en/clients.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,6 @@
207207
"copy": "Copy config",
208208
"copied": "Copied",
209209
"keyOnceWarning": "The API key is shown only in this snippet. Store it in ~/.cursor/mcp.json now — McpMux cannot display it again.",
210-
"fallbackNote": "Per-repo install via Workspaces remains available if you prefer not to use npx/mcp-remote."
210+
"fallbackNote": "The snippet always targets http://127.0.0.1 (local gateway), even when Connections shows a public tunnel. Per-repo install via Workspaces remains available if you prefer not to use npx/mcp-remote."
211211
}
212212
}

crates/mcpmux-gateway/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ uuid.workspace = true
4949
chrono.workspace = true
5050
dashmap = "6.1"
5151
parking_lot = "0.12"
52+
netstat2 = "0.11"
5253

5354
# Crypto for OAuth PKCE
5455
base64 = "0.22"
@@ -73,6 +74,9 @@ oauth2 = "5"
7374
mcpmux-core.workspace = true
7475
mcpmux-storage.workspace = true
7576

77+
[target.'cfg(unix)'.dependencies]
78+
libc = "0.2"
79+
7680
[dev-dependencies]
7781
tokio = { workspace = true, features = ["test-util", "macros"] }
7882
tracing-test = "0.2"

0 commit comments

Comments
 (0)