codegraphcontext 0.5.1__py3-none-any.whl → 0.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraphcontext/api/app.py +6 -1
- codegraphcontext/api/auth.py +123 -0
- codegraphcontext/api/mcp_sse.py +55 -19
- codegraphcontext/api/router.py +9 -5
- codegraphcontext/cli/cli_helpers.py +236 -25
- codegraphcontext/cli/config_manager.py +159 -22
- codegraphcontext/cli/hook_manager.py +26 -4
- codegraphcontext/cli/main.py +286 -25
- codegraphcontext/cli/project_config.py +218 -0
- codegraphcontext/cli/registry_commands.py +6 -6
- codegraphcontext/cli/setup_wizard.py +61 -56
- codegraphcontext/cli/simulator.py +343 -0
- codegraphcontext/core/bundle_registry.py +45 -6
- codegraphcontext/core/cgc_bundle.py +102 -31
- codegraphcontext/core/cgcignore.py +33 -12
- codegraphcontext/core/database.py +5 -1
- codegraphcontext/core/database_embedded_kuzu.py +1300 -0
- codegraphcontext/core/database_falkordb.py +141 -30
- codegraphcontext/core/database_falkordb_remote.py +31 -11
- codegraphcontext/core/database_kuzu.py +37 -1203
- codegraphcontext/core/database_ladybug.py +37 -1177
- codegraphcontext/core/database_nornic.py +5 -1
- codegraphcontext/core/graph_query.py +45 -0
- codegraphcontext/core/simulator.py +589 -0
- codegraphcontext/core/watcher.py +165 -40
- codegraphcontext/prompts.py +52 -2
- codegraphcontext/server.py +134 -18
- codegraphcontext/tool_definitions.py +171 -20
- codegraphcontext/tools/code_finder.py +55 -45
- codegraphcontext/tools/graph_builder.py +45 -12
- codegraphcontext/tools/handlers/analysis_handlers.py +28 -9
- codegraphcontext/tools/handlers/management_handlers.py +33 -7
- codegraphcontext/tools/handlers/query_handlers.py +10 -4
- codegraphcontext/tools/indexing/discovery.py +6 -2
- codegraphcontext/tools/indexing/embeddings.py +71 -49
- codegraphcontext/tools/indexing/persistence/writer.py +116 -91
- codegraphcontext/tools/indexing/pipeline.py +108 -16
- codegraphcontext/tools/indexing/pre_scan.py +14 -2
- codegraphcontext/tools/indexing/resolution/calls.py +54 -29
- codegraphcontext/tools/indexing/resolution/inheritance.py +9 -1
- codegraphcontext/tools/indexing/resolution/post_resolution.py +11 -20
- codegraphcontext/tools/indexing/schema.py +89 -47
- codegraphcontext/tools/indexing/schema_contract.py +1 -0
- codegraphcontext/tools/indexing/scip_pipeline.py +25 -10
- codegraphcontext/tools/indexing/vector_resolver.py +72 -57
- codegraphcontext/tools/languages/c.py +1 -1
- codegraphcontext/tools/languages/cpp.py +1 -1
- codegraphcontext/tools/languages/css.py +1 -1
- codegraphcontext/tools/languages/dart.py +1 -1
- codegraphcontext/tools/languages/elisp.py +1 -1
- codegraphcontext/tools/languages/elixir.py +3 -3
- codegraphcontext/tools/languages/go.py +3 -3
- codegraphcontext/tools/languages/html.py +1 -1
- codegraphcontext/tools/languages/javascript.py +32 -27
- codegraphcontext/tools/languages/lua.py +1 -1
- codegraphcontext/tools/languages/perl.py +1 -1
- codegraphcontext/tools/languages/python.py +80 -44
- codegraphcontext/tools/languages/ruby.py +2 -2
- codegraphcontext/tools/languages/rust.py +1 -1
- codegraphcontext/tools/languages/typescript.py +31 -27
- codegraphcontext/tools/languages/typescriptjsx.py +3 -3
- codegraphcontext/tools/query_tool_languages/c_toolkit.py +1 -1
- codegraphcontext/tools/query_tool_languages/cpp_toolkit.py +22 -18
- codegraphcontext/tools/query_tool_languages/csharp_toolkit.py +2 -2
- codegraphcontext/tools/query_tool_languages/dart_toolkit.py +2 -2
- codegraphcontext/tools/query_tool_languages/go_toolkit.py +1 -1
- codegraphcontext/tools/query_tool_languages/haskell_toolkit.py +3 -3
- codegraphcontext/tools/query_tool_languages/java_toolkit.py +1 -1
- codegraphcontext/tools/query_tool_languages/javascript_toolkit.py +1 -1
- codegraphcontext/tools/query_tool_languages/perl_toolkit.py +2 -2
- codegraphcontext/tools/query_tool_languages/python_toolkit.py +1 -1
- codegraphcontext/tools/query_tool_languages/ruby_toolkit.py +1 -1
- codegraphcontext/tools/query_tool_languages/rust_toolkit.py +1 -1
- codegraphcontext/tools/query_tool_languages/scala_toolkit.py +2 -2
- codegraphcontext/tools/query_tool_languages/swift_toolkit.py +2 -2
- codegraphcontext/tools/query_tool_languages/typescript_toolkit.py +1 -1
- codegraphcontext/tools/report_generator.py +7 -7
- codegraphcontext/tools/scip_indexer.py +45 -3
- codegraphcontext/tools/system.py +13 -16
- codegraphcontext/tools/tree_sitter_parser.py +41 -96
- codegraphcontext/utils/cypher_ddl.py +80 -0
- codegraphcontext/utils/cypher_readonly.py +5 -0
- codegraphcontext/utils/debug_log.py +5 -0
- codegraphcontext/utils/gcf_encoder.py +58 -0
- codegraphcontext/utils/visualize_graph.py +461 -0
- codegraphcontext/viz/server.py +366 -33
- {codegraphcontext-0.5.1.dist-info → codegraphcontext-0.5.3.dist-info}/METADATA +214 -22
- codegraphcontext-0.5.3.dist-info/RECORD +137 -0
- {codegraphcontext-0.5.1.dist-info → codegraphcontext-0.5.3.dist-info}/WHEEL +1 -1
- codegraphcontext/viz/dist/assets/__vite-browser-external-9wXp6ZBx.js +0 -1
- codegraphcontext/viz/dist/assets/function-calls-BtRHrqa2.png +0 -0
- codegraphcontext/viz/dist/assets/graph-total-D1fBAugo.png +0 -0
- codegraphcontext/viz/dist/assets/hero-graph-2voMJp2a.jpg +0 -0
- codegraphcontext/viz/dist/assets/hierarchy-DGADo0YT.png +0 -0
- codegraphcontext/viz/dist/assets/index-C-187lf0.js +0 -5587
- codegraphcontext/viz/dist/assets/index-fNAa6jgv.css +0 -1
- codegraphcontext/viz/dist/assets/parser-pyodide.worker-BgsDfaad.js +0 -370
- codegraphcontext/viz/dist/assets/parser.worker-_nvrecvj.js +0 -233
- codegraphcontext/viz/dist/assets/tree-sitter-qKYAACSa.wasm +0 -0
- codegraphcontext/viz/dist/cgcIcon.png +0 -0
- codegraphcontext/viz/dist/favicon.ico +0 -0
- codegraphcontext/viz/dist/index.html +0 -32
- codegraphcontext/viz/dist/logo-icon.svg +0 -85
- codegraphcontext/viz/dist/logo.svg +0 -100
- codegraphcontext/viz/dist/placeholder.svg +0 -1
- codegraphcontext/viz/dist/preview-image.png +0 -0
- codegraphcontext/viz/dist/robots.txt +0 -14
- codegraphcontext/viz/dist/wasm/tree-sitter-c.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-core.js +0 -1
- codegraphcontext/viz/dist/wasm/tree-sitter-cpp.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-dart.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-go.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-java.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-javascript.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-kotlin.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-perl.wasm +0 -1
- codegraphcontext/viz/dist/wasm/tree-sitter-php.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-python.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-ruby.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-rust.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-swift.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-tsx.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-typescript.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter.wasm +0 -0
- codegraphcontext/viz/dist/wasm/web-tree-sitter.js +0 -4007
- codegraphcontext/viz/dist/wasm/web-tree-sitter.wasm +0 -0
- codegraphcontext-0.5.1.dist-info/RECORD +0 -167
- {codegraphcontext-0.5.1.dist-info → codegraphcontext-0.5.3.dist-info}/entry_points.txt +0 -0
- {codegraphcontext-0.5.1.dist-info → codegraphcontext-0.5.3.dist-info}/licenses/LICENSE +0 -0
- {codegraphcontext-0.5.1.dist-info → codegraphcontext-0.5.3.dist-info}/top_level.txt +0 -0
codegraphcontext/api/app.py
CHANGED
|
@@ -4,9 +4,14 @@ from fastapi import FastAPI
|
|
|
4
4
|
from fastapi.responses import HTMLResponse
|
|
5
5
|
from fastapi.middleware.cors import CORSMiddleware
|
|
6
6
|
from .router import router
|
|
7
|
+
from .auth import log_auth_status
|
|
7
8
|
from .mcp_sse import handle_sse, handle_messages
|
|
8
9
|
|
|
9
10
|
def create_app() -> FastAPI:
|
|
11
|
+
# Log whether API-key auth is active. Emits a prominent security warning
|
|
12
|
+
# when the gateway is running unauthenticated (CGC_API_KEY unset).
|
|
13
|
+
log_auth_status()
|
|
14
|
+
|
|
10
15
|
app = FastAPI(
|
|
11
16
|
title="CodeGraphContext Gateway",
|
|
12
17
|
description="HTTP API gateway for CodeGraphContext MCP server. Enables integration with ChatGPT Actions, Claude, and web frontends.",
|
|
@@ -61,7 +66,7 @@ def create_app() -> FastAPI:
|
|
|
61
66
|
<a href="/docs" class="btn">View API Docs</a>
|
|
62
67
|
<div class="links">
|
|
63
68
|
<a href="/openapi.json">OpenAPI Spec</a>
|
|
64
|
-
<a href="https://github.com/
|
|
69
|
+
<a href="https://github.com/CodeGraphContext/CodeGraphContext" target="_blank">GitHub</a>
|
|
65
70
|
</div>
|
|
66
71
|
</div>
|
|
67
72
|
</body>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# src/codegraphcontext/api/auth.py
|
|
2
|
+
"""Optional API-key authentication for the CodeGraphContext HTTP API.
|
|
3
|
+
|
|
4
|
+
Backward-compatible and opt-in:
|
|
5
|
+
|
|
6
|
+
* When **no** key is configured, the API behaves exactly as it did before
|
|
7
|
+
(every endpoint is unauthenticated) but a prominent security warning is
|
|
8
|
+
logged at startup so operators know the gateway is wide open.
|
|
9
|
+
* When a key **is** configured (via the ``CGC_API_KEY`` environment variable
|
|
10
|
+
or the standard config mechanism), every protected router endpoint requires
|
|
11
|
+
it, supplied as either ``Authorization: Bearer <key>`` or ``X-API-Key: <key>``.
|
|
12
|
+
Missing/incorrect keys receive an HTTP ``401``.
|
|
13
|
+
|
|
14
|
+
The comparison uses :func:`secrets.compare_digest` to avoid timing side
|
|
15
|
+
channels.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import secrets
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from fastapi import Header, HTTPException
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Config/env key that holds the API key. Reused as the config_manager key so
|
|
28
|
+
# ``cgc config set CGC_API_KEY <value>`` works as well.
|
|
29
|
+
API_KEY_ENV = "CGC_API_KEY"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_configured_api_key() -> Optional[str]:
|
|
33
|
+
"""Return the configured API key, or ``None`` when auth is disabled.
|
|
34
|
+
|
|
35
|
+
Resolution order (highest priority first):
|
|
36
|
+
|
|
37
|
+
1. The ``CGC_API_KEY`` environment variable (read directly so auth works
|
|
38
|
+
even when no config file is present).
|
|
39
|
+
2. The value stored via the existing config mechanism
|
|
40
|
+
(:func:`codegraphcontext.cli.config_manager.get_config_value`).
|
|
41
|
+
|
|
42
|
+
Empty / whitespace-only values are treated as "not configured".
|
|
43
|
+
"""
|
|
44
|
+
env_val = os.getenv(API_KEY_ENV)
|
|
45
|
+
if env_val and env_val.strip():
|
|
46
|
+
return env_val.strip()
|
|
47
|
+
|
|
48
|
+
# Fall back to the existing config mechanism (global/local .env, defaults).
|
|
49
|
+
try:
|
|
50
|
+
from codegraphcontext.cli.config_manager import get_config_value
|
|
51
|
+
|
|
52
|
+
cfg_val = get_config_value(API_KEY_ENV)
|
|
53
|
+
if cfg_val and cfg_val.strip():
|
|
54
|
+
return cfg_val.strip()
|
|
55
|
+
except Exception: # pragma: no cover - config lookup is best-effort here
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_provided_key(
|
|
62
|
+
authorization: Optional[str], x_api_key: Optional[str]
|
|
63
|
+
) -> Optional[str]:
|
|
64
|
+
"""Pull the caller-supplied key from the Authorization or X-API-Key header."""
|
|
65
|
+
if authorization:
|
|
66
|
+
value = authorization.strip()
|
|
67
|
+
if value.lower().startswith("bearer "):
|
|
68
|
+
return value[len("bearer ") :].strip()
|
|
69
|
+
# Accept a bare token in the Authorization header as a convenience.
|
|
70
|
+
return value or None
|
|
71
|
+
if x_api_key and x_api_key.strip():
|
|
72
|
+
return x_api_key.strip()
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _keys_match(provided: str, configured: str) -> bool:
|
|
77
|
+
"""Constant-time comparison that never raises on non-ASCII input."""
|
|
78
|
+
try:
|
|
79
|
+
return secrets.compare_digest(
|
|
80
|
+
provided.encode("utf-8"), configured.encode("utf-8")
|
|
81
|
+
)
|
|
82
|
+
except (TypeError, AttributeError):
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def require_api_key(
|
|
87
|
+
authorization: Optional[str] = Header(default=None),
|
|
88
|
+
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""FastAPI dependency that enforces the API key when one is configured.
|
|
91
|
+
|
|
92
|
+
No-op (backward compatible) when no key is set; otherwise requires a valid
|
|
93
|
+
key via ``Authorization: Bearer <key>`` or ``X-API-Key: <key>``, returning
|
|
94
|
+
HTTP ``401`` on a missing or incorrect key.
|
|
95
|
+
"""
|
|
96
|
+
configured = get_configured_api_key()
|
|
97
|
+
if not configured:
|
|
98
|
+
# Auth disabled -> preserve the pre-existing unauthenticated behavior.
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
provided = _extract_provided_key(authorization, x_api_key)
|
|
102
|
+
if not provided or not _keys_match(provided, configured):
|
|
103
|
+
raise HTTPException(
|
|
104
|
+
status_code=401,
|
|
105
|
+
detail="Invalid or missing API key",
|
|
106
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def log_auth_status() -> None:
|
|
111
|
+
"""Log the authentication status at app startup.
|
|
112
|
+
|
|
113
|
+
Emits a prominent WARNING when the API is unauthenticated so the insecure
|
|
114
|
+
default is impossible to miss in logs.
|
|
115
|
+
"""
|
|
116
|
+
if get_configured_api_key():
|
|
117
|
+
logger.info("API key authentication is ENABLED (CGC_API_KEY is set).")
|
|
118
|
+
else:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"⚠️ CodeGraphContext API is running WITHOUT authentication. "
|
|
121
|
+
"Anyone who can reach this server can index code, run Cypher queries "
|
|
122
|
+
"and call tools. Set CGC_API_KEY to require a key."
|
|
123
|
+
)
|
codegraphcontext/api/mcp_sse.py
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# src/codegraphcontext/api/mcp_sse.py
|
|
2
2
|
import json
|
|
3
3
|
import asyncio
|
|
4
|
-
|
|
4
|
+
import logging
|
|
5
|
+
import anyio
|
|
6
|
+
from fastapi import Request, Response
|
|
5
7
|
from mcp.server import Server
|
|
6
8
|
from mcp.server.models import InitializationOptions
|
|
7
9
|
from mcp.types import Tool, TextContent, ServerCapabilities, ToolsCapability
|
|
8
10
|
from mcp.server.sse import SseServerTransport
|
|
9
|
-
|
|
10
11
|
from codegraphcontext.api.router import get_server
|
|
11
12
|
from codegraphcontext.server import _strip_workspace_prefix, _apply_response_token_limit
|
|
12
13
|
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
13
16
|
# Create the MCP Server instance using the SDK
|
|
14
17
|
mcp_server = Server("CodeGraphContext")
|
|
15
18
|
|
|
@@ -32,38 +35,71 @@ async def handle_call_tool(name: str, arguments: dict | None) -> list[TextConten
|
|
|
32
35
|
server = get_server()
|
|
33
36
|
args = arguments or {}
|
|
34
37
|
|
|
35
|
-
# Execute via the existing handler logic
|
|
36
38
|
result = await server.handle_tool_call(name, args)
|
|
37
39
|
result = _strip_workspace_prefix(result)
|
|
38
40
|
|
|
39
41
|
if "error" in result:
|
|
40
42
|
return [TextContent(type="text", text=f"Error: {result['error']}")]
|
|
41
43
|
|
|
42
|
-
# Format result as JSON string for the AI, with the same token budget
|
|
43
|
-
# the stdio transport applies.
|
|
44
44
|
response_text = json.dumps(result, indent=2)
|
|
45
45
|
response_text = _apply_response_token_limit(name, response_text)
|
|
46
46
|
return [TextContent(type="text", text=response_text)]
|
|
47
47
|
|
|
48
|
-
# Create the SSE transport.
|
|
49
|
-
# The messages_url is where the client will POST JSON-RPC messages.
|
|
48
|
+
# Create the SSE transport.
|
|
50
49
|
sse = SseServerTransport("/api/v1/mcp/messages")
|
|
51
50
|
|
|
52
51
|
async def handle_sse(request: Request):
|
|
53
52
|
"""Entry point for the SSE connection."""
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
logger.info("SSE client connected")
|
|
54
|
+
try:
|
|
55
|
+
async with sse.connect_sse(request.scope, request.receive, request._send) as (read_stream, write_stream):
|
|
56
|
+
await mcp_server.run(
|
|
57
|
+
read_stream,
|
|
58
|
+
write_stream,
|
|
59
|
+
InitializationOptions(
|
|
60
|
+
server_name="CodeGraphContext",
|
|
61
|
+
server_version="0.1.0",
|
|
62
|
+
capabilities=ServerCapabilities(
|
|
63
|
+
tools=ToolsCapability(listChanged=False)
|
|
64
|
+
)
|
|
63
65
|
)
|
|
64
66
|
)
|
|
65
|
-
|
|
67
|
+
except anyio.EndOfStream:
|
|
68
|
+
logger.debug("SSE client disconnected cleanly (stream ended)")
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
logger.debug("SSE connection closed: %s", type(exc).__name__)
|
|
71
|
+
finally:
|
|
72
|
+
logger.info("SSE client disconnected — handler exited, resources freed")
|
|
73
|
+
|
|
66
74
|
|
|
67
75
|
async def handle_messages(request: Request):
|
|
68
|
-
"""Endpoint for receiving messages from the client.
|
|
69
|
-
|
|
76
|
+
"""Endpoint for receiving messages from the client.
|
|
77
|
+
|
|
78
|
+
Uses a buffer framing collector to ensure the full JSON-RPC payload
|
|
79
|
+
is received before processing. This prevents crashes caused by large
|
|
80
|
+
responses being split across SSE line boundaries.
|
|
81
|
+
"""
|
|
82
|
+
# Buffer the complete request body before any parsing occurs.
|
|
83
|
+
# request.body() accumulates all chunks, preventing partial JSON reads.
|
|
84
|
+
raw_body = await request.body()
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
json.loads(raw_body)
|
|
88
|
+
except json.JSONDecodeError as e:
|
|
89
|
+
return Response(
|
|
90
|
+
content=json.dumps({
|
|
91
|
+
"jsonrpc": "2.0",
|
|
92
|
+
"error": {
|
|
93
|
+
"code": -32700,
|
|
94
|
+
"message": f"Parse error: incomplete or malformed JSON-RPC message: {e}"
|
|
95
|
+
},
|
|
96
|
+
"id": None
|
|
97
|
+
}),
|
|
98
|
+
status_code=400,
|
|
99
|
+
media_type="application/json"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
await sse.handle_post_message(request.scope, request.receive, request._send)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
logger.debug("Message handler closed: %s", type(exc).__name__)
|
codegraphcontext/api/router.py
CHANGED
|
@@ -5,17 +5,21 @@ from typing import Dict, Any, List
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from .schemas import (
|
|
8
|
-
IndexRequest,
|
|
9
|
-
QueryRequest,
|
|
10
|
-
SearchRequest,
|
|
11
|
-
ToolCallRequest,
|
|
8
|
+
IndexRequest,
|
|
9
|
+
QueryRequest,
|
|
10
|
+
SearchRequest,
|
|
11
|
+
ToolCallRequest,
|
|
12
12
|
ApiResponse
|
|
13
13
|
)
|
|
14
|
+
from .auth import require_api_key
|
|
14
15
|
from codegraphcontext.server import MCPServer
|
|
15
16
|
|
|
16
17
|
import socket
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
# Optional API-key auth is enforced on every route below via require_api_key.
|
|
20
|
+
# It is a no-op (backward compatible) unless CGC_API_KEY is configured, in
|
|
21
|
+
# which case each request must supply the key. See codegraphcontext.api.auth.
|
|
22
|
+
router = APIRouter(dependencies=[Depends(require_api_key)])
|
|
19
23
|
|
|
20
24
|
# Global server instance (initialized on startup)
|
|
21
25
|
_server_instance: MCPServer = None
|
|
@@ -9,6 +9,7 @@ import time
|
|
|
9
9
|
import os
|
|
10
10
|
from typing import Optional, List, Dict, Any
|
|
11
11
|
import typer
|
|
12
|
+
from rich import box
|
|
12
13
|
from rich.console import Console
|
|
13
14
|
from rich.table import Table
|
|
14
15
|
from rich.progress import (
|
|
@@ -85,6 +86,45 @@ def _print_call_resolution_diagnostics(graph_builder: GraphBuilder, limit: int =
|
|
|
85
86
|
console.print(table)
|
|
86
87
|
|
|
87
88
|
|
|
89
|
+
def _format_extension_counts(files_by_extension: Dict[str, int]) -> str:
|
|
90
|
+
if not files_by_extension:
|
|
91
|
+
return "None"
|
|
92
|
+
return ", ".join(
|
|
93
|
+
f"{extension}: {count}" for extension, count in files_by_extension.items()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _print_index_execution_summary(graph_builder: GraphBuilder) -> None:
|
|
98
|
+
summary = getattr(graph_builder, "last_index_summary", None)
|
|
99
|
+
if not summary:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
table = Table(
|
|
103
|
+
title="CGC Index Execution Summary",
|
|
104
|
+
show_header=True,
|
|
105
|
+
header_style="bold cyan",
|
|
106
|
+
box=box.ASCII,
|
|
107
|
+
)
|
|
108
|
+
table.add_column("Metric", style="cyan", no_wrap=True)
|
|
109
|
+
table.add_column("Value", style="green", overflow="fold")
|
|
110
|
+
table.add_row("Total scanned files", str(summary.get("total_scanned_files", 0)))
|
|
111
|
+
table.add_row(
|
|
112
|
+
"Files by extension",
|
|
113
|
+
_format_extension_counts(summary.get("files_by_extension", {})),
|
|
114
|
+
)
|
|
115
|
+
failed_files = summary.get("failed_files", 0)
|
|
116
|
+
if failed_files:
|
|
117
|
+
table.add_row("[red]Files failed to parse[/red]", f"[red]{failed_files}[/red]")
|
|
118
|
+
table.add_row("Function nodes", str(summary.get("function_nodes", 0)))
|
|
119
|
+
table.add_row("Class nodes", str(summary.get("class_nodes", 0)))
|
|
120
|
+
table.add_row("CALLS edges", str(summary.get("call_edges", 0)))
|
|
121
|
+
table.add_row(
|
|
122
|
+
"Serialization seconds",
|
|
123
|
+
f"{summary.get('serialization_seconds', 0.0):.2f}",
|
|
124
|
+
)
|
|
125
|
+
console.print(table)
|
|
126
|
+
|
|
127
|
+
|
|
88
128
|
def _initialize_services(
|
|
89
129
|
cli_context_flag: Optional[str] = None,
|
|
90
130
|
cwd: Optional[Path] = None,
|
|
@@ -315,7 +355,8 @@ def index_helper(path: str, context: Optional[str] = None):
|
|
|
315
355
|
time_end = time.time()
|
|
316
356
|
elapsed = time_end - time_start
|
|
317
357
|
_print_call_resolution_diagnostics(graph_builder)
|
|
318
|
-
|
|
358
|
+
_print_index_execution_summary(graph_builder)
|
|
359
|
+
console.print(f"[green]Successfully finished indexing: {path_obj} in {elapsed:.2f} seconds[/green]")
|
|
319
360
|
|
|
320
361
|
# Check if auto-watch is enabled
|
|
321
362
|
try:
|
|
@@ -363,6 +404,7 @@ def add_package_helper(package_name: str, language: str, context: Optional[str]
|
|
|
363
404
|
try:
|
|
364
405
|
asyncio.run(_run_index_with_progress(graph_builder, package_path, is_dependency=True, cgcignore_path=ctx.cgcignore_path))
|
|
365
406
|
_print_call_resolution_diagnostics(graph_builder)
|
|
407
|
+
_print_index_execution_summary(graph_builder)
|
|
366
408
|
console.print(f"[green]Successfully finished indexing package: {package_name}[/green]")
|
|
367
409
|
except Exception as e:
|
|
368
410
|
console.print(f"[bold red]An error occurred during package indexing:[/bold red] {e}")
|
|
@@ -415,8 +457,13 @@ def delete_helper(repo_path: str, context: Optional[str] = None):
|
|
|
415
457
|
else:
|
|
416
458
|
console.print(f"[yellow]Repository not found in graph: {repo_path}[/yellow]")
|
|
417
459
|
console.print("[dim]Tip: Use 'cgc list' to see available repositories.[/dim]")
|
|
460
|
+
raise typer.Exit(code=1)
|
|
461
|
+
except typer.Exit:
|
|
462
|
+
# Control flow, not an error — let it through so the exit code survives.
|
|
463
|
+
raise
|
|
418
464
|
except Exception as e:
|
|
419
465
|
console.print(f"[bold red]An error occurred:[/bold red] {e}")
|
|
466
|
+
raise typer.Exit(code=1)
|
|
420
467
|
finally:
|
|
421
468
|
db_manager.close_driver()
|
|
422
469
|
|
|
@@ -480,7 +527,13 @@ def cypher_helper(query: str, context: Optional[str] = None):
|
|
|
480
527
|
raise typer.Exit(code=1)
|
|
481
528
|
|
|
482
529
|
backend = getattr(db_manager, "get_backend_type", lambda: "neo4j")()
|
|
483
|
-
|
|
530
|
+
# Neo4j honours default_access_mode natively; FalkorDB routes READ sessions
|
|
531
|
+
# through GRAPH.RO_QUERY for server-enforced read-only execution.
|
|
532
|
+
session_kwargs = (
|
|
533
|
+
{"default_access_mode": "READ"}
|
|
534
|
+
if backend in ("neo4j", "falkordb", "falkordb-remote")
|
|
535
|
+
else {}
|
|
536
|
+
)
|
|
484
537
|
|
|
485
538
|
try:
|
|
486
539
|
with db_manager.get_driver().session(**session_kwargs) as session:
|
|
@@ -525,8 +578,122 @@ import uvicorn
|
|
|
525
578
|
import urllib.parse
|
|
526
579
|
from ..viz.server import run_server, set_db_manager
|
|
527
580
|
|
|
581
|
+
_OFFLINE_VIZ_DEFAULT_QUERY = """
|
|
582
|
+
MATCH (n)-[r]->(m)
|
|
583
|
+
RETURN n, r, m
|
|
584
|
+
LIMIT 300
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _render_offline_visualization(
|
|
589
|
+
db_manager,
|
|
590
|
+
repo_path: Optional[str] = None,
|
|
591
|
+
cypher_query: Optional[str] = None,
|
|
592
|
+
output_path: Optional[str] = None,
|
|
593
|
+
) -> None:
|
|
594
|
+
"""Render the graph with the dependency-free single-file HTML renderer.
|
|
595
|
+
|
|
596
|
+
The React frontend in ``viz/dist`` is not shipped in the wheel, which made
|
|
597
|
+
every visualization entry point a hard failure on a plain ``pip install``.
|
|
598
|
+
``utils/visualize_graph.py`` is a complete, zero-dependency renderer that
|
|
599
|
+
was sitting in the package with no callers — this wires it up so the
|
|
600
|
+
command degrades to a working (simpler) view instead of exiting 1.
|
|
601
|
+
"""
|
|
602
|
+
from ..utils.visualize_graph import open_in_browser, build_graph_data
|
|
603
|
+
|
|
604
|
+
query = cypher_query or _OFFLINE_VIZ_DEFAULT_QUERY
|
|
605
|
+
nodes: Dict[Any, Dict[str, Any]] = {}
|
|
606
|
+
edges: List[Dict[str, Any]] = []
|
|
607
|
+
|
|
608
|
+
def _ident(value) -> Optional[str]:
|
|
609
|
+
return None if value is None else str(value)
|
|
610
|
+
|
|
611
|
+
# Neo4j/Falkor return driver objects carrying .labels / .type; Kùzu and
|
|
612
|
+
# Ladybug return plain dicts carrying _label plus _src/_dst. Check the
|
|
613
|
+
# driver attributes first — a driver object may also be dict-like.
|
|
614
|
+
def _is_relationship(value) -> bool:
|
|
615
|
+
if hasattr(value, "labels"):
|
|
616
|
+
return False
|
|
617
|
+
if hasattr(value, "type"):
|
|
618
|
+
return True
|
|
619
|
+
return isinstance(value, dict) and "_src" in value and "_dst" in value
|
|
620
|
+
|
|
621
|
+
def _is_node(value) -> bool:
|
|
622
|
+
if hasattr(value, "labels"):
|
|
623
|
+
return True
|
|
624
|
+
if hasattr(value, "type"):
|
|
625
|
+
return False
|
|
626
|
+
# Kùzu relationships also carry _label, so _src/_dst is what
|
|
627
|
+
# distinguishes them from nodes.
|
|
628
|
+
return isinstance(value, dict) and "_label" in value and "_src" not in value
|
|
629
|
+
|
|
630
|
+
def _node_payload(value) -> Dict[str, Any]:
|
|
631
|
+
if hasattr(value, "labels"):
|
|
632
|
+
props = dict(value)
|
|
633
|
+
labels = list(getattr(value, "labels", []) or [])
|
|
634
|
+
label = labels[0] if labels else "Node"
|
|
635
|
+
node_id = getattr(value, "element_id", None) or getattr(value, "id", None)
|
|
636
|
+
else:
|
|
637
|
+
props, label = dict(value), value.get("_label", "Node")
|
|
638
|
+
node_id = value.get("_id")
|
|
639
|
+
if node_id is None:
|
|
640
|
+
node_id = props.get("path") or props.get("name")
|
|
641
|
+
return {
|
|
642
|
+
"id": _ident(node_id),
|
|
643
|
+
"label": label,
|
|
644
|
+
"name": props.get("name") or props.get("path") or "",
|
|
645
|
+
"file_path": props.get("path", ""),
|
|
646
|
+
"line_number": props.get("line_number"),
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
def _edge_payload(value) -> Optional[Dict[str, Any]]:
|
|
650
|
+
start = getattr(value, "start_node", None)
|
|
651
|
+
end = getattr(value, "end_node", None)
|
|
652
|
+
if start is not None and end is not None:
|
|
653
|
+
return {
|
|
654
|
+
"source": _ident(getattr(start, "element_id", None) or getattr(start, "id", None)),
|
|
655
|
+
"target": _ident(getattr(end, "element_id", None) or getattr(end, "id", None)),
|
|
656
|
+
"type": getattr(value, "type", "RELATED"),
|
|
657
|
+
}
|
|
658
|
+
if isinstance(value, dict):
|
|
659
|
+
return {
|
|
660
|
+
"source": _ident(value.get("_src")),
|
|
661
|
+
"target": _ident(value.get("_dst")),
|
|
662
|
+
"type": value.get("_label", "RELATED"),
|
|
663
|
+
}
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
with db_manager.get_driver().session() as session:
|
|
667
|
+
for record in session.run(query):
|
|
668
|
+
values = list(record.values()) if hasattr(record, "values") else list(record)
|
|
669
|
+
for value in values:
|
|
670
|
+
if _is_node(value):
|
|
671
|
+
payload = _node_payload(value)
|
|
672
|
+
if payload["id"] is not None:
|
|
673
|
+
nodes[payload["id"]] = payload
|
|
674
|
+
elif _is_relationship(value):
|
|
675
|
+
edge = _edge_payload(value)
|
|
676
|
+
if edge and edge["source"] and edge["target"]:
|
|
677
|
+
edges.append(edge)
|
|
678
|
+
|
|
679
|
+
if not nodes:
|
|
680
|
+
console.print(
|
|
681
|
+
"[yellow]No graph data returned — index a repository first "
|
|
682
|
+
"with 'cgc index <path>'.[/yellow]"
|
|
683
|
+
)
|
|
684
|
+
raise typer.Exit(code=1)
|
|
685
|
+
|
|
686
|
+
title = f"CGC Code Graph — {Path(repo_path).name}" if repo_path else "CGC Code Graph"
|
|
687
|
+
graph_data = build_graph_data(list(nodes.values()), edges)
|
|
688
|
+
path = open_in_browser(graph_data, title=title, output_path=output_path)
|
|
689
|
+
console.print(
|
|
690
|
+
f"[green]Rendered {len(nodes)} nodes and {len(edges)} edges to[/green] {path}"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
528
694
|
def visualize_helper(
|
|
529
695
|
repo_path: Optional[str] = None,
|
|
696
|
+
host: str = "127.0.0.1",
|
|
530
697
|
port: int = 8000,
|
|
531
698
|
context: Optional[str] = None,
|
|
532
699
|
cypher_query: Optional[str] = None,
|
|
@@ -585,8 +752,15 @@ def visualize_helper(
|
|
|
585
752
|
"[dim]then sync[/dim] [cyan]website/dist[/cyan] [dim]→[/dim] "
|
|
586
753
|
"[cyan]src/codegraphcontext/viz/dist[/cyan][dim].[/dim]"
|
|
587
754
|
)
|
|
588
|
-
|
|
589
|
-
|
|
755
|
+
console.print(
|
|
756
|
+
"\n[yellow]Falling back to the built-in offline renderer.[/yellow]"
|
|
757
|
+
)
|
|
758
|
+
try:
|
|
759
|
+
return _render_offline_visualization(
|
|
760
|
+
db_manager, repo_path=repo_path, cypher_query=cypher_query
|
|
761
|
+
)
|
|
762
|
+
finally:
|
|
763
|
+
db_manager.close_driver()
|
|
590
764
|
|
|
591
765
|
index_html = static_dir / "index.html"
|
|
592
766
|
if not index_html.is_file():
|
|
@@ -621,7 +795,7 @@ def visualize_helper(
|
|
|
621
795
|
threading.Thread(target=open_browser, daemon=True).start()
|
|
622
796
|
|
|
623
797
|
try:
|
|
624
|
-
run_server(host=
|
|
798
|
+
run_server(host=host, port=port, static_dir=str(static_dir))
|
|
625
799
|
except Exception as e:
|
|
626
800
|
console.print(f"[bold red]An error occurred while running the server:[/bold red] {e}")
|
|
627
801
|
raise typer.Exit(code=1)
|
|
@@ -666,6 +840,7 @@ def reindex_helper(path: str, context: Optional[str] = None):
|
|
|
666
840
|
time_end = time.time()
|
|
667
841
|
elapsed = time_end - time_start
|
|
668
842
|
_print_call_resolution_diagnostics(graph_builder)
|
|
843
|
+
_print_index_execution_summary(graph_builder)
|
|
669
844
|
console.print(f"[green]Successfully re-indexed: {path} in {elapsed:.2f} seconds[/green]")
|
|
670
845
|
except Exception as e:
|
|
671
846
|
console.print(f"[bold red]An error occurred during re-indexing:[/bold red] {e}")
|
|
@@ -774,17 +949,23 @@ def stats_helper(path: str = None, context: Optional[str] = None):
|
|
|
774
949
|
return
|
|
775
950
|
|
|
776
951
|
# Get stats
|
|
777
|
-
# Get stats using separate queries to handle depth and avoid Cartesian products
|
|
952
|
+
# Get stats using separate queries to handle depth and avoid Cartesian products.
|
|
953
|
+
# `count(x)` counts *rows*, and a variable-length CONTAINS* match
|
|
954
|
+
# yields one row per distinct path to the node: a method is
|
|
955
|
+
# reachable as Repo->..->File->Function and again as
|
|
956
|
+
# Repo->..->File->Class->Function, a nested function three ways.
|
|
957
|
+
# count(DISTINCT x) is required or the totals are inflated —
|
|
958
|
+
# functions were over-reported by ~55% on CGC's own repo.
|
|
778
959
|
# 1. Files
|
|
779
|
-
file_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(f:File) RETURN count(f) as c"
|
|
960
|
+
file_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(f:File) RETURN count(DISTINCT f) as c"
|
|
780
961
|
file_count = session.run(file_query, path=repo_path_str).single()["c"]
|
|
781
|
-
|
|
962
|
+
|
|
782
963
|
# 2. Functions (including methods in classes)
|
|
783
|
-
func_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(func:Function) RETURN count(func) as c"
|
|
964
|
+
func_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(func:Function) RETURN count(DISTINCT func) as c"
|
|
784
965
|
func_count = session.run(func_query, path=repo_path_str).single()["c"]
|
|
785
|
-
|
|
966
|
+
|
|
786
967
|
# 3. Classes
|
|
787
|
-
class_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(c:Class) RETURN count(c) as c"
|
|
968
|
+
class_query = "MATCH (r:Repository {path: $path})-[:CONTAINS*]->(c:Class) RETURN count(DISTINCT c) as c"
|
|
788
969
|
class_count = session.run(class_query, path=repo_path_str).single()["c"]
|
|
789
970
|
|
|
790
971
|
# 4. Modules (imported) - Note: Module nodes are outside the repo structure usually, connected via IMPORTS
|
|
@@ -850,7 +1031,12 @@ def stats_helper(path: str = None, context: Optional[str] = None):
|
|
|
850
1031
|
db_manager.close_driver()
|
|
851
1032
|
|
|
852
1033
|
|
|
853
|
-
def watch_helper(
|
|
1034
|
+
def watch_helper(
|
|
1035
|
+
path: str,
|
|
1036
|
+
context: Optional[str] = None,
|
|
1037
|
+
use_polling: Optional[bool] = None,
|
|
1038
|
+
sync_on_start: bool = False,
|
|
1039
|
+
):
|
|
854
1040
|
"""Watch a directory for changes and auto-update the graph (blocking mode)."""
|
|
855
1041
|
import logging
|
|
856
1042
|
from ..core.watcher import CodeWatcher
|
|
@@ -913,11 +1099,17 @@ def watch_helper(path: str, context: Optional[str] = None, use_polling: Optional
|
|
|
913
1099
|
|
|
914
1100
|
# Add the directory to watch
|
|
915
1101
|
if is_indexed:
|
|
916
|
-
|
|
1102
|
+
if sync_on_start:
|
|
1103
|
+
console.print("[green]✓[/green] Already indexed. Synchronizing current files...")
|
|
1104
|
+
else:
|
|
1105
|
+
console.print("[green]✓[/green] Already indexed. Watching for future changes only.")
|
|
1106
|
+
console.print(
|
|
1107
|
+
"[dim]Use 'cgc index --force' or 'cgc watch --sync-on-start' to reconcile existing changes.[/dim]"
|
|
1108
|
+
)
|
|
917
1109
|
watcher.watch_directory(
|
|
918
1110
|
str(path_obj),
|
|
919
1111
|
perform_initial_scan=False,
|
|
920
|
-
sync_on_start=
|
|
1112
|
+
sync_on_start=sync_on_start,
|
|
921
1113
|
cgcignore_path=ctx.cgcignore_path,
|
|
922
1114
|
)
|
|
923
1115
|
else:
|
|
@@ -960,24 +1152,43 @@ def watch_helper(path: str, context: Optional[str] = None, use_polling: Optional
|
|
|
960
1152
|
finally:
|
|
961
1153
|
watcher.stop()
|
|
962
1154
|
db_manager.close_driver()
|
|
963
|
-
console.print("[green]✓[/green] Watcher stopped.
|
|
1155
|
+
console.print("[green]✓[/green] Watcher stopped.")
|
|
964
1156
|
|
|
965
1157
|
|
|
966
1158
|
|
|
967
1159
|
def unwatch_helper(path: str):
|
|
968
|
-
"""
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1160
|
+
"""Report that unwatching is not available from the CLI.
|
|
1161
|
+
|
|
1162
|
+
This command touches no watcher state at all — it never has. It used to
|
|
1163
|
+
print a note, echo the requested path back as "Path specified:" (which
|
|
1164
|
+
reads like confirmation that something happened, even for a path that was
|
|
1165
|
+
never watched and does not exist) and exit 0.
|
|
1166
|
+
"""
|
|
1167
|
+
console.print(
|
|
1168
|
+
"[bold red]Error:[/bold red] 'cgc unwatch' is not supported from the CLI — "
|
|
1169
|
+
"it cannot reach a watcher started in another process."
|
|
1170
|
+
)
|
|
1171
|
+
console.print("[dim]For CLI watch mode, press Ctrl+C in the terminal running 'cgc watch'.[/dim]")
|
|
1172
|
+
console.print(
|
|
1173
|
+
"[dim]For MCP mode, call the 'unwatch_directory' tool from your IDE.[/dim]"
|
|
1174
|
+
)
|
|
1175
|
+
raise typer.Exit(code=1)
|
|
972
1176
|
|
|
973
1177
|
|
|
974
1178
|
def list_watching_helper():
|
|
975
|
-
"""
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
console.print(
|
|
1179
|
+
"""Report that listing watched paths is not available from the CLI.
|
|
1180
|
+
|
|
1181
|
+
Same as unwatch: no watcher state is consulted, so exiting 0 advertised a
|
|
1182
|
+
successful listing of nothing.
|
|
1183
|
+
"""
|
|
1184
|
+
console.print(
|
|
1185
|
+
"[bold red]Error:[/bold red] 'cgc watching' is not supported from the CLI — "
|
|
1186
|
+
"it cannot reach a watcher started in another process."
|
|
1187
|
+
)
|
|
1188
|
+
console.print("[dim]For CLI watch mode, check the terminal where you ran 'cgc watch'.[/dim]")
|
|
1189
|
+
console.print("[dim]For MCP mode: start 'cgc mcp start', then call the "
|
|
1190
|
+
"'list_watched_paths' tool from your IDE.[/dim]")
|
|
1191
|
+
raise typer.Exit(code=1)
|
|
981
1192
|
|
|
982
1193
|
|
|
983
1194
|
def setup_scip_helper() -> None:
|