Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'

- name: Download cgc-windows.exe artifact
uses: actions/download-artifact@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/db-parity-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
python-version: "3.12"
cache: 'pip'

- name: Install dependencies
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,18 @@ jobs:

steps:
- name: Check out code
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.14'
python-version: '3.12'
cache: 'pip'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
pip install -e .[dev,parsing]

- name: Run end-to-end tests
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.14"
python-version: "3.12"
cache: 'pip'

- name: Install system deps
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
prune website
prune images
recursive-include src/codegraphcontext/viz/dist *
recursive-include src/codegraphcontext/roles *.md
recursive-include vscode-extension *
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ package-dir = { "" = "src" }
include-package-data = true

[tool.setuptools.package-data]
codegraphcontext = ["viz/dist/**/*"]
codegraphcontext = ["viz/dist/**/*", "roles/*.md"]

[tool.setuptools.packages.find]
where = ["src"]
Expand Down
32 changes: 30 additions & 2 deletions src/codegraphcontext/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,21 +127,49 @@ def mcp_setup():
configure_mcp_client()

@mcp_app.command("start")
def mcp_start():
def mcp_start(
role: Optional[str] = typer.Option(
None,
"--role",
help="AI role injected into the MCP initialize response: "
"reviewer, architect, auditor, explainer, custom, or neutral. "
"Default: neutral (no injection).",
),
role_file: Optional[str] = typer.Option(
None,
"--role-file",
help="Path to a custom role .md file (max 64 KB). "
"Used with --role custom, or alone to imply custom.",
),
):
"""
Start the CodeGraphContext MCP server.

Starts the server which listens for JSON-RPC requests from stdin.
This is used by IDE integrations (VS Code, Cursor, Zed, etc.).

Role precedence: --role > CGC_MCP_ROLE env > .cgc/config.json > neutral.
"""
console.print("[bold green]Starting CodeGraphContext Server...[/bold green]")
_load_credentials()

# Resolve role prompt before starting the server so errors surface early.
role_prompt = None
if role or role_file:
try:
from codegraphcontext.roles import resolve_role_prompt
role_prompt = resolve_role_prompt(cli_role=role, cli_role_file=role_file)
except ValueError as e:
console.print(f"[bold red]Role Error:[/bold red] {e}")
raise typer.Exit(code=1) from e
if role_prompt:
console.print(f"[dim]AI role active ({role or 'custom'}).[/dim]")

server = None
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
server = MCPServer(loop=loop, cwd=Path.cwd())
server = MCPServer(loop=loop, cwd=Path.cwd(), role_prompt=role_prompt)
loop.run_until_complete(server.run())
except ValueError as e:
# This typically happens if credentials are still not found after all checks.
Expand Down
42 changes: 29 additions & 13 deletions src/codegraphcontext/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,45 +131,61 @@
"""


def build_system_prompt(project_root: Optional[Path] = None) -> str:
def build_system_prompt(
project_root: Optional[Path] = None,
role_prompt: Optional[str] = None,
) -> str:
"""
Build the complete system prompt by prepending custom prompt files.

Args:
project_root: Root directory of the project. If None, uses current directory.

role_prompt: Optional AI role text prepended before everything else.
When None or empty, behaviour is identical to previous versions.

