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
44 changes: 40 additions & 4 deletions code_review_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 <command> --help{r} {d}for details{r}
""")
Expand Down Expand Up @@ -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()

Expand All @@ -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":
Expand Down
31 changes: 26 additions & 5 deletions code_review_graph/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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``
Expand All @@ -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__":
Expand Down
41 changes: 41 additions & 0 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down