commit fb1476f28aabe14c8864bad151b302a525006e53 Author: MohamedAlawakey Date: Sat Nov 22 16:18:29 2025 +0200 GitPasha MCP Server - Ready for deployment diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ea0cc52 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Logs +logs/ +*.log + +# Environments +.env.example +.venv +.linux_cline +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Git +.git +.gitignore + +# IDE +.vscode/ +.idea/ + +# Documentation +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3352ce --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# For GitBasha +GITPASHA_API_KEY='your_api_key' +GITPASHA_BASE_URL=https://app.gitpasha.com/api/v1 +GITPASHA__USERNAME='your_user_name' + +LOG_LEVEL=DEBUG +LOG_FILE=logs/gitpasha.log + +MCP_TRANSPORT=sse +HOST=0.0.0.0 +PORT=8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..001303e --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# logs +logs/ +*.log + +# Environments +.env +.venv +.linux_cline + +# Python cache +__pycache__/ +*.pyc diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6310492 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +COPY requirements.txt . +RUN python -m pip install --upgrade pip setuptools wheel \ + && pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create logs directory +RUN mkdir -p /app/logs + +# Expose port +EXPOSE 8000 + +# Environment variables +ENV PYTHONUNBUFFERED=1 +ENV MCP_TRANSPORT=sse +ENV HOST=0.0.0.0 +ENV PORT=8000 + +# Build metadata +ENV BUILD_DATE=2025-11-22 +ENV VERSION=2.0 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ + CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8000)); s.close()" || exit 1 + +# Run with python directly +CMD ["python", "-u", "main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..27eb51a --- /dev/null +++ b/README.md @@ -0,0 +1,161 @@ +# Git Pasha MCP Server + +Simple MCP server for managing Git Pasha repositories. + +## File Structure + +``` +gitpasha-mcp/ +├── main.py +├── requirements.txt +├── .env +├── .env.example +├── .gitignore +├── README.md +├── helpers/ +│ ├── __init__.py +│ ├── headers.py +│ ├── descriptions.py +│ ├── error_formatter.py +│ ├── logger.py +│ └── http_client.py +├── api/ +│ ├── __init__.py +│ ├── repos/ +│ │ ├── __init__.py +│ │ ├── create.py +│ │ ├── update.py +│ │ └── delete.py +│ ├── issues/ +│ │ ├── __init__.py +│ │ ├── list.py +│ │ ├── create.py +│ │ └── update.py +│ ├── files/ +│ │ ├── __init__.py +│ │ └── create_readme.py +│ └── pulls/ +│ ├── __init__.py +│ └── open.py +└── tools/ + ├── __init__.py + ├── repo_create.py + ├── repo_update.py + ├── repo_delete.py + ├── issue_create.py + ├── issue_list.py + ├── issue_update.py + └── pr_open.py +``` + +## Features + +- ✅ Create new repositories (with auto-suggested descriptions) +- ✅ Delete existing repositories +- ✅ Edit repository details +- ✅ List all repositories +- ✅ Get description suggestions + +## Setup + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Setup API Key + +Copy `.env.example` to `.env` and add your Git Pasha API key: + +```bash +cp .env.example .env +``` + +Edit `.env` and add your key: +``` +GITPASHA_API_KEY=your_actual_key_here +``` + +Get your API key from: https://app.gitpasha.com/settings + +### 3. Run Locally + +```bash +python server.py +``` + +### 4. Add to Claude Desktop + +Edit Claude config file: + +**Mac/Linux:** `~/Library/Application Support/Claude/claude_desktop_config.json` + +**Windows:** `%APPDATA%\Claude\claude_desktop_config.json` + +Add this: + +```json +{ + "mcpServers": { + "gitpasha": { + "command": "python", + "args": ["C:\\Users\\Yours\\Desktop\\MCP Server\\git basha\\server.py"] + } + } +} +``` + +#### > 📝 **Important Note** +Make sure to go to the following path: +```bash +C:\Users\Alawakey\AppData\Local\AnthropicClaude\app-0.13.64\logs\gitpasha.log +``` + +- If you find the file `logs\gitpasha.log` ✅ — everything is fine, and the server can run properly. +- If the file **doesn’t exist** ❌ — you need to **create it manually** so the server can start correctly. + + +## Usage Examples + +### Create Repository +``` +Create a new repo called "my-awesome-api" +``` +Auto-suggests: "RESTful API service" + +### Edit Repository +``` +Edit repo "old-name" to have description "New description" +``` + +### Delete Repository +``` +Delete repo "test-repo" with confirmation +``` + +### List Repositories +``` +Show me all my repositories +``` + +## Tools Available + +1. **create_repo** - Create new repository +2. **delete_repo** - Delete repository (requires confirmation) +3. **edit_repo** - Update repository settings +4. **list_repos** - List all repositories +5. **get_description_suggestion** - Get description idea for repo name + +## Notes + +- All descriptions are auto-suggested in English +- Simple and clean code +- Works locally and with Claude Desktop +- Requires Git Pasha API key + +# create repo --> enter the repo name + description +# delete repo --> enter the user name / name repo. EX: (mohamedelawakey/testat) +# update repo --> enter the repo like steps of delete the enter the new name if you want then description if also you want, then chose if you want the repo private or no + +# to update the issues enter the (mohamedelawakey/testat) as a repo, issue as a number of these issue, title, and all as you want diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..85a8f2a --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,15 @@ +from api.repos import api_create_repo, api_update_repo, api_delete_repo +from api.issues import api_list_issues, api_create_issue, api_update_issue +from api.files import api_create_readme +from api.pulls import api_open_pr + +__all__ = [ + "api_create_repo", + "api_update_repo", + "api_delete_repo", + "api_list_issues", + "api_create_issue", + "api_update_issue", + "api_create_readme", + "api_open_pr", +] diff --git a/api/files/__init__.py b/api/files/__init__.py new file mode 100644 index 0000000..24b57cc --- /dev/null +++ b/api/files/__init__.py @@ -0,0 +1,5 @@ +from api.files.create_readme import api_create_readme + +__all__ = [ + "api_create_readme" +] diff --git a/api/files/create_readme.py b/api/files/create_readme.py new file mode 100644 index 0000000..19201f6 --- /dev/null +++ b/api/files/create_readme.py @@ -0,0 +1,37 @@ +import os +import base64 +from typing import Dict, Any +from helpers import get_headers, build_client + +USERNAME = os.getenv("GITPASHA__USERNAME", "") +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_create_readme(repo: str, content: str) -> Dict[str, Any]: + if "/" not in repo: + if not USERNAME: + raise ValueError("GITPASHA__USERNAME not set in .env") + repo = f"{USERNAME}/{repo}" + + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}/contents/README.md" + encoded = base64.b64encode(content.encode("utf-8")).decode("utf-8") + body = { + "message": "Add README", + "content": encoded, + } + res = client.put( + url, + headers=get_headers(), + json=body + ) + + if res.status_code < 300: + return res.json() + return { + "error": "Failed to create README", + "response": res.text, + } diff --git a/api/issues/__init__.py b/api/issues/__init__.py new file mode 100644 index 0000000..7c90f49 --- /dev/null +++ b/api/issues/__init__.py @@ -0,0 +1,9 @@ +from api.issues.list import api_list_issues +from api.issues.create import api_create_issue +from api.issues.update import api_update_issue + +__all__ = [ + "api_list_issues", + "api_create_issue", + "api_update_issue" +] diff --git a/api/issues/create.py b/api/issues/create.py new file mode 100644 index 0000000..a1ec196 --- /dev/null +++ b/api/issues/create.py @@ -0,0 +1,39 @@ +import os +from typing import Dict, Any, Optional, List +from helpers import get_headers, build_client + +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_create_issue( + repo: str, + title: str, + body: str = "", + labels: Optional[List[str]] = None, + assignees: Optional[List[str]] = None +) -> Dict[str, Any]: + username = os.getenv("GITPASHA__USERNAME", "") + + if "/" not in repo: + if not username: + raise ValueError("GITPASHA__USERNAME not set in .env") + repo = f"{username}/{repo}" + + payload: Dict[str, Any] = {"title": title, "body": body} + if labels: + payload["labels"] = labels + if assignees: + payload["assignees"] = assignees + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}/issues" + res = client.post( + url, + headers=get_headers(), + json=payload + ) + res.raise_for_status() + data = res.json() + return data diff --git a/api/issues/list.py b/api/issues/list.py new file mode 100644 index 0000000..1540a79 --- /dev/null +++ b/api/issues/list.py @@ -0,0 +1,38 @@ +import os +from typing import List, Dict, Any, Optional +from helpers import get_headers, build_client + + +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_list_issues( + repo: str, + state: Optional[str] = None +) -> List[Dict[str, Any]]: + username = os.getenv("GITPASHA__USERNAME", "") + + if "/" not in repo: + if not username: + raise ValueError("GITPASHA__USERNAME not set in .env") + repo = f"{username}/{repo}" + + params = {} + if state: + params["state"] = state + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}/issues" + res = client.get( + url, + headers=get_headers(), + params=params + ) + res.raise_for_status() + try: + data = res.json() + except Exception: + data = [] + return data diff --git a/api/issues/update.py b/api/issues/update.py new file mode 100644 index 0000000..181b3ec --- /dev/null +++ b/api/issues/update.py @@ -0,0 +1,78 @@ +import os +from typing import Dict, Any, Optional, List +from helpers import get_headers, build_client + +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_update_issue( + repo: str, + issue: str, + title: Optional[str] = None, + body: Optional[str] = None, + state: Optional[str] = None, + labels: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + comment: Optional[str] = None +) -> Dict[str, Any]: + repo = repo.strip() + issue = issue.strip() + + username = os.getenv("GITPASHA__USERNAME", "") + + if "/" not in repo: + if not username: + raise ValueError("GITPASHA__USERNAME not set in .env") + repo = f"{username}/{repo}" + + out: Dict[str, Any] = {} + with build_client() as client: + patch_payload: Dict[str, Any] = {} + if title is not None: + patch_payload["title"] = title + if body is not None: + patch_payload["body"] = body + if state is not None: + patch_payload["state"] = state + if labels is not None: + patch_payload["labels"] = labels + if assignees is not None: + patch_payload["assignees"] = assignees + + if patch_payload: + url = f"{BASE_URL}/repos/{repo}/issues/{issue}" + res = client.patch( + url, + headers=get_headers(), + json=patch_payload + ) + + if res.status_code == 405: + res = client.put( + url, + headers=get_headers(), + json=patch_payload + ) + res.raise_for_status() + out["update"] = res.json() if res.text else {"status": "ok"} + + if comment: + url_c = f"{BASE_URL}/repos/{repo}/issues/{issue}/comments" + res_c = client.post( + url_c, + headers=get_headers(), + json={ + "body": comment + } + ) + res_c.raise_for_status() + out["comment"] = res_c.json() if res_c.text else { + "status": "commented" + } + + return out or { + "status": "no-op" + } diff --git a/api/pulls/__init__.py b/api/pulls/__init__.py new file mode 100644 index 0000000..966a31a --- /dev/null +++ b/api/pulls/__init__.py @@ -0,0 +1,5 @@ +from api.pulls.open import api_open_pr + +__all__ = [ + "api_open_pr" +] diff --git a/api/pulls/open.py b/api/pulls/open.py new file mode 100644 index 0000000..3889361 --- /dev/null +++ b/api/pulls/open.py @@ -0,0 +1,39 @@ +import os +from typing import Dict, Any +from helpers import get_headers, build_client + +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_open_pr( + repo: str, + title: str, + head: str, + base: str, + body: str = "" +) -> Dict[str, Any]: + username = os.getenv("GITPASHA__USERNAME", "") + + if "/" not in repo: + if not username: + raise ValueError("GITPASHA__USERNAME not set in .env") + repo = f"{username}/{repo}" + + payload = { + "title": title, + "head": head, + "base": base, + "body": body + } + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}/pulls" + res = client.post( + url, + headers=get_headers(), + json=payload + ) + res.raise_for_status() + return res.json() diff --git a/api/repos/__init__.py b/api/repos/__init__.py new file mode 100644 index 0000000..8611bf9 --- /dev/null +++ b/api/repos/__init__.py @@ -0,0 +1,9 @@ +from api.repos.create import api_create_repo +from api.repos.update import api_update_repo +from api.repos.delete import api_delete_repo + +__all__ = [ + "api_create_repo", + "api_update_repo", + "api_delete_repo" +] diff --git a/api/repos/create.py b/api/repos/create.py new file mode 100644 index 0000000..22b7aa6 --- /dev/null +++ b/api/repos/create.py @@ -0,0 +1,30 @@ +import os +from typing import Dict, Any +import httpx +from helpers import get_headers, build_client + +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_create_repo( + name: str, + description: str, + private: bool = False +) -> Dict[str, Any]: + payload = { + "name": name, + "description": description, + "private": private + } + with build_client() as client: + res = client.post( + f"{BASE_URL}/user/repos", + headers=get_headers(), + json=payload, + ) + res.raise_for_status() + data = res.json() + return data diff --git a/api/repos/delete.py b/api/repos/delete.py new file mode 100644 index 0000000..7759608 --- /dev/null +++ b/api/repos/delete.py @@ -0,0 +1,33 @@ +import os +from typing import Dict, Any +from helpers import get_headers, build_client + +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_delete_repo(repo: str) -> Dict[str, Any]: + repo = repo.strip() + username = os.getenv("GITPASHA__USERNAME", "") + + if "/" not in repo: + if not username: + raise ValueError("GITPASHA__USERNAME not set in .env") + repo = f"{username}/{repo}" + + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}" + res = client.delete(url, headers=get_headers()) + + try: + response_data = res.json() if res.text else {} + except Exception: + response_data = {} + + if res.status_code in (200, 202, 204): + return {"status": "deleted", **response_data} + else: + res.raise_for_status() + return response_data or {"status": "deleted"} diff --git a/api/repos/update.py b/api/repos/update.py new file mode 100644 index 0000000..834d145 --- /dev/null +++ b/api/repos/update.py @@ -0,0 +1,43 @@ +import os +from typing import Dict, Any, Optional +from helpers import get_headers, build_client + +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1" +).rstrip("/") + + +def api_update_repo( + repo: str, + name: Optional[str] = None, + description: Optional[str] = None, + private: Optional[bool] = None +) -> Dict[str, Any]: + username = os.getenv("GITPASHA__USERNAME", "") + + if "/" not in repo: + if not username: + raise ValueError("GITPASHA__USERNAME not set in .env") + repo = f"{username}/{repo}" + + payload: Dict[str, Any] = {} + if name is not None: + payload["name"] = name + if description is not None: + payload["description"] = description + if private is not None: + payload["private"] = bool(private) + if not payload: + raise ValueError("No fields to update") + + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}" + res = client.patch(url, headers=get_headers(), json=payload) + if res.status_code == 405: + res = client.put(url, headers=get_headers(), json=payload) + res.raise_for_status() + try: + return res.json() + except Exception: + return {"status": "ok"} diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000..13456c4 --- /dev/null +++ b/helpers/__init__.py @@ -0,0 +1,13 @@ +from helpers.logger import log +from helpers.headers import get_headers +from helpers.descriptions import suggest_description +from helpers.error_formatter import format_error +from helpers.http_client import build_client + +__all__ = [ + "log", + "get_headers", + "suggest_description", + "format_error", + "build_client" +] diff --git a/helpers/descriptions.py b/helpers/descriptions.py new file mode 100644 index 0000000..e6bc529 --- /dev/null +++ b/helpers/descriptions.py @@ -0,0 +1,3 @@ +def suggest_description(name: str) -> str: + name = (name or "").strip() + return f"{name or 'test'}: Repository for testing and experiments" diff --git a/helpers/error_formatter.py b/helpers/error_formatter.py new file mode 100644 index 0000000..c3f14ab --- /dev/null +++ b/helpers/error_formatter.py @@ -0,0 +1,15 @@ +import httpx + + +def format_error(prefix: str, e: Exception) -> str: + if isinstance(e, httpx.HTTPStatusError): + code = e.response.status_code + text = e.response.text + if code in (301, 302, 307, 308): + return f"{prefix}: {code} - Redirect detected. Probably web UI, not API." + if "CSRF" in text: + return f"{prefix}: {code} - CSRF error. Wrong domain." + if code in (401, 403): + return f"{prefix}: {code} - Invalid or expired API key." + return f"{prefix}: {code} - {text}" + return f"{prefix}: {str(e)}" diff --git a/helpers/headers.py b/helpers/headers.py new file mode 100644 index 0000000..f9903dc --- /dev/null +++ b/helpers/headers.py @@ -0,0 +1,10 @@ +import os + + +def get_headers(): + api_key = os.getenv("GITPASHA_API_KEY", "") + return { + "Authorization": f"token {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } diff --git a/helpers/http_client.py b/helpers/http_client.py new file mode 100644 index 0000000..65fb01a --- /dev/null +++ b/helpers/http_client.py @@ -0,0 +1,33 @@ +import httpx +from helpers.logger import log + + +def log_request(req: httpx.Request): + safe_headers = { + k: ("" if k.lower() == "authorization" else v) + for k, v in req.headers.items() + } + log.info(f"=> {req.method} {req.url}") + log.debug(f"Headers: {safe_headers}") + try: + log.debug(f"Body: {req.content.decode()[:1000]}") + except Exception: + pass + + +def log_response(res: httpx.Response): + log.info(f"<= {res.status_code} {res.request.method} {res.request.url}") + try: + if res.status_code >= 400: + content = res.read() + log.debug(f"Response: {content.decode()[:2000]}") + except Exception: + pass + + +def build_client() -> httpx.Client: + return httpx.Client( + follow_redirects=False, + timeout=120.0, + event_hooks={"request": [log_request], "response": [log_response]}, + ) diff --git a/helpers/logger.py b/helpers/logger.py new file mode 100644 index 0000000..448a8b1 --- /dev/null +++ b/helpers/logger.py @@ -0,0 +1,16 @@ +import os +import logging + +LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper() +LOG_FILE = os.getenv("LOG_FILE", "logs/gitpasha.log") + +logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.DEBUG), + format="%(asctime)s | %(levelname)s | %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE, mode="a", encoding="utf-8"), + logging.StreamHandler() + ], +) + +log = logging.getLogger("gitpasha") diff --git a/main.py b/main.py new file mode 100644 index 0000000..6e3c2bc --- /dev/null +++ b/main.py @@ -0,0 +1,47 @@ +import os +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP +from helpers import log +from tools import ( + repo_create_tool, + repo_update_tool, + repo_delete_tool, + issue_list_tool, + issue_create_tool, + issue_update_tool, + pr_open_tool, +) + +load_dotenv() + +mcp = FastMCP("Git Pasha Server") + +# Register all tools +mcp.tool()(repo_create_tool) +mcp.tool()(repo_update_tool) +mcp.tool()(repo_delete_tool) +mcp.tool()(issue_list_tool) +mcp.tool()(issue_create_tool) +mcp.tool()(issue_update_tool) +mcp.tool()(pr_open_tool) + +if __name__ == "__main__": + base_url = os.getenv("GITPASHA_BASE_URL", "https://app.gitpasha.com/api/v1") + transport_type = os.getenv("MCP_TRANSPORT", "stdio") + + log.info(f"Starting MCP server | BASE_URL={base_url} | Transport={transport_type}") + + if transport_type == "sse": + # Run SSE with explicit host/port binding + import uvicorn + host = os.getenv("HOST", "0.0.0.0") + port = int(os.getenv("PORT", "8000")) + + log.info(f"Starting SSE server on {host}:{port}") + + # Get the SSE app and run with uvicorn + app = mcp.sse_app() + uvicorn.run(app, host=host, port=port, log_level="info") + else: + # Run stdio for local development + mcp.run(transport="stdio") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d19837 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +python-dotenv==1.0.0 +httpx==0.28.1 +mcp[cli]==1.14.1 +requests==2.32.5 +fastapi==0.101.1 +uvicorn==0.38.0 +pydantic==2.12.4 +starlette>=0.27,<0.28 diff --git a/server.py b/server.py new file mode 100644 index 0000000..ece0de8 --- /dev/null +++ b/server.py @@ -0,0 +1,511 @@ +import os +import json +import logging +import base64 +from typing import Optional, Dict, Any, List +import httpx +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP + +# Setup +load_dotenv() + +LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper() +LOG_FILE = os.getenv("LOG_FILE", "server.log") + +logging.basicConfig( + level=getattr(logging, LOG_LEVEL, logging.DEBUG), + format="%(asctime)s | %(levelname)s | %(message)s", + handlers=[ + logging.FileHandler(LOG_FILE, mode="a", encoding="utf-8"), + logging.StreamHandler() # still prints to console + ], +) + +log = logging.getLogger("gitpasha") + +mcp = FastMCP("Git Pasha Server") + +API_KEY = os.getenv("GITPASHA_API_KEY", "") +BASE_URL = os.getenv( + "GITPASHA_BASE_URL", + "https://app.gitpasha.com/api/v1").rstrip("/") + +# "https://app.gitpasha.com/api/v1/repos/search?sort=stars&order=desc&limit=6" + +if not API_KEY: + log.warning("GITPASHA_API_KEY not set in .env") + + +# Helpers +def get_headers() -> Dict[str, str]: + return { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + +def suggest_description(name: str) -> str: + name = (name or "").strip() + return f"{name or 'test'}: Repository for testing and experiments" + + +def log_request(req: httpx.Request): + safe_headers = { + k: ("" if k.lower() == "authorization" else v) for k, + v in req.headers.items() + } + log.info(f"=> {req.method} {req.url}") + log.debug(f"Headers: {safe_headers}") + try: + log.debug(f"Body: {req.content.decode()[:1000]}") + except Exception: + pass + + +def log_response(res: httpx.Response): + log.info(f"<= {res.status_code} {res.request.method} {res.request.url}") + log.debug(f"Response: {res.text[:2000]}") + + +def build_client() -> httpx.Client: + """Create a sync HTTP client with logging hooks""" + return httpx.Client( + follow_redirects=False, + timeout=30.0, + event_hooks={"request": [log_request], "response": [log_response]}, + ) + + +def format_error(prefix: str, e: Exception) -> str: + """Format different error types""" + if isinstance(e, httpx.HTTPStatusError): + code = e.response.status_code + text = e.response.text + if code in (301, 302, 307, 308): + return f"{prefix}: {code} - Redirect detected. Probably web UI, not API." + if "CSRF" in text: + return f"{prefix}: {code} - CSRF error. Wrong domain." + if code in (401, 403): + return f"{prefix}: {code} - Invalid or expired API key." + return f"{prefix}: {code} - {text}" + return f"{prefix}: {str(e)}" + + +# Core API calls (sync) +def api_create_repo( + name: str, + description: Optional[str] = None, + private: bool = False +) -> Dict[str, Any]: + """Create a repository""" + payload = { + "name": name, + "description": description or suggest_description(name), + "private": private + } + with build_client() as client: + # Use /user/repos for creating a repository according to Gitea API + res = client.post( + f"{BASE_URL}/user/repos", + headers=get_headers(), + json=payload, + ) + res.raise_for_status() + return res.json() + + +def api_update_repo( + repo: str, *, name: Optional[str] = None, + description: Optional[str] = None, private: Optional[bool] = None +) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if name is not None: + payload["name"] = name + if description is not None: + payload["description"] = description + if private is not None: + payload["private"] = bool(private) + if not payload: + raise ValueError("No fields to update") + + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}" # repo = "owner/repo" + res = client.patch(url, headers=get_headers(), json=payload) + if res.status_code == 405: + res = client.put(url, headers=get_headers(), json=payload) + res.raise_for_status() + return res.json() if res.text else {"status": "ok"} + + +def api_delete_repo(repo: str) -> Dict[str, Any]: + """Delete repository""" + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}" # repo = "owner/repo" + res = client.delete(url, headers=get_headers()) + if res.status_code in (200, 202, 204): + return {"status": "deleted"} + res.raise_for_status() + return res.json() if res.text else {"status": "deleted"} + + +def api_create_readme(repo: str, content: str) -> Dict[str, Any]: + """Create a README.md file in the repository.""" + with build_client() as client: + # Use PUT on contents endpoint as per Gitea API + url = f"{BASE_URL}/repos/{repo}/contents/README.md" + # Encode content in base64 per API spec + encoded = base64.b64encode(content.encode("utf-8")).decode("utf-8") + body = { + "message": "Add README", + "content": encoded, + } + res = client.put(url, headers=get_headers(), json=body) + if res.status_code < 300: + return res.json() + return { + "error": "Failed to create README", + "response": res.text, + } + + +# Issues +def api_list_issues( + repo: str, + state: Optional[str] = None +) -> List[Dict[str, Any]]: + """List issues. state can be 'open', 'closed', or 'all'""" + params = {} + if state: + params["state"] = state + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}/issues" # repo = "owner/repo" + res = client.get(url, headers=get_headers(), params=params) + res.raise_for_status() + try: + return res.json() + except Exception: + return [] + + +def api_create_issue( + repo: str, + title: str, + body: str = "", + labels: Optional[List[str]] = None, + assignees: Optional[List[str]] = None +) -> Dict[str, Any]: + """Create a new issue""" + payload: Dict[str, Any] = {"title": title, "body": body} + if labels: + payload["labels"] = labels + if assignees: + payload["assignees"] = assignees + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}/issues" # repo = "owner/repo" + res = client.post(url, headers=get_headers(), json=payload) + res.raise_for_status() + return res.json() + + +def api_update_issue( + repo: str, + issue: str, + title: Optional[str] = None, + body: Optional[str] = None, + state: Optional[str] = None, + labels: Optional[List[str]] = None, + assignees: Optional[List[str]] = None, + comment: Optional[str] = None +) -> Dict[str, Any]: + """Update an issue and/or add a comment""" + out: Dict[str, Any] = {} + with build_client() as client: + # Update fields + patch_payload: Dict[str, Any] = {} + if title is not None: + patch_payload["title"] = title + if body is not None: + patch_payload["body"] = body + if state is not None: + patch_payload["state"] = state # e.g., "open" or "closed" + if labels is not None: + patch_payload["labels"] = labels + if assignees is not None: + patch_payload["assignees"] = assignees + + if patch_payload: + url = f"{BASE_URL}/repos/{repo}/issues/{issue}" + res = client.patch(url, headers=get_headers(), json=patch_payload) + if res.status_code == 405: + res = client.put( + url, + headers=get_headers(), + json=patch_payload + ) + res.raise_for_status() + out["update"] = res.json() if res.text else {"status": "ok"} + + # Optional comment + if comment: + url_c = f"{BASE_URL}/repos/{repo}/issues/{issue}/comments" + res_c = client.post( + url_c, + headers=get_headers(), + json={ + "body": comment + } + ) + res_c.raise_for_status() + out["comment"] = res_c.json() if res_c.text else { + "status": "commented" + } + + return out or {"status": "no-op"} + + +# Pull Requests +def api_open_pr( + repo: str, + title: str, + head: str, + base: str, + body: str = "" +) -> Dict[str, Any]: + """Open a pull request from head -> base""" + payload = {"title": title, "head": head, "base": base, "body": body} + with build_client() as client: + url = f"{BASE_URL}/repos/{repo}/pulls" # repo = "owner/repo" + res = client.post(url, headers=get_headers(), json=payload) + res.raise_for_status() + return res.json() + + +# MCP Tools +@mcp.tool() +def create_repo( + name: str, + description: str = "", + private: bool = False, + create_readme: bool = True, + readme_content: str = "# Test Repository\n\nThis repository is for testing.\n", +) -> str: + """Create repo and optional README""" + if not API_KEY: + return "GITPASHA_API_KEY not set" + + desc = description or suggest_description(name) + log.info(f"Creating repo: {name}") + try: + repo = api_create_repo(name, desc, private) + repo_id = repo.get("full_name") or repo.get("name") or name # "owner/repo" + repo_url = repo.get("html_url") or repo.get("web_url") or "N/A" + readme_status = "skipped" + if create_readme: + try: + r = api_create_readme(str(repo_id), readme_content) + readme_status = "created" if "error" not in r else "failed" + except Exception as e: + readme_status = f"failed: {e}" + result = { + "status": "success", + "name": name, + "description": desc, + "private": private, + "repo_url": repo_url, + "readme": readme_status + } + return json.dumps(result, ensure_ascii=False, indent=2) + except httpx.HTTPStatusError as e: + return format_error("Repo creation failed", e) + except Exception as e: + log.exception("Unexpected error") + return f"{e}" + + +@mcp.tool() +def update_repo( + repo: str, + name: str = "", + description: str = "", + set_private: bool = False, + private: bool = False, +) -> str: + """Update a repository metadata""" + try: + new_name = name or None + new_desc = description or None + new_priv = private if set_private else None + out = api_update_repo( + repo, + name=new_name, + description=new_desc, + private=new_priv + ) + return json.dumps( + { + "status": "success", + "repo": repo, + "result": out + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Repo update failed", e) + except Exception as e: + log.exception("Unexpected error while updating repo") + return f"{e}" + + +@mcp.tool() +def delete_repo(repo: str) -> str: + """Delete a repository""" + try: + out = api_delete_repo(repo) + return json.dumps( + { + "status": "success", + "repo": repo, + "result": out + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Repo delete failed", e) + except Exception as e: + log.exception("Unexpected error while deleting repo") + return f"{e}" + + +# Issues tools +@mcp.tool() +def issues_list(repo: str, state: str = "open") -> str: + """List issues. state: open|closed|all""" + try: + items = api_list_issues( + repo, + state if state in ("open", "closed", "all") else None) + return json.dumps( + { + "status": "success", + "count": len(items), + "items": items + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Issues list failed", e) + except Exception as e: + log.exception("Unexpected error while listing issues") + return f"{e}" + + +@mcp.tool() +def issue_create( + repo: str, + title: str, + body: str = "", + labels_csv: str = "", + assignees_csv: str = "" +) -> str: + """Create issue. labels_csv and assignees_csv are comma-separated""" + try: + labels = [ + x.strip() for x in labels_csv.split(",") if x.strip() + ] if labels_csv else None + assignees = [ + x.strip() for x in assignees_csv.split(",") if x.strip() + ] if assignees_csv else None + item = api_create_issue( + repo, + title=title, + body=body, + labels=labels, + assignees=assignees + ) + return json.dumps( + { + "status": "success", + "item": item + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Issue create failed", e) + except Exception as e: + log.exception("Unexpected error while creating issue") + return f"{e}" + + +@mcp.tool() +def issue_update( + repo: str, + issue: str, + title: str = "", + body: str = "", + state: str = "", + labels_csv: str = "", + assignees_csv: str = "", + comment: str = "", +) -> str: + """Update issue fields and/or add a comment""" + try: + labels = [ + x.strip() for x in labels_csv.split(",") if x.strip() + ] if labels_csv else None + assignees = [ + x.strip() for x in assignees_csv.split(",") if x.strip() + ] if assignees_csv else None + st = state or None + # Empty strings -> None (skip) + t = title or None + b = body or None + c = comment or None + out = api_update_issue( + repo, + issue, + title=t, + body=b, + state=st, + labels=labels, + assignees=assignees, + comment=c + ) + return json.dumps( + { + "status": "success", + "result": out + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Issue update failed", e) + except Exception as e: + log.exception("Unexpected error while updating issue") + return f"{e}" + + +# Pull Requests tools +@mcp.tool() +def pr_open(repo: str, + title: str, + head: str, + base: str, + body: str = "" + ) -> str: + """Open a pull request from head -> base""" + try: + pr = api_open_pr(repo, title=title, head=head, base=base, body=body) + return json.dumps({"status": "success", "pr": pr}, ensure_ascii=False) + except httpx.HTTPStatusError as e: + return format_error("PR open failed", e) + except Exception as e: + log.exception("Unexpected error while opening PR") + return f"{e}" + + +# Entry Point +if __name__ == "__main__": + log.info(f"Starting MCP server | BASE_URL={BASE_URL}") + mcp.run(transport="stdio") diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..9c420e8 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,17 @@ +from tools.repo_create import repo_create_tool +from tools.repo_update import repo_update_tool +from tools.repo_delete import repo_delete_tool +from tools.issue_list import issue_list_tool +from tools.issue_create import issue_create_tool +from tools.issue_update import issue_update_tool +from tools.pr_open import pr_open_tool + +__all__ = [ + "repo_create_tool", + "repo_update_tool", + "repo_delete_tool", + "issue_list_tool", + "issue_create_tool", + "issue_update_tool", + "pr_open_tool", +] diff --git a/tools/issue_create.py b/tools/issue_create.py new file mode 100644 index 0000000..26cd00c --- /dev/null +++ b/tools/issue_create.py @@ -0,0 +1,39 @@ +import json +from helpers import format_error, log +from api import api_create_issue +import httpx + + +def issue_create_tool( + repo: str, + title: str, + body: str = "", + labels_csv: str = "", + assignees_csv: str = "" +) -> str: + try: + labels = [ + x.strip() for x in labels_csv.split(",") if x.strip() + ] if labels_csv else None + assignees = [ + x.strip() for x in assignees_csv.split(",") if x.strip() + ] if assignees_csv else None + item = api_create_issue( + repo, + title=title, + body=body, + labels=labels, + assignees=assignees + ) + return json.dumps( + { + "status": "success", + "item": item + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Issue create failed", e) + except Exception as e: + log.exception("Unexpected error while creating issue") + return f"{e}" diff --git a/tools/issue_list.py b/tools/issue_list.py new file mode 100644 index 0000000..6d014ea --- /dev/null +++ b/tools/issue_list.py @@ -0,0 +1,25 @@ +import json +from helpers import format_error, log +from api import api_list_issues +import httpx + + +def issue_list_tool(repo: str, state: str = "open") -> str: + try: + items = api_list_issues( + repo, + state if state in ("open", "closed", "all") else None + ) + return json.dumps( + { + "status": "success", + "count": len(items), + "items": items + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Issues list failed", e) + except Exception as e: + log.exception("Unexpected error while listing issues") + return f"{e}" diff --git a/tools/issue_update.py b/tools/issue_update.py new file mode 100644 index 0000000..2a1c861 --- /dev/null +++ b/tools/issue_update.py @@ -0,0 +1,49 @@ +import json +from helpers import format_error, log +from api import api_update_issue +import httpx + + +def issue_update_tool( + repo: str, + issue: str, + title: str = "", + body: str = "", + state: str = "", + labels_csv: str = "", + assignees_csv: str = "", + comment: str = "", +) -> str: + try: + labels = [ + x.strip() for x in labels_csv.split(",") if x.strip() + ] if labels_csv else None + assignees = [ + x.strip() for x in assignees_csv.split(",") if x.strip() + ] if assignees_csv else None + st = state or None + t = title or None + b = body or None + c = comment or None + out = api_update_issue( + repo, + issue, + title=t, + body=b, + state=st, + labels=labels, + assignees=assignees, + comment=c + ) + return json.dumps( + { + "status": "success", + "result": out + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Issue update failed", e) + except Exception as e: + log.exception("Unexpected error while updating issue") + return f"{e}" diff --git a/tools/pr_open.py b/tools/pr_open.py new file mode 100644 index 0000000..16d8412 --- /dev/null +++ b/tools/pr_open.py @@ -0,0 +1,33 @@ +import json +from helpers import format_error, log +from api import api_open_pr +import httpx + + +def pr_open_tool( + repo: str, + title: str, + head: str, + base: str, + body: str = "" +) -> str: + try: + pr = api_open_pr( + repo, + title=title, + head=head, + base=base, + body=body + ) + return json.dumps( + { + "status": "success", + "pr": pr + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("PR open failed", e) + except Exception as e: + log.exception("Unexpected error while opening PR") + return f"{e}" diff --git a/tools/repo_create.py b/tools/repo_create.py new file mode 100644 index 0000000..efa97d0 --- /dev/null +++ b/tools/repo_create.py @@ -0,0 +1,47 @@ +import os +import json +import asyncio +from helpers import log, format_error, suggest_description +from api import api_create_repo, api_create_readme +import httpx + + +def repo_create_tool( + name: str, + description: str = "", + private: bool = False, + create_readme: bool = True, + readme_content: str = "# Test Repository\n\nThis repository is for testing.\n", +) -> str: + api_key = os.getenv("GITPASHA_API_KEY", "") + if not api_key: + return "GITPASHA_API_KEY not set" + + desc = description or suggest_description(name) + log.info(f"Creating repo: {name}") + + try: + repo = api_create_repo(name, desc, private) + repo_id = repo.get("full_name") or repo.get("name") or name + repo_url = repo.get("html_url") or repo.get("web_url") or "N/A" + readme_status = "skipped" + if create_readme: + try: + r = api_create_readme(str(repo_id), readme_content) + readme_status = "created" if "error" not in r else "failed" + except Exception as e: + readme_status = f"failed: {e}" + result = { + "status": "success", + "name": name, + "description": desc, + "private": private, + "repo_url": repo_url, + "readme": readme_status + } + return json.dumps(result, ensure_ascii=False, indent=2) + except httpx.HTTPStatusError as e: + return format_error("Repo creation failed", e) + except Exception as e: + log.exception("Unexpected error") + return f"{e}" diff --git a/tools/repo_delete.py b/tools/repo_delete.py new file mode 100644 index 0000000..05b7c8d --- /dev/null +++ b/tools/repo_delete.py @@ -0,0 +1,22 @@ +import json +from helpers import format_error, log +from api import api_delete_repo +import httpx + + +def repo_delete_tool(repo: str) -> str: + try: + out = api_delete_repo(repo) + return json.dumps( + { + "status": "success", + "repo": repo, + "result": out + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Repo delete failed", e) + except Exception as e: + log.exception("Unexpected error while deleting repo") + return f"{e}" diff --git a/tools/repo_update.py b/tools/repo_update.py new file mode 100644 index 0000000..ba7927a --- /dev/null +++ b/tools/repo_update.py @@ -0,0 +1,36 @@ +import json +from helpers import format_error, log +from api import api_update_repo +import httpx + + +def repo_update_tool( + repo: str, + name: str = "", + description: str = "", + set_private: bool = False, + private: bool = False, +) -> str: + try: + new_name = name or None + new_desc = description or None + new_priv = private if set_private else None + out = api_update_repo( + repo, + name=new_name, + description=new_desc, + private=new_priv + ) + return json.dumps( + { + "status": "success", + "repo": repo, + "result": out + }, + ensure_ascii=False + ) + except httpx.HTTPStatusError as e: + return format_error("Repo update failed", e) + except Exception as e: + log.exception("Unexpected error while updating repo") + return f"{e}"