Skip to content

Commit 98b2337

Browse files
committed
fix: critical problem in cwd cursor, claude
1 parent ce08510 commit 98b2337

10 files changed

Lines changed: 538 additions & 17 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
---
2+
title: "Cross-Host CWD Sanity Guard for Cursor and Claude Code MCP"
3+
status: accepted
4+
tags:
5+
- "cursor"
6+
- "claude-code"
7+
- "multi-host"
8+
- "plugin"
9+
---
10+
11+
## Idea
12+
13+
Extend the existing Codex cache-cwd guard in `bin/archcore` (Step 0b) with a cross-host sanity check (Step 0c) that refuses to start the archcore MCP server when cwd does not look like a user project root, plus ship a `cursor.mcp.json` template and README guidance so users register the server with `cwd: "${workspaceFolder}"` from the start.
14+
15+
Three cooperating pieces:
16+
17+
1. **`bin/archcore` Step 0c — cross-host sanity guard.** When invoked as `archcore mcp` and the bypass env vars are unset, refuse if cwd is `/`, `$HOME`, `$CLAUDE_PLUGIN_ROOT`, `$CURSOR_PLUGIN_ROOT`, or a directory without any of these project markers: `.git`, `.archcore`, `package.json`, `go.mod`, `pyproject.toml`, `Cargo.toml`, `pom.xml`, `build.gradle`, `build.gradle.kts`. The refusal prints per-host fix instructions (Cursor: add `cwd: "${workspaceFolder}"`; Claude: launch from project dir; Codex: install shell wrapper). $HOME and the plugin-root env vars are compared via `cd "$VAR" && pwd -P` so symlinked install dirs match too.
18+
19+
2. **`cursor.mcp.json` template at the plugin root.** Cursor plugin manifests do not register MCP servers — users configure them in `~/.cursor/mcp.json` or `.cursor/mcp.json`. We ship a canonical snippet with `cwd: "${workspaceFolder}"` *and* `env.ARCHCORE_CWD: "${workspaceFolder}"` (belt-and-braces; the launcher honors `ARCHCORE_CWD` in Step 0 even on hosts that ignore `cwd`).
20+
21+
3. **`bin/archcore` stderr diagnostic.** On every `archcore mcp` start, one line on stderr: `[archcore mcp] cwd=<X> archcore_dir=<X>/.archcore (exists|missing)`. Surfaces in Cursor's MCP server panel and Claude Code's `/mcp` output, so users can verify which project the server actually attached to.
22+
23+
## Value
24+
25+
**Before.** A single global `~/.cursor/mcp.json` entry without `cwd` made archcore stick to whichever workspace Cursor opened first, leaking that project's `.archcore/` into every other project the user opened later. Diagnosis time: hours — the MCP responded successfully with plausible-looking but wrong documents. Same failure class hit Claude Code in multi-repo workspaces and when launched not from a project root.
26+
27+
**After.** Wrong cwd is converted from a silent successful read of the wrong project into a loud refusal with a per-host one-line fix. The diagnostic line on every start gives users a positive confirmation of which project the server is attached to. The template snippet makes the correct setup the obvious copy-paste.
28+
29+
## Possible Implementation
30+
31+
Shipped:
32+
33+
- `bin/archcore`:
34+
- Resolves `ARCHCORE_ALLOW_ANY_CWD` (canonical) with `ARCHCORE_ALLOW_PLUGIN_CWD` (back-compat alias) into `_archcore_allow_any_cwd`. Both Step 0b and Step 0c honor it.
35+
- **Step 0c**: cross-host sanity guard with five refuse conditions (filesystem root, `$HOME`, `$CLAUDE_PLUGIN_ROOT`, `$CURSOR_PLUGIN_ROOT`, no project markers). Refusal message lists the per-host fix and the escape hatch. Step 0b (cache-cwd refusal) remains the first line of defense for Codex and prints the wrapper recipe inline.
36+
- **Diagnostic**: one stderr line per `archcore mcp` start with the resolved cwd and `.archcore/` presence.
37+
- `cursor.mcp.json` at the plugin root: snippet that users copy into their `.cursor/mcp.json` (or `~/.cursor/mcp.json` if pinning with `cwd: "${workspaceFolder}"`).
38+
- `README.md`: new "Cursor: project-scoped MCP setup" block under the Cursor install steps with the snippet, the rationale, and a pointer to the refusal log line.
39+
- `Makefile`: `cursor.mcp.json` added to `JSON_FILES` so `make check-json` validates it.
40+
- `test/unit/launcher.bats`: new cases for Step 0c (HOME refuse, plugin-root refuse, no-marker refuse, `.git`-marker pass, `.archcore`-marker pass, `package.json`-marker pass, `ARCHCORE_CWD`-rebase pass, `ARCHCORE_ALLOW_ANY_CWD` bypass, legacy `ARCHCORE_ALLOW_PLUGIN_CWD` alias still works, diagnostic-log presence/absence). Existing cases adapted by adding `.git` to fake plugin install / user project fixtures and `2>/dev/null` to assertions that pin exact stdout.
41+
- `test/structure/cursor-plugin.bats` (new): pins the `cursor.mcp.json` contract — `cwd: "${workspaceFolder}"` and `env.ARCHCORE_CWD: "${workspaceFolder}"` must both be present; `command` must invoke `archcore` with `args[0] == "mcp"`; README must reference the template.
42+
43+
## Risks and Constraints
44+
45+
- **False positives on minimal projects.** A user opening a brand-new directory before `git init` (no project markers yet) hits the guard. Mitigation: error message includes the escape hatch (`ARCHCORE_ALLOW_ANY_CWD=1`) and lists the markers; the common workflow is `git init` before invoking archcore anyway.
46+
- **Resolved-symlink comparison.** `$HOME`, `$CLAUDE_PLUGIN_ROOT`, `$CURSOR_PLUGIN_ROOT` are all compared via `cd "$VAR" 2>/dev/null && pwd -P` so symlinked install dirs match too. If the env var points at a non-existent path, the check silently skips (no false refusal). Important on macOS where `$HOME=/Users/<u>` but `pwd -P` on `cd $HOME` yields the same — and on Linux distros where `/home/<u>` is symlinked.
47+
- **Stderr noise from the diagnostic.** One line per MCP start. Cursor's MCP server panel and Claude Code's `/mcp` show stderr — that is exactly where users want this. Not visible to chat output.
48+
- **Cross-host neutrality.** Step 0c has no host detection — it refuses based on cwd alone. The fix instructions cover all three hosts. Codex was already protected by Step 0b; Step 0c piggybacks safely (escape hatch + Codex-specific cwd marker `*/plugins/cache/*` already handled in 0b first).
49+
- **`cursor.mcp.json` is a template, not an auto-installed file.** Cursor does not pick it up from the plugin root. Users copy the snippet into their own config. Auto-install would require a host hook (Cursor `sessionStart`) and risk overwriting user configs — rejected for now.
50+
51+
## Verification
52+
53+
A/B reproducer against the launcher (no MCP host required). **Important:** isolate `CLAUDE_PLUGIN_DATA` / `XDG_DATA_HOME` to a scratch dir — otherwise the launcher exec's any locally cached real CLI binary and you end up testing the live server instead of the launcher.
54+
55+
```sh
56+
PLUGIN=/path/to/plugin
57+
TMP=$(mktemp -d)
58+
PETPROJECT="$TMP/pet"
59+
SCRATCH_CACHE="$TMP/scratch-cache"
60+
mkdir -p "$PETPROJECT" "$SCRATCH_CACHE"
61+
(cd "$PETPROJECT" && git init -q && mkdir .archcore)
62+
63+
# Common env: no archcore on PATH, cache directories pointed at scratch (miss),
64+
# ARCHCORE_SKIP_DOWNLOAD=1 so we exit at the download step with a "not cached"
65+
# message instead of fetching from GitHub.
66+
common_env=(env -i HOME="$HOME" PATH=/usr/bin:/bin USER="$USER" LANG=C TERM=xterm
67+
CLAUDE_PLUGIN_DATA="$SCRATCH_CACHE" XDG_DATA_HOME="$SCRATCH_CACHE"
68+
ARCHCORE_SKIP_DOWNLOAD=1)
69+
70+
# 1) BAD cwd ($HOME) -> guard refuses
71+
(cd "$HOME" && "${common_env[@]}" "$PLUGIN/bin/archcore" mcp 2>&1) \
72+
| grep -q "Refusing to start MCP"
73+
74+
# 2) BAD cwd (plain tmpdir, no markers) -> "no project markers"
75+
(cd "$TMP" && "${common_env[@]}" "$PLUGIN/bin/archcore" mcp 2>&1) \
76+
| grep -q "no project markers"
77+
78+
# 3) GOOD cwd (has .git + .archcore) -> guard passes, hits cache miss
79+
(cd "$PETPROJECT" && "${common_env[@]}" "$PLUGIN/bin/archcore" mcp 2>&1) \
80+
| grep -q "not cached"
81+
82+
# 4) ARCHCORE_ALLOW_ANY_CWD=1 lets a bad cwd through
83+
(cd "$HOME" && "${common_env[@]}" ARCHCORE_ALLOW_ANY_CWD=1 "$PLUGIN/bin/archcore" mcp 2>&1) \
84+
| grep -q "not cached"
85+
86+
# 5) Back-compat: legacy ARCHCORE_ALLOW_PLUGIN_CWD=1 alias still works
87+
(cd "$HOME" && "${common_env[@]}" ARCHCORE_ALLOW_PLUGIN_CWD=1 "$PLUGIN/bin/archcore" mcp 2>&1) \
88+
| grep -q "not cached"
89+
90+
# 6) Diagnostic stderr line emitted on every successful mcp start
91+
(cd "$PETPROJECT" && "${common_env[@]}" "$PLUGIN/bin/archcore" mcp 2>&1) \
92+
| grep -qE '\[archcore mcp\] cwd=.*\(exists\)'
93+
94+
rm -rf "$TMP"
95+
```
96+
97+
Bats coverage: `bats test/unit/launcher.bats test/structure/cursor-plugin.bats` exercises the same matrix in isolation (mock archcore + scratch tmpdirs). 42 tests across the two files, all green.

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "archcore",
33
"description": "Make your AI agent code with your project's architecture, rules, and decisions.",
4-
"version": "0.3.14",
4+
"version": "0.3.15",
55
"author": {
66
"name": "Archcore"
77
},

