Skip to content

Commit 1e4cecf

Browse files
committed
chore(scripts): promote Cursor env-probe to a tracked diagnostic
Move the throwaway .scratch wrapper into scripts/cursor-env-probe.mjs so mcp-remote still execs after logging argv/env/pwd, plus a summarizer that reprints the Aug 20 substitution cuts. pnpm probe:cursor-env prints the mcp.json swap; probe:cursor-env:summary reads $HOME/Desktop/mcpmux-env-probe.log. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 0c1503b commit 1e4cecf

6 files changed

Lines changed: 478 additions & 3 deletions

File tree

docs/manual/cursor-workspace-bridge.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,25 @@ writes a literal path into `.cursor/mcp.json` with no variable to substitute,
126126
which is why it never flakes. Note that it also writes the bearer token into a
127127
file inside the repo and does not add a `.gitignore` entry, so exclude it
128128
yourself before committing.
129+
130+
## How to re-measure
131+
132+
The 21% / 29% / 4% figures above came from a 282-spawn `env-probe` wrap of
133+
`mcp-remote`. Re-run that after a Cursor update with the committed scripts:
134+
135+
1. `pnpm probe:cursor-env` prints a `~/.cursor/mcp.json` snippet. `command` is
136+
`node`; the first arg is the absolute path to
137+
[`scripts/cursor-env-probe.mjs`](../../scripts/cursor-env-probe.mjs). Paste
138+
it over the existing `mcpmux` entry (keep your real `MCPMUX_API_KEY`).
139+
2. Reload MCP. Use editor and Agents windows until you have hundreds of
140+
spawns. Each spawn appends one record to
141+
`$HOME/Desktop/mcpmux-env-probe.log` (override with `MCPMUX_ENV_PROBE_LOG`),
142+
then execs `mcp-remote` so the session still works.
143+
3. `pnpm probe:cursor-env:summary` reprints the Aug 20 cuts: unresolved rate
144+
overall and by `CURSOR_AGENT`, folder-count histogram, membership vs
145+
`WFP[0]` on resolved multi-folder spawns, unexpanded
146+
`${WORKSPACE_FOLDER_PATHS}` count.
147+
4. Restore the generated bridge config (Connections → Global Cursor setup).
148+
149+
The wrapper logs argv/env/pwd only. It does not contain tokens from the child
150+
stdio. The Desktop log path is outside the repo; do not copy it in.

docs/planning/cursor-workspace-routing-bridge.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
# Cursor Workspace Routing via Global `mcp-remote` Bridge
22

33
**Last Updated:** Aug 20, 2026
4-
**Status:** Complete (Phases 1–3) — Agents Window spike **done**; multi-root ambiguity gate covers resolver + bind + list_servers note. Non-empty header without session id is **held then pinned** (`dcc2977`). Empty `${workspaceFolder}` **measured and bounded** (`efabe48`); the residual ~16% is inherent, not a gap.
4+
**Status:** Complete (Phases 1–3) — Agents Window spike **done**; multi-root ambiguity gate covers resolver + bind + list_servers note. Non-empty header without session id is **held then pinned** (`dcc2977`). Empty `${workspaceFolder}` **measured and bounded** (`efabe48`); the residual ~16% is inherent, not a gap. Re-measure via `pnpm probe:cursor-env` / `pnpm probe:cursor-env:summary`.
55
**Branch:** `dev-rebased`
66

77
### Resolved question (Aug 20, 2026) — supersedes the Aug 14 open question
88

99
The Aug 14 entry asked when and why Cursor spawns an `mcp-remote` child without
1010
resolving `${workspaceFolder}`, and assumed the Agents window was responsible. A
11-
282-spawn `env-probe` wrapper answered it, and the assumption was wrong:
11+
282-spawn env-probe wrapper answered it, and the assumption was wrong. Re-run
12+
via [`scripts/cursor-env-probe.mjs`](../../scripts/cursor-env-probe.mjs) and
13+
[`scripts/cursor-env-probe-summary.mjs`](../../scripts/cursor-env-probe-summary.mjs)
14+
(`pnpm probe:cursor-env` / `pnpm probe:cursor-env:summary`; recipe in
15+
[`cursor-workspace-bridge.md`](../manual/cursor-workspace-bridge.md) How to
16+
re-measure):
1217