Returns:
Complete system prompt with custom prompts prepended to the base prompt.
Complete system prompt with role (if any) and custom prompts prepended
to the base prompt.
"""
# Import here to avoid circular dependencies
try:
from codegraphcontext.cli.project_config import get_prompt_file_contents

# Get custom prompt contents
custom_prompts = get_prompt_file_contents(project_root)

if not custom_prompts:
# No custom prompts, return base prompt
# No custom prompts, return base prompt (with role prepended if given)
if role_prompt:
return f"{role_prompt}\n\n---\n\n{LLM_SYSTEM_PROMPT}"
return LLM_SYSTEM_PROMPT
# Build combined prompt: custom prompts first, then base prompt

# Build combined prompt: role first (if any), then custom prompts, then base
combined_parts = []


# Add role prompt at the very top so it frames everything below
if role_prompt:
combined_parts.append(role_prompt)

# Add custom prompts
for i, custom_content in enumerate(custom_prompts, 1):
combined_parts.append(f"# Custom Instructions {i}\n\n{custom_content}")

# Add separator and base prompt
combined_parts.append("---\n")
combined_parts.append(LLM_SYSTEM_PROMPT)

return "\n\n".join(combined_parts)

except ImportError:
# If project_config is not available (shouldn't happen in normal use)
logger.warning("Could not import project_config module, using base prompt only")
if role_prompt:
return f"{role_prompt}\n\n---\n\n{LLM_SYSTEM_PROMPT}"
return LLM_SYSTEM_PROMPT
except Exception as e:
# Log error but continue with base prompt to maintain backward compatibility
logger.warning(f"Error loading custom prompts: {e}. Using base prompt only.")
if role_prompt:
return f"{role_prompt}\n\n---\n\n{LLM_SYSTEM_PROMPT}"
return LLM_SYSTEM_PROMPT
181 changes: 181 additions & 0 deletions src/codegraphcontext/roles/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# src/codegraphcontext/roles/__init__.py
"""Configurable AI role prompts for the MCP server.

Roles are loaded once at server startup and injected into the MCP
``initialize`` response (both ``instructions`` and
``serverInfo.systemPrompt``). When no role is configured the injection
is skipped entirely so existing users see byte-identical behaviour.

Precedence for role selection (highest first):

1. ``--role`` CLI flag on ``cgc mcp start``
2. ``CGC_MCP_ROLE`` environment variable
3. ``role`` key in ``.cgc/config.json``
4. Built-in default: *none* (no injection)

``--role-file`` overrides the *content* source regardless of which role
name was selected. Without an explicit role it implies ``custom``.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import Optional

# Built-in role names that ship as .md files alongside this module.
BUILTIN_ROLES = frozenset({"reviewer", "architect", "auditor", "explainer"})

# Every valid --role value the user may pass.
VALID_ROLES = BUILTIN_ROLES | {"custom", "neutral"}

# Role files are injected into every MCP response, so they must stay small.
MAX_ROLE_FILE_BYTES = 64 * 1024 # 64 KB

# The documented default location for a user-authored custom role file.
DEFAULT_CUSTOM_ROLE_PATH = Path.home() / ".codegraphcontext" / "role.md"


def _builtin_role_path(role: str) -> Path:
return Path(__file__).parent / f"{role}.md"


def load_builtin_role(role: str) -> str:
"""Load a built-in role .md file shipped with the package."""
if role not in BUILTIN_ROLES:
raise ValueError(
f"Unknown built-in role {role!r}. Valid roles: {', '.join(sorted(VALID_ROLES))}"
)
path = _builtin_role_path(role)
content = path.read_text(encoding="utf-8").strip()
if not content:
raise ValueError(f"Built-in role file is empty: {path}")
return content


def _is_role_path_allowed(path: Path) -> bool:
"""Check *path* against the path sandbox with documented exceptions.

Role files are user configuration, not code to index, so the
documented config directory ``~/.codegraphcontext/`` is always
allowed regardless of ``CGC_ALLOWED_ROOTS``.

The ``CGC_ALLOW_ALL_PATHS`` escape hatch from #1726 is honoured if
present so role files work identically once that lands.
"""
if os.environ.get("CGC_ALLOW_ALL_PATHS", "").strip().lower() in ("1", "true", "yes"):
return True

resolved = path.resolve()

# Always allow the user config directory.
config_dir = (Path.home() / ".codegraphcontext").resolve()
try:
resolved.relative_to(config_dir)
return True
except ValueError:
pass

# Otherwise apply the standard path sandbox.
from ..utils.path_sandbox import is_path_allowed

return is_path_allowed(resolved)


