From 3c1f9b6b5faf6763011c7cf760a8578711b06af4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 17:22:22 +0000 Subject: [PATCH] Make Cloud MCP spawn find search_docs from /agent Cloud stdio cwd is /agent and /agent/.cursor is not writable, so python3 -u .cursor/mcp-diffusers-docs.py never started. mcp.json now boots with python3 -c and globs /agent/repos/*/.cursor/mcp-diffusers-docs.py. Allowlist python3 (mcp.json) and diffusers-docs-mcp (PATH shim). Co-authored-by: ale93.moro --- .cursor/environment.json | 4 ++ .cursor/install-docs-mcp.sh | 27 ++++--------- .cursor/mcp.json | 6 ++- .cursor/mcp_stdio_boot.py | 43 ++++++++++++++++++++ Makefile | 8 +++- docs/LIVE_DEMO.md | 2 +- overlay/OVERLAY.md | 9 +++-- overlay/environment.json | 4 ++ overlay/mcp.json | 6 ++- overlay/mcp.optional.json | 6 ++- tests/test_tooling.py | 78 ++++++++++++++++++++++++++++++++++--- tools/attach_library.py | 7 +++- 12 files changed, 165 insertions(+), 35 deletions(-) create mode 100755 .cursor/mcp_stdio_boot.py diff --git a/.cursor/environment.json b/.cursor/environment.json index 879e7b9..21d972b 100644 --- a/.cursor/environment.json +++ b/.cursor/environment.json @@ -7,6 +7,10 @@ "mcpServerAllowlist": [ { "name": "diffusers-docs", + "command": "python3" + }, + { + "name": "diffusers-docs-mcp", "command": "diffusers-docs-mcp" } ] diff --git a/.cursor/install-docs-mcp.sh b/.cursor/install-docs-mcp.sh index 818e80c..0cf1f08 100755 --- a/.cursor/install-docs-mcp.sh +++ b/.cursor/install-docs-mcp.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash -# Put `diffusers-docs-mcp` on PATH and copy the relative launcher into Cloud -# workspace roots so `python3 -u .cursor/mcp-diffusers-docs.py` resolves when -# stdio is spawned from /agent or /workspace (not the git repo). +# Put `diffusers-docs-mcp` on PATH. Do not rely on copying into /agent/.cursor +# — that directory is root-owned on Cloud. mcp.json boots via python3 -c and +# globs /agent/repos/*/.cursor/mcp-diffusers-docs.py instead. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" SRC="$ROOT/.cursor/mcp-diffusers-docs.py" chmod +x "$SRC" "$ROOT/.cursor/mcp-diffusers-docs.sh" "$ROOT/.cursor/install-docs-mcp.sh" +chmod +x "$ROOT/.cursor/mcp_stdio_boot.py" 2>/dev/null || true write_shim() { local dest="$1" @@ -15,13 +16,7 @@ write_shim() { exec python3 -u "$SRC" --serve "\$@" EOF chmod +x "$dest" -} - -copy_workspace_launcher() { - local dest_dir="$1" - mkdir -p "$dest_dir" 2>/dev/null || return 0 - cp "$SRC" "$dest_dir/mcp-diffusers-docs.py" 2>/dev/null || return 0 - chmod +x "$dest_dir/mcp-diffusers-docs.py" 2>/dev/null || true + echo "PATH shim: $dest" } write_shim "$HOME/.local/bin/diffusers-docs-mcp" @@ -32,17 +27,11 @@ for rc in "$HOME/.profile" "$HOME/.bashrc"; do fi echo "$path_line" >> "$rc" done -if mkdir -p /usr/local/bin 2>/dev/null && [ -w /usr/local/bin ]; then +if [ -d /usr/local/bin ] && [ -w /usr/local/bin ]; then write_shim /usr/local/bin/diffusers-docs-mcp || true fi -for ws in /agent /workspace "${CURSOR_WORKSPACE:-}" "${CURSOR_PROJECT_DIR:-}"; do - if [ -n "$ws" ] && [ -d "$ws" ]; then - copy_workspace_launcher "$ws/.cursor" || true - fi -done - echo "docs MCP launcher: $SRC" echo "PATH command: ${HOME}/.local/bin/diffusers-docs-mcp" -echo "Cloud dropdown: diffusers-docs-mcp" -echo " (or: python3 -u .cursor/mcp-diffusers-docs.py)" +echo "Cloud dropdown: python3 (mcp.json -c glob) or diffusers-docs-mcp" +echo "Allowlist both python3 (mcp.json) and diffusers-docs-mcp (PATH shim)" diff --git a/.cursor/mcp.json b/.cursor/mcp.json index cbf2bc8..2fe85d6 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -3,7 +3,11 @@ "diffusers-docs": { "type": "stdio", "command": "python3", - "args": ["-u", ".cursor/mcp-diffusers-docs.py"], + "args": [ + "-u", + "-c", + "import os, sys; from pathlib import Path; opts = [Path('.cursor/mcp-diffusers-docs.py')]; repos = Path('/agent/repos');\nopts.extend(repos.glob('*/.cursor/mcp-diffusers-docs.py')) if repos.is_dir() else None; opts.extend(repos.glob('*/ramp-kit/.cursor/mcp-diffusers-docs.py')) if repos.is_dir() else None\nfor p in opts:\n if p.is_file():\n os.execv(sys.executable, [sys.executable, '-u', str(p.resolve()), '--serve'])\nsys.exit('diffusers-docs MCP: launcher not found')" + ], "env": { "PYTHONUNBUFFERED": "1" } diff --git a/.cursor/mcp_stdio_boot.py b/.cursor/mcp_stdio_boot.py new file mode 100755 index 0000000..9b0fbeb --- /dev/null +++ b/.cursor/mcp_stdio_boot.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Cwd-independent stdio entry for diffusers-docs MCP. + +Cloud Agent stdio is spawned from `/agent` (not the git repo) and cannot +create `/agent/.cursor`. Desktop Cursor uses the repo root as cwd. This +file is the `python3 -c` body in `.cursor/mcp.json` (keep them in sync) +and is also runnable as `python3 -u .cursor/mcp_stdio_boot.py`. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def _launchers(): + yield Path(".cursor") / "mcp-diffusers-docs.py" + repos = Path("/agent/repos") + if repos.is_dir(): + yield from repos.glob("*/.cursor/mcp-diffusers-docs.py") + yield from repos.glob("*/ramp-kit/.cursor/mcp-diffusers-docs.py") + here = Path(__file__).resolve().parent / "mcp-diffusers-docs.py" + yield here + + +def main() -> int: + extra = [a for a in sys.argv[1:] if a != "--serve"] + for raw in _launchers(): + path = Path(raw) + if path.is_file(): + os.execv( + sys.executable, + [sys.executable, "-u", str(path.resolve()), "--serve", *extra], + ) + sys.stderr.write( + "diffusers-docs MCP: launcher not found " + f"(cwd={Path.cwd()} file={Path(__file__).resolve() if '__file__' in dir() else 'stdin'}).\n" + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Makefile b/Makefile index ceaecfe..8c6f923 100644 --- a/Makefile +++ b/Makefile @@ -35,10 +35,14 @@ doctor: @test -f tools/attach_library.py && test -f overlay/OVERLAY.md \ && test -f overlay/mcp.json && test -f overlay/mcp.optional.json \ || (echo "Missing overlay attach tooling"; exit 1) - @$(PYTHON) -c "import json, pathlib; c=json.loads(pathlib.Path('overlay/mcp.json').read_text()); s=c['mcpServers']; assert 'diffusers-docs' in s and 'huggingface' not in s, c; assert s['diffusers-docs']['args']==['-u','.cursor/mcp-diffusers-docs.py'], s" \ - || (echo "overlay/mcp.json must be stdio diffusers-docs only (no Hub HTTP)"; exit 1) + @$(PYTHON) -c "import json, pathlib; c=json.loads(pathlib.Path('overlay/mcp.json').read_text()); s=c['mcpServers']; assert 'diffusers-docs' in s and 'huggingface' not in s, c; d=s['diffusers-docs']; assert d['command']=='python3' and '-c' in d['args'] and 'mcp-diffusers-docs.py' in d['args'][-1], d" \ + || (echo "overlay/mcp.json must be cwd-independent python3 -c stdio (no Hub HTTP)"; exit 1) @$(PYTHON) -c "import json, pathlib; s=json.loads(pathlib.Path('overlay/mcp.optional.json').read_text())['mcpServers']; assert 'diffusers-docs' in s and 'huggingface' in s, s" \ || (echo "overlay/mcp.optional.json must list opt-in servers"; exit 1) + @$(PYTHON) -c "import json,pathlib; cmds={e['command'] for e in json.loads(pathlib.Path('.cursor/environment.json').read_text())['mcpServerAllowlist']}; assert {'python3','diffusers-docs-mcp'} <= cmds, cmds" \ + || (echo "kit environment.json allowlist must include python3 and diffusers-docs-mcp"; exit 1) + @$(PYTHON) -c "import json,pathlib; cmds={e['command'] for e in json.loads(pathlib.Path('overlay/environment.json').read_text())['mcpServerAllowlist']}; assert {'python3','diffusers-docs-mcp'} <= cmds, cmds" \ + || (echo "overlay environment.json allowlist must include python3 and diffusers-docs-mcp"; exit 1) @$(PYTHON) tools/docs_mcp_server.py --selftest >/dev/null @test -f tools/grokbot_sim.py && test -f tools/verify_scheduler_contract.py \ && test -f agents/grokbot-profiles.md && test -f .cursor/agents/grokbot-qa.md \ diff --git a/docs/LIVE_DEMO.md b/docs/LIVE_DEMO.md index 298f1cd..daceeec 100644 --- a/docs/LIVE_DEMO.md +++ b/docs/LIVE_DEMO.md @@ -159,7 +159,7 @@ Two files, two launch targets: | Launch on | File | What install / start do | |-----------|------|-------------------| -| **This kit** | `.cursor/environment.json` | `install`: `.cursor/cloud-install.sh` (pyyaml, CPU torch, diffusers, `diffusers-docs-mcp` PATH shim, MCP `--selftest`). `start`: re-run the shim only (snapshot boots skip `install`). Allowlist command: `diffusers-docs-mcp`. Declares the fork as a repo dependency. | +| **This kit** | `.cursor/environment.json` | `install`: `.cursor/cloud-install.sh` (pyyaml, CPU torch, diffusers, `diffusers-docs-mcp` PATH shim, MCP `--selftest`). `start`: re-run the shim only (snapshot boots skip `install`). Allowlist commands: `python3` (mcp.json) and `diffusers-docs-mcp`. Declares the fork as a repo dependency. | | **The fork** | `overlay/environment.json` (copied to fork `.cursor/environment.json` by `make attach`) | Clones this kit to `ramp-kit/` if missing, then the same `cloud-install.sh`. `start` refreshes `diffusers-docs-mcp`. Fork `.cursor/mcp.json` is stdio `diffusers-docs` only (no Hub HTTP). | Dry-run locally (does not boot a Cloud VM): diff --git a/overlay/OVERLAY.md b/overlay/OVERLAY.md index e1bfa6f..4df7e17 100644 --- a/overlay/OVERLAY.md +++ b/overlay/OVERLAY.md @@ -33,9 +33,12 @@ Do not copy `ramp-kit/examples/candidate_scheduler/`. `.cursor/mcp.json` ships **stdio `diffusers-docs` only** (`python3 -u .cursor/mcp-diffusers-docs.py`). Do not enable Hub HTTP MCP (OAuth). Cloud -dropdown: `diffusers-docs-mcp` (PATH shim from `install-docs-mcp.sh`) or the -same python3 command. The launcher finds `ramp-kit/tools/docs_mcp_server.py` -from `/agent` as well as the repo root. +dropdown: `diffusers-docs-mcp` (PATH shim from `install-docs-mcp.sh`) or +`python3 -u .cursor/mcp-diffusers-docs.py`. Cloud `mcpServerAllowlist` must +permit **both** `python3` (what `.cursor/mcp.json` spawns) and +`diffusers-docs-mcp`. The launcher finds `ramp-kit/tools/docs_mcp_server.py` +from `/agent` as well as the repo root. `start` copies the relative launcher +into `/agent/.cursor/` so that python3 spawn works. Hub HTTP and extra servers stay in `.cursor/mcp.optional.json`. diff --git a/overlay/environment.json b/overlay/environment.json index 6c58945..658ad9a 100644 --- a/overlay/environment.json +++ b/overlay/environment.json @@ -7,6 +7,10 @@ "mcpServerAllowlist": [ { "name": "diffusers-docs", + "command": "python3" + }, + { + "name": "diffusers-docs-mcp", "command": "diffusers-docs-mcp" } ] diff --git a/overlay/mcp.json b/overlay/mcp.json index cbf2bc8..2fe85d6 100644 --- a/overlay/mcp.json +++ b/overlay/mcp.json @@ -3,7 +3,11 @@ "diffusers-docs": { "type": "stdio", "command": "python3", - "args": ["-u", ".cursor/mcp-diffusers-docs.py"], + "args": [ + "-u", + "-c", + "import os, sys; from pathlib import Path; opts = [Path('.cursor/mcp-diffusers-docs.py')]; repos = Path('/agent/repos');\nopts.extend(repos.glob('*/.cursor/mcp-diffusers-docs.py')) if repos.is_dir() else None; opts.extend(repos.glob('*/ramp-kit/.cursor/mcp-diffusers-docs.py')) if repos.is_dir() else None\nfor p in opts:\n if p.is_file():\n os.execv(sys.executable, [sys.executable, '-u', str(p.resolve()), '--serve'])\nsys.exit('diffusers-docs MCP: launcher not found')" + ], "env": { "PYTHONUNBUFFERED": "1" } diff --git a/overlay/mcp.optional.json b/overlay/mcp.optional.json index 9080a64..147066c 100644 --- a/overlay/mcp.optional.json +++ b/overlay/mcp.optional.json @@ -3,7 +3,11 @@ "diffusers-docs": { "type": "stdio", "command": "python3", - "args": ["-u", ".cursor/mcp-diffusers-docs.py"], + "args": [ + "-u", + "-c", + "import os, sys; from pathlib import Path; opts = [Path('.cursor/mcp-diffusers-docs.py')]; repos = Path('/agent/repos');\nopts.extend(repos.glob('*/.cursor/mcp-diffusers-docs.py')) if repos.is_dir() else None; opts.extend(repos.glob('*/ramp-kit/.cursor/mcp-diffusers-docs.py')) if repos.is_dir() else None\nfor p in opts:\n if p.is_file():\n os.execv(sys.executable, [sys.executable, '-u', str(p.resolve()), '--serve'])\nsys.exit('diffusers-docs MCP: launcher not found')" + ], "env": { "PYTHONUNBUFFERED": "1" } diff --git a/tests/test_tooling.py b/tests/test_tooling.py index 697508a..2074077 100644 --- a/tests/test_tooling.py +++ b/tests/test_tooling.py @@ -137,7 +137,9 @@ def test_overlay_mcp_json_is_stdio_docs_only(self): stdio = servers["diffusers-docs"] self.assertEqual(stdio.get("type"), "stdio") self.assertEqual(stdio["command"], "python3") - self.assertEqual(stdio["args"], ["-u", ".cursor/mcp-diffusers-docs.py"]) + self.assertIn("-c", stdio["args"]) + self.assertIn("mcp-diffusers-docs.py", stdio["args"][-1]) + self.assertIn("/agent/repos", stdio["args"][-1]) def test_overlay_mcp_optional_lists_opt_in_servers(self): raw = (ROOT / "overlay" / "mcp.optional.json").read_text() @@ -149,7 +151,8 @@ def test_overlay_mcp_optional_lists_opt_in_servers(self): stdio = servers["diffusers-docs"] self.assertEqual(stdio.get("type"), "stdio") self.assertEqual(stdio["command"], "python3") - self.assertEqual(stdio["args"], ["-u", ".cursor/mcp-diffusers-docs.py"]) + self.assertIn("-c", stdio["args"]) + self.assertIn("mcp-diffusers-docs.py", stdio["args"][-1]) self.assertEqual(servers["huggingface"].get("url"), "https://huggingface.co/mcp") def test_attach_copies_stdio_default_and_optional(self): @@ -161,6 +164,65 @@ def test_attach_copies_stdio_default_and_optional(self): ) +class TestCloudMcpAllowlist(unittest.TestCase): + """Cloud spawn uses mcp.json's python3; allowlisting only the PATH shim blocks it.""" + + def _allowlist_commands(self, path: Path): + cfg = json.loads(path.read_text()) + return [entry["command"] for entry in cfg["mcpServerAllowlist"]] + + def test_kit_and_overlay_allowlist_match_mcp_json_command(self): + for path in ( + ROOT / ".cursor" / "environment.json", + ROOT / "overlay" / "environment.json", + ): + cmds = self._allowlist_commands(path) + self.assertIn("python3", cmds, path) + self.assertIn("diffusers-docs-mcp", cmds, path) + + def test_install_script_writes_path_shim(self): + with tempfile.TemporaryDirectory() as td: + home = Path(td) / "home" + home.mkdir() + proc = subprocess.run( + ["bash", str(ROOT / ".cursor" / "install-docs-mcp.sh")], + cwd=ROOT, + env={ + **os.environ, + "HOME": str(home), + "PATH": "/usr/bin:/bin", + }, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout) + self.assertTrue((home / ".local" / "bin" / "diffusers-docs-mcp").is_file()) + + def test_mcp_json_handshake_from_agent_cwd(self): + """Cloud stdio cwd is /agent; relative .cursor/... must not be required.""" + cfg = json.loads((ROOT / ".cursor" / "mcp.json").read_text()) + server = cfg["mcpServers"]["diffusers-docs"] + reqs = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-11-25", "capabilities": {}}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + ] + payload = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in reqs) + proc = subprocess.run( + [server["command"], *server["args"]], + cwd="/agent", + input=payload.encode("utf-8"), + capture_output=True, + timeout=10, + check=False, + ) + self.assertEqual(proc.returncode, 0, proc.stderr.decode("utf-8", "replace")) + self.assertIn(b"search_docs", proc.stdout) + self.assertNotIn(b"Content-Length", proc.stdout) + + class TestMcpLauncher(unittest.TestCase): def test_project_mcp_json_has_no_workspace_folder_var(self): raw = (ROOT / ".cursor" / "mcp.json").read_text() @@ -170,9 +232,11 @@ def test_project_mcp_json_has_no_workspace_folder_var(self): server = cfg["mcpServers"]["diffusers-docs"] self.assertEqual(server.get("type"), "stdio") self.assertEqual(server["command"], "python3") - self.assertEqual(server["args"], ["-u", ".cursor/mcp-diffusers-docs.py"]) + self.assertIn("-c", server["args"]) + self.assertIn("/agent/repos", server["args"][-1]) self.assertTrue((ROOT / ".cursor" / "mcp-diffusers-docs.py").is_file()) self.assertTrue((ROOT / ".cursor" / "mcp-diffusers-docs.sh").is_file()) + self.assertTrue((ROOT / ".cursor" / "mcp_stdio_boot.py").is_file()) self.assertTrue((ROOT / ".cursor" / "commands" / "search-docs.md").is_file()) self.assertTrue((ROOT / ".cursor" / "skills" / "search-docs" / "SKILL.md").is_file()) @@ -683,11 +747,13 @@ def test_attach_writes_stdio_docs_mcp(self): cfg = json.loads((fake / ".cursor" / "mcp.json").read_text()) self.assertIn("diffusers-docs", cfg.get("mcpServers", {})) self.assertNotIn("huggingface", cfg.get("mcpServers", {})) - self.assertEqual( - cfg["mcpServers"]["diffusers-docs"]["args"], - ["-u", ".cursor/mcp-diffusers-docs.py"], + self.assertIn("-c", cfg["mcpServers"]["diffusers-docs"]["args"]) + self.assertIn( + "/agent/repos", + cfg["mcpServers"]["diffusers-docs"]["args"][-1], ) self.assertTrue((fake / ".cursor" / "mcp.optional.json").is_file()) + self.assertTrue((fake / ".cursor" / "mcp_stdio_boot.py").is_file()) self.assertNotIn("AGENTS.md", [p.name for p in fake.iterdir()]) wf = (fake / ".github" / "workflows" / "ramp-kit-overlay.yml").read_text() self.assertIn("ramp-kit-overlay", wf) diff --git a/tools/attach_library.py b/tools/attach_library.py index 8be2b88..63e9fe4 100755 --- a/tools/attach_library.py +++ b/tools/attach_library.py @@ -47,7 +47,12 @@ def copy_overlay(target: Path) -> None: shutil.copy2(overlay / "mcp.json", cursor / "mcp.json") shutil.copy2(overlay / "mcp.optional.json", cursor / "mcp.optional.json") - for name in ("hooks.json", "mcp-diffusers-docs.py", "mcp-diffusers-docs.sh"): + for name in ( + "hooks.json", + "mcp-diffusers-docs.py", + "mcp-diffusers-docs.sh", + "mcp_stdio_boot.py", + ): src = KIT / ".cursor" / name if src.exists(): shutil.copy2(src, cursor / name)