1588 lines
59 KiB
Python
1588 lines
59 KiB
Python
"""MCP server entry point for Code Review Graph.
|
|
|
|
Run as: code-review-graph serve
|
|
Communicates via stdio (standard MCP transport), or use
|
|
``code-review-graph serve --http`` for Streamable HTTP on localhost (port 5555
|
|
by default). The HTTP transport validates ``Host`` and ``Origin`` so the loopback
|
|
endpoint cannot be driven cross-origin (e.g. via DNS rebinding); see
|
|
``code_review_graph.http_origin_guard``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Optional
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.server.context import Context
|
|
|
|
from . import incremental as _incremental
|
|
from .graph import GraphStore
|
|
from .incremental import find_project_root, get_db_path, start_watch_thread
|
|
from .prompts import (
|
|
architecture_map_prompt,
|
|
debug_issue_prompt,
|
|
onboard_developer_prompt,
|
|
pre_merge_check_prompt,
|
|
project_review_prompt,
|
|
review_changes_prompt,
|
|
unified_review_prompt,
|
|
)
|
|
from .tools import (
|
|
apply_refactor_func,
|
|
build_or_update_graph,
|
|
community_health_func,
|
|
coverage_func,
|
|
cross_repo_search_func,
|
|
dedupe_findings_func,
|
|
deep_read_plan_func,
|
|
detect_changes_func,
|
|
embed_graph,
|
|
find_large_functions,
|
|
generate_report_func,
|
|
generate_wiki_func,
|
|
get_affected_flows_func,
|
|
get_architecture_overview_func,
|
|
get_bridge_nodes_func,
|
|
get_community_func,
|
|
get_docs_section,
|
|
get_flow,
|
|
get_hub_nodes_func,
|
|
get_impact_radius,
|
|
get_knowledge_gaps_func,
|
|
get_minimal_context,
|
|
get_review_context,
|
|
get_suggested_questions_func,
|
|
get_surprising_connections_func,
|
|
get_wiki_page_func,
|
|
list_communities_func,
|
|
list_flows,
|
|
list_graph_stats,
|
|
list_repos_func,
|
|
query_graph,
|
|
refactor_func,
|
|
run_postprocess,
|
|
save_coverage_index_func,
|
|
score_review_func,
|
|
semantic_search_nodes,
|
|
traverse_graph_func,
|
|
with_provenance,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# NOTE: Thread-safe for stdio MCP (single-threaded). If adding HTTP/SSE
|
|
# transport with concurrent requests, replace with contextvars.ContextVar.
|
|
_default_repo_root: str | None = None
|
|
|
|
|
|
def _resolve_repo_root(repo_root: Optional[str]) -> Optional[str]:
|
|
"""Resolve repo_root for a tool call.
|
|
|
|
Order of precedence:
|
|
1. Explicit ``repo_root`` passed by the MCP client (highest).
|
|
2. ``--repo`` CLI flag passed to ``code-review-graph serve``
|
|
(captured in ``_default_repo_root``).
|
|
3. None — the underlying impl will fall back to the server's cwd.
|
|
|
|
All MCP tools that accept ``repo_root`` should use this helper so
|
|
``serve --repo <X>`` applies consistently, including
|
|
``get_docs_section_tool``. See: #222.
|
|
"""
|
|
return repo_root if repo_root else _default_repo_root
|
|
|
|
|
|
class _ProgressSink:
|
|
"""Thread-safe progress channel from a worker thread back to the event loop.
|
|
|
|
The worker thread (``asyncio.to_thread``) calls the ``progress_cb`` that
|
|
the engine functions accept; each call stores the latest ``(fraction,
|
|
message)`` under a lock. The event-loop coroutine reads it via
|
|
:meth:`snapshot` on every heartbeat so ``Context.report_progress`` runs in
|
|
the MCP request context (where the contextvar / progress token lives) —
|
|
never from the worker thread.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._fraction = 0.0
|
|
self._message: Optional[str] = None
|
|
|
|
def progress_cb(self, fraction: float, message: Optional[str]) -> None:
|
|
with self._lock:
|
|
self._fraction = fraction
|
|
self._message = message
|
|
|
|
def snapshot(self) -> tuple[float, Optional[str]]:
|
|
with self._lock:
|
|
return self._fraction, self._message
|
|
|
|
|
|
#: Default interval (seconds) between MCP progress notifications. Must stay well
|
|
#: below the MCP SDK's 60s request timeout so opencode's
|
|
#: ``resetTimeoutOnProgress`` keeps the request alive on large repos.
|
|
_PROGRESS_HEARTBEAT = 15.0
|
|
|
|
|
|
async def _run_with_progress(
|
|
ctx: Context,
|
|
fn: Callable[..., Any],
|
|
*args: Any,
|
|
heartbeat: float = _PROGRESS_HEARTBEAT,
|
|
tool_timeout: int = 0,
|
|
provenance_root: Optional[str] = None,
|
|
**kwargs: Any,
|
|
) -> Any:
|
|
"""Run a blocking tool body in a thread while keeping the MCP client alive.
|
|
|
|
The MCP SDK default request timeout is 60s; opencode resets it whenever a
|
|
``notifications/progress`` arrives (``resetTimeoutOnProgress``). This
|
|
helper runs ``fn`` via ``asyncio.to_thread`` and, in the event-loop
|
|
coroutine (which is inside the MCP request context, so ``request_ctx`` /
|
|
``progressToken`` are available), periodically calls
|
|
``ctx.report_progress``. If ``fn``'s engine accepts a ``progress_cb``, a
|
|
thread-safe sink relays real progress; otherwise a heartbeat keeps the
|
|
connection alive regardless.
|
|
|
|
``provenance_root``, when given, wraps the result with
|
|
:func:`with_provenance` (called from the worker thread).
|
|
|
|
``tool_timeout`` (seconds, ``CRG_TOOL_TIMEOUT``; 0 = disabled) is a
|
|
server-side backstop that returns a readable error dict instead of letting
|
|
the client time out.
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
sink = _ProgressSink()
|
|
|
|
# Bridge: if the target accepts progress_cb, wire the thread-safe sink in.
|
|
try:
|
|
import inspect as _inspect
|
|
|
|
accepts_cb = "progress_cb" in _inspect.signature(fn).parameters
|
|
except (TypeError, ValueError):
|
|
accepts_cb = False
|
|
|
|
if accepts_cb:
|
|
kwargs["progress_cb"] = sink.progress_cb
|
|
|
|
def _worker() -> Any:
|
|
result = fn(*args, **kwargs)
|
|
result = with_provenance(result, provenance_root)
|
|
return result
|
|
|
|
async def _run() -> Any:
|
|
task = asyncio.create_task(asyncio.to_thread(_worker))
|
|
try:
|
|
while not task.done():
|
|
if ctx is not None:
|
|
fraction, message = sink.snapshot()
|
|
await ctx.report_progress(fraction, 1, message or "processing...")
|
|
try:
|
|
await asyncio.wait_for(asyncio.shield(task), timeout=heartbeat)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
return task.result()
|
|
except Exception:
|
|
task.cancel()
|
|
raise
|
|
|
|
if tool_timeout > 0:
|
|
try:
|
|
return await asyncio.wait_for(_run(), timeout=tool_timeout)
|
|
except asyncio.TimeoutError:
|
|
message = (
|
|
f"tool timed out after {tool_timeout}s (CRG_TOOL_TIMEOUT). "
|
|
"Increase CRG_TOOL_TIMEOUT or reduce the review scope."
|
|
)
|
|
error_response = {"status": "error", "error": message, "summary": message}
|
|
if provenance_root is not None:
|
|
return await asyncio.to_thread(with_provenance, error_response, provenance_root)
|
|
return error_response
|
|
return await _run()
|
|
|
|
|
|
mcp = FastMCP(
|
|
"code-review-graph",
|
|
instructions=(
|
|
"Persistent incremental knowledge graph for token-efficient, "
|
|
"context-aware code reviews. Parses your codebase with Tree-sitter, "
|
|
"builds a structural graph, and provides smart impact analysis."
|
|
),
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def build_or_update_graph_tool(
|
|
full_rebuild: bool = False,
|
|
repo_root: Optional[str] = None,
|
|
base: Optional[str] = None,
|
|
postprocess: str = "full",
|
|
recurse_submodules: Optional[bool] = None,
|
|
embedding_provider: Optional[str] = None,
|
|
embedding_model: Optional[str] = None,
|
|
) -> dict:
|
|
"""Build or incrementally update the code knowledge graph.
|
|
|
|
Call this first to initialize the graph, or after making changes.
|
|
By default performs an incremental update (only changed files).
|
|
Set full_rebuild=True to re-parse every file.
|
|
|
|
Runs the blocking full_build / incremental_update work in a thread
|
|
via ``asyncio.to_thread`` so the stdio event loop stays responsive.
|
|
Without this wrapper, long builds deadlocked on Windows because
|
|
``ProcessPoolExecutor`` (used by parallel parsing) interacted badly
|
|
with the sync handler blocking the only event-loop thread. See:
|
|
#46, #136.
|
|
|
|
Args:
|
|
full_rebuild: If True, re-parse all files. Default: False (incremental).
|
|
repo_root: Repository root path. Auto-detected from current directory if omitted.
|
|
base: Git ref to diff against for incremental updates. When omitted,
|
|
resolves automatically to the commit the graph was last built at,
|
|
so one update catches everything since the last sync (not just the
|
|
latest commit). Pass an explicit ref to override.
|
|
postprocess: Post-processing level: "full" (default), "minimal" (signatures+FTS only),
|
|
or "none" (skip all post-processing). Use "minimal" for faster builds.
|
|
recurse_submodules: If True, include files from git submodules.
|
|
When None (default), falls back to CRG_RECURSE_SUBMODULES env var.
|
|
embedding_provider: Exact provider for an explicit post-build embedding
|
|
refresh. Must be supplied with embedding_model. Default: disabled.
|
|
embedding_model: Exact model for an explicit post-build embedding
|
|
refresh. Must be supplied with embedding_provider. Default: disabled.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
|
|
def _run() -> dict:
|
|
return with_provenance(build_or_update_graph(
|
|
full_rebuild=full_rebuild, repo_root=root, base=base,
|
|
postprocess=postprocess, recurse_submodules=recurse_submodules,
|
|
embedding_provider=embedding_provider,
|
|
embedding_model=embedding_model,
|
|
), root)
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@mcp.tool()
|
|
async def run_postprocess_tool(
|
|
flows: bool = True,
|
|
communities: bool = True,
|
|
fts: bool = True,
|
|
repo_root: Optional[str] = None,
|
|
embedding_provider: Optional[str] = None,
|
|
embedding_model: Optional[str] = None,
|
|
) -> dict:
|
|
"""Run post-processing on existing graph (flows, communities, FTS index).
|
|
|
|
Use after building with postprocess="none" or "minimal", or to re-run
|
|
expensive steps independently. Signatures are always computed.
|
|
|
|
Offloaded to a thread via ``asyncio.to_thread`` so community
|
|
detection on large graphs doesn't block the MCP event loop. See:
|
|
#46, #136.
|
|
|
|
Args:
|
|
flows: Run flow detection. Default: True.
|
|
communities: Run community detection. Default: True.
|
|
fts: Rebuild FTS index. Default: True.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
embedding_provider: Exact provider for an explicit embedding refresh.
|
|
Must be supplied with embedding_model. Default: disabled.
|
|
embedding_model: Exact model for an explicit embedding refresh.
|
|
Must be supplied with embedding_provider. Default: disabled.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
|
|
def _run() -> dict:
|
|
return with_provenance(run_postprocess(
|
|
flows=flows, communities=communities, fts=fts, repo_root=root,
|
|
embedding_provider=embedding_provider,
|
|
embedding_model=embedding_model,
|
|
), root)
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_minimal_context_tool(
|
|
task: str = "",
|
|
changed_files: Optional[list[str]] = None,
|
|
repo_root: Optional[str] = None,
|
|
base: str = "HEAD~1",
|
|
) -> dict:
|
|
"""Get ultra-compact context for any task (~100 tokens). Always call this first.
|
|
|
|
Returns graph stats, risk score, top communities/flows, and suggested
|
|
next tools in a single compact response. Use this as the entry point
|
|
before any other graph tool to minimize token usage. Returns
|
|
``status: not_ready`` with a build suggestion when the graph is missing,
|
|
empty, or known to have been built at a different Git commit.
|
|
|
|
Args:
|
|
task: What you are doing (e.g. "review PR #42", "debug login timeout").
|
|
changed_files: Explicit list of changed files. Auto-detected if omitted.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
base: Git ref for diff comparison. Default: HEAD~1.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_minimal_context(
|
|
task=task, changed_files=changed_files,
|
|
repo_root=root, base=base,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_impact_radius_tool(
|
|
changed_files: Optional[list[str]] = None,
|
|
max_depth: int = 2,
|
|
repo_root: Optional[str] = None,
|
|
base: str = "HEAD~1",
|
|
detail_level: str = "standard",
|
|
) -> dict:
|
|
"""Analyze the blast radius of changed files in the codebase.
|
|
|
|
Shows which functions, classes, and files are impacted by changes.
|
|
Auto-detects changed files from git if not specified.
|
|
|
|
Args:
|
|
changed_files: List of changed file paths (relative to repo root). Auto-detected if omitted.
|
|
max_depth: Number of hops to traverse in the dependency graph. Default: 2.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
base: Git ref for auto-detecting changes. Default: HEAD~1.
|
|
detail_level: "standard" for full output, "minimal" for compact summary. Default: standard.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_impact_radius(
|
|
changed_files=changed_files, max_depth=max_depth,
|
|
repo_root=root, base=base, detail_level=detail_level,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def query_graph_tool(
|
|
pattern: str,
|
|
target: str,
|
|
repo_root: Optional[str] = None,
|
|
detail_level: str = "standard",
|
|
max_results: int = 100,
|
|
) -> dict:
|
|
"""Run a predefined graph query to explore code relationships.
|
|
|
|
Available patterns:
|
|
- callers_of: Find functions that call the target
|
|
- references_to: Find nodes that reference the target
|
|
- callees_of: Find functions called by the target
|
|
- imports_of: Find what the target imports
|
|
- importers_of: Find files that import the target
|
|
- children_of: Find nodes contained in a file or class
|
|
- tests_for: Find tests for the target
|
|
- inheritors_of: Find classes inheriting from the target
|
|
- triggers_of: Find methods invoked by a scheduler or other trigger
|
|
- triggered_by: Find schedulers or other triggers that invoke the target
|
|
- publishers_of: Find methods that publish an event
|
|
- listeners_of: Find methods that listen for an event
|
|
- handlers_of: Find methods that handle an endpoint
|
|
- endpoints_for: Find endpoints handled by a method
|
|
- consumers_of: Find classes that consume a Spring configuration property
|
|
- file_summary: Get all nodes in a file
|
|
|
|
Args:
|
|
pattern: Query pattern name (see above).
|
|
target: Node name, qualified name, or file path to query.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
detail_level: "standard" for full output, "minimal" for compact summary. Default: standard.
|
|
max_results: Maximum results to return. Default: 100.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(query_graph(
|
|
pattern=pattern, target=target, repo_root=root,
|
|
detail_level=detail_level, max_results=max_results,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_review_context_tool(
|
|
changed_files: Optional[list[str]] = None,
|
|
max_depth: int = 2,
|
|
include_source: bool = True,
|
|
max_lines_per_file: int = 200,
|
|
repo_root: Optional[str] = None,
|
|
base: str = "HEAD~1",
|
|
detail_level: str = "standard",
|
|
) -> dict:
|
|
"""Generate a focused, token-efficient review context for code changes.
|
|
|
|
Combines impact analysis with source snippets and review guidance.
|
|
Use this for comprehensive code reviews.
|
|
|
|
Args:
|
|
changed_files: Files to review. Auto-detected from git diff if omitted.
|
|
max_depth: Impact radius depth. Default: 2.
|
|
include_source: Include source code snippets. Default: True.
|
|
max_lines_per_file: Max source lines per file. Default: 200.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
base: Git ref for change detection. Default: HEAD~1.
|
|
detail_level: "standard" for full output, "minimal" for
|
|
token-efficient summary. Default: standard.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_review_context(
|
|
changed_files=changed_files, max_depth=max_depth,
|
|
include_source=include_source, max_lines_per_file=max_lines_per_file,
|
|
repo_root=root, base=base, detail_level=detail_level,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def semantic_search_nodes_tool(
|
|
query: str,
|
|
kind: Optional[str] = None,
|
|
limit: int = 20,
|
|
repo_root: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
provider: Optional[str] = None,
|
|
detail_level: str = "standard",
|
|
) -> dict:
|
|
"""Search for code entities by name, keyword, or semantic similarity.
|
|
|
|
Uses vector embeddings for semantic search when available (run embed_graph_tool
|
|
first, with a provider of your choice: "local" needs sentence-transformers,
|
|
"openai" / "google" / "minimax" / "voyage" need their respective env vars).
|
|
Falls back to FTS5 / keyword matching when no matching embeddings exist for
|
|
the given provider.
|
|
|
|
Args:
|
|
query: Search string to match against node names.
|
|
kind: Optional filter: File, Class, Function, Type, or Test.
|
|
limit: Maximum results. Default: 20.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
model: Embedding model for query vectors. Must match the model used
|
|
during embed_graph. Falls back to CRG_EMBEDDING_MODEL env var
|
|
(local), CRG_OPENAI_MODEL (openai), or CRG_VOYAGE_MODEL (voyage).
|
|
provider: Embedding provider: "local" (default), "openai", "google",
|
|
"minimax", or "voyage". Must match the provider used during
|
|
embed_graph.
|
|
detail_level: "standard" for full output, "minimal" for compact summary. Default: standard.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(semantic_search_nodes(
|
|
query=query, kind=kind, limit=limit, repo_root=root,
|
|
model=model, provider=provider, detail_level=detail_level,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
async def embed_graph_tool(
|
|
repo_root: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
provider: Optional[str] = None,
|
|
) -> dict:
|
|
"""Compute vector embeddings for all graph nodes to enable semantic search.
|
|
|
|
Requires: pip install code-review-graph[embeddings] (local provider only;
|
|
cloud providers use stdlib urllib).
|
|
Default provider: local. Default model: all-MiniLM-L6-v2.
|
|
Override provider via `provider` param, model via `model` param or
|
|
CRG_EMBEDDING_MODEL / CRG_OPENAI_MODEL / CRG_VOYAGE_MODEL env vars.
|
|
Changing the model or provider re-embeds all nodes automatically.
|
|
|
|
After running this, semantic_search_nodes_tool will use vector similarity
|
|
instead of keyword matching for much better results.
|
|
|
|
Runs the blocking sentence-transformers / Gemini / HTTP inference in a
|
|
thread via ``asyncio.to_thread`` so the stdio event loop stays
|
|
responsive — without this wrapper, embedding a large graph would
|
|
silently hang the MCP server on Windows. See: #46, #136.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
model: Embedding model. For local: HuggingFace ID/path; for openai:
|
|
model ID (e.g. "text-embedding-3-small"); for google: Gemini
|
|
model ID; for voyage: Voyage model ID (e.g. "voyage-code-3").
|
|
Falls back to CRG_EMBEDDING_MODEL / CRG_OPENAI_MODEL /
|
|
CRG_VOYAGE_MODEL env vars as appropriate.
|
|
provider: "local" (default), "openai", "google", "minimax", or "voyage".
|
|
"openai" requires CRG_OPENAI_BASE_URL + CRG_OPENAI_API_KEY +
|
|
CRG_OPENAI_MODEL env vars and accepts any OpenAI-compatible
|
|
endpoint (real OpenAI, Azure, new-api, LiteLLM, vLLM, etc.).
|
|
"voyage" requires VOYAGE_API_KEY and defaults to voyage-code-3
|
|
unless a model arg or CRG_VOYAGE_MODEL is supplied.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
|
|
def _run() -> dict:
|
|
return with_provenance(embed_graph(
|
|
repo_root=root, model=model, provider=provider,
|
|
), root)
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@mcp.tool()
|
|
def list_graph_stats_tool(
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Get aggregate statistics about the code knowledge graph.
|
|
|
|
Shows total nodes, edges, languages, files, and last update time.
|
|
Useful for checking if the graph is built and up to date.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(list_graph_stats(repo_root=root), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_docs_section_tool(
|
|
section_name: str,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Get a specific section from the LLM-optimized documentation reference.
|
|
|
|
Returns only the requested section content for minimal token usage.
|
|
Use this before answering any user question about the plugin.
|
|
|
|
Available sections: usage, review-delta, review-pr, commands, legal,
|
|
watch, embeddings, languages, troubleshooting.
|
|
|
|
Args:
|
|
section_name: The section to retrieve (e.g. "review-delta", "usage").
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
return get_docs_section(
|
|
section_name=section_name,
|
|
repo_root=_resolve_repo_root(repo_root),
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
def find_large_functions_tool(
|
|
min_lines: int = 50,
|
|
kind: Optional[str] = None,
|
|
file_path_pattern: Optional[str] = None,
|
|
limit: int = 50,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Find functions, classes, or files exceeding a line-count threshold.
|
|
|
|
Useful for decomposition audits, code quality checks, and enforcing
|
|
size limits during code review. Results are ordered by line count.
|
|
|
|
Args:
|
|
min_lines: Minimum line count to flag. Default: 50.
|
|
kind: Optional filter: Function, Class, File, or Test.
|
|
file_path_pattern: Filter by file path substring (e.g. "components/").
|
|
limit: Maximum results. Default: 50.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(find_large_functions(
|
|
min_lines=min_lines, kind=kind, file_path_pattern=file_path_pattern,
|
|
limit=limit, repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def list_flows_tool(
|
|
sort_by: str = "criticality",
|
|
limit: int = 50,
|
|
kind: Optional[str] = None,
|
|
detail_level: str = "standard",
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""List execution flows in the codebase, sorted by criticality.
|
|
|
|
Each flow represents a call chain starting from an entry point
|
|
(HTTP handler, CLI command, test function, etc.). Use this to
|
|
understand the main execution paths through the codebase.
|
|
|
|
Args:
|
|
sort_by: Sort column: criticality, depth, node_count, file_count, or name.
|
|
limit: Maximum flows to return. Default: 50.
|
|
kind: Optional filter by entry point kind (e.g. "Test", "Function").
|
|
detail_level: "standard" (default) returns full flow data; "minimal"
|
|
returns only name, criticality, and node_count per flow.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(list_flows(
|
|
repo_root=root, sort_by=sort_by, limit=limit, kind=kind,
|
|
detail_level=detail_level,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_flow_tool(
|
|
flow_id: Optional[int] = None,
|
|
flow_name: Optional[str] = None,
|
|
include_source: bool = False,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Get detailed information about a single execution flow.
|
|
|
|
Returns the full call path with each step's function name, file, and
|
|
line numbers. Optionally includes source code snippets for each step.
|
|
|
|
Provide either flow_id (from list_flows_tool) or flow_name to search by name.
|
|
|
|
Args:
|
|
flow_id: Database ID of the flow.
|
|
flow_name: Name to search for (partial match). Ignored if flow_id given.
|
|
include_source: Include source code snippets for each step. Default: False.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_flow(
|
|
flow_id=flow_id, flow_name=flow_name,
|
|
include_source=include_source, repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_affected_flows_tool(
|
|
changed_files: Optional[list[str]] = None,
|
|
base: str = "HEAD~1",
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Find execution flows affected by changed files.
|
|
|
|
Identifies which execution flows pass through nodes in the changed files.
|
|
Useful during code review to understand which user-facing or critical paths
|
|
are impacted by a change. Auto-detects changed files from git if not specified.
|
|
|
|
Args:
|
|
changed_files: List of changed file paths (relative to repo root). Auto-detected if omitted.
|
|
base: Git ref for auto-detecting changes. Default: HEAD~1.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_affected_flows_func(
|
|
changed_files=changed_files, base=base, repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def list_communities_tool(
|
|
sort_by: str = "size",
|
|
min_size: int = 0,
|
|
detail_level: str = "standard",
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""List detected code communities in the codebase.
|
|
|
|
Each community represents a cluster of related code entities (functions,
|
|
classes) detected via the Leiden algorithm or file-based grouping.
|
|
Use this to understand the high-level structure of the codebase.
|
|
|
|
Args:
|
|
sort_by: Sort column: size, cohesion, or name.
|
|
min_size: Minimum community size to include. Default: 0.
|
|
detail_level: "standard" (default) returns full community data;
|
|
"minimal" returns only name, size, and cohesion
|
|
per community.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(list_communities_func(
|
|
repo_root=root, sort_by=sort_by, min_size=min_size,
|
|
detail_level=detail_level,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_community_tool(
|
|
community_name: Optional[str] = None,
|
|
community_id: Optional[int] = None,
|
|
include_members: bool = False,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Get detailed information about a single code community.
|
|
|
|
Returns community metadata including size, cohesion, dominant language,
|
|
and member list. Optionally includes full node details for each member.
|
|
|
|
Provide either community_id (from list_communities_tool) or community_name
|
|
to search by name.
|
|
|
|
Args:
|
|
community_name: Name to search for (partial match). Ignored if community_id given.
|
|
community_id: Database ID of the community.
|
|
include_members: Include full member node details. Default: False.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_community_func(
|
|
community_name=community_name, community_id=community_id,
|
|
include_members=include_members, repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_architecture_overview_tool(
|
|
repo_root: Optional[str] = None,
|
|
detail_level: str = "minimal",
|
|
) -> dict:
|
|
"""Generate an architecture overview based on community structure.
|
|
|
|
Builds a high-level view of the codebase architecture by analyzing
|
|
community boundaries and cross-community coupling. Includes warnings
|
|
for high coupling between communities.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
detail_level: "minimal" (default) drops community member lists
|
|
and aggregates cross-community edges to one row per
|
|
community pair (typical reduction: 600KB -> <5KB);
|
|
"standard" returns full per-edge detail.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_architecture_overview_func(
|
|
repo_root=root,
|
|
detail_level=detail_level,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
async def detect_changes_tool(
|
|
base: str = "HEAD~1",
|
|
changed_files: Optional[list[str]] = None,
|
|
include_source: bool = False,
|
|
max_depth: int = 2,
|
|
repo_root: Optional[str] = None,
|
|
detail_level: str = "standard",
|
|
ctx: Context = None,
|
|
) -> dict:
|
|
"""Detect changes and produce risk-scored, priority-ordered review guidance.
|
|
|
|
Primary tool for code review. Maps git diffs to affected functions,
|
|
flows, communities, and test coverage gaps. Returns risk scores and
|
|
prioritized review items. Replaces get_review_context for change-aware reviews.
|
|
|
|
Runs in a worker thread while the event loop reports progress
|
|
notifications (keeps MCP clients such as opencode from timing out on
|
|
large repos).
|
|
|
|
Args:
|
|
base: Git ref to diff against. Default: HEAD~1.
|
|
changed_files: List of changed file paths (relative to repo root). Auto-detected if omitted.
|
|
include_source: Include source code snippets for changed functions. Default: False.
|
|
max_depth: Impact radius depth for BFS traversal. Default: 2.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
detail_level: "standard" for full output, "minimal" for
|
|
token-efficient summary. Default: standard.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
|
return await _run_with_progress(
|
|
ctx, detect_changes_func,
|
|
base=base, changed_files=changed_files,
|
|
include_source=include_source, max_depth=max_depth,
|
|
repo_root=root, detail_level=detail_level,
|
|
tool_timeout=tool_timeout, provenance_root=root,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def score_review_tool(
|
|
changed_files: Optional[list[str]] = None,
|
|
base: str = "HEAD~1",
|
|
include_churn: bool = True,
|
|
repo_root: Optional[str] = None,
|
|
detail_level: str = "standard",
|
|
all_files: bool = False,
|
|
ctx: Context = None,
|
|
) -> dict:
|
|
"""Compute objective Layer-2 review metrics for changed files.
|
|
|
|
Runs the git-history / graph risk factors plus the heuristic metrics
|
|
(SQL risk, exception coverage, redundancy, high-risk density,
|
|
vulnerability) that back the unified-review scoring. These five
|
|
objective metrics are the full report metric set; ``llm_judged`` is
|
|
returned empty for backward compatibility.
|
|
|
|
Runs in a worker thread while the event loop reports progress
|
|
notifications (keeps MCP clients such as opencode from timing out on
|
|
large repos).
|
|
|
|
Args:
|
|
changed_files: Files to score (auto-detected from git diff if
|
|
omitted).
|
|
base: Git ref to diff against. Default: HEAD~1.
|
|
include_churn: Include git-churn risk factors. Default: True.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
detail_level: "standard" for full output, "minimal" for
|
|
token-efficient summary. Default: standard.
|
|
all_files: When True, score every source file in the graph,
|
|
ignoring ``changed_files`` and the git diff. Used for
|
|
whole-project reviews (default: False).
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
|
return await _run_with_progress(
|
|
ctx, score_review_func,
|
|
changed_files=changed_files, base=base,
|
|
include_churn=include_churn, repo_root=root,
|
|
detail_level=detail_level, all_files=all_files,
|
|
tool_timeout=tool_timeout, provenance_root=root,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
def dedupe_findings_tool(
|
|
findings: list,
|
|
suppress_prior: Optional[list] = None,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Merge review findings by fingerprint and boost multi-source confidence.
|
|
|
|
Findings with the same ``path:line:category`` fingerprint are merged
|
|
(highest confidence wins); findings confirmed by more than one source
|
|
(main review + specialist subagents) get confidence +1 (cap 10).
|
|
Low-confidence findings are routed to the appendix or suppressed, and a
|
|
PR quality score is computed as ``max(0, 10 - (critical*2 +
|
|
informational*0.5))``.
|
|
|
|
Args:
|
|
findings: Raw finding dicts with ``path``, ``category``,
|
|
``severity``, ``confidence`` and optional ``source``/``line``.
|
|
suppress_prior: Previously user-skipped findings (from a prior
|
|
review-log) to suppress when their file has not changed.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
return dedupe_findings_func(
|
|
findings=findings, suppress_prior=suppress_prior,
|
|
repo_root=repo_root,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def coverage_tool(
|
|
deep_read_files: list,
|
|
all_files: bool = True,
|
|
include_churn: bool = True,
|
|
gate: str = "high_risk",
|
|
include_prior: bool = False,
|
|
file_read_ranges: Optional[dict] = None,
|
|
file_semantic_units: Optional[dict] = None,
|
|
repo_root: Optional[str] = None,
|
|
ctx: Context = None,
|
|
) -> dict:
|
|
"""Compute file-count review coverage for deep-read files.
|
|
|
|
Coverage = number of deep-read files / number of all source files.
|
|
Per-file risk weight (worst metric grade + graph topology hits +
|
|
normalised git churn) still ranks the priority deep-read list so the
|
|
highest-risk files are read first. Backs the project-review Step 7.5
|
|
coverage self-check (G3).
|
|
|
|
Runs in a worker thread while the event loop reports progress
|
|
notifications (keeps MCP clients such as opencode from timing out on
|
|
large repos).
|
|
|
|
Args:
|
|
deep_read_files: Files the agent actually deep-read during the
|
|
review (relative or absolute paths). Required.
|
|
all_files: When True (default), the denominator is every source
|
|
file in the graph (whole-project semantics).
|
|
include_churn: Include git-churn as the w3 weight term.
|
|
gate: Gate mode: ``"high_risk"`` (default) / ``"overall"`` /
|
|
``"both"`` (both overall and high-risk must meet the target) /
|
|
``"both+line"`` (both + line/unit three-piece gate) /
|
|
``"line+unit"`` (line + unit only, feature reviews; file-count
|
|
/ high-risk coverage skipped and returned as ``None``).
|
|
include_prior: When True, merge the cross-round coverage index
|
|
(files whose SHA is unchanged) into the deep-read set.
|
|
file_read_ranges: Optional {rel_path: [[s,e],...]} of line ranges a
|
|
sub-agent actually read per deep-read file. REQUIRED for
|
|
gate="both+line"/"line+unit" — without it those files are
|
|
FAIL-CLOSED as line gaps (line coverage 0%).
|
|
file_semantic_units: Optional {rel_path: [{range,kind,name}, ...]}
|
|
reported semantic units per deep-read file (same gates).
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
|
|
Returns:
|
|
Dict with coverage_pct, grade, deep_read_count, total_files,
|
|
target_reached, target, remaining_files_to_target,
|
|
priority_deep_read_files, uncovered_files, silent_files and note.
|
|
``silent_files`` feeds the G2 spot-check sampling.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
|
return await _run_with_progress(
|
|
ctx, coverage_func,
|
|
deep_read_files=deep_read_files, all_files=all_files,
|
|
include_churn=include_churn, gate=gate,
|
|
include_prior=include_prior,
|
|
file_read_ranges=file_read_ranges or {},
|
|
file_semantic_units=file_semantic_units or {},
|
|
repo_root=root,
|
|
tool_timeout=tool_timeout, provenance_root=root,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
async def deep_read_plan_tool(
|
|
repo_root: Optional[str] = None,
|
|
target_coverage: float = 85.0,
|
|
batch_size: int = 40,
|
|
include_churn: bool = True,
|
|
include_prior: bool = False,
|
|
deep_read_files: Optional[list] = None,
|
|
ctx: Context = None,
|
|
) -> dict:
|
|
"""Generate a grouped deep-read plan that closes the coverage gap.
|
|
|
|
Greedily selects the highest-risk files not yet deep-read until the
|
|
file-count overall coverage target is reached, then groups them by
|
|
parent directory (≤ ``batch_size`` per group) so each group can be
|
|
dispatched to one parallel sub-agent. With ``include_prior``, files
|
|
already deep-read in earlier rounds (coverage index, SHA unchanged)
|
|
are excluded so incremental reviews only re-read what actually
|
|
changed or is new.
|
|
|
|
Runs in a worker thread while the event loop reports progress
|
|
notifications (keeps MCP clients such as opencode from timing out on
|
|
large repos).
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
target_coverage: Overall coverage target percentage (default 85).
|
|
batch_size: Max files per group (default 40).
|
|
include_churn: Include git-churn as the w3 weight term.
|
|
include_prior: Exclude files already covered by the cross-round
|
|
coverage index from the plan.
|
|
deep_read_files: Files already deep-read this round.
|
|
|
|
Returns:
|
|
Dict with current_coverage_pct, current/target/remaining file
|
|
counts, planned_files (priority-ordered), directory groups and
|
|
estimated_batches.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
|
return await _run_with_progress(
|
|
ctx, deep_read_plan_func,
|
|
repo_root=root, target_coverage=target_coverage,
|
|
batch_size=batch_size, include_churn=include_churn,
|
|
include_prior=include_prior, deep_read_files=deep_read_files,
|
|
tool_timeout=tool_timeout, provenance_root=root,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
def save_coverage_index_tool(
|
|
deep_read_files: list,
|
|
file_read_ranges: Optional[dict] = None,
|
|
file_semantic_units: Optional[dict] = None,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Persist the deep-read file list to the cross-round coverage index.
|
|
|
|
Records each file's per-file SHA at HEAD so a later review can tell
|
|
which previously-deep-read files are still current (SHA unchanged)
|
|
and which are stale and must be re-read. Call at the end of every
|
|
review round (after the report is generated).
|
|
|
|
Args:
|
|
deep_read_files: Files deep-read this round (relative or absolute).
|
|
file_read_ranges: Optional {rel_path: [[s,e],...]} line ranges to
|
|
persist so a later review can reuse line coverage of
|
|
SHA-unchanged files.
|
|
file_semantic_units: Optional {rel_path: [{range,kind,name},...]}
|
|
semantic units to persist likewise.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
|
|
Returns:
|
|
Dict with index_path, entry count and head_sha.
|
|
"""
|
|
return save_coverage_index_func(
|
|
deep_read_files=deep_read_files,
|
|
file_read_ranges=file_read_ranges or {},
|
|
file_semantic_units=file_semantic_units or {},
|
|
repo_root=repo_root,
|
|
)
|
|
|
|
|
|
@mcp.tool()
|
|
def community_health_tool(
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Check node->community attribution health (nodes.community_id).
|
|
|
|
Detects the desync where ``communities.size`` is correct but
|
|
``nodes.community_id`` is mostly NULL (e.g. after an incremental
|
|
rebuild). Returns ``needs_postprocess``; the review skill should run
|
|
``code-review-graph postprocess`` when True before computing coverage.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
|
|
Returns:
|
|
Dict with total_nodes, attributed_nodes, non_file_nodes,
|
|
attribution_pct, needs_postprocess and note.
|
|
"""
|
|
return community_health_func(repo_root=repo_root)
|
|
|
|
|
|
@mcp.tool()
|
|
async def generate_report_tool(
|
|
review_data: dict,
|
|
output_path: Optional[str] = None,
|
|
repo_root: Optional[str] = None,
|
|
format: str = "both",
|
|
) -> dict:
|
|
"""Generate code review reports (HTML and/or Markdown).
|
|
|
|
Injects ``review_data`` (output of ``score_review_tool`` plus
|
|
``dedupe_findings_tool`` results and free-form verdict/tier/scope) into
|
|
the bundled ``report-template.html`` as ``{{REPORT_DATA}}`` (HTML) and/or
|
|
renders a standalone Chinese Markdown report. Both are written by
|
|
default. ``output_path`` is the base name without an extension.
|
|
|
|
Offloaded to a thread via ``asyncio.to_thread`` — rendering loads the
|
|
template asset and writes the output file(s).
|
|
|
|
Args:
|
|
review_data: Review data dict (metrics, findings, verdict, tier,
|
|
scope, files, baseline, timestamp, manual_review).
|
|
output_path: Output base path (no extension). Defaults to
|
|
``<repo_root>/code-review-report``.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
format: Output format. ``html``, ``markdown``, or ``both``
|
|
(default: both).
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
|
|
def _run() -> dict:
|
|
return with_provenance(generate_report_func(
|
|
review_data=review_data, output_path=output_path,
|
|
repo_root=root, format=format,
|
|
), root)
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@mcp.tool()
|
|
def refactor_tool(
|
|
mode: str = "rename",
|
|
old_name: Optional[str] = None,
|
|
new_name: Optional[str] = None,
|
|
kind: Optional[str] = None,
|
|
file_pattern: Optional[str] = None,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Graph-powered refactoring operations.
|
|
|
|
Unified entry point for rename previews, dead code detection, and
|
|
refactoring suggestions.
|
|
|
|
Modes:
|
|
- rename: Preview renaming a symbol. Returns an edit list and a refactor_id
|
|
to pass to apply_refactor_tool. Requires old_name and new_name.
|
|
- dead_code: Find unreferenced functions/classes (no callers, tests, or
|
|
importers, and not entry points).
|
|
- suggest: Get community-driven refactoring suggestions (move misplaced
|
|
functions, remove dead code).
|
|
|
|
Args:
|
|
mode: Operation mode: "rename", "dead_code", or "suggest".
|
|
old_name: (rename) Current symbol name to rename.
|
|
new_name: (rename) Desired new name for the symbol.
|
|
kind: (dead_code) Optional filter: Function or Class.
|
|
file_pattern: (dead_code) Filter by file path substring.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(refactor_func(
|
|
mode=mode, old_name=old_name, new_name=new_name,
|
|
kind=kind, file_pattern=file_pattern, repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def apply_refactor_tool(
|
|
refactor_id: str,
|
|
repo_root: Optional[str] = None,
|
|
dry_run: bool = False,
|
|
) -> dict:
|
|
"""Apply a previously previewed refactoring to source files.
|
|
|
|
Takes a refactor_id from a prior refactor_tool(mode="rename") call and
|
|
applies the exact string replacements to the target files. Previews
|
|
expire after 10 minutes.
|
|
|
|
Security: All edit paths are validated to be within the repo root.
|
|
Only exact string replacements are performed (no regex, no eval).
|
|
|
|
Args:
|
|
refactor_id: The refactor ID from refactor_tool's response.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
dry_run: If True, return a unified diff of what would change
|
|
without touching any files. The refactor_id remains valid so
|
|
the same preview can be applied in a follow-up call without
|
|
dry_run. Use this for a human-in-the-loop review before
|
|
committing changes to disk. See: #176
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(apply_refactor_func(
|
|
refactor_id=refactor_id, repo_root=root,
|
|
dry_run=dry_run,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
async def generate_wiki_tool(
|
|
repo_root: Optional[str] = None,
|
|
force: bool = False,
|
|
) -> dict:
|
|
"""Generate a markdown wiki from the code community structure.
|
|
|
|
Creates a wiki page for each detected community and an index page.
|
|
Pages are written to .code-review-graph/wiki/ inside the repository.
|
|
Only regenerates pages whose content has changed unless force=True.
|
|
|
|
Offloaded to a thread via ``asyncio.to_thread`` — on large graphs
|
|
the page-generation loop touches every community and issues many
|
|
SQLite reads, which would block the MCP event loop. See: #46, #136.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
force: If True, regenerate all pages even if content unchanged. Default: False.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
|
|
def _run() -> dict:
|
|
return with_provenance(generate_wiki_func(
|
|
repo_root=root, force=force,
|
|
), root)
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_wiki_page_tool(
|
|
community_name: str,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Retrieve a specific wiki page by community name.
|
|
|
|
Returns the markdown content of the wiki page for the given community.
|
|
The wiki must have been generated first via generate_wiki_tool.
|
|
|
|
Args:
|
|
community_name: Community name to look up.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_wiki_page_func(
|
|
community_name=community_name, repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_hub_nodes_tool(
|
|
top_n: int = 10,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Find the most connected nodes in the codebase (architectural hotspots).
|
|
|
|
Hub nodes have the highest total degree (in + out edges). Changes to
|
|
them have disproportionate blast radius. Excludes File nodes.
|
|
|
|
Args:
|
|
top_n: Number of top hubs to return. Default: 10.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_hub_nodes_func(
|
|
repo_root=root, top_n=top_n,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_bridge_nodes_tool(
|
|
top_n: int = 10,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Find architectural chokepoints via betweenness centrality.
|
|
|
|
Bridge nodes sit on shortest paths between many node pairs.
|
|
If they break, multiple code regions lose connectivity.
|
|
Uses sampling approximation for graphs > 5000 nodes.
|
|
|
|
Args:
|
|
top_n: Number of top bridges to return. Default: 10.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_bridge_nodes_func(
|
|
repo_root=root, top_n=top_n,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_knowledge_gaps_tool(
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Identify structural weaknesses in the codebase graph.
|
|
|
|
Finds isolated nodes (disconnected), thin communities (< 3 members),
|
|
untested hotspots (high-degree nodes without test coverage), and
|
|
single-file communities.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_knowledge_gaps_func(
|
|
repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_surprising_connections_tool(
|
|
top_n: int = 15,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Find unexpected architectural coupling via composite surprise scoring.
|
|
|
|
Scores edges by: cross-community (+0.3), cross-language (+0.2),
|
|
peripheral-to-hub (+0.2), cross-test-boundary (+0.15), and
|
|
unusual edge kinds (+0.15).
|
|
|
|
Args:
|
|
top_n: Number of top surprises to return. Default: 15.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_surprising_connections_func(
|
|
repo_root=root, top_n=top_n,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def get_suggested_questions_tool(
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""Auto-generate review questions from graph analysis.
|
|
|
|
Produces prioritized questions about: bridge nodes needing tests,
|
|
untested hub nodes, surprising cross-community coupling, thin
|
|
communities, and untested hotspots.
|
|
|
|
Args:
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(get_suggested_questions_func(
|
|
repo_root=root,
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def traverse_graph_tool(
|
|
query: str,
|
|
mode: str = "bfs",
|
|
depth: int = 3,
|
|
token_budget: int = 2000,
|
|
repo_root: Optional[str] = None,
|
|
) -> dict:
|
|
"""BFS/DFS traversal from best-matching node with token budget.
|
|
|
|
Free-form graph exploration: finds the node best matching your
|
|
query, then traverses outward via BFS or DFS up to the given
|
|
depth, collecting connected nodes within the token budget.
|
|
|
|
Args:
|
|
query: Search string to find the starting node.
|
|
mode: Traversal mode: "bfs" (breadth-first) or "dfs"
|
|
(depth-first). Default: bfs.
|
|
depth: Max traversal depth (1-6). Default: 3.
|
|
token_budget: Approximate token limit for results.
|
|
Default: 2000.
|
|
repo_root: Repository root path. Auto-detected if omitted.
|
|
"""
|
|
root = _resolve_repo_root(repo_root)
|
|
return with_provenance(traverse_graph_func(
|
|
query=query, mode=mode, depth=depth,
|
|
token_budget=token_budget,
|
|
repo_root=root or "",
|
|
), root)
|
|
|
|
|
|
@mcp.tool()
|
|
def list_repos_tool() -> dict:
|
|
"""List all registered repositories in the multi-repo registry.
|
|
|
|
Returns the list of repos registered at ~/.code-review-graph/registry.json.
|
|
Use the CLI 'register' command to add repos.
|
|
"""
|
|
return list_repos_func()
|
|
|
|
|
|
@mcp.tool()
|
|
def cross_repo_search_tool(
|
|
query: str,
|
|
kind: Optional[str] = None,
|
|
limit: int = 20,
|
|
) -> dict:
|
|
"""Search for code entities across all registered repositories.
|
|
|
|
Runs hybrid search on each registered repo's graph database and interleaves
|
|
results by repository-local rank. Equal ranks follow registry order, and up
|
|
to ``limit`` results per searched repo may be returned. Register repos first
|
|
with the CLI 'register' command.
|
|
|
|
Args:
|
|
query: Search string to match against node names.
|
|
kind: Optional filter: File, Class, Function, Type, or Test.
|
|
limit: Maximum results per repo. Default: 20.
|
|
"""
|
|
return cross_repo_search_func(query=query, kind=kind, limit=limit)
|
|
|
|
|
|
@mcp.prompt()
|
|
def review_changes(base: str = "HEAD~1") -> list[dict]:
|
|
"""Pre-commit review workflow using detect_changes, affected_flows, and test gaps.
|
|
|
|
Produces a structured code review with risk levels and actionable findings.
|
|
|
|
Args:
|
|
base: Git ref to diff against. Default: HEAD~1.
|
|
"""
|
|
return review_changes_prompt(base=base)
|
|
|
|
|
|
@mcp.prompt()
|
|
def architecture_map() -> list[dict]:
|
|
"""Architecture documentation using communities, flows, and Mermaid diagrams.
|
|
|
|
Generates a comprehensive architecture map with module summaries and coupling warnings.
|
|
"""
|
|
return architecture_map_prompt()
|
|
|
|
|
|
@mcp.prompt()
|
|
def debug_issue(description: str = "") -> list[dict]:
|
|
"""Guided debugging using search, flow tracing, and recent changes.
|
|
|
|
Systematic debugging workflow that traces execution paths and identifies root causes.
|
|
|
|
Args:
|
|
description: Description of the issue to debug.
|
|
"""
|
|
return debug_issue_prompt(description=description)
|
|
|
|
|
|
@mcp.prompt()
|
|
def onboard_developer() -> list[dict]:
|
|
"""New developer orientation using stats, architecture, and critical flows.
|
|
|
|
Creates an onboarding guide covering codebase structure, key modules, and patterns.
|
|
"""
|
|
return onboard_developer_prompt()
|
|
|
|
|
|
@mcp.prompt()
|
|
def pre_merge_check(base: str = "HEAD~1") -> list[dict]:
|
|
"""PR readiness check with risk scoring, test gaps, and dead code detection.
|
|
|
|
Produces a merge readiness report with risk assessment and recommendations.
|
|
|
|
Args:
|
|
base: Git ref to diff against. Default: HEAD~1.
|
|
"""
|
|
return pre_merge_check_prompt(base=base)
|
|
|
|
|
|
@mcp.prompt()
|
|
def unified_review(base: str = "HEAD~1") -> list[dict]:
|
|
"""Three-layer unified review (CRG graph context + scoring + dedupe + report).
|
|
|
|
Fuses graph context with the objective scoring metrics, finding merge,
|
|
and the standalone HTML report. READ-ONLY: every finding waits for a
|
|
manual fix decision. Runs at the fixed standard tier (all layers).
|
|
|
|
Args:
|
|
base: Git ref to diff against. Default: HEAD~1.
|
|
"""
|
|
return unified_review_prompt(base=base)
|
|
|
|
|
|
@mcp.prompt()
|
|
def project_review(scope: str = "whole-project", target: str = "") -> list[dict]:
|
|
"""Whole-project or single-feature code review (not diff-based).
|
|
|
|
Reviews the entire codebase (whole-project) or a single feature/module
|
|
(feature) using graph-wide analysis and objective scoring, independent
|
|
of the git diff. READ-ONLY: every finding waits for a manual fix
|
|
decision.
|
|
|
|
Args:
|
|
scope: Review scope. ``whole-project`` reviews every source file;
|
|
``feature`` reviews only the target's code.
|
|
target: Feature/module/function keyword when scope="feature".
|
|
"""
|
|
return project_review_prompt(scope=scope, target=target)
|
|
|
|
|
|
def _apply_tool_filter(tools: str | None = None) -> None:
|
|
"""Remove tools not listed in the allow-list.
|
|
|
|
Accepts a comma-separated string of tool names to keep. When set,
|
|
every registered MCP tool whose name is **not** in the list is
|
|
removed via ``FastMCP.remove_tool()``.
|
|
|
|
The allow-list can be supplied in two ways (first match wins):
|
|
|
|
1. ``tools`` argument (from ``serve --tools ...``).
|
|
2. ``CRG_TOOLS`` environment variable.
|
|
|
|
When neither is set, all tools remain available.
|
|
|
|
This is useful for token-constrained environments: CRG exposes 28+
|
|
tools by default (~8k description tokens per LLM turn). Filtering
|
|
to a working set of 5-10 tools can reduce overhead by 70-85%.
|
|
|
|
Example::
|
|
|
|
# via CLI
|
|
code-review-graph serve --tools query_graph_tool,semantic_search_nodes_tool
|
|
|
|
# via env var
|
|
CRG_TOOLS=query_graph_tool,semantic_search_nodes_tool
|
|
"""
|
|
import asyncio
|
|
import os
|
|
|
|
raw = tools or os.environ.get("CRG_TOOLS")
|
|
if not raw:
|
|
return
|
|
allowed = {t.strip() for t in raw.split(",") if t.strip()}
|
|
if not allowed:
|
|
return
|
|
# FastMCP >=3 exposes tool enumeration via the async ``list_tools``
|
|
# method. ``_apply_tool_filter`` is typically called from
|
|
# ``main()`` before the MCP event loop starts, but tests may invoke
|
|
# it from within a running event loop — in that case ``asyncio.run``
|
|
# raises ``RuntimeError``. Fall back to running the coroutine on a
|
|
# dedicated short-lived loop in a worker thread. Earlier code path
|
|
# relied on ``mcp._tool_manager._tools`` which is a private
|
|
# attribute that was removed in fastmcp>=3.0.
|
|
def _list_tool_names() -> list[str]:
|
|
coro_factory = mcp.list_tools
|
|
try:
|
|
asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return [t.name for t in asyncio.run(coro_factory())]
|
|
import concurrent.futures
|
|
|
|
def _runner() -> list[str]:
|
|
return [t.name for t in asyncio.run(coro_factory())]
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
return pool.submit(_runner).result()
|
|
|
|
for name in _list_tool_names():
|
|
if name not in allowed:
|
|
mcp.local_provider.remove_tool(name)
|
|
|
|
|
|
|
|
def main(
|
|
repo_root: str | None = None,
|
|
tools: str | None = None,
|
|
auto_watch: bool = False,
|
|
*,
|
|
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``
|
|
(used by ``full_build``) over a stdio MCP transport — the combination
|
|
produces silent hangs on ``build_or_update_graph_tool`` and
|
|
``embed_graph_tool``. Switching to ``WindowsSelectorEventLoopPolicy``
|
|
before fastmcp starts its loop avoids the deadlock.
|
|
See: #46, #136
|
|
|
|
Args:
|
|
repo_root: Default repository root for all tool calls.
|
|
tools: Comma-separated list of tool names to expose.
|
|
Falls back to ``CRG_TOOLS`` env var. When unset, all
|
|
tools are available.
|
|
auto_watch: Start filesystem watcher in a background daemon thread
|
|
while the MCP server runs.
|
|
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
|
|
root = Path(repo_root) if repo_root else find_project_root()
|
|
_default_repo_root = str(root)
|
|
_apply_tool_filter(tools)
|
|
|
|
previous_stdio_state = _incremental._MCP_STDIO_ACTIVE
|
|
_incremental._MCP_STDIO_ACTIVE = transport == "stdio"
|
|
watch_store: GraphStore | None = None
|
|
try:
|
|
if auto_watch:
|
|
watch_store = GraphStore(get_db_path(root))
|
|
thread = start_watch_thread(root, watch_store, daemon=True)
|
|
if thread is None:
|
|
logger.warning("Auto-watch was requested but could not be started")
|
|
|
|
if sys.platform == "win32":
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
# Pre-warm sentence-transformers on the main thread before fastmcp's
|
|
# event loop starts. Lazy-loading ``torch`` + tokenizers inside an
|
|
# executor worker thread deadlocks ``semantic_search_nodes_tool`` on
|
|
# Windows stdio MCP (DLL init / OpenMP thread-pool registration grabs
|
|
# locks the loop needs). #385 added ``asyncio.to_thread`` to peer
|
|
# tools but cannot fix this case — the dangerous initialization has
|
|
# to happen on the main thread before any worker thread is spawned.
|
|
from .embeddings import prewarm_local_embeddings
|
|
|
|
prewarm_local_embeddings()
|
|
|
|
if transport == "stdio":
|
|
# Stdio MCP must keep stdout strictly JSON-RPC. FastMCP's banner/update
|
|
# notices corrupt the handshake stream on clients like Codex CLI.
|
|
mcp.run(transport="stdio", show_banner=False)
|
|
elif transport == "streamable-http":
|
|
if host is None or port is None:
|
|
raise ValueError("streamable-http transport requires host and port")
|
|
# Validate Host/Origin on the loopback HTTP endpoint. Without it a web
|
|
# page the user visits can point a hostname it controls at 127.0.0.1
|
|
# (DNS rebinding) and drive the tools, which read the user's code.
|
|
# Non-browser MCP clients send no Origin and are unaffected; see
|
|
# code_review_graph.http_origin_guard.
|
|
from .http_origin_guard import build_http_middleware
|
|
|
|
mcp.run(
|
|
transport="streamable-http",
|
|
host=host,
|
|
port=port,
|
|
middleware=build_http_middleware(host, port),
|
|
)
|
|
else:
|
|
raise ValueError(f"unsupported transport: {transport!r}")
|
|
finally:
|
|
if watch_store is not None:
|
|
watch_store.close()
|
|
_incremental._MCP_STDIO_ACTIVE = previous_stdio_state
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|