1318
- **Failure is ~21% overall and worse in editor windows** (29%) than Agents
1419
windows (4%). It happens at every folder count from zero to five. This is a
@@ -231,6 +236,8 @@ Removes the "hand-assemble JSON" friction so the bridge is actually usable by so
231236
| [`crates/mcpmux-gateway/src/mcp/oauth_middleware.rs`](../../crates/mcpmux-gateway/src/mcp/oauth_middleware.rs) | `→ MCP` logs `session_id` + `workspace_header`; non-empty header without sid is held then pinned; empty header still warn-skips. Reads the set header with the same hold-then-apply, and warns when either header arrives as an unexpanded `${…}` template |
232237
| [`crates/mcpmux-gateway/src/services/meta_tools/set_workspace_root.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/set_workspace_root.rs) | Refuses a declared root that isn't in the caller's folder set — closes the self-service grant where any approved client could name any path |
233238
| [`apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts`](../../apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts) | Emits `X-Mcpmux-Workspace-Set:${WORKSPACE_FOLDER_PATHS}` alongside the active-folder header |
239+
| [`scripts/cursor-env-probe.mjs`](../../scripts/cursor-env-probe.mjs) | Drop-in Node wrapper that logs argv/env/pwd then execs `mcp-remote`; `pnpm probe:cursor-env` prints the mcp.json swap |
240+
| [`scripts/cursor-env-probe-summary.mjs`](../../scripts/cursor-env-probe-summary.mjs) | Reprints the Aug 20 study cuts from `$HOME/Desktop/mcpmux-env-probe.log` |
234241
| [`crates/mcpmux-gateway/src/mcp/handler.rs`](../../crates/mcpmux-gateway/src/mcp/handler.rs) | Resolver resolved log includes `workspace_root` |
235242
| [`crates/mcpmux-gateway/src/services/feature_set_resolver.rs`](../../crates/mcpmux-gateway/src/services/feature_set_resolver.rs) | Multi-root ambiguity → `PendingRoots` when `get()` returns >1 root (no pin) |
236243
| [`crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs`](../../crates/mcpmux-gateway/src/services/meta_tools/bind_workspace.rs) | Same multi-root gate on bind; fat recoverable error + pre-approval info log |

docs/planning/resilience-routing-leftovers.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ None of these are being implemented in this pass. Each entry is enough to start
3131

3232
### 1. Empty `${workspaceFolder}`**partially shipped (`efabe48`)**
3333

34-
The spawn-path repro this entry asked for is done. An `env-probe` wrapper captured 282 real `mcp-remote` spawns and settled the open questions:
34+
The spawn-path repro this entry asked for is done. An env-probe wrapper captured 282 real `mcp-remote` spawns and settled the open questions. That wrapper is now
35+
[`scripts/cursor-env-probe.mjs`](../../scripts/cursor-env-probe.mjs) plus
36+
[`scripts/cursor-env-probe-summary.mjs`](../../scripts/cursor-env-probe-summary.mjs)
37+
(`pnpm probe:cursor-env` / `pnpm probe:cursor-env:summary`). Recipe:
38+
[`cursor-workspace-bridge.md`](../manual/cursor-workspace-bridge.md) How to re-measure.
3539

3640
- **Not an Agents-window problem.** Editor windows fail to substitute `${workspaceFolder}` 29% of the time, Agents windows 4%, across folder counts from zero to five. Overall failure is ~21%. The old attribution in this doc and in the `oauth_middleware` warn was wrong; both are corrected.
3741
- **The literal reaches `mcp-remote`, which expands it to empty.** Cursor passes `${workspaceFolder}` through unsubstituted; `mcp-remote`'s own `${ENV}` pass finds no such variable and rewrites it to an empty string. That is why the gateway sees present-but-empty rather than a literal.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"build:web:admin": "pnpm --filter @mcpmux/desktop build:web:admin",
1919
"release:key": "pnpm --filter @mcpmux/desktop tauri signer generate -w ~/.tauri/mcpmux.key",
2020
"remote:smoke": "node scripts/remote-gateway-smoke.mjs",
21+
"probe:cursor-env": "node scripts/cursor-env-probe.mjs --help",
22+
"probe:cursor-env:summary": "node scripts/cursor-env-probe-summary.mjs",
2123
"count-tokens": "cargo test -p mcpmux-gateway meta_tools_token_budget_report -- --nocapture && python3 scripts/count-meta-tool-tokens.py",
2224
"test": "pnpm test:rust && pnpm test:ts",
2325
"test:rust": "cargo nextest run --workspace",
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Summarize a cursor-env-probe log using the Aug 20, 2026 study cuts.
4+
*
5+
* Usage:
6+
* pnpm probe:cursor-env:summary
7+
* node scripts/cursor-env-probe-summary.mjs [log-path]
8+
* node scripts/cursor-env-probe-summary.mjs --self-check
9+
*
10+
* Default log: $HOME/Desktop/mcpmux-env-probe.log
11+
* Override: MCPMUX_ENV_PROBE_LOG or the first positional arg
12+
*/
13+
14+
import { readFileSync } from 'node:fs';
15+
import os from 'node:os';
16+
import path from 'node:path';
17+
import { fileURLToPath } from 'node:url';
18+
19+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
20+
21+
/**
22+
* Default probe log path (same as the wrapper).
23+
* @returns {string}
24+
*/
25+
export function defaultProbeLogPath() {
26+
return (
27+
process.env.MCPMUX_ENV_PROBE_LOG || path.join(os.homedir(), 'Desktop', 'mcpmux-env-probe.log')
28+
);
29+
}
30+
31+
/**
32+
* @typedef {{
33+
* argv: string[],
34+
* env: Record<string, string>,
35+
* workspaceHeader: string | null,
36+
* isAgent: boolean,
37+
* folderSet: string[],
38+
* }} ProbeSpawn
39+
*/
40+
41+
/**
42+
* Parse one or more `=== … ===` records from a probe log.
43+
* @param {string} text
44+
* @returns {ProbeSpawn[]}
45+
*/
46+
export function parseProbeLog(text) {
47+
const blocks = text.split(/^=== /m).filter((block) => block.trim());
48+
return blocks.map(parseBlock);
49+
}
50+
51+
/**
52+
* @param {string} block
53+
* @returns {ProbeSpawn}
54+
*/
55+
function parseBlock(block) {
56+
const argv = [];
57+
/** @type {Record<string, string>} */
58+
const env = {};
59+
let section = '';
60+
for (const line of block.split('\n')) {
61+
if (line === '--- argv ---') {
62+
section = 'argv';
63+
continue;
64+
}
65+
if (line === '--- env (sorted) ---') {
66+
section = 'env';
67+
continue;
68+
}
69+
if (line === '--- pwd ---') {
70+
section = 'pwd';
71+
continue;
72+
}
73+
if (section === 'argv') {
74+
const match = line.match(/^\[(\d+)\] (.*)$/);
75+
if (match) argv[Number(match[1])] = match[2];
76+
} else if (section === 'env') {
77+
const eq = line.indexOf('=');
78+
if (eq > 0) env[line.slice(0, eq)] = line.slice(eq + 1);
79+
}
80+
}
81+
return {
82+
argv,
83+
env,
84+
workspaceHeader: extractWorkspaceHeader(argv),
85+
isAgent: env.CURSOR_AGENT === '1',
86+
folderSet: parseFolderSet(env.WORKSPACE_FOLDER_PATHS ?? ''),
87+
};
88+
}
89+
90+
/**
91+
* Active-folder value from the workspace header, or a legacy argv[0] path.
92+
* @param {string[]} argv
93+
* @returns {string | null}
94+
*/
95+
export function extractWorkspaceHeader(argv) {
96+
for (const arg of argv) {
97+
if (arg?.startsWith('X-Mcpmux-Workspace:') && !arg.startsWith('X-Mcpmux-Workspace-Set:')) {
98+
return arg.slice('X-Mcpmux-Workspace:'.length);
99+
}
100+
}
101+
const first = argv[0];
102+
if (!first) return null;
103+
if (first === '${workspaceFolder}' || first.startsWith('/') || first.startsWith('${')) {
104+
return first;
105+
}
106+
return /^[A-Za-z]:[\\/]/.test(first) ? first : null;
107+
}
108+
109+
/**
110+
* Split Cursor's comma-separated folder set, dropping unexpanded templates.
111+
* @param {string} raw
112+
* @returns {string[]}
113+
*/
114+
export function parseFolderSet(raw) {
115+
return raw
116+
.split(',')
117+
.map((entry) => entry.trim())
118+
.filter((entry) => entry && !entry.includes('${'));
119+
}
120+
121+
/**
122+
* @param {string | null} header
123+
* @returns {boolean}
124+
*/
125+
function isUnresolvedWorkspace(header) {
126+
return header === '${workspaceFolder}' || header === '' || header == null;
127+
}
128+
129+
/**
130+
* @typedef {{
131+
* spawns: number,
132+
* unresolved: number,
133+
* resolved: number,
134+
* editorSpawns: number,
135+
* editorUnresolved: number,
136+
* agentSpawns: number,
137+
* agentUnresolved: number,
138+
* folderCounts: Record<string, number>,
139+
* multiResolved: number,
140+
* memberOfSet: number,
141+
* firstOfSet: number,
142+
* unexpandedSet: number,
143+
* }} ProbeSummary
144+
*/
145+
146+
/**
147+
* Compute the Aug 20 study cuts from parsed spawns.
148+
* @param {ProbeSpawn[]} spawns
149+
* @returns {ProbeSummary}
150+
*/
151+
export function summarizeSpawns(spawns) {
152+
/** @type {ProbeSummary} */
153+
const summary = {
154+
spawns: spawns.length,
155+
unresolved: 0,
156+
resolved: 0,
157+
editorSpawns: 0,
158+
editorUnresolved: 0,
159+
agentSpawns: 0,
160+
agentUnresolved: 0,
161+
folderCounts: {},
162+
multiResolved: 0,
163+
memberOfSet: 0,
164+
firstOfSet: 0,
165+
unexpandedSet: 0,
166+
};
167+
168+
for (const spawn of spawns) {
169+
const unresolved = isUnresolvedWorkspace(spawn.workspaceHeader);
170+
if (unresolved) summary.unresolved += 1;
171+
else summary.resolved += 1;
172+
173+
if (spawn.isAgent) {
174+
summary.agentSpawns += 1;
175+
if (unresolved) summary.agentUnresolved += 1;
176+
} else {
177+
summary.editorSpawns += 1;
178+
if (unresolved) summary.editorUnresolved += 1;
179+
}
180+
181+
const rawSet = spawn.env.WORKSPACE_FOLDER_PATHS ?? '';
182+
if (rawSet.includes('${')) summary.unexpandedSet += 1;
183+
184+
const n = spawn.folderSet.length;
185+
const key = String(n);
186+
summary.folderCounts[key] = (summary.folderCounts[key] ?? 0) + 1;
187+
188+
if (!unresolved && n > 1 && spawn.workspaceHeader) {
189+
summary.multiResolved += 1;
190+
const active = spawn.workspaceHeader;
191+
if (spawn.folderSet.includes(active)) summary.memberOfSet += 1;
192+
if (spawn.folderSet[0] === active) summary.firstOfSet += 1;
193+
}
194+
}
195+
196+
return summary;
197+
}
198+
199+
/**
200+
* @param {number} part
201+
* @param {number} whole
202+
* @returns {string}
203+
*/
204+
function pct(part, whole) {
205+
if (whole === 0) return 'n/a';
206+
return `${((100 * part) / whole).toFixed(1)}%`;
207+
}
208+
209+
/**
210+
* Print a human-readable summary.
211+
* @param {ProbeSummary} summary
212+
* @returns {string}
213+
*/
214+
export function formatSummary(summary) {
215+
const counts = Object.entries(summary.folderCounts)
216+
.sort(([a], [b]) => Number(a) - Number(b))
217+
.map(([n, c]) => ` ${n} folders: ${c}`)
218+
.join('\n');
219+
return [
220+
`spawns: ${summary.spawns}`,
221+
`${'${workspaceFolder}'} unresolved: ${summary.unresolved} (${pct(summary.unresolved, summary.spawns)})`,
222+
`resolved: ${summary.resolved}`,
223+
`editor: ${summary.editorUnresolved}/${summary.editorSpawns} unresolved (${pct(summary.editorUnresolved, summary.editorSpawns)})`,
224+
`agent (CURSOR_AGENT): ${summary.agentUnresolved}/${summary.agentSpawns} unresolved (${pct(summary.agentUnresolved, summary.agentSpawns)})`,
225+
`folder-count histogram:`,
226+
counts || ' (none)',
227+
`multi-folder resolved: ${summary.multiResolved}`,
228+
` active in set: ${summary.memberOfSet} (${pct(summary.memberOfSet, summary.multiResolved)})`,
229+
` active == WFP[0]: ${summary.firstOfSet} (${pct(summary.firstOfSet, summary.multiResolved)})`,
230+
`unexpanded WORKSPACE_FOLDER_PATHS: ${summary.unexpandedSet}`,
231+
].join('\n');
232+
}
233+
234+
/**
235+
* Tiny fixture that fails if the parser or cuts regress.
236+
* @returns {void}
237+
*/
238+
export function selfCheck() {
239+
const fixture = `=== 2026-08-20T00:00:00.000Z ===
240+
--- argv ---
241+
[0] -y
242+
[1] mcp-remote
243+
[2] --header
244+
[3] X-Mcpmux-Workspace:/repos/alpha
245+
--- env (sorted) ---
246+
CURSOR_AGENT=1
247+
WORKSPACE_FOLDER_PATHS=/repos/alpha,/repos/beta
248+
--- pwd ---
249+
/Users/joe
250+
251+
=== 2026-08-20T00:00:01.000Z ===
252+
--- argv ---
253+
[0] -y
254+
[1] --header
255+
[2] X-Mcpmux-Workspace:\${workspaceFolder}
256+
--- env (sorted) ---
257+
WORKSPACE_FOLDER_PATHS=/repos/alpha,/repos/beta
258+
--- pwd ---
259+
/Users/joe
260+
261+
=== 2026-08-20T00:00:02.000Z ===
262+
--- argv ---
263+
[0] \${workspaceFolder}
264+
--- env (sorted) ---
265+
WORKSPACE_FOLDER_PATHS=\${WORKSPACE_FOLDER_PATHS}
266+
--- pwd ---
267+
/Users/joe
268+
`;
269+
const summary = summarizeSpawns(parseProbeLog(fixture));
270+
const checks = [
271+
summary.spawns === 3,
272+
summary.unresolved === 2,
273+
summary.resolved === 1,
274+
summary.agentSpawns === 1 && summary.agentUnresolved === 0,
275+
summary.editorSpawns === 2 && summary.editorUnresolved === 2,
276+
summary.multiResolved === 1 && summary.memberOfSet === 1 && summary.firstOfSet === 1,
277+
summary.unexpandedSet === 1,
278+
];
279+
if (checks.some((ok) => !ok)) {
280+
console.error('self-check failed\n', formatSummary(summary));
281+
process.exit(1);
282+
}
283+
console.log('self-check ok');
284+
}
285+
286+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === SCRIPT_PATH;
287+
if (isMain) {
288+
const args = process.argv.slice(2);
289+
if (args[0] === '--self-check') {
290+
selfCheck();
291+
process.exit(0);
292+
}
293+
const logPath = args[0] || defaultProbeLogPath();
294+
let text;
295+
try {
296+
text = readFileSync(logPath, 'utf8');
297+
} catch (err) {
298+
console.error(`cursor-env-probe-summary: cannot read ${logPath}: ${err.message}`);
299+
process.exit(1);
300+
}
301+
console.log(`log: ${logPath}`);
302+
console.log(formatSummary(summarizeSpawns(parseProbeLog(text))));
303+
}

0 commit comments

Comments
 (0)