mcp-github-agent 0.1.0__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.
- mcp_github_agent-0.1.0.dist-info/METADATA +153 -0
- mcp_github_agent-0.1.0.dist-info/RECORD +27 -0
- mcp_github_agent-0.1.0.dist-info/WHEEL +5 -0
- mcp_github_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_github_agent-0.1.0.dist-info/top_level.txt +2 -0
- src/__init__.py +0 -0
- src/analyzers/base.py +21 -0
- src/analyzers/ruff.py +49 -0
- src/audit.py +120 -0
- src/config.py +36 -0
- src/diff_parser.py +36 -0
- src/github_client.py +111 -0
- src/main.py +29 -0
- src/policy.py +125 -0
- src/review.py +107 -0
- src/review_engine.py +83 -0
- src/tools.py +291 -0
- tests/__init__.py +0 -0
- tests/test_audit.py +181 -0
- tests/test_config.py +41 -0
- tests/test_diff_parser.py +74 -0
- tests/test_github_client.py +173 -0
- tests/test_main.py +48 -0
- tests/test_policy.py +133 -0
- tests/test_review_engine.py +162 -0
- tests/test_ruff_analyzer.py +78 -0
- tests/test_tools.py +326 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-github-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server giving AI agents controlled GitHub access with policy guard and audit trail
|
|
5
|
+
Author-email: Morgan Fu <maogenfu0121@163.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/FMorgan-111/github-mcp-server
|
|
8
|
+
Project-URL: Repository, https://github.com/FMorgan-111/github-mcp-server
|
|
9
|
+
Project-URL: Issues, https://github.com/FMorgan-111/github-mcp-server/issues
|
|
10
|
+
Keywords: mcp,github,ai-agent,claude-code,codex,policy,audit
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: fastmcp
|
|
24
|
+
Requires-Dist: httpx
|
|
25
|
+
Requires-Dist: python-dotenv
|
|
26
|
+
|
|
27
|
+
# GitHub MCP Agent Server
|
|
28
|
+
|
|
29
|
+
[](https://github.com/FMorgan-111/github-mcp-server/actions/workflows/ci.yml)
|
|
30
|
+
[](https://www.python.org/downloads/release/python-3100/)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
|
|
33
|
+
> MCP server giving AI agents controlled GitHub access with policy guard and audit trail.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Architecture
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
┌───────────────┐ MCP stdio ┌───────────────────┐
|
|
41
|
+
│ AI Agent │ ◄──────────────────────► │ GitHub MCP Server │
|
|
42
|
+
│ (Claude, Codex│ JSON-RPC 2.0 │ │
|
|
43
|
+
│ or any MCP │ ┌─┴───────────────┐ │
|
|
44
|
+
│ client) │ │ Policy Guard │ │
|
|
45
|
+
└───────────────┘ ├─────────────────┤ │
|
|
46
|
+
│ Audit Log │ │
|
|
47
|
+
└─────────────────┘ │
|
|
48
|
+
┌──────────────────────────────────────────────┐ │
|
|
49
|
+
│ github_client.py │ │
|
|
50
|
+
│ (httpx) │ │
|
|
51
|
+
│ GitHub API │ │
|
|
52
|
+
└──────────────────────────────────────────────┘ │
|
|
53
|
+
┌──────────────────────────────────────────────┐ │
|
|
54
|
+
│ Review Engine (ruff + regex) │ │
|
|
55
|
+
└──────────────────────────────────────────────┘ │
|
|
56
|
+
└───┘
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Features & Tools
|
|
62
|
+
|
|
63
|
+
| Tool | Description | Example Input | Example Output |
|
|
64
|
+
|-------------------|--------------------------------------------|-------------------------------------------------------------------------------|---------------------------------------------|
|
|
65
|
+
| `search_code` | Search GitHub repo code by query | `search_code("def main", "owner/repo")` | List of matching files with paths & URLs |
|
|
66
|
+
| `list_issues` | List open or closed issues | `list_issues("owner/repo", state="open")` | Formatted list of issues |
|
|
67
|
+
| `create_issue` | Create a new issue (policy-guarded) | `create_issue("owner/repo", "Bug: timeout", "Steps to reproduce...")` | Confirmation with issue URL |
|
|
68
|
+
| `get_pr_diff` | Fetch raw diff of a pull request | `get_pr_diff("owner/repo", pr_number=7)` | Unified diff as text |
|
|
69
|
+
| `create_pr` | Create a pull request between branches | `create_pr("owner/repo", "Add feature", "Details...", head="feat", base="main")` | Confirmation with PR URL |
|
|
70
|
+
| `review_pr_diff` | Run local automated code review rules | `review_pr_diff("owner/repo", pr_number=7)` | List of warnings and errors found |
|
|
71
|
+
| `comment_pr_review`| Post review findings as PR comments | `comment_pr_review("owner/repo", pr_number=7)` | Number of comments posted |
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Security Model
|
|
76
|
+
|
|
77
|
+
- **Repository Allowlist:** Only repositories explicitly allowed by policy can be accessed.
|
|
78
|
+
- **Branch Protection:** PRs to protected branches are subject to branch protection rules before merging.
|
|
79
|
+
- **Dry-Run Mode:** Execute operations without side effects for safe testing and auditing.
|
|
80
|
+
- **Audit Logging:** All actions logged in JSONL format with precise timestamps to maintain traceability.
|
|
81
|
+
- **Policy Configuration:** Start from `policy.example.json` to configure repository allowlists and protected branches.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## FAQ
|
|
86
|
+
|
|
87
|
+
**Why not call the GitHub API directly?**
|
|
88
|
+
Direct API calls lack centralized policy enforcement, audit logging, and uniform tooling support for AI agents. This MCP server adds a controlled, auditable layer for safer automation.
|
|
89
|
+
|
|
90
|
+
**What MCP clients work with this?**
|
|
91
|
+
Agents like Claude Code, OpenAI Codex, and any client supporting the MCP JSON-RPC 2.0 transport can connect to this server.
|
|
92
|
+
|
|
93
|
+
**How is this different from the official GitHub MCP server?**
|
|
94
|
+
This project's key differentiator is policy enforcement. Repository allowlists prevent agents from touching unauthorized repos, branch protection blocks accidental PRs to sensitive branches, dry-run mode supports safe validation, and write actions are audit-logged with timestamps.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Quick Start
|
|
99
|
+
|
|
100
|
+
### Prerequisites
|
|
101
|
+
|
|
102
|
+
- Python 3.10+
|
|
103
|
+
- GitHub personal access token with `repo` scope
|
|
104
|
+
|
|
105
|
+
### Install
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install fastmcp httpx python-dotenv
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Configure and Run
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
git clone https://github.com/FMorgan-111/github-mcp-server.git
|
|
115
|
+
cd github-mcp-server
|
|
116
|
+
cp .env.example .env && nano .env
|
|
117
|
+
python3 -m src.main
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Connect with Claude Code
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
claude mcp add github-agent -- python3 /path/to/github-mcp-server/src/main.py
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
pip install -e . --break-system-packages
|
|
132
|
+
python3 -m pytest tests/ -v
|
|
133
|
+
python3 -m src.main
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
23 tests, all passing.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Deployment with Docker
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
docker build -t github-mcp-server .
|
|
144
|
+
docker run -e GITHUB_TOKEN=ghp_your_token_here -i github-mcp-server
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The `-e GITHUB_TOKEN=...` flag passes the token into the container because the local `.env` file is not copied into the image.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
src/audit.py,sha256=g6A_eFiE8J7JR1sCXuVnIhvFTNX3en2fkEO3u57B1Pw,4072
|
|
3
|
+
src/config.py,sha256=yQNjY8-cwbpsmoHJQM510bSjsHtMzlsdEeHCkaLDiZE,923
|
|
4
|
+
src/diff_parser.py,sha256=_Kov_1Rca3yCG14vmhwlHlcdRv5qjtI1sttGzTNnGRE,1282
|
|
5
|
+
src/github_client.py,sha256=r33uLy0qSiKI9QOUxSu4WKPx6OXL4mWyAG2Iwh9SsjE,4476
|
|
6
|
+
src/main.py,sha256=QF9cGxSUH7HsiQHgdh6SbhRJbDQuB4l-sFRVhwP8NyA,727
|
|
7
|
+
src/policy.py,sha256=p3Nl4FywiEbb9gSbGjlRxW4A6wlLsY8IOhPEIrdQ7Fk,4647
|
|
8
|
+
src/review.py,sha256=I0_gSxNyBiLKG4dvFsqzu_QV-rZHXMNDZm8WJ_UI0PM,4299
|
|
9
|
+
src/review_engine.py,sha256=snsa7MjHwd94rueENuTdGOArHO5cAd8qhX0in4YmY-M,2804
|
|
10
|
+
src/tools.py,sha256=C7vLFL8rMWTkHgg06jvufUlfp854BEy9brCB-AhfrjE,9956
|
|
11
|
+
src/analyzers/base.py,sha256=B82EoBGWBikAVUo9t2vHLA7uQQxNWLGh-kB9zrjlMEA,544
|
|
12
|
+
src/analyzers/ruff.py,sha256=gcP6lFO-agYesDxHoldUnBfbpkFTI96vXWkaqd7tgQs,1498
|
|
13
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
tests/test_audit.py,sha256=LuP0d6T6Q1AyhJBqoJc1XK-Zjzex7AG7RFk8-nvvVsc,6612
|
|
15
|
+
tests/test_config.py,sha256=uVFgkmo3IOfnYPziBgdr5fAv5xziTcTIqTd4oulyVz4,1610
|
|
16
|
+
tests/test_diff_parser.py,sha256=JRH2xWL0zQYQOtJalecplWRE44tFTVT6QbCQ2gPlExk,1648
|
|
17
|
+
tests/test_github_client.py,sha256=rEO3RnT9jc0y9ePqji18D0j1kYghYboKJFffAvjJom4,6307
|
|
18
|
+
tests/test_main.py,sha256=1Yugo8k_B8WbuLBm2ovyDojMmGHwQL4rexyqfv5x1Pk,1489
|
|
19
|
+
tests/test_policy.py,sha256=KiyFJjvyV-qSS5dwChRtgC3Ec77Ev9OLbKSHk0LAczg,5142
|
|
20
|
+
tests/test_review_engine.py,sha256=lmpN9Jd3VQ7QI4udr-EH7j0T7DeJafoo-6VQeuQ9xaI,5624
|
|
21
|
+
tests/test_ruff_analyzer.py,sha256=CdDhIRZtjF-py1zGWpwiJRjGrwfrjO51K47DSFBnruM,2451
|
|
22
|
+
tests/test_tools.py,sha256=5dSkd1rPwDsRGRL2kBQ_UVmwveQp4TpL2aN4SFC5ADs,14269
|
|
23
|
+
mcp_github_agent-0.1.0.dist-info/METADATA,sha256=J3iBTSJ2ZPg04wsRsBiTVpF2FLsOf6LHmHv8ioSFIsU,7558
|
|
24
|
+
mcp_github_agent-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
25
|
+
mcp_github_agent-0.1.0.dist-info/entry_points.txt,sha256=9NAVe9kKlXQM0qPY4-hFkjPzrk8GrrknzGYuBDnKBUg,45
|
|
26
|
+
mcp_github_agent-0.1.0.dist-info/top_level.txt,sha256=KW3xgkz9NLMTcmmzgKvW8RFpCFkRIQ085qzq2diFf68,10
|
|
27
|
+
mcp_github_agent-0.1.0.dist-info/RECORD,,
|
src/__init__.py
ADDED
|
File without changes
|
src/analyzers/base.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Analyzer protocol — pluggable code analysis backends."""
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Finding:
|
|
8
|
+
severity: str # "error" | "warning" | "info"
|
|
9
|
+
file: str
|
|
10
|
+
line: int
|
|
11
|
+
rule: str # e.g. "F401", "E501"
|
|
12
|
+
message: str
|
|
13
|
+
suggestion: str = ""
|
|
14
|
+
source: str = "" # "ruff", "bandit", "regex"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Analyzer(Protocol):
|
|
18
|
+
"""Any backend that takes file path → list of Findings."""
|
|
19
|
+
|
|
20
|
+
def analyze(self, file_path: str) -> list[Finding]:
|
|
21
|
+
...
|
src/analyzers/ruff.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Ruff subprocess analyzer — run ruff check as JSON, return Findings."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .base import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RuffAnalyzer:
|
|
10
|
+
"""Run ruff check on a file, return structured findings."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, ruff_cmd: str = "ruff"):
|
|
13
|
+
self.cmd = ruff_cmd
|
|
14
|
+
|
|
15
|
+
def analyze(self, file_path: str) -> list[Finding]:
|
|
16
|
+
path = Path(file_path)
|
|
17
|
+
if path.suffix != ".py":
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
[self.cmd, "check", "--output-format", "json", str(path)],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
timeout=30,
|
|
26
|
+
)
|
|
27
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
if result.returncode not in (0, 1):
|
|
31
|
+
return [] # ruff crash / config error? skip
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
violations = json.loads(result.stdout or "[]")
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
findings = []
|
|
39
|
+
for v in violations:
|
|
40
|
+
findings.append(Finding(
|
|
41
|
+
severity="error" if v.get("fix") else "warning",
|
|
42
|
+
file=v.get("filename", str(path)),
|
|
43
|
+
line=v.get("location", {}).get("row", 0),
|
|
44
|
+
rule=v.get("code", ""),
|
|
45
|
+
message=v.get("message", ""),
|
|
46
|
+
suggestion=v.get("fix", {}).get("message", ""),
|
|
47
|
+
source="ruff",
|
|
48
|
+
))
|
|
49
|
+
return findings
|
src/audit.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Audit logger — JSONL write operations for traceability."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AuditLogger:
|
|
11
|
+
"""Write structured operation audits as JSONL to a sink."""
|
|
12
|
+
|
|
13
|
+
REDACT_KEYS = {
|
|
14
|
+
"GITHUB_TOKEN", "token", "password", "api_key",
|
|
15
|
+
"authorization", "auth", "secret"
|
|
16
|
+
}
|
|
17
|
+
ALLOWED_DIRS = {"/var/log/github-mcp", "stdout", "stderr"}
|
|
18
|
+
|
|
19
|
+
def __init__(self, sink: str = "stdout"):
|
|
20
|
+
"""
|
|
21
|
+
Args:
|
|
22
|
+
sink: "stdout", "stderr", or an absolute path under allowed dirs.
|
|
23
|
+
"""
|
|
24
|
+
self.sink = sink
|
|
25
|
+
self._validate_sink()
|
|
26
|
+
self._fobj: Optional[object] = None
|
|
27
|
+
|
|
28
|
+
def _validate_sink(self) -> None:
|
|
29
|
+
if self.sink in ("stdout", "stderr"):
|
|
30
|
+
return
|
|
31
|
+
if not os.path.isabs(self.sink):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Audit file sink must be absolute path, got: {self.sink}"
|
|
34
|
+
)
|
|
35
|
+
# Prevent traversal: resolve symlinks, then check the canonical
|
|
36
|
+
# parent directory is under an allowed tree (if configured).
|
|
37
|
+
# For production, restrict via GITHUB_AUDIT_DIR_ALLOWLIST.
|
|
38
|
+
parent = os.path.dirname(os.path.abspath(self.sink))
|
|
39
|
+
allowlist_str = os.environ.get("GITHUB_AUDIT_DIR_ALLOWLIST", "")
|
|
40
|
+
if allowlist_str:
|
|
41
|
+
allowed = allowlist_str.split(",")
|
|
42
|
+
if not any(
|
|
43
|
+
os.path.commonpath([parent, d]) == d for d in allowed
|
|
44
|
+
):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Audit sink parent {parent} not in "
|
|
47
|
+
f"GITHUB_AUDIT_DIR_ALLOWLIST ({allowlist_str})"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _get_stream(self):
|
|
51
|
+
if self.sink == "stdout":
|
|
52
|
+
return sys.stdout
|
|
53
|
+
if self.sink == "stderr":
|
|
54
|
+
return sys.stderr
|
|
55
|
+
if self._fobj is None:
|
|
56
|
+
parent = os.path.dirname(self.sink)
|
|
57
|
+
if parent:
|
|
58
|
+
os.makedirs(parent, exist_ok=True)
|
|
59
|
+
self._fobj = open(self.sink, "a", encoding="utf-8")
|
|
60
|
+
return self._fobj
|
|
61
|
+
|
|
62
|
+
def log(
|
|
63
|
+
self,
|
|
64
|
+
tool: str,
|
|
65
|
+
action: str,
|
|
66
|
+
repo: str,
|
|
67
|
+
dry_run: bool = False,
|
|
68
|
+
policy_decision: str = "allow",
|
|
69
|
+
policy_rule: str = "",
|
|
70
|
+
request_body: Optional[dict] = None,
|
|
71
|
+
response: Optional[dict] = None,
|
|
72
|
+
error: Optional[str] = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Write one audit entry."""
|
|
75
|
+
entry = {
|
|
76
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
77
|
+
"request_id": uuid.uuid4().hex[:12],
|
|
78
|
+
"tool": tool,
|
|
79
|
+
"action": action,
|
|
80
|
+
"repo": repo,
|
|
81
|
+
"dry_run": dry_run,
|
|
82
|
+
"policy": {
|
|
83
|
+
"decision": policy_decision,
|
|
84
|
+
"matched_rule": policy_rule,
|
|
85
|
+
},
|
|
86
|
+
"request": _redact(request_body) if isinstance(request_body, dict) else None,
|
|
87
|
+
"response": _redact(response) if isinstance(response, dict) else None,
|
|
88
|
+
"error": error,
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
stream = self._get_stream()
|
|
92
|
+
stream.write(json.dumps(entry, ensure_ascii=False, default=str) + "\n")
|
|
93
|
+
stream.flush()
|
|
94
|
+
except Exception as e:
|
|
95
|
+
# Audit must never crash the tool, but log the failure somewhere
|
|
96
|
+
print(f"[audit] write failed: {e}", file=sys.stderr)
|
|
97
|
+
|
|
98
|
+
def close(self) -> None:
|
|
99
|
+
if self._fobj:
|
|
100
|
+
self._fobj.close()
|
|
101
|
+
self._fobj = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _redact(obj):
|
|
105
|
+
"""Recursively redact sensitive keys from dicts and lists."""
|
|
106
|
+
if isinstance(obj, dict):
|
|
107
|
+
out = {}
|
|
108
|
+
for k, v in obj.items():
|
|
109
|
+
if k.lower() in AuditLogger.REDACT_KEYS:
|
|
110
|
+
out[k] = "***REDACTED***"
|
|
111
|
+
elif isinstance(v, (dict, list)):
|
|
112
|
+
out[k] = _redact(v)
|
|
113
|
+
elif isinstance(v, str) and len(v) > 200:
|
|
114
|
+
out[k] = v[:200] + "..."
|
|
115
|
+
else:
|
|
116
|
+
out[k] = v
|
|
117
|
+
return out
|
|
118
|
+
if isinstance(obj, list):
|
|
119
|
+
return [_redact(item) for item in obj]
|
|
120
|
+
return obj
|
src/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""GitHub MCP Agent — configuration"""
|
|
2
|
+
import os
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
load_dotenv()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_github_token() -> str:
|
|
9
|
+
token = os.environ.get("GITHUB_TOKEN", "")
|
|
10
|
+
if not token:
|
|
11
|
+
raise ValueError(
|
|
12
|
+
"GITHUB_TOKEN not set. "
|
|
13
|
+
"Create a .env file with GITHUB_TOKEN=ghp_xxx "
|
|
14
|
+
"or set the environment variable."
|
|
15
|
+
)
|
|
16
|
+
return token
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_github_api_base() -> str:
|
|
20
|
+
return os.environ.get("GITHUB_API_BASE", "https://api.github.com")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_policy_path() -> str:
|
|
24
|
+
return os.environ.get("GITHUB_POLICY_PATH", "policy.json")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_policy_required() -> bool:
|
|
28
|
+
return os.environ.get("GITHUB_POLICY_REQUIRED", "").lower() in ("true", "1", "yes")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_audit_sink() -> str:
|
|
32
|
+
return os.environ.get("GITHUB_AUDIT_LOG", "stdout")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_dry_run_enabled() -> bool:
|
|
36
|
+
return os.environ.get("GITHUB_DRY_RUN", "").lower() in ("true", "1", "yes")
|
src/diff_parser.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Parse unified diff → changed files + line map."""
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class ChangedFile:
|
|
8
|
+
path: str
|
|
9
|
+
added_lines: set[int] = field(default_factory=set)
|
|
10
|
+
removed_lines: set[int] = field(default_factory=set)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_diff(diff_text: str) -> list[ChangedFile]:
|
|
14
|
+
"""Parse unified diff, return changed files with line numbers."""
|
|
15
|
+
files: dict[str, ChangedFile] = {}
|
|
16
|
+
current_file = ""
|
|
17
|
+
new_line = 0
|
|
18
|
+
|
|
19
|
+
for line in diff_text.split("\n"):
|
|
20
|
+
if line.startswith("+++ b/"):
|
|
21
|
+
current_file = line[6:]
|
|
22
|
+
files[current_file] = ChangedFile(path=current_file)
|
|
23
|
+
new_line = 0
|
|
24
|
+
elif line.startswith("@@") and current_file:
|
|
25
|
+
m = re.search(r"\+(\d+)", line)
|
|
26
|
+
if m:
|
|
27
|
+
new_line = int(m.group(1)) - 1
|
|
28
|
+
elif line.startswith("+") and not line.startswith("+++") and current_file:
|
|
29
|
+
new_line += 1
|
|
30
|
+
files[current_file].added_lines.add(new_line)
|
|
31
|
+
elif line.startswith(" ") and current_file:
|
|
32
|
+
new_line += 1
|
|
33
|
+
elif line.startswith("-") and not line.startswith("---") and current_file:
|
|
34
|
+
files[current_file].removed_lines.add(new_line + 1)
|
|
35
|
+
|
|
36
|
+
return [f for f in files.values() if f.added_lines]
|
src/github_client.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""GitHub API client wrapper using httpx"""
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import Optional, Dict, Any, List, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GitHubClient:
|
|
7
|
+
def __init__(self, token: str, base_url: str = "https://api.github.com"):
|
|
8
|
+
self.base_url = base_url.rstrip('/')
|
|
9
|
+
self.headers = {
|
|
10
|
+
"Authorization": f"Bearer {token}",
|
|
11
|
+
"Accept": "application/vnd.github+json",
|
|
12
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def search_code(self, query: str, repo: Optional[str] = None) -> Dict[str, Any]:
|
|
16
|
+
try:
|
|
17
|
+
q = f"repo:{repo} {query}" if repo else query
|
|
18
|
+
with httpx.Client(timeout=20) as client:
|
|
19
|
+
response = client.get(
|
|
20
|
+
f"{self.base_url}/search/code",
|
|
21
|
+
headers=self.headers,
|
|
22
|
+
params={"q": q}
|
|
23
|
+
)
|
|
24
|
+
response.raise_for_status()
|
|
25
|
+
data = response.json()
|
|
26
|
+
return {
|
|
27
|
+
"items": [
|
|
28
|
+
{
|
|
29
|
+
"path": item["path"],
|
|
30
|
+
"repo": item["repository"]["full_name"],
|
|
31
|
+
"url": item["html_url"]
|
|
32
|
+
}
|
|
33
|
+
for item in data.get("items", [])
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
except Exception as e:
|
|
37
|
+
return {"error": f"Search failed: {str(e)}"}
|
|
38
|
+
|
|
39
|
+
def list_issues(self, repo: str, state: str = "open") -> Dict[str, Any]:
|
|
40
|
+
try:
|
|
41
|
+
with httpx.Client(timeout=20) as client:
|
|
42
|
+
response = client.get(
|
|
43
|
+
f"{self.base_url}/repos/{repo}/issues",
|
|
44
|
+
headers=self.headers,
|
|
45
|
+
params={"state": state}
|
|
46
|
+
)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
return response.json()
|
|
49
|
+
except Exception as e:
|
|
50
|
+
return {"error": f"List issues failed: {str(e)}"}
|
|
51
|
+
|
|
52
|
+
def create_issue(self, repo: str, title: str, body: str) -> Dict[str, Any]:
|
|
53
|
+
try:
|
|
54
|
+
with httpx.Client(timeout=20) as client:
|
|
55
|
+
response = client.post(
|
|
56
|
+
f"{self.base_url}/repos/{repo}/issues",
|
|
57
|
+
headers=self.headers,
|
|
58
|
+
json={"title": title, "body": body}
|
|
59
|
+
)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
return response.json()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return {"error": f"Create issue failed: {str(e)}"}
|
|
64
|
+
|
|
65
|
+
def get_pr_diff(self, repo: str, pr_number: int) -> Dict[str, Any]:
|
|
66
|
+
try:
|
|
67
|
+
headers = {**self.headers, "Accept": "application/vnd.github.v3.diff"}
|
|
68
|
+
with httpx.Client(timeout=20) as client:
|
|
69
|
+
response = client.get(
|
|
70
|
+
f"{self.base_url}/repos/{repo}/pulls/{pr_number}",
|
|
71
|
+
headers=headers
|
|
72
|
+
)
|
|
73
|
+
response.raise_for_status()
|
|
74
|
+
return {"diff": response.text}
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return {"error": f"Get PR diff failed: {str(e)}"}
|
|
77
|
+
|
|
78
|
+
def create_pr(self, repo: str, title: str, body: str, head: str, base: str) -> Dict[str, Any]:
|
|
79
|
+
try:
|
|
80
|
+
with httpx.Client(timeout=20) as client:
|
|
81
|
+
response = client.post(
|
|
82
|
+
f"{self.base_url}/repos/{repo}/pulls",
|
|
83
|
+
headers=self.headers,
|
|
84
|
+
json={"title": title, "body": body, "head": head, "base": base}
|
|
85
|
+
)
|
|
86
|
+
response.raise_for_status()
|
|
87
|
+
return response.json()
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return {"error": f"Create PR failed: {str(e)}"}
|
|
90
|
+
|
|
91
|
+
def create_review_comment(
|
|
92
|
+
self, repo: str, pr_number: int, body: str,
|
|
93
|
+
commit_id: str = "", path: str = "", line: int = 0
|
|
94
|
+
) -> Dict[str, Any]:
|
|
95
|
+
"""Post a review comment on a PR line."""
|
|
96
|
+
try:
|
|
97
|
+
payload = {"body": body}
|
|
98
|
+
if commit_id and path and line > 0:
|
|
99
|
+
payload["commit_id"] = commit_id
|
|
100
|
+
payload["path"] = path
|
|
101
|
+
payload["line"] = line
|
|
102
|
+
with httpx.Client(timeout=20) as client:
|
|
103
|
+
resp = client.post(
|
|
104
|
+
f"{self.base_url}/repos/{repo}/pulls/{pr_number}/reviews",
|
|
105
|
+
headers=self.headers,
|
|
106
|
+
json=payload,
|
|
107
|
+
)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
return resp.json()
|
|
110
|
+
except Exception as e:
|
|
111
|
+
return {"error": f"Review comment failed: {str(e)}"}
|
src/main.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""GitHub MCP Server entry point"""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import signal
|
|
5
|
+
|
|
6
|
+
# Ensure the project dir is on the path so imports work regardless of cwd
|
|
7
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
8
|
+
|
|
9
|
+
from src.tools import mcp
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
"""Run the MCP server"""
|
|
14
|
+
try:
|
|
15
|
+
# Handle SIGTERM gracefully for container environments
|
|
16
|
+
def signal_handler(signum, frame):
|
|
17
|
+
sys.exit(0)
|
|
18
|
+
|
|
19
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
20
|
+
mcp.run(transport="stdio")
|
|
21
|
+
except KeyboardInterrupt:
|
|
22
|
+
sys.exit(0)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
print(f"Error starting MCP server: {e}", file=sys.stderr)
|
|
25
|
+
sys.exit(1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|