def load_role_file(path: Path | str) -> str:
"""Load a user-authored role file with validation.

Raises ``ValueError`` with a clear message when the path is not a
regular file, exceeds the size cap, is outside allowed roots, or is
empty.
"""
path = Path(path).expanduser()

if not path.exists():
raise ValueError(f"Role file not found: {path}")

if not path.is_file():
raise ValueError(
f"Role path is not a regular file (directories and special files are not allowed): {path}"
)

if not _is_role_path_allowed(path):
raise ValueError(
f"Role file path is outside allowed roots: {path}. "
f"Place it under ~/.codegraphcontext/ or add its directory to CGC_ALLOWED_ROOTS."
)

size = path.stat().st_size
if size > MAX_ROLE_FILE_BYTES:
raise ValueError(
f"Role file too large: {size} bytes (max {MAX_ROLE_FILE_BYTES}). "
f"Role files are injected into every MCP response and must stay small."
)

content = path.read_text(encoding="utf-8").strip()
if not content:
raise ValueError(f"Role file is empty: {path}")

return content


def resolve_role_prompt(
cli_role: Optional[str] = None,
cli_role_file: Optional[str] = None,
project_root: Optional[Path] = None,
) -> Optional[str]:
"""Resolve the effective role prompt text following the documented precedence.

Returns ``None`` when no role is configured — callers must treat
``None`` as "inject nothing" to preserve byte-identical default
behaviour.

Raises ``ValueError`` for unknown role names or invalid role files.
"""
# --- Select the role name ------------------------------------------------
role_name: Optional[str] = None

if cli_role:
role_name = cli_role
elif os.environ.get("CGC_MCP_ROLE"):
role_name = os.environ["CGC_MCP_ROLE"]
elif cli_role_file:
# --role-file without an explicit --role implies custom.
role_name = "custom"
else:
role_name = _role_from_project_config(project_root)

# Normalise: None, empty, or "neutral" all mean "no injection".
if role_name is None:
return None
role_name = role_name.strip().lower()
if not role_name or role_name == "neutral":
return None

if role_name not in VALID_ROLES:
raise ValueError(
f"Unknown role {role_name!r}. Valid roles: {', '.join(sorted(VALID_ROLES))}"
)

# --- Resolve the content source -----------------------------------------
if role_name == "custom":
source = cli_role_file or str(DEFAULT_CUSTOM_ROLE_PATH)
return load_role_file(source)

# Built-in role: --role-file still overrides the content if given.
if cli_role_file:
return load_role_file(cli_role_file)

return load_builtin_role(role_name)


def _role_from_project_config(project_root: Optional[Path]) -> Optional[str]:
"""Read the ``role`` key from ``.cgc/config.json``, if present."""
try:
from ..cli.project_config import load_project_config

config = load_project_config(project_root)
role = config.get("role")
return str(role) if role else None
except Exception:
return None
10 changes: 10 additions & 0 deletions src/codegraphcontext/roles/architect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Role: Software Architect

You are a software architect. When analyzing code graph data, focus on:

- **Structure**: module boundaries, dependency direction, and layering
- **Coupling**: identify high-fan-out functions, god classes, and circular dependencies
- **Patterns**: recognize established design patterns and where they are broken
- **Evolution**: assess how changes would propagate through the dependency graph

When explaining relationships, trace the path from entry points through the call graph. Suggest structural improvements only when there is evidence of a concrete problem — not as general advice. Reference specific nodes and edges from the graph to ground every claim.
10 changes: 10 additions & 0 deletions src/codegraphcontext/roles/auditor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Role: Code Auditor

You are a code auditor focused on risk and technical debt. When analyzing code graph data, prioritize:

- **Dead code**: functions, classes, and imports with no callers or references
- **Complexity**: functions with high cyclomatic complexity and deep call chains
- **Risk areas**: modules with many dependents where a change has high blast radius
- **Coverage gaps**: code paths that exist in the graph but are never reached from entry points

Quantify findings where possible — "N functions with no callers" rather than "some dead code." Rank issues by risk (high blast radius and high complexity first). Every finding must reference a specific node from the graph.
Loading