diff --git a/README.md b/README.md index 7a4b492e..9ad50ab2 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ Evolve provides both a standard MCP server and a full Web UI (Dashboard & Entity > See `altk_evolve/frontend/ui/README.md` for more frontend development details. #### Starting Both Automatically -The easiest way to start both the MCP Server (on standard input/output) and the HTTP UI backend is to run the module directly: +The easiest way to start both the MCP Server (on standard input/output) and the HTTP UI backend is to run the exported launcher: ```bash -uv run python -m evolve.frontend.mcp +uv run evolve-mcp ``` This will start the UI server in the background on port `8000` and the MCP server in the foreground. You can then access the UI locally by opening your browser to: `http://127.0.0.1:8000/ui/` @@ -75,18 +75,18 @@ This will start the UI server in the background on port `8000` and the MCP serve #### Starting the UI Standalone If you only want to access the Web UI and API (without the MCP server stdio blocking the terminal), you can run the FastAPI application directly using `uvicorn`: ```bash -uv run uvicorn evolve.frontend.mcp.mcp_server:app --host 127.0.0.1 --port 8000 +uv run uvicorn altk_evolve.frontend.mcp.mcp_server:app --host 127.0.0.1 --port 8000 ``` Then navigate to `http://127.0.0.1:8000/ui/`. #### Starting only the MCP Server If you're attaching Evolve to an MCP client that requires a direct command (like Claude Desktop): ```bash -uv run fastmcp run altk_evolve/frontend/mcp/mcp_server.py --transport stdio +uv run evolve-mcp ``` Or for SSE transport: ```bash -uv run fastmcp run altk_evolve/frontend/mcp/mcp_server.py --transport sse --port 8201 +uv run evolve-mcp --transport sse --port 8201 ``` Verify it's running: diff --git a/altk_evolve/frontend/mcp/__main__.py b/altk_evolve/frontend/mcp/__main__.py index 9d5b5fcf..90568c54 100644 --- a/altk_evolve/frontend/mcp/__main__.py +++ b/altk_evolve/frontend/mcp/__main__.py @@ -1,3 +1,4 @@ +import argparse import logging import sys import threading @@ -8,6 +9,28 @@ logger = logging.getLogger("evolve-mcp") +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run the Evolve MCP server") + parser.add_argument( + "--transport", + choices=("stdio", "sse"), + default="stdio", + help="MCP transport to use (default: stdio)", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Host for SSE transport (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", + type=int, + default=8201, + help="Port for SSE transport (default: 8201)", + ) + return parser + + def run_api_server(): """Run the FastAPI server for UI and API in a background thread.""" try: @@ -21,13 +44,17 @@ def main(): """ Main entry point for the server. """ - # Start the HTTP API/UI server in a daemon thread so it dies when the parent dies - api_thread = threading.Thread(target=run_api_server, daemon=True) - api_thread.start() + args = _build_parser().parse_args() try: - # Start FastMCP using stdio (which blocks) - mcp.run() + if args.transport == "stdio": + # Start the HTTP API/UI server in a daemon thread so it dies when the parent dies + api_thread = threading.Thread(target=run_api_server, daemon=True) + api_thread.start() + # Start FastMCP using stdio (which blocks) + mcp.run() + else: + mcp.run(transport="sse", host=args.host, port=args.port) except KeyboardInterrupt: logger.info("MCP server stopped by user (KeyboardInterrupt)") sys.exit(0) diff --git a/pyproject.toml b/pyproject.toml index bd40b7ac..4063ab46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ examples = [ [project.scripts] evolve = "altk_evolve.cli.cli:app" +evolve-mcp = "altk_evolve.frontend.mcp.__main__:main" [dependency-groups] dev = [ diff --git a/tests/unit/test_mcp_launcher.py b/tests/unit/test_mcp_launcher.py new file mode 100644 index 00000000..9d5193c2 --- /dev/null +++ b/tests/unit/test_mcp_launcher.py @@ -0,0 +1,56 @@ +import tomllib +from pathlib import Path + +from altk_evolve.frontend.mcp import __main__ as launcher + + +def test_pyproject_exports_mcp_launcher_script() -> None: + pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml" + parsed = tomllib.loads(pyproject_path.read_text()) + + assert parsed["project"]["scripts"]["evolve-mcp"] == "altk_evolve.frontend.mcp.__main__:main" + + +def test_stdio_launcher_starts_ui_thread(monkeypatch) -> None: + thread_calls: list[tuple[object, bool]] = [] + run_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + class FakeThread: + def __init__(self, target, daemon): + thread_calls.append((target, daemon)) + + def start(self) -> None: + thread_calls.append(("started", True)) + + monkeypatch.setattr(launcher.threading, "Thread", FakeThread) + monkeypatch.setattr(launcher.mcp, "run", lambda *args, **kwargs: run_calls.append((args, kwargs))) + monkeypatch.setattr(launcher.sys, "argv", ["evolve-mcp"]) + + launcher.main() + + assert thread_calls[0] == (launcher.run_api_server, True) + assert thread_calls[1] == ("started", True) + assert run_calls == [((), {})] + + +def test_sse_launcher_skips_ui_thread(monkeypatch) -> None: + thread_called = False + run_calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + class FakeThread: + def __init__(self, target, daemon): + nonlocal thread_called + thread_called = True + + def start(self) -> None: + nonlocal thread_called + thread_called = True + + monkeypatch.setattr(launcher.threading, "Thread", FakeThread) + monkeypatch.setattr(launcher.mcp, "run", lambda *args, **kwargs: run_calls.append((args, kwargs))) + monkeypatch.setattr(launcher.sys, "argv", ["evolve-mcp", "--transport", "sse", "--host", "0.0.0.0", "--port", "9300"]) + + launcher.main() + + assert thread_called is False + assert run_calls == [((), {"transport": "sse", "host": "0.0.0.0", "port": 9300})]