From 3b432180582b1a8754d4160cfcc9fa5f134c7cab Mon Sep 17 00:00:00 2001 From: Prateep07 Date: Thu, 29 Oct 2026 20:55:52 +0530 Subject: [PATCH] Add Cursor MCP registration command --- nitrostack/cli/main.py | 90 +++++++++++++++++++++++++++++++++++++++--- tests/test_cli.py | 52 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 6 deletions(-) diff --git a/nitrostack/cli/main.py b/nitrostack/cli/main.py index 1ab03b3..7e83754 100644 --- a/nitrostack/cli/main.py +++ b/nitrostack/cli/main.py @@ -1666,8 +1666,7 @@ def register_server(name: str, file_path: str): print(f"Error: Script file '{file_path}' does not exist.") sys.exit(1) - abs_file_path = os.path.abspath(file_path) - python_exe = sys.executable + server_entry = _build_server_entry(file_path) config_paths = get_claude_config_paths() if not config_paths: @@ -1691,10 +1690,7 @@ def register_server(name: str, file_path: str): if "mcpServers" not in config: config["mcpServers"] = {} - config["mcpServers"][name] = { - "command": python_exe, - "args": [abs_file_path] - } + config["mcpServers"][name] = server_entry # Create directory if needed os.makedirs(os.path.dirname(path), exist_ok=True) @@ -1711,6 +1707,74 @@ def register_server(name: str, file_path: str): else: print("Error: Failed to register the server in any configuration files.") + +def _build_server_entry(file_path: str): + """Build the stdio server entry shared by register and Cursor configs.""" + return { + "command": sys.executable, + "args": [os.path.abspath(file_path)], + } + + +def _write_json_atomic(path: str, data): + """Replace a JSON config atomically so readers never see a partial write.""" + import json + import tempfile + + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + fd, temp_path = tempfile.mkstemp(prefix=".mcp-", suffix=".tmp", dir=directory) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + f.write("\n") + os.replace(temp_path, path) + except Exception: + try: + os.unlink(temp_path) + except OSError: + pass + raise + + +def register_cursor_server(name: str, file_path: str = "main.py", *, global_config: bool = False, transport: str = "command"): + """Merge this project into Cursor's project or user MCP configuration.""" + import json + + if transport not in ("command", "streamable-http", "sse"): + raise ValueError("transport must be command, streamable-http, or sse") + if transport == "command" and not os.path.exists(file_path): + print(f"Error: Script file '{file_path}' does not exist.") + sys.exit(1) + + if global_config: + config_path = os.path.join(os.path.expanduser("~"), ".cursor", "mcp.json") + else: + config_path = os.path.join(os.getcwd(), ".cursor", "mcp.json") + + config = {} + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + except (json.JSONDecodeError, OSError): + config = {} + if not isinstance(config, dict): + config = {} + servers = config.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + config["mcpServers"] = servers + + if transport == "command": + entry = _build_server_entry(file_path) + else: + route = "mcp" if transport == "streamable-http" else "sse" + entry = {"url": f"http://localhost:3000/{route}"} + servers[name] = entry + _write_json_atomic(config_path, config) + print(f"Successfully registered server '{name}' in Cursor config: {config_path}") + def main(): if hasattr(sys.stdout, "reconfigure"): try: @@ -1769,6 +1833,18 @@ def main(): reg_parser.add_argument("--name", default=os.path.basename(os.getcwd()), help="Name of the server (defaults to folder name)") reg_parser.add_argument("--file", default="main.py", help="Python script to register (defaults to main.py)") + # cursor command + cursor_parser = subparsers.add_parser("cursor", help="Register this server in Cursor MCP configuration") + cursor_parser.add_argument("--global", dest="global_config", action="store_true", help="Write to ~/.cursor/mcp.json") + cursor_parser.add_argument( + "--transport", + choices=("command", "streamable-http", "sse"), + default="command", + help="Connection type (default: command)", + ) + cursor_parser.add_argument("--name", default=os.path.basename(os.getcwd()), help="Name of the server") + cursor_parser.add_argument("--file", default="main.py", help="Python script to register for command transport") + # generate command gen_parser = subparsers.add_parser( "generate", @@ -1870,6 +1946,8 @@ def _run_command(fn, *fn_args, **fn_kwargs): run_start(port=args.port, widget=args.widget) elif args.command == "register": register_server(args.name, args.file) + elif args.command == "cursor": + register_cursor_server(args.name, args.file, global_config=args.global_config, transport=args.transport) elif args.command == "generate": if not args.generator: gen_parser.print_help() diff --git a/tests/test_cli.py b/tests/test_cli.py index 2aaf2ed..7aa35f1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -41,6 +41,7 @@ init_project, main, print_banner, + register_cursor_server, register_server, run_dev, run_start, @@ -802,6 +803,50 @@ def test_register_server_writes_config_and_handles_errors(): shutil.rmtree(tmp, ignore_errors=True) +def test_cursor_registration_merges_project_config_and_is_idempotent(tmp_path, monkeypatch): + import json + + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + (project / "main.py").write_text("print('hi')\n", encoding="utf-8") + config_path = project / ".cursor" / "mcp.json" + config_path.parent.mkdir() + config_path.write_text( + json.dumps({"mcpServers": {"other": {"url": "http://example.test/mcp"}}}), + encoding="utf-8", + ) + + register_cursor_server("demo", "main.py") + register_cursor_server("demo", "main.py") + + config = json.loads(config_path.read_text(encoding="utf-8")) + assert config["mcpServers"]["other"] == {"url": "http://example.test/mcp"} + assert config["mcpServers"]["demo"]["command"] == sys.executable + assert config["mcpServers"]["demo"]["args"] == [str((project / "main.py").resolve())] + assert len(config["mcpServers"]) == 2 + + +def test_cursor_registration_global_target_and_http_transports(tmp_path, monkeypatch): + import json + + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr("nitrostack.cli.main.os.path.expanduser", lambda value: str(home) if value == "~" else value) + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + + register_cursor_server("web", global_config=True, transport="streamable-http") + register_cursor_server("events", global_config=True, transport="sse") + + config_path = home / ".cursor" / "mcp.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + assert config["mcpServers"]["web"] == {"url": "http://localhost:3000/mcp"} + assert config["mcpServers"]["events"] == {"url": "http://localhost:3000/sse"} + assert not (project / ".cursor" / "mcp.json").exists() + + def test_main_dispatches_commands(): from unittest.mock import patch @@ -851,6 +896,13 @@ def test_main_dispatches_commands(): ) as reg: main() reg.assert_called_once_with("x", "main.py") + + with patch( + "sys.argv", + ["nitrostack-py", "cursor", "--global", "--transport", "sse", "--name", "x"], + ), patch("nitrostack.cli.main.register_cursor_server") as cursor: + main() + cursor.assert_called_once_with("x", "main.py", global_config=True, transport="sse") print("Success! main() dispatches init/dev/start/generate/register.")