.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "archcore",
33
"description": "Make your AI agent code with your project's architecture, rules, and decisions.",
4-
"version": "0.3.14",
4+
"version": "0.3.15",
55
"author": {
66
"name": "Archcore"
77
},

.cursor-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "archcore",
33
"displayName": "Archcore",
44
"description": "Make your AI agent code with your project's architecture, rules, and decisions.",
5-
"version": "0.3.14",
5+
"version": "0.3.15",
66
"author": {
77
"name": "Archcore"
88
},

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ ALL_SCRIPTS := $(BIN_SCRIPTS) $(LIB_SCRIPTS)
66
JSON_FILES := .claude-plugin/plugin.json .claude-plugin/marketplace.json \
77
.cursor-plugin/plugin.json .cursor-plugin/marketplace.json \
88
.codex-plugin/plugin.json .codex.mcp.json .agents/plugins/marketplace.json \
9-
hooks/hooks.json hooks/cursor.hooks.json hooks/codex.hooks.json .mcp.json
9+
hooks/hooks.json hooks/cursor.hooks.json hooks/codex.hooks.json \
10+
.mcp.json cursor.mcp.json
1011

1112
.PHONY: test test-codex-smoke lint check-json check-perms verify all
1213

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ claude plugin install archcore@archcore-plugins
3232

