Files

292 lines
10 KiB
Python

#!/usr/bin/env python3
"""
GitLab MCP Server for MPM CoWork
Provides read-only access to MPM's GitLab repositories.
Credentials are stored in macOS Keychain via the `keyring` library.
Run the `setup_credentials` tool once after installation to store your PAT.
"""
import os
import json
import httpx
try:
import keyring
KEYRING_AVAILABLE = True
except ImportError:
KEYRING_AVAILABLE = False
from mcp.server.fastmcp import FastMCP
KEYRING_SERVICE = "mpm-gitlab"
KEYRING_USERNAME = "personal_access_token"
GITLAB_API_URL = os.environ.get("GITLAB_API_URL", "https://gitlab.com/api/v4")
mcp = FastMCP("gitlab-mpm")
def get_token() -> str:
"""Resolve PAT from Keychain first, fall back to environment variable."""
if KEYRING_AVAILABLE:
token = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
if token:
return token
return os.environ.get("GITLAB_PERSONAL_ACCESS_TOKEN", "")
def gitlab_headers():
return {"PRIVATE-TOKEN": get_token(), "Content-Type": "application/json"}
def gitlab_get(path: str, params: dict = None) -> dict | list:
token = get_token()
if not token:
raise ValueError(
"No GitLab PAT found. Run the `setup_credentials` tool to store your token in Keychain."
)
url = f"{GITLAB_API_URL}/{path.lstrip('/')}"
with httpx.Client(timeout=30) as client:
response = client.get(url, headers=gitlab_headers(), params=params or {})
response.raise_for_status()
return response.json()
# ── Credential management ─────────────────────────────────────────────────────
@mcp.tool()
def setup_credentials(personal_access_token: str) -> str:
"""
Store a GitLab Personal Access Token securely in macOS Keychain.
Only needs to be run once after installation, or when rotating the token.
The token requires read_api scope on gitlab.com.
"""
if not KEYRING_AVAILABLE:
return "Error: `keyring` package is not available. Cannot store credentials."
keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, personal_access_token)
# Quick validation ping
try:
result = gitlab_get("user")
username = result.get("username", "unknown")
return f"Token saved to Keychain successfully. Authenticated as: {username}"
except Exception as e:
return f"Token saved to Keychain, but validation failed: {e}. Check that the token has read_api scope."
@mcp.tool()
def check_credentials() -> str:
"""Check whether a GitLab PAT is stored and working."""
token = get_token()
if not token:
return "No token found. Run `setup_credentials` to store your PAT."
source = "Keychain" if (KEYRING_AVAILABLE and keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)) else "environment variable"
try:
result = gitlab_get("user")
username = result.get("username", "unknown")
return f"Token found ({source}). Authenticated as: {username}"
except Exception as e:
return f"Token found ({source}) but API call failed: {e}"
@mcp.tool()
def clear_credentials() -> str:
"""Remove the stored GitLab PAT from macOS Keychain."""
if not KEYRING_AVAILABLE:
return "Error: `keyring` package is not available."
existing = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
if not existing:
return "No token found in Keychain — nothing to clear."
keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
return "Token removed from Keychain."
# ── Project discovery ─────────────────────────────────────────────────────────
@mcp.tool()
def list_projects(search: str = "", per_page: int = 20) -> str:
"""List GitLab projects the current token has access to (membership only). Optionally filter by name."""
params = {
"per_page": per_page,
"order_by": "last_activity_at",
"sort": "desc",
"membership": "true",
}
if search:
params["search"] = search
projects = gitlab_get("projects", params)
results = [
{
"id": p["id"],
"name": p["name"],
"path": p["path_with_namespace"],
"description": p.get("description", ""),
"default_branch": p.get("default_branch", "main"),
"last_activity": p.get("last_activity_at", ""),
}
for p in projects
]
return json.dumps(results, indent=2)
@mcp.tool()
def list_group_projects(group_path: str, per_page: int = 50) -> str:
"""List all projects within a GitLab group. group_path is the group's URL slug (e.g. 'mpmedia-andriod')."""
import urllib.parse
encoded = urllib.parse.quote(group_path, safe="")
params = {
"per_page": per_page,
"order_by": "last_activity_at",
"sort": "desc",
"include_subgroups": "true",
}
projects = gitlab_get(f"groups/{encoded}/projects", params)
results = [
{
"id": p["id"],
"name": p["name"],
"path": p["path_with_namespace"],
"description": p.get("description", ""),
"default_branch": p.get("default_branch", "main"),
"last_activity": p.get("last_activity_at", ""),
}
for p in projects
]
return json.dumps(results, indent=2)
@mcp.tool()
def get_project(project_id: str) -> str:
"""Get details for a specific project by ID or path (e.g. 'group/project-name')."""
import urllib.parse
encoded = urllib.parse.quote(project_id, safe="")
project = gitlab_get(f"projects/{encoded}")
return json.dumps({
"id": project["id"],
"name": project["name"],
"path": project["path_with_namespace"],
"description": project.get("description", ""),
"default_branch": project.get("default_branch", "main"),
"web_url": project.get("web_url", ""),
"visibility": project.get("visibility", ""),
"last_activity": project.get("last_activity_at", ""),
}, indent=2)
# ── Repository browsing ───────────────────────────────────────────────────────
@mcp.tool()
def list_repository_tree(project_id: str, path: str = "", branch: str = "", recursive: bool = False) -> str:
"""List files and directories in a repository. project_id can be numeric ID or 'group/project'."""
import urllib.parse
encoded = urllib.parse.quote(str(project_id), safe="")
params = {"per_page": 100}
if path:
params["path"] = path
if branch:
params["ref"] = branch
if recursive:
params["recursive"] = "true"
items = gitlab_get(f"projects/{encoded}/repository/tree", params)
return json.dumps(items, indent=2)
@mcp.tool()
def get_file_contents(project_id: str, file_path: str, branch: str = "") -> str:
"""Get the contents of a file from a repository."""
import urllib.parse
import base64
encoded_project = urllib.parse.quote(str(project_id), safe="")
encoded_file = urllib.parse.quote(file_path, safe="")
params = {}
if branch:
params["ref"] = branch
file_data = gitlab_get(f"projects/{encoded_project}/repository/files/{encoded_file}", params)
content = base64.b64decode(file_data.get("content", "")).decode("utf-8", errors="replace")
return json.dumps({
"file_path": file_data.get("file_path", ""),
"branch": file_data.get("ref", ""),
"size": file_data.get("size", 0),
"content": content,
}, indent=2)
@mcp.tool()
def search_code(project_id: str, query: str, per_page: int = 20) -> str:
"""Search for code within a specific project."""
import urllib.parse
encoded = urllib.parse.quote(str(project_id), safe="")
params = {"scope": "blobs", "search": query, "per_page": per_page}
results = gitlab_get(f"projects/{encoded}/search", params)
simplified = [
{
"filename": r.get("filename", ""),
"path": r.get("path", ""),
"data": r.get("data", "")[:500],
"ref": r.get("ref", ""),
}
for r in results
]
return json.dumps(simplified, indent=2)
@mcp.tool()
def list_branches(project_id: str) -> str:
"""List branches for a project."""
import urllib.parse
encoded = urllib.parse.quote(str(project_id), safe="")
branches = gitlab_get(f"projects/{encoded}/repository/branches", {"per_page": 50})
return json.dumps([
{
"name": b["name"],
"default": b.get("default", False),
"last_commit": b.get("commit", {}).get("title", ""),
"committed_at": b.get("commit", {}).get("committed_date", ""),
}
for b in branches
], indent=2)
@mcp.tool()
def list_commits(project_id: str, branch: str = "", path: str = "", per_page: int = 20) -> str:
"""List recent commits for a project, optionally filtered by branch or file path."""
import urllib.parse
encoded = urllib.parse.quote(str(project_id), safe="")
params = {"per_page": per_page}
if branch:
params["ref_name"] = branch
if path:
params["path"] = path
commits = gitlab_get(f"projects/{encoded}/repository/commits", params)
return json.dumps([
{
"id": c["short_id"],
"title": c["title"],
"author": c.get("author_name", ""),
"date": c.get("committed_date", ""),
"message": c.get("message", "")[:200],
}
for c in commits
], indent=2)
@mcp.tool()
def get_commit(project_id: str, commit_sha: str) -> str:
"""Get details for a specific commit including diff stats."""
import urllib.parse
encoded = urllib.parse.quote(str(project_id), safe="")
commit = gitlab_get(f"projects/{encoded}/repository/commits/{commit_sha}")
return json.dumps({
"id": commit.get("id", ""),
"short_id": commit.get("short_id", ""),
"title": commit.get("title", ""),
"message": commit.get("message", ""),
"author_name": commit.get("author_name", ""),
"authored_date": commit.get("authored_date", ""),
"stats": commit.get("stats", {}),
"web_url": commit.get("web_url", ""),
}, indent=2)
if __name__ == "__main__":
mcp.run(transport="stdio")