Skip to content
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,15 @@ The server provides Azure DevOps integration for monitoring and analyzing Azure

### Lean MCP Interface (Context-Optimized)

The server provides an alternative **lean interface** that reduces context consumption by ~97% (from ~30k tokens to ~900 tokens). Instead of exposing all 56 tools upfront, it uses a 3-meta-tool pattern:
The server provides an alternative **lean interface** that reduces context consumption by ~97% (from ~30k tokens to ~900 tokens). Instead of exposing all 63 tools upfront, it uses a 3-meta-tool pattern:

| Meta-Tool | Purpose |
|-----------|---------|
| `discover_tools(pattern)` | List available tools with optional filtering |
| `get_tool_spec(tool_name)` | Get full schema for a specific tool on-demand |
| `execute_tool(tool_name, params)` | Execute any tool dynamically |

**Tool Coverage**: All 55 tools remain accessible (27 git, 25 GitHub, 3 Azure DevOps).
**Tool Coverage**: All 62 tools remain accessible (27 git, 32 GitHub, 3 Azure DevOps).

**Usage Example**:
```python
Expand Down
106 changes: 82 additions & 24 deletions src/mcp_server_git/git/_stash_ops.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Stash operations for MCP Git Server."""

import logging
import os
import re
import subprocess

from ..utils.git_import import GitCommandError, Repo
from ..utils.git_import import Repo

logger = logging.getLogger(__name__)

Expand All @@ -13,64 +16,119 @@
"git_stash_drop",
]

# Git environment variables that can interfere with worktree stash operations.
# Stripping these lets git auto-detect the repo from the working directory,
# which matches what a plain shell `git stash` does.
_GIT_ENV_KEYS = frozenset(
{"GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"}
)

_STASH_ID_RE = re.compile(r"^stash@\{\d+\}$")


def _clean_git_env() -> dict[str, str]:
"""Return os.environ without keys that confuse git in worktrees."""
return {k: v for k, v in os.environ.items() if k not in _GIT_ENV_KEYS}


def _run_git(args: list[str], cwd: str) -> subprocess.CompletedProcess[str]:
"""Run a git command in *cwd* with a worktree-safe environment."""
return subprocess.run(
["git", *args],
cwd=cwd,
env=_clean_git_env(),
capture_output=True,
text=True,
)


def git_stash_list(repo: Repo) -> str:
"""List all stashes"""
try:
stash_list = repo.git.stash("list")
if not stash_list.strip():
if repo.working_dir is None:
return "❌ Stash operation requires a non-bare repository"
result = _run_git(["stash", "list"], cwd=repo.working_dir)
if result.returncode != 0:
return f"❌ Stash list failed: {result.stderr.strip() or result.stdout.strip()}"
output = result.stdout.strip()
if not output:
return "No stashes found"
return f"Stash list:\n{stash_list}"
except GitCommandError as e:
return f"❌ Stash list failed: {str(e)}"
return f"Stash list:\n{output}"
except Exception as e:
return f"❌ Stash list error: {str(e)}"


def git_stash_push(
repo: Repo, message: str | None = None, include_untracked: bool = False
) -> str:
"""Create a new stash"""
"""Create a new stash.

Uses subprocess rather than repo.git.stash() to avoid GitPython's GIT_DIR
handling that causes exit-code-1 failures in git linked worktrees (#189).
"""
try:
args = ["push"]
if repo.working_dir is None:
return "❌ Stash operation requires a non-bare repository"
args = ["stash", "push"]
if include_untracked:
args.append("--include-untracked")
if message:
args.extend(["-m", message])

repo.git.stash(*args)
return "✅ Successfully created stash" + (f": {message}" if message else "")
except GitCommandError as e:
return f"❌ Stash push failed: {str(e)}"
result = _run_git(args, cwd=repo.working_dir)

if result.returncode == 0:
return "✅ Successfully created stash" + (f": {message}" if message else "")

combined = result.stdout.strip() or result.stderr.strip()
if result.returncode == 1 and "No local changes to save" in combined:
return "ℹ️ No local changes to stash"

error = result.stderr.strip() or result.stdout.strip()
return f"❌ Stash push failed: {error}"
except Exception as e:
return f"❌ Stash push error: {str(e)}"


def git_stash_pop(repo: Repo, stash_id: str | None = None) -> str:
"""Apply and remove a stash"""
try:
if repo.working_dir is None:
return "❌ Stash operation requires a non-bare repository"
if stash_id and not _STASH_ID_RE.match(stash_id):
return f"❌ Invalid stash ID: {stash_id!r} (expected format: stash@{{N}})"
args = ["stash", "pop"]
if stash_id:
args.append(stash_id)

result = _run_git(args, cwd=repo.working_dir)
if result.returncode != 0:
return f"❌ Stash pop failed: {result.stderr.strip() or result.stdout.strip()}"

if stash_id:
repo.git.stash("pop", stash_id)
return f"✅ Successfully popped stash {stash_id}"
else:
repo.git.stash("pop")
return "✅ Successfully popped latest stash"
except GitCommandError as e:
return f"❌ Stash pop failed: {str(e)}"
return "✅ Successfully popped latest stash"
except Exception as e:
return f"❌ Stash pop error: {str(e)}"


def git_stash_drop(repo: Repo, stash_id: str | None = None) -> str:
"""Remove a stash without applying it"""
try:
if repo.working_dir is None:
return "❌ Stash operation requires a non-bare repository"
if stash_id and not _STASH_ID_RE.match(stash_id):
return f"❌ Invalid stash ID: {stash_id!r} (expected format: stash@{{N}})"
args = ["stash", "drop"]
if stash_id:
args.append(stash_id)

result = _run_git(args, cwd=repo.working_dir)
if result.returncode != 0:
return f"❌ Stash drop failed: {result.stderr.strip() or result.stdout.strip()}"

if stash_id:
repo.git.stash("drop", stash_id)
return f"✅ Successfully dropped stash {stash_id}"
else:
repo.git.stash("drop")
return "✅ Successfully dropped latest stash"
except GitCommandError as e:
return f"❌ Stash drop failed: {str(e)}"
return "✅ Successfully dropped latest stash"
except Exception as e:
return f"❌ Stash drop error: {str(e)}"
1 change: 1 addition & 0 deletions src/mcp_server_git/github/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .release_assets import * # noqa: F401,F403
from .releases import * # noqa: F401,F403
from .repos import * # noqa: F401,F403
from .scanning import * # noqa: F401,F403
from .security import * # noqa: F401,F403
from .workflows import * # noqa: F401,F403

Expand Down
83 changes: 82 additions & 1 deletion src/mcp_server_git/github/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import re

from pydantic import BaseModel, field_validator, model_validator
from typing import Literal

from pydantic import BaseModel, Field, field_validator, model_validator

# ============================================================================
# Validation Constants
Expand Down Expand Up @@ -980,3 +982,82 @@ def validate_org_name(cls, v: str | None) -> str | None:
"can only contain alphanumeric characters and hyphens"
)
return v


# ============================================================================
# GHAS Forensics Models (Issue #186)
# ============================================================================


class GitHubListRulesets(BaseModel):
"""Model for listing rulesets defined on a repository."""

repo_owner: str
repo_name: str
per_page: int | None = Field(default=None, ge=1, le=100, description="Results per page (max 100)")
page: int | None = Field(default=None, ge=1, description="Page number")


class GitHubGetRuleset(BaseModel):
"""Model for fetching a specific ruleset by ID.

Returns full ruleset config including required_status_checks,
code_scanning rules, and bypass actors.
"""

repo_owner: str
repo_name: str
ruleset_id: int # Numeric ruleset ID (from github_list_rulesets)


class GitHubGetBranchRules(BaseModel):
"""Model for listing all rules that apply to a branch.

Returns both classic branch-protection rules and ruleset-based rules
active for the given branch ref.
"""

repo_owner: str
repo_name: str
branch: str # Branch name or glob pattern — no ref-name validation (API accepts patterns)


class GitHubListCodeScanningAlerts(BaseModel):
"""Model for listing code scanning alerts with optional filters."""

repo_owner: str
repo_name: str
state: Literal["open", "closed", "dismissed", "fixed"] | None = None
severity: Literal["critical", "high", "medium", "low", "warning", "note", "error"] | None = None
tool_name: str | None = None # Code-scanning tool (e.g. 'CodeQL') — free-form
ref: str | None = None # Branch name or refs/pull/N/head
per_page: int | None = Field(default=None, ge=1, le=100, description="Results per page (max 100)")
page: int | None = Field(default=None, ge=1, description="Page number")


class GitHubListCodeScanningAnalyses(BaseModel):
"""Model for listing code scanning analyses for a repository."""

repo_owner: str
repo_name: str
ref: str | None = None # Branch name or refs/pull/N/head
tool_name: str | None = None # Code-scanning tool (e.g. 'CodeQL')
per_page: int | None = Field(default=None, ge=1, le=100, description="Results per page (max 100)")
page: int | None = Field(default=None, ge=1, description="Page number")


class GitHubGetCodeScanningDefaultSetup(BaseModel):
"""Model for fetching the default-setup configuration for code scanning."""

repo_owner: str
repo_name: str


class GitHubListSecretScanningAlerts(BaseModel):
"""Model for listing secret scanning alerts for a repository."""

repo_owner: str
repo_name: str
state: Literal["open", "resolved"] | None = None
per_page: int | None = Field(default=None, ge=1, le=100, description="Results per page (max 100)")
page: int | None = Field(default=None, ge=1, description="Page number")
Loading