diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py index 70b0b7c..dc51dd3 100644 --- a/code_review_graph/cli.py +++ b/code_review_graph/cli.py @@ -7,7 +7,7 @@ code-review-graph update [--base BASE] code-review-graph watch code-review-graph status - code-review-graph serve + code-review-graph serve [--http] [--host ADDR] [--port PORT] code-review-graph visualize code-review-graph wiki code-review-graph detect-changes [--base BASE] [--brief] @@ -92,7 +92,7 @@ def _print_banner() -> None: {g}repos{r} List registered repositories {g}postprocess{r} Run post-processing {d}(flows, communities, FTS){r} {g}eval{r} Run evaluation benchmarks - {g}serve{r} Start MCP server + {g}serve{r} Start MCP server {d}(stdio, or {g}--http{r} on localhost:5555){r} {d}Run{r} {b}code-review-graph --help{r} {d}for details{r} """) @@ -449,8 +449,29 @@ def main() -> None: detect_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") # serve - serve_cmd = sub.add_parser("serve", help="Start MCP server (stdio transport)") + serve_cmd = sub.add_parser( + "serve", + help="Start MCP server (stdio by default, or HTTP on localhost with --http)", + ) serve_cmd.add_argument("--repo", default=None, help="Repository root (auto-detected)") + serve_cmd.add_argument( + "--http", + action="store_true", + help="Listen for MCP over Streamable HTTP on localhost (default port 5555)", + ) + serve_cmd.add_argument( + "--host", + default=None, + metavar="ADDR", + help="Bind address for --http (default: 127.0.0.1)", + ) + serve_cmd.add_argument( + "--port", + type=int, + default=None, + metavar="PORT", + help="Port for --http (default: 5555)", + ) args = ap.parse_args() @@ -464,7 +485,22 @@ def main() -> None: if args.command == "serve": from .main import main as serve_main - serve_main(repo_root=args.repo) + + if args.port is not None and not args.http: + serve_cmd.error("--port requires --http") + if args.host is not None and not args.http: + serve_cmd.error("--host requires --http") + if args.http: + host = args.host if args.host is not None else "127.0.0.1" + port = args.port if args.port is not None else 5555 + serve_main( + repo_root=args.repo, + transport="streamable-http", + host=host, + port=port, + ) + else: + serve_main(repo_root=args.repo) return if args.command == "eval": diff --git a/code_review_graph/main.py b/code_review_graph/main.py index a36f68a..00ad74b 100644 --- a/code_review_graph/main.py +++ b/code_review_graph/main.py @@ -1,7 +1,9 @@ """MCP server entry point for Code Review Graph. Run as: code-review-graph serve -Communicates via stdio (standard MCP transport). +Communicates via stdio (standard MCP transport), or use +``code-review-graph serve --http`` for Streamable HTTP on localhost (port 5555 +by default). """ from __future__ import annotations @@ -41,6 +43,7 @@ get_suggested_questions_func, get_surprising_connections_func, get_wiki_page_func, + traverse_graph_func, list_communities_func, list_flows, list_graph_stats, @@ -49,7 +52,6 @@ refactor_func, run_postprocess, semantic_search_nodes, - traverse_graph_func, ) # NOTE: Thread-safe for stdio MCP (single-threaded). If adding HTTP/SSE @@ -888,8 +890,14 @@ def pre_merge_check(base: str = "HEAD~1") -> list[dict]: return pre_merge_check_prompt(base=base) -def main(repo_root: str | None = None) -> None: - """Run the MCP server via stdio. +def main( + repo_root: str | None = None, + *, + transport: str = "stdio", + host: str | None = None, + port: int | None = None, +) -> None: + """Run the MCP server (stdio or HTTP). On Windows, Python 3.8+ defaults to ``ProactorEventLoop``, which interacts poorly with ``concurrent.futures.ProcessPoolExecutor`` @@ -898,13 +906,26 @@ def main(repo_root: str | None = None) -> None: ``embed_graph_tool``. Switching to ``WindowsSelectorEventLoopPolicy`` before fastmcp starts its loop avoids the deadlock. See: #46, #136 + + Args: + repo_root: Optional default repository root for tools. + transport: ``"stdio"`` (default) or ``"streamable-http"`` for local HTTP. + host: Bind address when using HTTP (required for HTTP; set by CLI). + port: Port when using HTTP (required for HTTP; set by CLI). """ global _default_repo_root _default_repo_root = repo_root if sys.platform == "win32": import asyncio asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - mcp.run(transport="stdio") + if transport == "stdio": + mcp.run(transport="stdio") + elif transport == "streamable-http": + if host is None or port is None: + raise ValueError("streamable-http transport requires host and port") + mcp.run(transport="streamable-http", host=host, port=port) + else: + raise ValueError(f"unsupported transport: {transport!r}") if __name__ == "__main__": diff --git a/tests/test_main.py b/tests/test_main.py index 07ee716..4230599 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -48,6 +48,47 @@ def test_client_arg_used_when_no_flag(self): assert crg_main._resolve_repo_root("/explicit") == "/explicit" +class TestServeMainTransport: + """``main()`` wires FastMCP to stdio or Streamable HTTP.""" + + def test_stdio_calls_mcp_run_stdio(self, monkeypatch): + calls: list[dict] = [] + + def fake_run(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr(crg_main.mcp, "run", fake_run) + crg_main.main(repo_root=None) + assert calls == [{"transport": "stdio"}] + + def test_http_calls_mcp_run_with_host_port(self, monkeypatch): + calls: list[dict] = [] + + def fake_run(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr(crg_main.mcp, "run", fake_run) + crg_main.main( + repo_root="/tmp/r", + transport="streamable-http", + host="127.0.0.1", + port=5555, + ) + assert calls == [ + { + "transport": "streamable-http", + "host": "127.0.0.1", + "port": 5555, + } + ] + + def test_streamable_http_without_host_port_raises(self): + with pytest.raises(ValueError, match="requires host and port"): + crg_main.main(transport="streamable-http", host=None, port=5555) + with pytest.raises(ValueError, match="requires host and port"): + crg_main.main(transport="streamable-http", host="127.0.0.1", port=None) + + class TestLongRunningToolsAreAsync: """Long-running MCP tools must be registered as coroutines so the asyncio event loop stays responsive while the work runs in a