Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 84 additions & 6 deletions nitrostack/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down
52 changes: 52 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
init_project,
main,
print_banner,
register_cursor_server,
register_server,
run_dev,
run_start,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.")


Expand Down
Loading