Skip to content

Commit 7093e56

Browse files
tirth8205claude
andauthored
fix: offload long-running MCP tools to a thread (#46, #136) (#231)
Limucc's v2.2.4 test on Windows 11 / Python 3.14 confirmed that the WindowsSelectorEventLoopPolicy fix from v2.2.4 was *necessary but not sufficient* — read-only tools work, but long-running tools still hang indefinitely over stdio MCP: build_or_update_graph_tool(full_rebuild=True) → hangs embed_graph_tool (sentence-transformers) → hangs Root cause: FastMCP 2.x dispatches sync handlers inline on the only event-loop thread. When a handler runs for more than a few seconds — especially one that spawns child processes (full_build uses ProcessPoolExecutor) or does CPU-bound inference (sentence-transformers) — the loop stops pumping stdin/stdout, Claude Code's request never gets a response, and the MCP client shows "Synthesizing…" forever. Fix: make the five heavy tool handlers ``async def`` and offload the blocking work with ``asyncio.to_thread``. The event loop stays responsive and the stdio transport keeps pumping. This is a no-op for short tools, zero-config, and works on every platform. Tools converted to async + asyncio.to_thread: - build_or_update_graph_tool (full_build / incremental_update) - run_postprocess_tool (community detection can be slow) - embed_graph_tool (sentence-transformers inference) - detect_changes_tool (git diff + BFS traversal) - generate_wiki_tool (many SQLite reads + file writes) The other 19 tools are fast SQLite-read paths and stay sync. Tests: tests/test_main.py::TestLongRunningToolsAreAsync - Asserts all 5 tools are registered as coroutines via FastMCP's get_tools() introspection - Defense-in-depth: asserts each tool's source literally contains "asyncio.to_thread" so we don't accidentally make a tool async without offloading the blocking work Verified locally on Python 3.11 / macOS: - ruff clean, mypy clean, bandit clean - 737 tests pass (+2 new async lock-in tests), coverage 74.63% - mcp.get_tools() returns 24 tools with the 5 above as coroutines Windows verification requested from @dev-limucc on #136 once v2.3.1 ships. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e366db8 commit 7093e56

2 files changed

Lines changed: 121 additions & 16 deletions

File tree

code_review_graph/main.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from __future__ import annotations
88

9+
import asyncio
910
import sys
1011
from typing import Optional
1112

@@ -77,7 +78,7 @@ def _resolve_repo_root(repo_root: Optional[str]) -> Optional[str]:
7778

7879

7980
@mcp.tool()
80-
def build_or_update_graph_tool(
81+
async def build_or_update_graph_tool(
8182
full_rebuild: bool = False,
8283
repo_root: Optional[str] = None,
8384
base: str = "HEAD~1",
@@ -90,6 +91,13 @@ def build_or_update_graph_tool(
9091
By default performs an incremental update (only changed files).
9192
Set full_rebuild=True to re-parse every file.
9293
94+
Runs the blocking full_build / incremental_update work in a thread
95+
via ``asyncio.to_thread`` so the stdio event loop stays responsive.
96+
Without this wrapper, long builds deadlocked on Windows because
97+
``ProcessPoolExecutor`` (used by parallel parsing) interacted badly
98+
with the sync handler blocking the only event-loop thread. See:
99+
#46, #136.
100+
93101
Args:
94102
full_rebuild: If True, re-parse all files. Default: False (incremental).
95103
repo_root: Repository root path. Auto-detected from current directory if omitted.
@@ -99,14 +107,18 @@ def build_or_update_graph_tool(
99107
recurse_submodules: If True, include files from git submodules.
100108
When None (default), falls back to CRG_RECURSE_SUBMODULES env var.
101109
"""
102-
return build_or_update_graph(
103-
full_rebuild=full_rebuild, repo_root=_resolve_repo_root(repo_root), base=base,
104-
postprocess=postprocess, recurse_submodules=recurse_submodules,
110+
return await asyncio.to_thread(
111+
build_or_update_graph,
112+
full_rebuild=full_rebuild,
113+
repo_root=_resolve_repo_root(repo_root),
114+
base=base,
115+
postprocess=postprocess,
116+
recurse_submodules=recurse_submodules,
105117
)
106118

107119

108120
@mcp.tool()
109-
def run_postprocess_tool(
121+
async def run_postprocess_tool(
110122
flows: bool = True,
111123
communities: bool = True,
112124
fts: bool = True,
@@ -117,14 +129,20 @@ def run_postprocess_tool(
117129
Use after building with postprocess="none" or "minimal", or to re-run
118130
expensive steps independently. Signatures are always computed.
119131
132+
Offloaded to a thread via ``asyncio.to_thread`` so community
133+
detection on large graphs doesn't block the MCP event loop. See:
134+
#46, #136.
135+
120136
Args:
121137
flows: Run flow detection. Default: True.
122138
communities: Run community detection. Default: True.
123139
fts: Rebuild FTS index. Default: True.
124140
repo_root: Repository root path. Auto-detected if omitted.
125141
"""
126-
return run_postprocess(
127-
flows=flows, communities=communities, fts=fts, repo_root=_resolve_repo_root(repo_root),
142+
return await asyncio.to_thread(
143+
run_postprocess,
144+
flows=flows, communities=communities, fts=fts,
145+
repo_root=_resolve_repo_root(repo_root),
128146
)
129147

130148

@@ -273,7 +291,7 @@ def semantic_search_nodes_tool(
273291

274292

275293
@mcp.tool()
276-
def embed_graph_tool(
294+
async def embed_graph_tool(
277295
repo_root: Optional[str] = None,
278296
model: Optional[str] = None,
279297
) -> dict:
@@ -287,12 +305,21 @@ def embed_graph_tool(
287305
After running this, semantic_search_nodes_tool will use vector similarity
288306
instead of keyword matching for much better results.
289307
308+
Runs the blocking sentence-transformers / Gemini inference in a
309+
thread via ``asyncio.to_thread`` so the stdio event loop stays
310+
responsive — without this wrapper, embedding a large graph would
311+
silently hang the MCP server on Windows. See: #46, #136.
312+
290313
Args:
291314
repo_root: Repository root path. Auto-detected if omitted.
292315
model: Embedding model name (HuggingFace ID or local path).
293316
Falls back to CRG_EMBEDDING_MODEL env var, then all-MiniLM-L6-v2.
294317
"""
295-
return embed_graph(repo_root=_resolve_repo_root(repo_root), model=model)
318+
return await asyncio.to_thread(
319+
embed_graph,
320+
repo_root=_resolve_repo_root(repo_root),
321+
model=model,
322+
)
296323

297324

298325
@mcp.tool()
@@ -501,7 +528,7 @@ def get_architecture_overview_tool(
501528

502529

503530
@mcp.tool()
504-
def detect_changes_tool(
531+
async def detect_changes_tool(
505532
base: str = "HEAD~1",
506533
changed_files: Optional[list[str]] = None,
507534
include_source: bool = False,
@@ -515,6 +542,10 @@ def detect_changes_tool(
515542
flows, communities, and test coverage gaps. Returns risk scores and
516543
prioritized review items. Replaces get_review_context for change-aware reviews.
517544
545+
Offloaded to a thread via ``asyncio.to_thread`` — runs `git diff`
546+
subprocesses and BFS traversals that can take several seconds on
547+
large repos. See: #46, #136.
548+
518549
Args:
519550
base: Git ref to diff against. Default: HEAD~1.
520551
changed_files: List of changed file paths (relative to repo root). Auto-detected if omitted.
@@ -524,7 +555,8 @@ def detect_changes_tool(
524555
detail_level: "standard" for full output, "minimal" for
525556
token-efficient summary. Default: standard.
526557
"""
527-
return detect_changes_func(
558+
return await asyncio.to_thread(
559+
detect_changes_func,
528560
base=base, changed_files=changed_files,
529561
include_source=include_source, max_depth=max_depth,
530562
repo_root=_resolve_repo_root(repo_root), detail_level=detail_level,
@@ -598,7 +630,7 @@ def apply_refactor_tool(
598630

599631

600632
@mcp.tool()
601-
def generate_wiki_tool(
633+
async def generate_wiki_tool(
602634
repo_root: Optional[str] = None,
603635
force: bool = False,
604636
) -> dict:
@@ -608,11 +640,19 @@ def generate_wiki_tool(
608640
Pages are written to .code-review-graph/wiki/ inside the repository.
609641
Only regenerates pages whose content has changed unless force=True.
610642
643+
Offloaded to a thread via ``asyncio.to_thread`` — on large graphs
644+
the page-generation loop touches every community and issues many
645+
SQLite reads, which would block the MCP event loop. See: #46, #136.
646+
611647
Args:
612648
repo_root: Repository root path. Auto-detected if omitted.
613649
force: If True, regenerate all pages even if content unchanged. Default: False.
614650
"""
615-
return generate_wiki_func(repo_root=_resolve_repo_root(repo_root), force=force)
651+
return await asyncio.to_thread(
652+
generate_wiki_func,
653+
repo_root=_resolve_repo_root(repo_root),
654+
force=force,
655+
)
616656

617657

618658
@mcp.tool()

tests/test_main.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"""Tests for the MCP server entry point.
22
33
Focused on the ``_resolve_repo_root`` helper that threads the
4-
``serve --repo <X>`` CLI flag into every tool wrapper. Previously only
5-
``get_docs_section_tool`` respected the flag and the other 21 tools
6-
silently fell back to cwd.
4+
``serve --repo <X>`` CLI flag into every tool wrapper, and on the
5+
set of tools that must be registered as async coroutines so the MCP
6+
stdio event loop stays responsive during long-running operations.
77
"""
88

99
from __future__ import annotations
1010

11+
import asyncio
12+
import inspect
13+
1114
import pytest
1215

1316
from code_review_graph import main as crg_main
@@ -43,3 +46,65 @@ def test_client_arg_wins_over_flag(self):
4346
def test_client_arg_used_when_no_flag(self):
4447
crg_main._default_repo_root = None
4548
assert crg_main._resolve_repo_root("/explicit") == "/explicit"
49+
50+
51+
class TestLongRunningToolsAreAsync:
52+
"""Long-running MCP tools must be registered as coroutines so the
53+
asyncio event loop stays responsive while the work runs in a
54+
background thread via ``asyncio.to_thread``. Without this, Windows
55+
MCP clients hang on ``build_or_update_graph_tool`` and
56+
``embed_graph_tool`` — see #46, #136.
57+
"""
58+
59+
HEAVY_TOOLS = {
60+
"build_or_update_graph_tool",
61+
"run_postprocess_tool",
62+
"embed_graph_tool",
63+
"detect_changes_tool",
64+
"generate_wiki_tool",
65+
}
66+
67+
@pytest.mark.asyncio
68+
async def test_heavy_tools_are_coroutines(self):
69+
tools = await crg_main.mcp.get_tools()
70+
registered: dict[str, bool] = {}
71+
for name, tool in tools.items():
72+
if name not in self.HEAVY_TOOLS:
73+
continue
74+
# FastMCP 2.x stores the underlying Python function on the
75+
# tool wrapper; attribute name has varied but is typically
76+
# ``fn`` on FunctionTool. Fall back to a few candidates.
77+
fn = (
78+
getattr(tool, "fn", None)
79+
or getattr(tool, "_func", None)
80+
or getattr(tool, "func", None)
81+
or tool
82+
)
83+
registered[name] = asyncio.iscoroutinefunction(fn)
84+
85+
missing = self.HEAVY_TOOLS - registered.keys()
86+
assert not missing, f"heavy tool(s) not registered at all: {missing}"
87+
88+
not_async = [name for name, is_async in registered.items() if not is_async]
89+
assert not not_async, (
90+
f"these tools must be async but were registered as sync, "
91+
f"which will hang the stdio event loop on Windows: {not_async}"
92+
)
93+
94+
@pytest.mark.asyncio
95+
async def test_heavy_tool_source_uses_to_thread(self):
96+
"""Defense in depth: the source of every heavy tool wrapper must
97+
literally call asyncio.to_thread so we don't accidentally turn
98+
a tool async without offloading the blocking work."""
99+
for tool_name in self.HEAVY_TOOLS:
100+
fn = getattr(crg_main, tool_name, None)
101+
assert fn is not None, f"{tool_name} not found on module"
102+
# The @mcp.tool() decorator wraps the original function; walk
103+
# through the wrapper to find the underlying source.
104+
underlying = getattr(fn, "fn", None) or fn
105+
source = inspect.getsource(underlying)
106+
assert "asyncio.to_thread" in source, (
107+
f"{tool_name} must call asyncio.to_thread to offload its "
108+
f"blocking work; otherwise Windows MCP clients will hang. "
109+
f"See #46, #136."
110+
)

0 commit comments

Comments
 (0)