From b6daa775980253fb9581c891b0c547257339ed88 Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Tue, 15 Jul 2025 11:16:25 +0300 Subject: feat: initial gitlab_python mcp --- pyproject.toml | 4 +- servers/gitlab_python/Dockerfile | 16 ++ servers/gitlab_python/README.md | 71 ++++++++ servers/gitlab_python/pyproject.toml | 37 +++++ .../src/mcp_server_gitlab_python/__init__.py | 6 + .../src/mcp_server_gitlab_python/__main__.py | 7 + .../src/mcp_server_gitlab_python/cli.py | 81 ++++++++++ .../src/mcp_server_gitlab_python/server.py | 178 +++++++++++++++++++++ servers/gitlab_python/tests/conftest.py | 1 + servers/gitlab_python/tests/test_server.py | 6 + uv.lock | 2 +- 11 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 servers/gitlab_python/Dockerfile create mode 100644 servers/gitlab_python/README.md create mode 100644 servers/gitlab_python/pyproject.toml create mode 100644 servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py create mode 100644 servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py create mode 100644 servers/gitlab_python/src/mcp_server_gitlab_python/cli.py create mode 100644 servers/gitlab_python/src/mcp_server_gitlab_python/server.py create mode 100644 servers/gitlab_python/tests/conftest.py create mode 100644 servers/gitlab_python/tests/test_server.py diff --git a/pyproject.toml b/pyproject.toml index 0d2a29d..90c29bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,14 +26,12 @@ dev = [ "black>=23.0.0", ] -[tool.setuptools.packages.find] -where = ["shared/src"] +# Removed [tool.setuptools.packages.find] section to fix build error [tool.ruff] target-version = "py310" line-length = 88 select = ["E", "F", "B", "I", "N", "UP", "ANN", "S", "A"] -ignore = ["ANN101"] # Missing type annotation for self [tool.ruff.isort] known-first-party = ["shared"] diff --git a/servers/gitlab_python/Dockerfile b/servers/gitlab_python/Dockerfile new file mode 100644 index 0000000..0a87b9d --- /dev/null +++ b/servers/gitlab_python/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Copy only the requirements first to leverage Docker cache +COPY . . + +# Install dependencies +RUN pip install --no-cache-dir uv && \ + uv pip install --system --no-cache-dir -e . + +# Expose the port for remote transport +EXPOSE 8080 + +# Run the server with remote transport by default +CMD ["mcp-gitlab-python", "--transport", "remote"] \ No newline at end of file diff --git a/servers/gitlab_python/README.md b/servers/gitlab_python/README.md new file mode 100644 index 0000000..bbd85f7 --- /dev/null +++ b/servers/gitlab_python/README.md @@ -0,0 +1,71 @@ +# GitLab Python MCP Server + +This MCP server provides integration with GitLab through the python-gitlab library. It allows LLM agents to interact with GitLab repositories and resources using the GitLab API directly (no CLI required). + +## Features + +- Find GitLab projects by name and retrieve their IDs +- Search for GitLab issues with various filters +- Create new GitLab issues +- (More features to be added in the future) + +## Prerequisites + +- Python 3.11 or higher +- GitLab account and personal access token + +## Configuration + +The server will attempt to discover GitLab settings in the following order: + +- **Server URL**: Parsed from the current git remote, or from the `GITLAB_URL` environment variable. +- **Token**: Parsed from `~/.config/glab-cli/config.yml` (YAML), or from the `GITLAB_TOKEN` environment variable. + +## Installation + +```bash +# Clone the repository +cd dawids-mcp-servers +``` + +## Usage + +### Running from repository root (recommended) + +```bash +uv --directory /path-to-repo/dawids-mcp-servers/servers/gitlab_python run mcp-gitlab-python --transport stdio + +uv --directory /path-to-repo/dawids-mcp-servers/servers/gitlab_python run mcp-gitlab-python --transport remote --host 0.0.0.0 --port 8080 +``` + +### Alternative: Running from server directory + +```bash +cd servers/gitlab_python +uv run mcp-gitlab-python --transport stdio +uv run mcp-gitlab-python --transport remote --host 0.0.0.0 --port 8080 +``` + +## Available Tools + +### find_project +Finds GitLab projects by name and returns their details. + +### search_issues +Search for GitLab issues with various filters. + +### create_issue +Creates a new GitLab issue and returns its URL. + +## Development + +### Running Tests + +```bash +cd /path/to/servers/gitlab_python +uv run pytest +``` + +## License + +MIT \ No newline at end of file diff --git a/servers/gitlab_python/pyproject.toml b/servers/gitlab_python/pyproject.toml new file mode 100644 index 0000000..30ad43d --- /dev/null +++ b/servers/gitlab_python/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "mcp-server-gitlab-python" +version = "0.1.0" +description = "An MCP server for GitLab using python-gitlab" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Your Name", email = "your.email@example.com"}, +] +dependencies = [ + "mcp>=1.6.0", + "pydantic>=2.11.0", + "python-gitlab>=4.4.0", + "PyYAML>=6.0.0" +] + +[tool.uv] +dev-dependencies = [ + "pytest>=8.3.5", + "pytest-asyncio>=0.26.0", + "pytest-cov>=6.0.0 ", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +asyncio_mode = "auto" + +[project.scripts] +mcp-gitlab-python = "mcp_server_gitlab_python.cli:run_server" \ No newline at end of file diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py b/servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py new file mode 100644 index 0000000..5ee1366 --- /dev/null +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/__init__.py @@ -0,0 +1,6 @@ +"""GitLab Python MCP Server package. + +This package provides an MCP server that integrates with GitLab using python-gitlab. +""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py b/servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py new file mode 100644 index 0000000..8f98d0b --- /dev/null +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/__main__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 +"""Command-line interface for the GitLab Python MCP server.""" + +from .cli import run_server + +if __name__ == "__main__": + run_server() \ No newline at end of file diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py b/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py new file mode 100644 index 0000000..1b4db35 --- /dev/null +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/cli.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Common CLI functionality for the GitLab Python MCP server. +""" + +import argparse +import asyncio +import logging +import sys +import os + +from .server import main + +logger = logging.getLogger("mcp_gitlab_python_server") + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="GitLab Python MCP Server") + parser.add_argument( + "--transport", + choices=["stdio", "remote"], + default="stdio", + help="Transport type (stdio or remote)", + ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Host to bind to for remote transport (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", + type=int, + default=8080, + help="Port to bind to for remote transport (default: 8080)", + ) + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default="INFO", + help="Set the logging level (default: INFO)", + ) + return parser.parse_args() + +def validate_args(args: argparse.Namespace) -> argparse.Namespace: + if (args.transport == "remote" and args.port < 1024 + and not sys.platform.startswith("win")): + logger.warning( + "Using a port below 1024 may require root privileges on Unix-like systems." + ) + return args + +def setup_logging(level: str, transport: str) -> None: + os.makedirs("logs", exist_ok=True) + file_handler = logging.FileHandler("logs/mcp_server.log") + file_handler.setLevel(getattr(logging, level)) + file_handler.setFormatter(logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + )) + console_handler = logging.StreamHandler() + if transport == "stdio": + console_handler.setLevel(logging.WARNING) + else: + console_handler.setLevel(getattr(logging, level)) + console_handler.setFormatter(logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + )) + root_logger = logging.getLogger() + root_logger.setLevel(getattr(logging, level)) + root_logger.handlers = [] + root_logger.addHandler(file_handler) + root_logger.addHandler(console_handler) + +def run_server() -> None: + args = validate_args(parse_args()) + setup_logging(args.log_level, args.transport) + try: + asyncio.run(main(args.transport, args.host, args.port)) + except KeyboardInterrupt: + logger.info("Server stopped by user") + except Exception as e: + logger.error(f"Error running server: {e}") + sys.exit(1) \ No newline at end of file diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py new file mode 100644 index 0000000..3005d30 --- /dev/null +++ b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py @@ -0,0 +1,178 @@ +import logging +import os +import sys +import yaml +import gitlab +import subprocess +from typing import Any, Optional +from mcp.server.fastmcp import FastMCP + +logger = logging.getLogger("mcp_gitlab_python_server") + +def get_git_remote_url(working_directory: str) -> Optional[str]: + try: + result = subprocess.run([ + "git", "remote", "get-url", "origin" + ], capture_output=True, text=True, check=False, cwd=working_directory) + if result.returncode == 0: + return result.stdout.strip() + except Exception as e: + logger.warning(f"Could not get git remote url: {e}") + return None + +def parse_gitlab_url_from_remote(remote_url: str) -> Optional[str]: + # Handles both SSH and HTTPS remotes + if remote_url.startswith("git@"): + # git@gitlab.com:namespace/project.git + host = remote_url.split('@')[1].split(':')[0] + return f"https://{host}" + elif remote_url.startswith("https://"): + # https://gitlab.com/namespace/project.git + parts = remote_url.split('/') + if len(parts) > 2: + return f"{parts[0]}//{parts[2]}" + return None + +def get_token_from_glab_config() -> Optional[str]: + config_path = os.path.expanduser("~/.config/glab-cli/config.yml") + if not os.path.exists(config_path): + return None + try: + with open(config_path, "r") as f: + config = yaml.safe_load(f) + # Try to find a token in the config (glab stores tokens per host) + hosts = config.get('hosts', {}) + for host, data in hosts.items(): + if 'token' in data: + return data['token'] + except Exception as e: + logger.warning(f"Could not parse glab-cli config: {e}") + return None + +def get_gitlab_settings(working_directory: str) -> tuple[str, str]: + # URL + url = os.environ.get("GITLAB_URL") + if not url: + remote_url = get_git_remote_url(working_directory) + if remote_url: + url = parse_gitlab_url_from_remote(remote_url) + if not url: + url = "https://gitlab.com" # fallback default + # Token + token = os.environ.get("GITLAB_TOKEN") + if not token: + token = get_token_from_glab_config() + if not token: + raise RuntimeError("No GitLab token found in env or glab-cli config.") + return url, token + +class GitLabPythonServer: + def __init__(self, working_directory: str): + url, token = get_gitlab_settings(working_directory) + self.gl = gitlab.Gitlab(url, private_token=token) + self.gl.auth() + + def find_project(self, project_name: str) -> list[dict[str, Any]]: + projects = self.gl.projects.list(search=project_name, all=True) + return [ + { + "id": p.id, + "name": p.name, + "path_with_namespace": p.path_with_namespace, + "web_url": p.web_url, + "description": p.description, + } + for p in projects + ] + + def search_issues(self, project: str, **filters) -> dict[str, Any]: + try: + proj = self.gl.projects.get(project) + issues = proj.issues.list(**filters, all=True) + return { + "issues": [ + { + "id": i.id, + "iid": i.iid, + "title": i.title, + "web_url": i.web_url, + "state": i.state, + "created_at": i.created_at, + "updated_at": i.updated_at, + } + for i in issues + ] + } + except Exception as e: + return {"error": str(e)} + + def create_issue(self, project: str, title: str, description: str, **kwargs) -> dict[str, Any]: + try: + proj = self.gl.projects.get(project) + issue = proj.issues.create({"title": title, "description": description, **kwargs}) + return {"url": issue.web_url} + except Exception as e: + return {"error": str(e)} + +def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP: + mcp = FastMCP("GitLab Python", host=host, port=port) + + @mcp.tool() + def find_project(project_name: str, working_directory: str) -> list[dict[str, Any]]: + """Find GitLab projects by name.""" + server = GitLabPythonServer(working_directory) + return server.find_project(project_name) + + @mcp.tool() + def search_issues( + project: str, + working_directory: str, + author_id: Optional[int] = None, + assignee_id: Optional[int] = None, + state: Optional[str] = None, + labels: Optional[list[str]] = None, + milestone: Optional[str] = None, + **kwargs + ) -> dict[str, Any]: + """Search for GitLab issues with various filters.""" + server = GitLabPythonServer(working_directory) + filters = {k: v for k, v in locals().items() if v is not None and k not in ["project", "working_directory", "kwargs"]} + if labels: + filters["labels"] = ",".join(labels) + filters.update(kwargs) + return server.search_issues(project, **filters) + + @mcp.tool() + def create_issue( + project: str, + title: str, + description: str, + working_directory: str, + labels: Optional[list[str]] = None, + assignee_ids: Optional[list[int]] = None, + milestone_id: Optional[int] = None, + **kwargs + ) -> dict[str, Any]: + """Create a new GitLab issue.""" + server = GitLabPythonServer(working_directory) + data = {} + if labels: + data["labels"] = labels + if assignee_ids: + data["assignee_ids"] = assignee_ids + if milestone_id: + data["milestone_id"] = milestone_id + data.update(kwargs) + return server.create_issue(project, title, description, **data) + + return mcp + +async def main(transport_type: str, host: str, port: int) -> None: + logger.info("Starting MCP GitLab Python Server") + mcp = create_server(host=host, port=port) + if transport_type == "stdio": + logger.info("Server running with stdio transport") + await mcp.run_stdio_async() + else: + logger.info(f"Server running with remote transport on {host}:{port}") + await mcp.run_sse_async() \ No newline at end of file diff --git a/servers/gitlab_python/tests/conftest.py b/servers/gitlab_python/tests/conftest.py new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/servers/gitlab_python/tests/conftest.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/servers/gitlab_python/tests/test_server.py b/servers/gitlab_python/tests/test_server.py new file mode 100644 index 0000000..ab5a0bb --- /dev/null +++ b/servers/gitlab_python/tests/test_server.py @@ -0,0 +1,6 @@ +import pytest +from mcp_server_gitlab_python.server import create_server + +def test_create_server(): + server = create_server() + assert server is not None \ No newline at end of file diff --git a/uv.lock b/uv.lock index e188721..afcfb8f 100644 --- a/uv.lock +++ b/uv.lock @@ -268,7 +268,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, - { name = "mcp", specifier = ">=0.1.0" }, + { name = "mcp", specifier = ">=1.6.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, -- cgit v1.2.3