This commit is contained in:
2026-03-26 15:49:51 +08:00
parent 09d45476ef
commit 924cb0b38b
15 changed files with 2323 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# 安装 git 和 ssh 客户端
RUN apt-get update && apt-get install -y \
gcc \
curl \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["python", "run_api_server.py"]
+199
View File
@@ -0,0 +1,199 @@
# Git Agent
通过 SSH 在远程 Azure 服务器上执行 Git 操作的 Agent,为 OpenClaw 提供 Git 仓库管理能力。
## 功能特性
- **git_clone** - 克隆远程仓库到目标服务器
- **git_pull** - 拉取远程仓库最新代码
- **git_push** - 推送本地代码到远程仓库(SSH Key 认证)
- **git_commit** - 提交本地变更
- **git_status** - 查看仓库状态
- **git_diff** - 查看文件变更
- **git_log** - 查看提交历史
## 架构
```
OpenClaw → Git Agent (AKS) → SSH → Azure 服务器 → Git 仓库
```
## 快速开始
### 环境变量
```bash
# 必需 - SSH 连接配置
export SSH_HOST=your-azure-server-ip
export SSH_USER=azureuser
export SSH_PRIVATE_KEY=base64-encoded-ssh-private-key
# 必需 - Git SSH Key(用于仓库认证)
export GIT_SSH_KEY=base64-encoded-git-ssh-key
# 可选
export SSH_PORT=22
export GIT_WORK_DIR=/home/azureuser/projects
export GIT_USERNAME="Git Agent"
export GIT_EMAIL="git-agent@taiji.io"
export API_HOST=0.0.0.0
export API_PORT=8000
```
### 本地运行
```bash
# 安装依赖
pip install -r requirements.txt
# 启动服务
python run_api_server.py
```
### Docker 运行
```bash
# 构建镜像
docker build -t git-agent .
# 运行容器
docker run -p 8000:8000 \
-e SSH_HOST=your-server-ip \
-e SSH_USER=azureuser \
-e SSH_PRIVATE_KEY=base64-key \
-e GIT_SSH_KEY=base64-git-key \
git-agent
```
## API 端点
| 端点 | 方法 | 描述 |
|------|------|------|
| `/` | GET | 服务状态 |
| `/health` | GET | 健康检查 |
| `/mcp` | POST | MCP JSON-RPC |
| `/mcp/sse` | GET/POST | MCP SSE 流式 |
| `/api/v1/clone` | POST | 克隆仓库 |
| `/api/v1/pull` | POST | 拉取代码 |
| `/api/v1/push` | POST | 推送代码 |
| `/api/v1/commit` | POST | 提交变更 |
| `/api/v1/status` | POST | 查看状态 |
| `/api/v1/diff` | POST | 查看差异 |
| `/api/v1/tools` | GET | 获取工具列表 |
## MCP 工具
| 工具名称 | 功能 |
|---------|------|
| `git_clone` | 克隆远程仓库到目标服务器 |
| `git_pull` | 拉取远程仓库最新代码 |
| `git_push` | 推送本地代码到远程仓库 |
| `git_commit` | 提交本地变更 |
| `git_status` | 查看仓库状态 |
| `git_diff` | 查看文件变更 |
| `git_log` | 查看提交历史 |
## 使用示例
### 克隆仓库
```bash
curl -X POST http://localhost:8000/api/v1/clone \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
"target_dir": "/home/azureuser/projects/taiji-AI-PAD"
}'
```
响应:
```json
{
"success": true,
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"branch": "main",
"commit": "abc1234",
"message": "Clone 完成"
}
```
### 推送代码
```bash
curl -X POST http://localhost:8000/api/v1/push \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
}'
```
## 项目结构
```
git_agent/
├── Dockerfile
├── README.md
├── USAGE.md
├── requirements.txt
├── run_api_server.py
├── common/
│ ├── __init__.py
│ └── agent_callback_utils.py
└── src/
├── __init__.py
└── server/
├── __init__.py
├── api_server.py # FastAPI + MCP HTTP
├── mcp_server.py # MCP 工具定义
└── ssh_client.py # SSH 连接管理
```
## SSH Key 配置
### 1. 生成 SSH Key(如果没有)
```bash
ssh-keygen -t ed25519 -C "git-agent@taiji.io" -f ~/.ssh/git_agent_key
```
### 2. 在 Git 服务器配置公钥
将 `~/.ssh/git_agent_key.pub` 的内容添加到 Git 服务器的 SSH Keys 中。
### 3. Base64 编码私钥
```bash
# Linux/macOS
cat ~/.ssh/git_agent_key | base64 -w 0
# Windows PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("$HOME\.ssh\git_agent_key"))
```
### 4. 设置环境变量
```bash
export GIT_SSH_KEY="base64编码后的私钥内容"
```
## 安全注意事项
1. **SSH 私钥保护**
- 私钥通过环境变量传入,不硬编码
- 运行时写入临时文件,设置 600 权限
- 操作完成后清理临时文件
2. **API 认证**
- 所有 API 端点需要 `api-key` 认证
- 支持 Header: `api-key` 或 `Authorization: Bearer <key>`
3. **网络安全**
- 建议 Agent 和目标服务器在同一 VNet
- 使用 SSH 加密通信
## 许可证
MIT License
+305
View File
@@ -0,0 +1,305 @@
# Git Agent 使用指南
## 概述
Git Agent 是一个通过 SSH 在远程 Azure 服务器上执行 Git 操作的 Agent。它为 OpenClaw 提供了完整的 Git 仓库管理能力,包括克隆、拉取、推送、提交等操作。
## 前置条件
1. **Azure 云服务器**
- 已安装 Git
- 已配置 SSH 访问
- 有足够的磁盘空间存储代码
2. **SSH 密钥**
- 用于连接 Azure 服务器的 SSH 私钥
- 用于 Git 仓库认证的 SSH 私钥(需在 Git 服务器配置公钥)
3. **环境变量**
- `SSH_HOST` - Azure 服务器 IP 或域名
- `SSH_USER` - SSH 用户名
- `SSH_PRIVATE_KEY` - 连接服务器的 SSH 私钥(Base64 编码)
- `GIT_SSH_KEY` - Git 仓库认证私钥(Base64 编码)
## 工具详解
### 1. git_clone - 克隆仓库
将远程 Git 仓库克隆到目标服务器。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| repo_url | string | 是 | Git 仓库地址(支持 SSH/HTTPS) |
| target_dir | string | 否 | 目标目录,默认从 URL 推断 |
| branch | string | 否 | 要克隆的分支 |
**示例:**
```bash
curl -X POST http://localhost:8000/api/v1/clone \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
"branch": "main"
}'
```
**返回:**
```json
{
"success": true,
"repo_url": "git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git",
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"branch": "main",
"commit": "abc1234",
"message": "Clone 完成: git@gitee.ath.cx:taijibaga/taiji-AI-PAD.git -> /home/azureuser/projects/taiji-AI-PAD"
}
```
### 2. git_pull - 拉取代码
从远程仓库拉取最新代码。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| work_dir | string | 是 | 本地仓库目录路径 |
| remote | string | 否 | 远程名称,默认 origin |
| branch | string | 否 | 要拉取的分支 |
**示例:**
```bash
curl -X POST http://localhost:8000/api/v1/pull \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
}'
```
**返回:**
```json
{
"success": true,
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"branch": "main",
"before_commit": "abc1234",
"after_commit": "def5678",
"files_changed": 5,
"message": "Pull 完成: 5 个文件变更"
}
```
### 3. git_push - 推送代码
将本地代码推送到远程仓库。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| work_dir | string | 是 | 本地仓库目录路径 |
| remote | string | 否 | 远程名称,默认 origin |
| branch | string | 否 | 要推送的分支 |
| force | boolean | 否 | 是否强制推送,默认 false |
**示例:**
```bash
curl -X POST http://localhost:8000/api/v1/push \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
}'
```
**返回:**
```json
{
"success": true,
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"remote": "origin",
"branch": "main",
"commits_pushed": 2,
"force": false,
"message": "Push 完成: 2 个 commit 推送到 origin/main"
}
```
### 4. git_commit - 提交变更
提交本地文件变更。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| work_dir | string | 是 | 本地仓库目录路径 |
| message | string | 是 | 提交信息 |
| add_all | boolean | 否 | 是否添加所有变更,默认 true |
**示例:**
```bash
curl -X POST http://localhost:8000/api/v1/commit \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"work_dir": "/home/azureuser/projects/taiji-AI-PAD",
"message": "feat: 添加新功能"
}'
```
**返回:**
```json
{
"success": true,
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"commit": "ghi9012",
"files_committed": 3,
"message": "Commit 完成: 3 个文件 (ghi9012)"
}
```
### 5. git_status - 查看状态
查看仓库当前状态。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| work_dir | string | 是 | 本地仓库目录路径 |
**示例:**
```bash
curl -X POST http://localhost:8000/api/v1/status \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"work_dir": "/home/azureuser/projects/taiji-AI-PAD"
}'
```
**返回:**
```json
{
"success": true,
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"branch": "main",
"commit": "abc1234",
"staged": ["file1.py", "file2.py"],
"modified": ["file3.py"],
"untracked": ["new_file.py"],
"is_clean": false,
"message": "2 staged, 1 modified, 1 untracked"
}
```
### 6. git_diff - 查看差异
查看文件变更内容。
**参数:**
| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| work_dir | string | 是 | 本地仓库目录路径 |
| file_path | string | 否 | 文件路径,不指定则显示所有 |
| staged | boolean | 否 | 是否查看暂存区,默认 false |
**示例:**
```bash
curl -X POST http://localhost:8000/api/v1/diff \
-H "Content-Type: application/json" \
-H "api-key: your-key" \
-d '{
"work_dir": "/home/azureuser/projects/taiji-AI-PAD",
"file_path": "src/main.py"
}'
```
**返回:**
```json
{
"success": true,
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"file_path": "src/main.py",
"staged": false,
"diff": "--- a/src/main.py\n+++ b/src/main.py\n@@ -1,3 +1,4 @@\n+# New comment\n import os",
"files_changed": 1,
"insertions": 1,
"deletions": 0
}
```
### 7. git_log - 查看历史
查看提交历史。
**MCP 调用:**
```json
{
"method": "tools/call",
"params": {
"name": "git_log",
"arguments": {
"work_dir": "/home/azureuser/projects/taiji-AI-PAD",
"count": 5
}
}
}
```
**返回:**
```json
{
"success": true,
"local_path": "/home/azureuser/projects/taiji-AI-PAD",
"commits": [
{"hash": "abc1234", "message": "feat: 添加新功能"},
{"hash": "def5678", "message": "fix: 修复 bug"},
{"hash": "ghi9012", "message": "docs: 更新文档"}
],
"count": 3
}
```
## 通过 OpenClaw 使用
在 OpenClaw(如 Telegram)中,你可以这样使用:
1. **克隆仓库**
> "帮我把 taiji-AI-PAD 仓库克隆到服务器"
2. **查看状态**
> "查看一下 taiji-AI-PAD 项目的 git 状态"
3. **提交并推送**
> "把 taiji-AI-PAD 的修改提交并推送,提交信息是'更新配置文件'"
## 错误处理
所有工具在失败时都会返回:
```json
{
"success": false,
"error": "错误信息",
"local_path": "/path/to/repo"
}
```
常见错误:
- `SSH 连接错误` - 检查 SSH_HOST、SSH_USER、SSH_PRIVATE_KEY 配置
- `Clone 失败` - 检查仓库地址和 GIT_SSH_KEY 配置
- `Push 失败` - 检查是否有推送权限,GIT_SSH_KEY 是否正确
## 注意事项
1. **SSH Key 格式**
- 必须是 Base64 编码
- 支持 RSA、ED25519 等格式
2. **仓库地址**
- 推荐使用 SSH 协议:`git@gitee.ath.cx:user/repo.git`
- 也支持 HTTPS(但需要额外配置认证)
3. **工作目录**
- 所有操作都在远程 Azure 服务器上执行
- 确保目标目录有足够的磁盘空间
+4
View File
@@ -0,0 +1,4 @@
"""Git Agent 通用模块"""
from .agent_callback_utils import AgentCallbackHandler, CallbackContextManager
__all__ = ['AgentCallbackHandler', 'CallbackContextManager']
+151
View File
@@ -0,0 +1,151 @@
"""
Agent回调工具 - 用于向Agent Manager回调运行时长记录
"""
import os
import time
import logging
import requests
from typing import Optional, List
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
class AgentCallbackHandler:
"""Agent回调处理器"""
def __init__(
self,
agent_name: Optional[str] = None,
user_id: Optional[str] = None,
callback_url: Optional[str] = None
):
self.agent_name = agent_name or os.getenv("POD_NAME", "unknown-agent")
self.user_id = user_id or os.getenv("USER_ID", "")
self.callback_url = callback_url or os.getenv(
"AGENT_CALLBACK_URL",
"http://mcp-server.taiji-ai.svc.cluster.local:8000/api/v1/billing/agent-callback"
)
self.start_time: Optional[datetime] = None
self.tools_used: List[str] = []
self.request_id: Optional[str] = None
logger.info(
"AgentCallbackHandler initialized: agent=%s, callback_url=%s",
self.agent_name,
self.callback_url,
)
def start_request(self, request_id: Optional[str] = None, user_id: Optional[str] = None):
self.start_time = datetime.now(timezone.utc)
self.tools_used = []
self.request_id = request_id or f"req-{int(time.time())}"
if user_id:
self.user_id = user_id
logger.info("Request started: request_id=%s, user_id=%s", self.request_id, self.user_id)
def add_tool_used(self, tool_name: str):
if tool_name not in self.tools_used:
self.tools_used.append(tool_name)
logger.debug("Tool used: %s", tool_name)
def end_request(self, tools_used: Optional[List[str]] = None) -> bool:
if not self.start_time:
logger.warning("Cannot end request: no start time recorded")
return False
if not self.user_id:
logger.warning("Cannot send callback: user_id not set")
return False
end_time = datetime.now(timezone.utc)
running_time = (end_time - self.start_time).total_seconds()
final_tools_used = tools_used if tools_used is not None else self.tools_used
success = self._send_callback(
running_time_seconds=int(running_time),
start_time=self.start_time,
end_time=end_time,
tools_used=final_tools_used
)
self.start_time = None
self.tools_used = []
self.request_id = None
return success
def _send_callback(
self,
running_time_seconds: int,
start_time: datetime,
end_time: datetime,
tools_used: List[str]
) -> bool:
try:
payload = {
"agentName": self.agent_name,
"userId": self.user_id,
"podRunningTimeSeconds": running_time_seconds,
"toolsUsed": tools_used,
"startTime": start_time.isoformat(),
"endTime": end_time.isoformat(),
"requestId": self.request_id
}
logger.info("Sending callback: %s", payload)
response = requests.post(
self.callback_url,
json=payload,
timeout=5
)
if response.status_code == 200:
logger.info("Callback sent successfully: %s", response.json())
return True
logger.error("Callback failed with status %s: %s", response.status_code, response.text)
return False
except requests.exceptions.RequestException as e:
logger.error("Failed to send callback: %s", str(e))
return False
except Exception as e:
logger.error("Unexpected error sending callback: %s", str(e))
return False
class CallbackContextManager:
"""回调上下文管理器 - 使用with语句自动处理开始和结束"""
def __init__(
self,
handler: AgentCallbackHandler,
request_id: Optional[str] = None,
user_id: Optional[str] = None,
tools_used: Optional[List[str]] = None
):
self.handler = handler
self.request_id = request_id
self.user_id = user_id
self.tools_used = tools_used or []
def __enter__(self):
self.handler.start_request(
request_id=self.request_id,
user_id=self.user_id
)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.handler.end_request(tools_used=self.tools_used)
return False
def add_tool(self, tool_name: str):
self.handler.add_tool_used(tool_name)
if tool_name not in self.tools_used:
self.tools_used.append(tool_name)
+17
View File
@@ -0,0 +1,17 @@
# Pydantic AI
pydantic-ai>=0.0.14
# MCP
mcp>=0.9.0
fastmcp>=0.1.0
# FastAPI
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
# HTTP Client
aiohttp>=3.9.0
requests>=2.31.0
# SSH
asyncssh>=2.14.0
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python
"""启动 API 服务器"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
if __name__ == '__main__':
from src.server.api_server import app
import uvicorn
import os
host = os.getenv('API_HOST', '0.0.0.0')
port = int(os.getenv('API_PORT', '8000'))
print(f"🚀 启动 Git Agent API: http://{host}:{port}")
uvicorn.run(app, host=host, port=port, log_level="info")
+1
View File
@@ -0,0 +1 @@
"""Git Agent 源代码包"""
+1
View File
@@ -0,0 +1 @@
"""Git Agent 服务器模块"""
+514
View File
@@ -0,0 +1,514 @@
"""
Git Agent HTTP API 服务器
提供 REST API 和 MCP HTTP/SSE 端点。
"""
import json
import uuid
import os
import time
import asyncio
import logging
from typing import Optional, Dict, Any, AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Header, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field
from common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
from .mcp_server import TOOL_MAP, TOOL_LIST
# ==================== 日志配置 ====================
logger = logging.getLogger(__name__)
# ==================== 配置 ====================
SERVER_NAME = "Git Agent API"
POD_NAME = os.getenv("POD_NAME", "git-agent")
USER_ID = os.getenv("USER_ID", "")
callback_handler: Optional[AgentCallbackHandler] = None
# 会话过期时间(秒)
SESSION_EXPIRE_SECONDS = 3600 # 1小时
# ==================== 会话管理 ====================
class SessionManager:
"""会话管理器 - 带过期清理"""
def __init__(self, expire_seconds: int = SESSION_EXPIRE_SECONDS):
self._sessions: Dict[str, Dict] = {}
self._expire_seconds = expire_seconds
def create(self, session_id: str) -> Dict:
"""创建会话"""
self._sessions[session_id] = {
"initialized": True,
"created_at": time.time()
}
return self._sessions[session_id]
def get(self, session_id: str) -> Optional[Dict]:
"""获取会话"""
return self._sessions.get(session_id)
def cleanup_expired(self):
"""清理过期会话"""
now = time.time()
expired = [
sid for sid, data in self._sessions.items()
if now - data.get("created_at", 0) > self._expire_seconds
]
for sid in expired:
del self._sessions[sid]
if expired:
logger.info(f"已清理 {len(expired)} 个过期会话")
@property
def count(self) -> int:
return len(self._sessions)
session_manager = SessionManager()
def validate_env_config():
"""验证必要的环境变量配置"""
warnings = []
if not os.getenv('SSH_HOST'):
warnings.append("SSH_HOST 未设置")
if not os.getenv('SSH_PRIVATE_KEY') and not os.getenv('SSH_PRIVATE_KEY_PATH') and not os.getenv('SSH_PASSWORD'):
warnings.append("SSH_PRIVATE_KEY、SSH_PRIVATE_KEY_PATH 或 SSH_PASSWORD 未设置")
if not os.getenv('GIT_SSH_KEY'):
warnings.append("GIT_SSH_KEY 未设置(Git 仓库认证可能失败)")
return warnings
# ==================== FastAPI 应用 ====================
@asynccontextmanager
async def lifespan(app: FastAPI):
global callback_handler
logger.info(f"🚀 {SERVER_NAME} 启动")
logger.info(f" SSH_HOST: {os.getenv('SSH_HOST', '未设置')}")
logger.info(f" SSH_USER: {os.getenv('SSH_USER', '未设置')}")
logger.info(f" GIT_WORK_DIR: {os.getenv('GIT_WORK_DIR', '/home/azureuser/projects')}")
# 验证环境变量
warnings = validate_env_config()
for warning in warnings:
logger.warning(f" ⚠️ {warning}")
callback_handler = AgentCallbackHandler(agent_name=POD_NAME, user_id=USER_ID)
# 启动会话清理任务
async def cleanup_task():
while True:
await asyncio.sleep(300) # 每5分钟清理一次
session_manager.cleanup_expired()
cleanup_task_handle = asyncio.create_task(cleanup_task())
yield
# 取消清理任务
cleanup_task_handle.cancel()
try:
await cleanup_task_handle
except asyncio.CancelledError:
pass
logger.info(f"🛑 {SERVER_NAME} 关闭")
app = FastAPI(
title=SERVER_NAME,
description="通过 SSH 在远程服务器上执行 Git 操作的 Agent",
version="1.0.0",
lifespan=lifespan
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==================== API Key 验证 ====================
async def verify_api_key(
api_key: Optional[str] = Header(None, alias="api-key"),
authorization: Optional[str] = Header(None)
) -> str:
"""验证 API Key"""
if api_key and api_key.strip() and api_key.strip() != "sk":
return api_key.strip()
if authorization:
key = authorization[7:].strip() if authorization.startswith("Bearer ") else authorization.strip()
if key and key != "sk":
return key
raise HTTPException(status_code=401, detail="缺少 API Key")
def get_api_key_from_request(request: Request) -> Optional[str]:
"""从请求头提取 API Key(不验证)"""
api_key = request.headers.get("api-key") or request.headers.get("api_key")
if not api_key:
auth = request.headers.get("Authorization")
if auth:
api_key = auth[7:] if auth.startswith("Bearer ") else auth
return api_key
# ==================== 健康检查 ====================
@app.get("/")
async def root():
return {
"service": SERVER_NAME,
"status": "running",
"tools": list(TOOL_MAP.keys()),
"callback_enabled": callback_handler is not None,
"config": {
"ssh_host": os.getenv('SSH_HOST', '未设置'),
"ssh_user": os.getenv('SSH_USER', '未设置'),
"git_work_dir": os.getenv('GIT_WORK_DIR', '/home/azureuser/projects')
}
}
@app.get("/health")
async def health():
return {
"status": "healthy",
"service": SERVER_NAME,
"callback_enabled": callback_handler is not None
}
# ==================== MCP 端点 ====================
async def run_with_callback(
tool_name: str,
func,
*args,
user_id: Optional[str] = None,
request_id: Optional[str] = None,
**kwargs
):
"""统一包装 callback 逻辑"""
if not callback_handler:
return await func(*args, **kwargs)
with CallbackContextManager(
handler=callback_handler,
user_id=user_id or USER_ID,
request_id=request_id or f"{tool_name}-{uuid.uuid4().hex}"
) as ctx:
ctx.add_tool(tool_name)
return await func(*args, **kwargs)
async def handle_mcp_request(data: Dict, session_id: str = None, api_key: str = None) -> Dict:
"""处理 MCP JSON-RPC 请求"""
method = data.get("method")
params = data.get("params", {})
req_id = data.get("id")
# tools/call 需要验证 API Key
if method == "tools/call" and (not api_key or api_key == "sk"):
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32001, "message": "缺少 API Key"}}
try:
if method == "initialize":
session_id = session_id or str(uuid.uuid4())
session_manager.create(session_id)
return {
"jsonrpc": "2.0", "id": req_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": SERVER_NAME, "version": "1.0.0"}
}
}
elif method == "tools/list":
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOL_LIST}}
elif method == "tools/call":
tool_name = params.get("name")
args = params.get("arguments", {})
if tool_name not in TOOL_MAP:
raise ValueError(f"Unknown tool: {tool_name}")
result = await run_with_callback(
tool_name,
TOOL_MAP[tool_name],
user_id=args.pop("user_id", None),
request_id=req_id or f"mcp-{tool_name}-{uuid.uuid4().hex}",
**args
)
return {
"jsonrpc": "2.0", "id": req_id,
"result": {"content": [{"type": "text", "text": str(result)}]}
}
elif method == "ping":
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
else:
raise ValueError(f"Unknown method: {method}")
except Exception as e:
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32603, "message": str(e)}}
@app.post("/mcp")
async def mcp_endpoint(request: Request):
"""MCP HTTP 端点"""
try:
body = await request.json()
session_id = request.headers.get("x-mcp-session-id")
api_key = get_api_key_from_request(request)
response = await handle_mcp_request(body, session_id, api_key)
return JSONResponse(content=response, headers={"x-mcp-session-id": session_id or ""})
except Exception as e:
return JSONResponse(status_code=400, content={"jsonrpc": "2.0", "error": {"code": -32700, "message": str(e)}})
@app.get("/mcp/sse")
async def mcp_sse(request: Request):
"""MCP SSE 端点"""
session_id = request.headers.get("x-mcp-session-id") or str(uuid.uuid4())
async def stream() -> AsyncGenerator[str, None]:
yield f"data: {json.dumps({'type': 'connection', 'sessionId': session_id})}\n\n"
try:
# 限制最大连接时间为 30 分钟
max_pings = 60 # 30秒 * 60 = 30分钟
ping_count = 0
while ping_count < max_pings:
# 检查客户端是否断开连接
if await request.is_disconnected():
break
await asyncio.sleep(30)
yield f"data: {json.dumps({'type': 'ping'})}\n\n"
ping_count += 1
except asyncio.CancelledError:
# 客户端断开连接
pass
return StreamingResponse(stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "x-mcp-session-id": session_id})
@app.post("/mcp/sse")
async def mcp_sse_post(request: Request):
"""MCP SSE POST 端点"""
try:
body = await request.json()
session_id = request.headers.get("x-mcp-session-id") or str(uuid.uuid4())
api_key = get_api_key_from_request(request)
async def stream() -> AsyncGenerator[str, None]:
response = await handle_mcp_request(body, session_id, api_key)
yield f"data: {json.dumps(response)}\n\n"
return StreamingResponse(stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "x-mcp-session-id": session_id})
except Exception as e:
return JSONResponse(status_code=400, content={"jsonrpc": "2.0", "error": {"code": -32700, "message": str(e)}})
# ==================== 业务 API ====================
class CloneRequest(BaseModel):
"""Clone 请求"""
repo_url: str = Field(..., description="Git 仓库地址")
target_dir: Optional[str] = Field(None, description="目标目录路径")
branch: Optional[str] = Field(None, description="要克隆的分支")
class PullRequest(BaseModel):
"""Pull 请求"""
work_dir: str = Field(..., description="本地仓库目录路径")
remote: str = Field("origin", description="远程仓库名称")
branch: Optional[str] = Field(None, description="要拉取的分支")
class PushRequest(BaseModel):
"""Push 请求"""
work_dir: str = Field(..., description="本地仓库目录路径")
remote: str = Field("origin", description="远程仓库名称")
branch: Optional[str] = Field(None, description="要推送的分支")
force: bool = Field(False, description="是否强制推送")
class CommitRequest(BaseModel):
"""Commit 请求"""
work_dir: str = Field(..., description="本地仓库目录路径")
message: str = Field(..., description="提交信息")
add_all: bool = Field(True, description="是否添加所有变更")
class StatusRequest(BaseModel):
"""Status 请求"""
work_dir: str = Field(..., description="本地仓库目录路径")
class DiffRequest(BaseModel):
"""Diff 请求"""
work_dir: str = Field(..., description="本地仓库目录路径")
file_path: Optional[str] = Field(None, description="文件路径")
staged: bool = Field(False, description="是否查看暂存区")
class LogRequest(BaseModel):
"""Log 请求"""
work_dir: str = Field(..., description="本地仓库目录路径")
count: int = Field(10, description="显示的提交数量", ge=1, le=1000)
oneline: bool = Field(True, description="是否使用单行格式")
@app.post("/api/v1/clone")
async def api_clone(request: CloneRequest, api_key: str = Depends(verify_api_key)):
"""克隆远程仓库"""
try:
result = await run_with_callback(
"git_clone",
TOOL_MAP['git_clone'],
repo_url=request.repo_url,
target_dir=request.target_dir,
branch=request.branch,
request_id=f"api-clone-{uuid.uuid4().hex}"
)
return json.loads(result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/pull")
async def api_pull(request: PullRequest, api_key: str = Depends(verify_api_key)):
"""拉取最新代码"""
try:
result = await run_with_callback(
"git_pull",
TOOL_MAP['git_pull'],
work_dir=request.work_dir,
remote=request.remote,
branch=request.branch,
request_id=f"api-pull-{uuid.uuid4().hex}"
)
return json.loads(result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/push")
async def api_push(request: PushRequest, api_key: str = Depends(verify_api_key)):
"""推送代码"""
try:
result = await run_with_callback(
"git_push",
TOOL_MAP['git_push'],
work_dir=request.work_dir,
remote=request.remote,
branch=request.branch,
force=request.force,
request_id=f"api-push-{uuid.uuid4().hex}"
)
return json.loads(result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/commit")
async def api_commit(request: CommitRequest, api_key: str = Depends(verify_api_key)):
"""提交变更"""
try:
result = await run_with_callback(
"git_commit",
TOOL_MAP['git_commit'],
work_dir=request.work_dir,
message=request.message,
add_all=request.add_all,
request_id=f"api-commit-{uuid.uuid4().hex}"
)
return json.loads(result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/status")
async def api_status(request: StatusRequest, api_key: str = Depends(verify_api_key)):
"""查看仓库状态"""
try:
result = await run_with_callback(
"git_status",
TOOL_MAP['git_status'],
work_dir=request.work_dir,
request_id=f"api-status-{uuid.uuid4().hex}"
)
return json.loads(result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/diff")
async def api_diff(request: DiffRequest, api_key: str = Depends(verify_api_key)):
"""查看文件变更"""
try:
result = await run_with_callback(
"git_diff",
TOOL_MAP['git_diff'],
work_dir=request.work_dir,
file_path=request.file_path,
staged=request.staged,
request_id=f"api-diff-{uuid.uuid4().hex}"
)
return json.loads(result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/v1/log")
async def api_log(request: LogRequest, api_key: str = Depends(verify_api_key)):
"""查看提交历史"""
try:
result = await run_with_callback(
"git_log",
TOOL_MAP['git_log'],
work_dir=request.work_dir,
count=request.count,
oneline=request.oneline,
request_id=f"api-log-{uuid.uuid4().hex}"
)
return json.loads(result)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/v1/tools")
async def api_tools():
"""获取工具列表"""
return {"tools": TOOL_LIST}
if __name__ == '__main__':
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
+811
View File
@@ -0,0 +1,811 @@
"""
Git Agent MCP 服务器 - 定义 Git 操作工具
提供通过 SSH 在远程服务器上执行 Git 命令的 MCP 工具。
"""
import json
import re
from typing import Optional
from mcp.server.fastmcp import FastMCP
from .ssh_client import (
get_ssh_client_from_env,
get_git_ssh_key,
get_default_work_dir,
get_git_user_config
)
# ==================== 配置 ====================
server = FastMCP('Git Agent')
# 系统提示词
SYSTEM_PROMPT = """你是 Git Agent,一个专业的 Git 操作助手。
你的核心能力:
1. 通过 SSH 连接到远程服务器
2. 在远程服务器上执行 Git 命令(clone, pull, push, commit, status, diff)
3. 返回操作结果和仓库状态
工作原则:
- 所有 Git 操作都在远程 Azure 服务器上执行
- 使用 SSH Key 进行 Git 仓库认证
- 返回详细的操作结果,包括本地路径、分支、commit 等信息
"""
# ==================== 辅助函数 ====================
def shell_escape(s: str) -> str:
"""转义字符串用于 shell 单引号包裹"""
if s is None:
return ''
return s.replace("'", "'\\''")
def extract_repo_name(repo_url: str) -> str:
"""从仓库 URL 提取仓库名称"""
# 移除 .git 后缀
url = repo_url.rstrip('/')
if url.endswith('.git'):
url = url[:-4]
# 提取最后一部分作为仓库名
parts = url.split('/')
return parts[-1] if parts else 'repo'
def parse_git_status(output: str) -> dict:
"""解析 git status --porcelain 输出
Git status --porcelain 格式:
- 第一列:暂存区状态 (M=modified, A=added, D=deleted, R=renamed, C=copied)
- 第二列:工作区状态 (M=modified, ?=untracked, !=ignored)
- 第三列:空格
- 第四列起:文件名
例如:
- "M file.txt" - 文件已暂存
- " M file.txt" - 文件已修改但未暂存
- "MM file.txt" - 文件已暂存且工作区有新修改
- "?? file.txt" - 未跟踪的文件
- "A file.txt" - 新添加的文件已暂存
"""
staged = []
modified = []
untracked = []
for line in output.strip().split('\n'):
if not line or len(line) < 3:
continue
index_status = line[0] # 暂存区状态
worktree_status = line[1] # 工作区状态
filename = line[3:]
# 未跟踪文件 (两列都是 ?)
if index_status == '?' and worktree_status == '?':
untracked.append(filename)
continue
# 暂存区有变更 (第一列不是空格和?)
if index_status in 'MADRC':
staged.append(filename)
# 工作区有修改 (第二列是 M)
if worktree_status == 'M':
modified.append(filename)
return {
'staged': staged,
'modified': modified,
'untracked': untracked,
'is_clean': len(staged) == 0 and len(modified) == 0 and len(untracked) == 0
}
def parse_git_diff_stat(output: str) -> dict:
"""解析 git diff --stat 输出"""
lines = output.strip().split('\n')
files_changed = 0
insertions = 0
deletions = 0
# 最后一行通常是统计信息
if lines:
last_line = lines[-1]
# 匹配 "X files changed, Y insertions(+), Z deletions(-)"
match = re.search(r'(\d+) files? changed', last_line)
if match:
files_changed = int(match.group(1))
match = re.search(r'(\d+) insertions?\(\+\)', last_line)
if match:
insertions = int(match.group(1))
match = re.search(r'(\d+) deletions?\(-\)', last_line)
if match:
deletions = int(match.group(1))
return {
'files_changed': files_changed,
'insertions': insertions,
'deletions': deletions
}
# ==================== MCP 工具定义 ====================
@server.tool()
async def git_clone(
repo_url: str,
target_dir: Optional[str] = None,
branch: Optional[str] = None
) -> str:
"""
克隆远程仓库到目标服务器
Args:
repo_url: Git 仓库地址(支持 HTTP/HTTPS/SSH 协议)
target_dir: 目标目录路径(可选,默认从 URL 推断)
branch: 要克隆的分支(可选,默认为仓库默认分支)
Returns:
JSON 格式的结果,包含 local_path、branch、commit 等信息
"""
# 确定目标目录(在 try 块外初始化,以便异常时可用)
git_ssh_key = get_git_ssh_key()
default_work_dir = get_default_work_dir()
if not target_dir:
repo_name = extract_repo_name(repo_url)
target_dir = f"{default_work_dir}/{repo_name}"
try:
async with get_ssh_client_from_env() as ssh_client:
# 构建 clone 命令(使用引号包裹路径以防止特殊字符问题)
clone_cmd = f"clone '{shell_escape(repo_url)}' '{shell_escape(target_dir)}'"
if branch:
clone_cmd += f" -b '{shell_escape(branch)}'"
# 确保父目录存在(使用字符串操作而非 os.path,因为是远程路径)
if '/' in target_dir:
parent_dir = '/'.join(target_dir.rstrip('/').split('/')[:-1])
if parent_dir:
await ssh_client.execute(f"mkdir -p '{shell_escape(parent_dir)}'")
# 执行 clone
result = await ssh_client.execute_git(clone_cmd, git_ssh_key=git_ssh_key, timeout=300)
if not result.success:
return json.dumps({
"success": False,
"error": result.stderr or "Clone 失败",
"repo_url": repo_url,
"local_path": target_dir
}, ensure_ascii=False)
# 获取当前分支和 commit
branch_result = await ssh_client.execute_git(
'rev-parse --abbrev-ref HEAD',
work_dir=target_dir
)
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
commit_result = await ssh_client.execute_git(
'rev-parse --short HEAD',
work_dir=target_dir
)
current_commit = commit_result.stdout.strip() if commit_result.success else 'unknown'
return json.dumps({
"success": True,
"repo_url": repo_url,
"local_path": target_dir,
"branch": current_branch,
"commit": current_commit,
"message": f"Clone 完成: {repo_url} -> {target_dir}"
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"success": False,
"error": str(e),
"repo_url": repo_url,
"local_path": target_dir
}, ensure_ascii=False)
@server.tool()
async def git_pull(
work_dir: str,
remote: str = "origin",
branch: Optional[str] = None
) -> str:
"""
拉取远程仓库最新代码
Args:
work_dir: 本地仓库目录路径
remote: 远程仓库名称,默认 origin
branch: 要拉取的分支(可选,默认为当前分支)
Returns:
JSON 格式的结果,包含 before_commit、after_commit、files_changed 等信息
"""
try:
git_ssh_key = get_git_ssh_key()
async with get_ssh_client_from_env() as ssh_client:
# 获取 pull 前的 commit
before_result = await ssh_client.execute_git(
'rev-parse --short HEAD',
work_dir=work_dir
)
before_commit = before_result.stdout.strip() if before_result.success else 'unknown'
# 获取当前分支
branch_result = await ssh_client.execute_git(
'rev-parse --abbrev-ref HEAD',
work_dir=work_dir
)
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
# 构建 pull 命令(转义 remote 和 branch 参数)
pull_cmd = f"pull '{shell_escape(remote)}'"
if branch:
pull_cmd += f" '{shell_escape(branch)}'"
# 执行 pull
result = await ssh_client.execute_git(
pull_cmd,
work_dir=work_dir,
git_ssh_key=git_ssh_key,
timeout=180
)
if not result.success:
return json.dumps({
"success": False,
"error": result.stderr or "Pull 失败",
"local_path": work_dir
}, ensure_ascii=False)
# 获取 pull 后的 commit
after_result = await ssh_client.execute_git(
'rev-parse --short HEAD',
work_dir=work_dir
)
after_commit = after_result.stdout.strip() if after_result.success else 'unknown'
# 统计变更文件数
files_changed = 0
if before_commit != after_commit:
diff_result = await ssh_client.execute_git(
f"diff --stat '{shell_escape(before_commit)}'..'{shell_escape(after_commit)}'",
work_dir=work_dir
)
if diff_result.success:
stat = parse_git_diff_stat(diff_result.stdout)
files_changed = stat['files_changed']
return json.dumps({
"success": True,
"local_path": work_dir,
"branch": current_branch,
"before_commit": before_commit,
"after_commit": after_commit,
"files_changed": files_changed,
"message": f"Pull 完成: {files_changed} 个文件变更" if files_changed > 0 else "已是最新"
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"success": False,
"error": str(e),
"local_path": work_dir
}, ensure_ascii=False)
@server.tool()
async def git_push(
work_dir: str,
remote: str = "origin",
branch: Optional[str] = None,
force: bool = False
) -> str:
"""
推送本地代码到远程仓库
Args:
work_dir: 本地仓库目录路径
remote: 远程仓库名称,默认 origin
branch: 要推送的分支(可选,默认为当前分支)
force: 是否强制推送,默认 False
Returns:
JSON 格式的结果,包含 commits_pushed 等信息
"""
try:
git_ssh_key = get_git_ssh_key()
async with get_ssh_client_from_env() as ssh_client:
# 获取当前分支
branch_result = await ssh_client.execute_git(
'rev-parse --abbrev-ref HEAD',
work_dir=work_dir
)
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
target_branch = branch or current_branch
# 获取待推送的 commit 数量(如果远程分支不存在,则跳过)
commits_to_push = 0
count_result = await ssh_client.execute_git(
f"rev-list --count '{shell_escape(remote)}/{shell_escape(target_branch)}'..HEAD",
work_dir=work_dir
)
if count_result.success:
try:
commits_to_push = int(count_result.stdout.strip())
except (ValueError, AttributeError):
commits_to_push = 0
# 构建 push 命令(转义参数)
push_cmd = f"push '{shell_escape(remote)}'"
if branch:
push_cmd += f" '{shell_escape(branch)}'"
if force:
push_cmd += ' --force'
# 执行 push
result = await ssh_client.execute_git(
push_cmd,
work_dir=work_dir,
git_ssh_key=git_ssh_key,
timeout=180
)
if not result.success:
return json.dumps({
"success": False,
"error": result.stderr or "Push 失败",
"local_path": work_dir
}, ensure_ascii=False)
return json.dumps({
"success": True,
"local_path": work_dir,
"remote": remote,
"branch": target_branch,
"commits_pushed": commits_to_push,
"force": force,
"message": f"Push 完成: {commits_to_push} 个 commit 推送到 {remote}/{target_branch}"
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"success": False,
"error": str(e),
"local_path": work_dir
}, ensure_ascii=False)
@server.tool()
async def git_commit(
work_dir: str,
message: str,
add_all: bool = True
) -> str:
"""
提交本地变更
Args:
work_dir: 本地仓库目录路径
message: 提交信息
add_all: 是否添加所有变更文件,默认 True
Returns:
JSON 格式的结果,包含 commit hash、files_committed 等信息
"""
try:
git_username, git_email = get_git_user_config()
async with get_ssh_client_from_env() as ssh_client:
# 配置 Git 用户信息(转义用户名和邮箱)
await ssh_client.execute_git(
f"config user.name '{shell_escape(git_username)}'",
work_dir=work_dir
)
await ssh_client.execute_git(
f"config user.email '{shell_escape(git_email)}'",
work_dir=work_dir
)
# 如果需要,添加所有变更
if add_all:
add_result = await ssh_client.execute_git('add -A', work_dir=work_dir)
if not add_result.success:
return json.dumps({
"success": False,
"error": f"git add 失败: {add_result.stderr}",
"local_path": work_dir
}, ensure_ascii=False)
# 获取暂存区文件数量
staged_result = await ssh_client.execute_git(
'diff --cached --name-only',
work_dir=work_dir
)
staged_files = [f for f in staged_result.stdout.strip().split('\n') if f]
files_count = len(staged_files)
if files_count == 0:
return json.dumps({
"success": True,
"local_path": work_dir,
"commit": None,
"files_committed": 0,
"message": "没有需要提交的变更"
}, ensure_ascii=False)
# 执行 commit
# 使用单引号包裹提交信息,并转义其中的单引号,避免命令注入
result = await ssh_client.execute_git(
f"commit -m '{shell_escape(message)}'",
work_dir=work_dir
)
if not result.success:
return json.dumps({
"success": False,
"error": result.stderr or "Commit 失败",
"local_path": work_dir
}, ensure_ascii=False)
# 获取新的 commit hash
commit_result = await ssh_client.execute_git(
'rev-parse --short HEAD',
work_dir=work_dir
)
commit_hash = commit_result.stdout.strip() if commit_result.success else 'unknown'
return json.dumps({
"success": True,
"local_path": work_dir,
"commit": commit_hash,
"files_committed": files_count,
"message": f"Commit 完成: {files_count} 个文件 ({commit_hash})"
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"success": False,
"error": str(e),
"local_path": work_dir
}, ensure_ascii=False)
@server.tool()
async def git_status(
work_dir: str
) -> str:
"""
查看仓库状态
Args:
work_dir: 本地仓库目录路径
Returns:
JSON 格式的结果,包含 branch、staged、modified、untracked 等信息
"""
try:
async with get_ssh_client_from_env() as ssh_client:
# 获取当前分支
branch_result = await ssh_client.execute_git(
'rev-parse --abbrev-ref HEAD',
work_dir=work_dir
)
current_branch = branch_result.stdout.strip() if branch_result.success else 'unknown'
# 获取当前 commit
commit_result = await ssh_client.execute_git(
'rev-parse --short HEAD',
work_dir=work_dir
)
current_commit = commit_result.stdout.strip() if commit_result.success else 'unknown'
# 获取状态
status_result = await ssh_client.execute_git(
'status --porcelain',
work_dir=work_dir
)
if not status_result.success:
return json.dumps({
"success": False,
"error": status_result.stderr or "获取状态失败",
"local_path": work_dir
}, ensure_ascii=False)
status = parse_git_status(status_result.stdout)
# 构建状态消息
parts = []
if status['staged']:
parts.append(f"{len(status['staged'])} staged")
if status['modified']:
parts.append(f"{len(status['modified'])} modified")
if status['untracked']:
parts.append(f"{len(status['untracked'])} untracked")
message = ', '.join(parts) if parts else "工作区干净"
return json.dumps({
"success": True,
"local_path": work_dir,
"branch": current_branch,
"commit": current_commit,
"staged": status['staged'],
"modified": status['modified'],
"untracked": status['untracked'],
"is_clean": status['is_clean'],
"message": message
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"success": False,
"error": str(e),
"local_path": work_dir
}, ensure_ascii=False)
@server.tool()
async def git_diff(
work_dir: str,
file_path: Optional[str] = None,
staged: bool = False
) -> str:
"""
查看文件变更
Args:
work_dir: 本地仓库目录路径
file_path: 文件路径(可选,不指定则显示所有变更)
staged: 是否查看暂存区变更,默认 False
Returns:
JSON 格式的结果,包含 diff 内容和统计信息
"""
try:
async with get_ssh_client_from_env() as ssh_client:
# 构建 diff 命令(转义 file_path 防止命令注入)
diff_cmd = 'diff'
if staged:
diff_cmd += ' --cached'
if file_path:
diff_cmd += f" -- '{shell_escape(file_path)}'"
# 获取 diff 内容
result = await ssh_client.execute_git(diff_cmd, work_dir=work_dir)
if not result.success:
return json.dumps({
"success": False,
"error": result.stderr or "获取 diff 失败",
"local_path": work_dir
}, ensure_ascii=False)
# 获取统计信息
stat_cmd = 'diff --stat'
if staged:
stat_cmd += ' --cached'
if file_path:
stat_cmd += f" -- '{shell_escape(file_path)}'"
stat_result = await ssh_client.execute_git(stat_cmd, work_dir=work_dir)
stat = parse_git_diff_stat(stat_result.stdout) if stat_result.success else {
'files_changed': 0,
'insertions': 0,
'deletions': 0
}
return json.dumps({
"success": True,
"local_path": work_dir,
"file_path": file_path,
"staged": staged,
"diff": result.stdout,
"files_changed": stat['files_changed'],
"insertions": stat['insertions'],
"deletions": stat['deletions']
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"success": False,
"error": str(e),
"local_path": work_dir
}, ensure_ascii=False)
@server.tool()
async def git_log(
work_dir: str,
count: int = 10,
oneline: bool = True
) -> str:
"""
查看提交历史
Args:
work_dir: 本地仓库目录路径
count: 显示的提交数量,默认 10
oneline: 是否使用单行格式,默认 True
Returns:
JSON 格式的结果,包含提交历史
"""
try:
# 验证 count 参数,防止注入
if not isinstance(count, int) or count < 1:
count = 10
if count > 1000:
count = 1000 # 限制最大数量
async with get_ssh_client_from_env() as ssh_client:
# 构建 log 命令
log_cmd = f'log -n {count}'
if oneline:
log_cmd += ' --oneline'
result = await ssh_client.execute_git(log_cmd, work_dir=work_dir)
if not result.success:
return json.dumps({
"success": False,
"error": result.stderr or "获取日志失败",
"local_path": work_dir
}, ensure_ascii=False)
# 解析日志
commits = []
for line in result.stdout.strip().split('\n'):
if line:
if oneline:
parts = line.split(' ', 1)
commits.append({
'hash': parts[0],
'message': parts[1] if len(parts) > 1 else ''
})
else:
commits.append(line)
return json.dumps({
"success": True,
"local_path": work_dir,
"commits": commits,
"count": len(commits)
}, ensure_ascii=False)
except Exception as e:
return json.dumps({
"success": False,
"error": str(e),
"local_path": work_dir
}, ensure_ascii=False)
# ==================== 工具映射(供 API 使用)====================
TOOL_MAP = {
'git_clone': git_clone,
'git_pull': git_pull,
'git_push': git_push,
'git_commit': git_commit,
'git_status': git_status,
'git_diff': git_diff,
'git_log': git_log,
}
TOOL_LIST = [
{
"name": "git_clone",
"description": "克隆远程仓库到目标服务器",
"inputSchema": {
"type": "object",
"properties": {
"repo_url": {"type": "string", "description": "Git 仓库地址"},
"target_dir": {"type": "string", "description": "目标目录路径(可选)"},
"branch": {"type": "string", "description": "要克隆的分支(可选)"}
},
"required": ["repo_url"]
}
},
{
"name": "git_pull",
"description": "拉取远程仓库最新代码",
"inputSchema": {
"type": "object",
"properties": {
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
"remote": {"type": "string", "description": "远程仓库名称,默认 origin"},
"branch": {"type": "string", "description": "要拉取的分支(可选)"}
},
"required": ["work_dir"]
}
},
{
"name": "git_push",
"description": "推送本地代码到远程仓库",
"inputSchema": {
"type": "object",
"properties": {
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
"remote": {"type": "string", "description": "远程仓库名称,默认 origin"},
"branch": {"type": "string", "description": "要推送的分支(可选)"},
"force": {"type": "boolean", "description": "是否强制推送,默认 false"}
},
"required": ["work_dir"]
}
},
{
"name": "git_commit",
"description": "提交本地变更",
"inputSchema": {
"type": "object",
"properties": {
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
"message": {"type": "string", "description": "提交信息"},
"add_all": {"type": "boolean", "description": "是否添加所有变更,默认 true"}
},
"required": ["work_dir", "message"]
}
},
{
"name": "git_status",
"description": "查看仓库状态",
"inputSchema": {
"type": "object",
"properties": {
"work_dir": {"type": "string", "description": "本地仓库目录路径"}
},
"required": ["work_dir"]
}
},
{
"name": "git_diff",
"description": "查看文件变更",
"inputSchema": {
"type": "object",
"properties": {
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
"file_path": {"type": "string", "description": "文件路径(可选)"},
"staged": {"type": "boolean", "description": "是否查看暂存区,默认 false"}
},
"required": ["work_dir"]
}
},
{
"name": "git_log",
"description": "查看提交历史",
"inputSchema": {
"type": "object",
"properties": {
"work_dir": {"type": "string", "description": "本地仓库目录路径"},
"count": {"type": "integer", "description": "显示的提交数量,默认 10"},
"oneline": {"type": "boolean", "description": "是否使用单行格式,默认 true"}
},
"required": ["work_dir"]
}
}
]
# 导出
__all__ = ['server', 'SYSTEM_PROMPT', 'TOOL_MAP', 'TOOL_LIST']
if __name__ == '__main__':
server.run()
+277
View File
@@ -0,0 +1,277 @@
"""
SSH 客户端模块 - 用于连接远程服务器执行 Git 命令
支持:
- SSH 连接到远程服务器
- 执行命令并返回结果
- 配置 Git SSH Key 用于仓库认证
"""
import os
import base64
import asyncio
import tempfile
import logging
from typing import Optional, Tuple
from dataclasses import dataclass
import asyncssh
logger = logging.getLogger(__name__)
@dataclass
class SSHConfig:
"""SSH 连接配置"""
host: str
user: str
port: int = 22
private_key: Optional[str] = None # Base64 编码的私钥
private_key_path: Optional[str] = None # 私钥文件路径
password: Optional[str] = None # SSH 密码(可选)
@dataclass
class CommandResult:
"""命令执行结果"""
success: bool
stdout: str
stderr: str
exit_code: int
def to_dict(self) -> dict:
return {
"success": self.success,
"stdout": self.stdout,
"stderr": self.stderr,
"exit_code": self.exit_code
}
class SSHClient:
"""异步 SSH 客户端"""
def __init__(self, config: SSHConfig):
self.config = config
self._temp_key_file: Optional[str] = None
self._temp_git_key_file: Optional[str] = None
async def __aenter__(self):
"""异步上下文管理器入口"""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""异步上下文管理器出口 - 确保清理临时文件"""
self.cleanup()
return False
def _get_private_key_path(self) -> Optional[str]:
"""获取私钥文件路径,如果是 Base64 编码则解码并写入临时文件"""
# 如果已经有临时文件,直接返回
if self._temp_key_file and os.path.exists(self._temp_key_file):
return self._temp_key_file
if self.config.private_key_path:
return self.config.private_key_path
if self.config.private_key:
try:
# 解码 Base64
key_content = base64.b64decode(self.config.private_key).decode('utf-8')
# 写入临时文件
fd, path = tempfile.mkstemp(prefix='ssh_key_', suffix='.pem')
with os.fdopen(fd, 'w') as f:
f.write(key_content)
# 设置权限为 600
os.chmod(path, 0o600)
self._temp_key_file = path
logger.info(f"SSH 私钥已写入临时文件: {path}")
return path
except Exception as e:
logger.error(f"解码 SSH 私钥失败: {e}")
return None
return None
def cleanup(self):
"""清理临时文件"""
if self._temp_key_file and os.path.exists(self._temp_key_file):
try:
os.remove(self._temp_key_file)
logger.info(f"已清理临时 SSH 私钥文件: {self._temp_key_file}")
except Exception as e:
logger.warning(f"清理临时文件失败: {e}")
self._temp_key_file = None
if self._temp_git_key_file and os.path.exists(self._temp_git_key_file):
try:
os.remove(self._temp_git_key_file)
logger.info(f"已清理临时 Git SSH 私钥文件: {self._temp_git_key_file}")
except Exception as e:
logger.warning(f"清理临时文件失败: {e}")
self._temp_git_key_file = None
async def execute(self, command: str, timeout: int = 60) -> CommandResult:
"""
执行远程命令
Args:
command: 要执行的命令
timeout: 超时时间(秒)
Returns:
CommandResult 对象
"""
key_path = self._get_private_key_path()
try:
# 构建连接参数
connect_kwargs = {
'host': self.config.host,
'port': self.config.port,
'username': self.config.user,
'known_hosts': None, # 禁用 known_hosts 检查
}
# 优先使用私钥认证,其次使用密码认证
if key_path:
connect_kwargs['client_keys'] = [key_path]
elif self.config.password:
connect_kwargs['password'] = self.config.password
logger.info(f"连接到 {self.config.user}@{self.config.host}:{self.config.port}")
async with asyncssh.connect(**connect_kwargs) as conn:
logger.info(f"执行命令: {command[:100]}...")
result = await asyncio.wait_for(
conn.run(command, check=False),
timeout=timeout
)
success = result.exit_status == 0
return CommandResult(
success=success,
stdout=result.stdout or '',
stderr=result.stderr or '',
exit_code=result.exit_status or 0
)
except asyncio.TimeoutError:
return CommandResult(
success=False,
stdout='',
stderr=f'命令执行超时({timeout}秒)',
exit_code=-1
)
except asyncssh.Error as e:
logger.error(f"SSH 连接错误: {e}")
return CommandResult(
success=False,
stdout='',
stderr=f'SSH 连接错误: {str(e)}',
exit_code=-1
)
except Exception as e:
logger.error(f"执行命令失败: {e}")
return CommandResult(
success=False,
stdout='',
stderr=f'执行失败: {str(e)}',
exit_code=-1
)
async def execute_git(
self,
git_command: str,
work_dir: Optional[str] = None,
git_ssh_key: Optional[str] = None,
timeout: int = 120
) -> CommandResult:
"""
执行 Git 命令
Args:
git_command: Git 命令(不包含 'git' 前缀)
work_dir: 工作目录
git_ssh_key: Git SSH 私钥(Base64 编码)
timeout: 超时时间(秒)
Returns:
CommandResult 对象
"""
# 构建完整命令
commands = []
# 如果提供了 Git SSH Key,配置 GIT_SSH_COMMAND
if git_ssh_key:
try:
key_content = base64.b64decode(git_ssh_key).decode('utf-8')
# 在远程服务器上创建临时密钥文件
# 使用 cat << 'EOF' 来安全地写入包含特殊字符的内容
commands.append('TEMP_KEY=$(mktemp)')
# 使用 base64 在远程服务器上解码,避免特殊字符问题
key_b64 = base64.b64encode(key_content.encode('utf-8')).decode('ascii')
commands.append(f'echo "{key_b64}" | base64 -d > $TEMP_KEY')
commands.append('chmod 600 $TEMP_KEY')
commands.append('export GIT_SSH_COMMAND="ssh -i $TEMP_KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"')
except Exception as e:
logger.error(f"解码 Git SSH Key 失败: {e}")
# 切换到工作目录
if work_dir:
# 转义工作目录中的特殊字符
safe_work_dir = work_dir.replace("'", "'\\''")
commands.append(f"cd '{safe_work_dir}'")
# 执行 Git 命令
commands.append(f'git {git_command}')
# 清理临时密钥
if git_ssh_key:
commands.append('rm -f $TEMP_KEY')
# 组合命令
full_command = ' && '.join(commands)
return await self.execute(full_command, timeout=timeout)
def get_ssh_client_from_env() -> SSHClient:
"""从环境变量创建 SSH 客户端"""
config = SSHConfig(
host=os.getenv('SSH_HOST', ''),
user=os.getenv('SSH_USER', 'azureuser'),
port=int(os.getenv('SSH_PORT', '22')),
private_key=os.getenv('SSH_PRIVATE_KEY'),
private_key_path=os.getenv('SSH_PRIVATE_KEY_PATH'),
password=os.getenv('SSH_PASSWORD')
)
if not config.host:
raise ValueError("SSH_HOST 环境变量未设置")
if not config.private_key and not config.private_key_path and not config.password:
raise ValueError("SSH_PRIVATE_KEY、SSH_PRIVATE_KEY_PATH 或 SSH_PASSWORD 环境变量未设置")
return SSHClient(config)
def get_git_ssh_key() -> Optional[str]:
"""从环境变量获取 Git SSH Key"""
return os.getenv('GIT_SSH_KEY')
def get_default_work_dir() -> str:
"""获取默认工作目录"""
return os.getenv('GIT_WORK_DIR', '/home/azureuser/projects')
def get_git_user_config() -> Tuple[str, str]:
"""获取 Git 用户配置"""
username = os.getenv('GIT_USERNAME', 'Git Agent')
email = os.getenv('GIT_EMAIL', 'git-agent@taiji.io')
return username, email