chore: init from code-review-graph-main snapshot (v2.3.7)
This commit is contained in:
@@ -0,0 +1,1172 @@
|
||||
"""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
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
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,
|
||||
review_changes_prompt,
|
||||
)
|
||||
from .tools import (
|
||||
apply_refactor_func,
|
||||
build_or_update_graph,
|
||||
cross_repo_search_func,
|
||||
detect_changes_func,
|
||||
embed_graph,
|
||||
find_large_functions,
|
||||
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,
|
||||
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
|
||||
|
||||
|
||||
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",
|
||||
) -> 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.
|
||||
|
||||
Offloaded to a thread via ``asyncio.to_thread`` — runs `git diff`
|
||||
subprocesses and BFS traversals that can take several seconds on
|
||||
large repos. See: #46, #136.
|
||||
|
||||
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)
|
||||
|
||||
def _run() -> dict:
|
||||
return with_provenance(detect_changes_func(
|
||||
base=base, changed_files=changed_files,
|
||||
include_source=include_source, max_depth=max_depth,
|
||||
repo_root=root, detail_level=detail_level,
|
||||
), root)
|
||||
|
||||
coro = asyncio.to_thread(_run)
|
||||
tool_timeout = int(os.environ.get("CRG_TOOL_TIMEOUT", "0"))
|
||||
if tool_timeout > 0:
|
||||
try:
|
||||
return await asyncio.wait_for(coro, timeout=tool_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
message = (
|
||||
f"detect_changes_tool timed out after {tool_timeout}s. "
|
||||
"Reduce scope with CRG_MAX_CHANGED_FUNCS / CRG_MAX_TRANSITIVE_FRONTIER, "
|
||||
"or increase CRG_TOOL_TIMEOUT."
|
||||
)
|
||||
error_response = {
|
||||
"status": "error",
|
||||
"error": message,
|
||||
"summary": message,
|
||||
}
|
||||
return await asyncio.to_thread(with_provenance, error_response, root)
|
||||
return await coro
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user