3333
Cursor reads the repo's `marketplace.json`, shows the plugin, and installs it.
3434

35+
> **Cursor: project-scoped MCP setup (important)**
36+
>
37+
> The Cursor plugin manifest does not register MCP servers (Cursor manages MCP through `~/.cursor/mcp.json` or `.cursor/mcp.json` instead). You configure `archcore` once, in one of those files. **Always pin the working directory** — otherwise a global registration leaks one project's docs into every other project.
38+
>
39+
> Use this snippet (also shipped as [`cursor.mcp.json`](./cursor.mcp.json) at the plugin root):
40+
>
41+
> ```json
42+
> {
43+
> "mcpServers": {
44+
> "archcore": {
45+
> "command": "archcore",
46+
> "args": ["mcp"],
47+
> "cwd": "${workspaceFolder}",
48+
> "env": { "ARCHCORE_CWD": "${workspaceFolder}" }
49+
> }
50+
> }
51+
> }
52+
> ```
53+
>
54+
> - `cwd: "${workspaceFolder}"` — Cursor expands this to the active project root on every server spawn, so each window's MCP attaches to the right `.archcore/`.
55+
> - `env.ARCHCORE_CWD` — belt-and-braces. If Cursor or another host ever fails to honor `cwd` (Claude Code's `cwd` field is silently ignored, [#17565](https://github.com/anthropics/claude-code/issues/17565)), the bundled `bin/archcore` launcher `cd`s to `$ARCHCORE_CWD` before exec.
56+
> - `command: "archcore"` — assumes the CLI is on `PATH` (via `curl install.sh`, `go install`, etc.). To use the launcher bundled with the plugin instead, set `command` to the absolute path of `bin/archcore` inside the installed plugin directory.
57+
>
58+
> The launcher refuses to start the MCP server if cwd looks wrong (`$HOME`, `/`, plugin install dir, or a directory with no project markers) and prints actionable instructions on stderr. If you see `[archcore launcher] Refusing to start MCP …` in Cursor's MCP server panel, the `cwd` field is missing or wrong.
59+
3560
**Codex CLI** — requires Codex CLI v0.117.0+ (March 2026 plugin system):
3661
3762
```bash

bin/archcore

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ if [ -n "${ARCHCORE_CWD:-}" ] && [ -d "$ARCHCORE_CWD" ]; then
5151
cd "$ARCHCORE_CWD" 2>/dev/null || true
5252
fi
5353

54+
# Resolve the bypass flag once. ARCHCORE_ALLOW_ANY_CWD is the canonical name
55+
# covering both legacy Codex cache-cwd guard (0b) and the cross-host sanity
56+
# guard (0c). ARCHCORE_ALLOW_PLUGIN_CWD is kept as a back-compat alias —
57+
# anyone already setting it for the old guard keeps working.
58+
_archcore_allow_any_cwd=0
59+
if [ "${ARCHCORE_ALLOW_ANY_CWD:-0}" = "1" ] || [ "${ARCHCORE_ALLOW_PLUGIN_CWD:-0}" = "1" ]; then
60+
_archcore_allow_any_cwd=1
61+
fi
62+
5463
# --- 0b. Guard: refuse to start MCP from this plugin's install dir ---
5564
#
5665
# Without a shell wrapper that sets ARCHCORE_CWD, Codex spawns the MCP child
@@ -69,10 +78,9 @@ fi
6978
# Step 0 (above) has already cd'd to $ARCHCORE_CWD when set, so reaching
7079
# here with cache cwd means the wrapper was not installed.
7180
#
72-
# Escape hatch: set ARCHCORE_ALLOW_PLUGIN_CWD=1 to bypass (only for plugin
73-
# maintainers running MCP against the plugin's own docs intentionally).
81+
# Escape hatch: ARCHCORE_ALLOW_ANY_CWD=1 (or legacy ARCHCORE_ALLOW_PLUGIN_CWD=1).
7482
if [ "${1:-}" = "mcp" ] \
75-
&& [ "${ARCHCORE_ALLOW_PLUGIN_CWD:-0}" != "1" ] \
83+
&& [ "$_archcore_allow_any_cwd" != "1" ] \
7684
&& [ -f .codex-plugin/plugin.json ] \
7785
&& [ -f .codex.mcp.json ] \
7886
&& [ -f bin/archcore ]
@@ -93,6 +101,91 @@ then
93101
esac
94102
fi
95103

104+
# --- 0c. Cross-host sanity guard: cwd must look like a user project ---
105+
#
106+
# Cursor and Claude Code spawn stdio MCP servers with cwd inherited from the
107+
# host process. That cwd can be wrong in several real-world ways:
108+
# - global ~/.cursor/mcp.json registers archcore without `cwd:
109+
# "${workspaceFolder}"` → first workspace folder leaks into every project
110+
# - Claude Code's `cwd` field in .mcp.json is silently ignored
111+
# (anthropics/claude-code#17565)
112+
# - host launched from $HOME or the plugin install dir
113+
#
114+
# The archcore CLI uses os.Getwd() to find `.archcore/` (no --root flag, no
115+
# env override on CLI side). Silent wrong-cwd = silent wrong project — the
116+
# exact failure mode this guard exists to prevent.
117+
#
118+
# Refuse when:
119+
# - subcommand is `mcp`
120+
# - bypass not set (ARCHCORE_ALLOW_ANY_CWD / ARCHCORE_ALLOW_PLUGIN_CWD)
121+
# - cwd matches one of the hard-bad locations, OR has no project marker
122+
#
123+
# Project markers checked: .git, .archcore, package.json, go.mod,
124+
# pyproject.toml, Cargo.toml, pom.xml, build.gradle, build.gradle.kts.
125+
# Anything else (Makefile, README.md, etc.) is too generic to count.
126+
if [ "${1:-}" = "mcp" ] && [ "$_archcore_allow_any_cwd" != "1" ]; then
127+
_cwd=$(pwd -P)
128+
_refuse=0
129+
_refuse_reason=""
130+
131+
if [ "$_cwd" = "/" ]; then
132+
_refuse=1; _refuse_reason="cwd is the filesystem root (/)"
133+
elif [ -n "${HOME:-}" ] \
134+
&& [ -d "$HOME" ] \
135+
&& [ "$_cwd" = "$(cd "$HOME" 2>/dev/null && pwd -P)" ]; then
136+
_refuse=1; _refuse_reason="cwd is \$HOME ($HOME) — not a project root"
137+
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] \
138+
&& [ -d "$CLAUDE_PLUGIN_ROOT" ] \
139+
&& [ "$_cwd" = "$(cd "$CLAUDE_PLUGIN_ROOT" 2>/dev/null && pwd -P)" ]; then
140+
_refuse=1; _refuse_reason="cwd is \$CLAUDE_PLUGIN_ROOT — the plugin install dir, not your project"
141+
elif [ -n "${CURSOR_PLUGIN_ROOT:-}" ] \
142+
&& [ -d "$CURSOR_PLUGIN_ROOT" ] \
143+
&& [ "$_cwd" = "$(cd "$CURSOR_PLUGIN_ROOT" 2>/dev/null && pwd -P)" ]; then
144+
_refuse=1; _refuse_reason="cwd is \$CURSOR_PLUGIN_ROOT — the plugin install dir, not your project"
145+
elif [ ! -e .git ] \
146+
&& [ ! -d .archcore ] \
147+
&& [ ! -f package.json ] \
148+
&& [ ! -f go.mod ] \
149+
&& [ ! -f pyproject.toml ] \
150+
&& [ ! -f Cargo.toml ] \
151+
&& [ ! -f pom.xml ] \
152+
&& [ ! -f build.gradle ] \
153+
&& [ ! -f build.gradle.kts ]; then
154+
_refuse=1; _refuse_reason="cwd has no project markers (.git, .archcore, package.json, go.mod, pyproject.toml, Cargo.toml, pom.xml, build.gradle)"
155+
fi
156+
157+
if [ "$_refuse" = "1" ]; then
158+
printf '[archcore launcher] Refusing to start MCP — %s.\n' "$_refuse_reason" >&2
159+
printf ' cwd: %s\n' "$_cwd" >&2
160+
printf ' The archcore MCP server reads .archcore/ relative to its cwd. Wrong cwd = wrong project.\n' >&2
161+
printf ' Fix one of:\n' >&2
162+
# shellcheck disable=SC2016 # literal Cursor placeholder ${workspaceFolder}; not a shell var
163+
printf ' Cursor: in .cursor/mcp.json (or ~/.cursor/mcp.json), add "cwd": "${workspaceFolder}" to the archcore entry.\n' >&2
164+
printf ' See README "Cursor: project-scoped MCP setup" for the full snippet.\n' >&2
165+
# shellcheck disable=SC2016 # literal backticks around `claude` / `cwd` for user-facing prose
166+
printf ' Claude: start `claude` from your project directory — Claude Code ignores the `cwd` mcp field.\n' >&2
167+
# shellcheck disable=SC2016 # literal shell snippet; do not expand $PWD/$argv here
168+
printf ' Codex: install the shell wrapper: codex() { ARCHCORE_CWD="$PWD" command codex "$@"; }\n' >&2
169+
printf ' Escape hatch (only if you know cwd is correct): ARCHCORE_ALLOW_ANY_CWD=1\n' >&2
170+
exit 1
171+
fi
172+
fi
173+
174+
# --- Diagnostic: one-line stderr log of the resolved cwd for MCP starts ---
175+
#
176+
# Surfaces in Cursor's MCP server panel and Claude Code's `/mcp` output, so
177+
# users can verify at a glance which project the server actually attached to.
178+
if [ "${1:-}" = "mcp" ]; then
179+
_cwd_log=$(pwd -P)
180+
if [ -d "$_cwd_log/.archcore" ]; then
181+
_archcore_state="exists"
182+
else
183+
_archcore_state="missing"
184+
fi
185+
printf '[archcore mcp] cwd=%s archcore_dir=%s/.archcore (%s)\n' \
186+
"$_cwd_log" "$_cwd_log" "$_archcore_state" >&2
187+
fi
188+
96189
# --- 1. $ARCHCORE_BIN override ---
97190
if [ -n "${ARCHCORE_BIN:-}" ] && [ -x "$ARCHCORE_BIN" ]; then
98191
exec "$ARCHCORE_BIN" "$@"

cursor.mcp.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"mcpServers": {
3+
"archcore": {
4+
"command": "archcore",
5+
"args": ["mcp"],
6+
"cwd": "${workspaceFolder}",
7+
"env": {
8+
"ARCHCORE_CWD": "${workspaceFolder}"
9+
}
10+
}
11+
}
12+
}

0 commit comments

Comments
 (0)