summaryrefslogtreecommitdiff
path: root/servers
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2025-07-15 16:05:19 +0300
committerDawid Rycerz <dawid@rycerz.xyz>2025-07-15 16:05:19 +0300
commit7a5eb0b5fb9106dd43377231d4ee03c65420bf85 (patch)
tree4fbb72d2c4fba2db854c0d49325ece225b43a0a8 /servers
parentb9967d37d7c80d56b9f7e1367708be9b066882fe (diff)
feat: update gitlab-python and cursor rules
Diffstat (limited to 'servers')
-rw-r--r--servers/gitlab_python/src/mcp_server_gitlab_python/server.py149
-rw-r--r--servers/gitlab_python/tests/test_server.py207
2 files changed, 353 insertions, 3 deletions
diff --git a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py
index 3005d30..9173df1 100644
--- a/servers/gitlab_python/src/mcp_server_gitlab_python/server.py
+++ b/servers/gitlab_python/src/mcp_server_gitlab_python/server.py
@@ -51,7 +51,7 @@ def get_token_from_glab_config() -> Optional[str]:
def get_gitlab_settings(working_directory: str) -> tuple[str, str]:
# URL
- url = os.environ.get("GITLAB_URL")
+ url = os.environ.get("GITLAB_HOST")
if not url:
remote_url = get_git_remote_url(working_directory)
if remote_url:
@@ -114,6 +114,118 @@ class GitLabPythonServer:
except Exception as e:
return {"error": str(e)}
+ def get_mr_diff(
+ self,
+ project: str,
+ mr_iid: int,
+ max_size_kb: int = 100,
+ filter_extensions: Optional[list[str]] = None,
+ ) -> dict[str, Any]:
+ import tempfile
+ if filter_extensions is None:
+ filter_extensions = [".lock", ".log"]
+ try:
+ proj = self.gl.projects.get(project)
+ mr = proj.mergerequests.get(mr_iid)
+ # Get the diff as a list of dicts (one per file)
+ diffs = mr.diffs.list(get_all=True)
+ # Build unified diff string
+ diff_content = ""
+ for diff in diffs:
+ old_path = diff.old_path
+ new_path = diff.new_path
+ # Filter by extension
+ if any(
+ old_path.endswith(ext) or new_path.endswith(ext)
+ for ext in filter_extensions
+ ):
+ continue
+ diff_content += f"diff --git a/{old_path} b/{new_path}\n"
+ diff_content += diff.diff + "\n"
+ diff_size_kb = len(diff_content.encode("utf-8")) / 1024
+ if diff_size_kb > max_size_kb:
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".diff",
+ prefix="mr_diff_",
+ delete=False,
+ encoding="utf-8"
+ ) as temp_file:
+ temp_file.write(diff_content)
+ temp_path = temp_file.name
+ return {
+ "diff_too_large": True,
+ "size_kb": round(diff_size_kb, 2),
+ "max_size_kb": max_size_kb,
+ "temp_file_path": temp_path,
+ "message": (
+ f"Diff is too large ({diff_size_kb:.2f} KB > "
+ f"{max_size_kb} KB). Content saved to temporary file: "
+ f"{temp_path}"
+ )
+ }
+ except Exception as e:
+ return {
+ "error": (
+ f"Diff is too large ({diff_size_kb:.2f} KB) and failed to "
+ f"create temporary file: {str(e)}"
+ )
+ }
+ return {
+ "diff": diff_content,
+ "size_kb": round(diff_size_kb, 2),
+ "temp_file_path": None
+ }
+ except Exception as e:
+ return {"error": str(e)}
+
+ def run_ci_pipeline(
+ self,
+ project: str,
+ branch: str = None,
+ variables: Optional[dict] = None,
+ web_mode: bool = False,
+ working_directory: str = None,
+ ) -> dict[str, Any]:
+ import subprocess
+ try:
+ proj = self.gl.projects.get(project)
+ ref = branch
+ if not ref:
+ # Try to detect current branch
+ try:
+ result = subprocess.run([
+ "git", "branch", "--show-current"
+ ], capture_output=True, text=True, check=False, cwd=working_directory)
+ if result.returncode == 0 and result.stdout.strip():
+ ref = result.stdout.strip()
+ except Exception:
+ ref = None
+ if not ref:
+ # If still no branch, let GitLab use default
+ ref = proj.default_branch
+ # Prepare variables
+ pipeline_vars = variables.copy() if variables else {}
+ if web_mode:
+ pipeline_vars["CI_PIPELINE_SOURCE"] = "web"
+ pipeline = proj.pipelines.create({
+ "ref": ref,
+ "variables": [
+ {"key": k, "value": v} for k, v in pipeline_vars.items()
+ ] if pipeline_vars else None
+ })
+ info = {
+ "success": True,
+ "pipeline_id": pipeline.id,
+ "pipeline_url": pipeline.web_url,
+ "branch": ref,
+ "web_mode": web_mode,
+ }
+ return info
+ 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)
@@ -165,6 +277,41 @@ def create_server(host: str = "127.0.0.1", port: int = 8080) -> FastMCP:
data.update(kwargs)
return server.create_issue(project, title, description, **data)
+ @mcp.tool()
+ def get_mr_diff(
+ project: str,
+ mr_iid: int,
+ working_directory: str,
+ max_size_kb: int = 100,
+ filter_extensions: Optional[list[str]] = None,
+ ) -> dict[str, Any]:
+ """Get the diff for a merge request."""
+ server = GitLabPythonServer(working_directory)
+ return server.get_mr_diff(
+ project=project,
+ mr_iid=mr_iid,
+ max_size_kb=max_size_kb,
+ filter_extensions=filter_extensions,
+ )
+
+ @mcp.tool()
+ def run_ci_pipeline(
+ project: str,
+ working_directory: str,
+ branch: str = None,
+ variables: Optional[dict] = None,
+ web_mode: bool = False,
+ ) -> dict[str, Any]:
+ """Run a CI/CD pipeline on GitLab."""
+ server = GitLabPythonServer(working_directory)
+ return server.run_ci_pipeline(
+ project=project,
+ branch=branch,
+ variables=variables,
+ web_mode=web_mode,
+ working_directory=working_directory,
+ )
+
return mcp
async def main(transport_type: str, host: str, port: int) -> None:
diff --git a/servers/gitlab_python/tests/test_server.py b/servers/gitlab_python/tests/test_server.py
index ab5a0bb..99cb788 100644
--- a/servers/gitlab_python/tests/test_server.py
+++ b/servers/gitlab_python/tests/test_server.py
@@ -1,6 +1,209 @@
import pytest
-from mcp_server_gitlab_python.server import create_server
+from mcp_server_gitlab_python.server import create_server, GitLabPythonServer
+from unittest.mock import MagicMock, patch
def test_create_server():
server = create_server()
- assert server is not None \ No newline at end of file
+ assert server is not None
+
+def make_mock_diff(old_path, new_path, diff):
+ m = MagicMock()
+ m.old_path = old_path
+ m.new_path = new_path
+ m.diff = diff
+ return m
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_get_mr_diff_small_diff(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ mr = MagicMock()
+ mock_gitlab.return_value.projects.get.return_value = proj
+ proj.mergerequests.get.return_value = mr
+ mr.diffs.list.return_value = [
+ make_mock_diff("file1.txt", "file1.txt", "diff content 1"),
+ make_mock_diff("file2.py", "file2.py", "diff content 2"),
+ ]
+ result = server.get_mr_diff("project/path", 1, max_size_kb=100)
+ assert "diff" in result
+ assert "file1.txt" in result["diff"]
+ assert result["temp_file_path"] is None
+ assert result["size_kb"] < 100
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+@patch("tempfile.NamedTemporaryFile")
+def test_get_mr_diff_large_diff_temp_file(mock_tempfile, mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ mr = MagicMock()
+ mock_gitlab.return_value.projects.get.return_value = proj
+ proj.mergerequests.get.return_value = mr
+ # Create a large diff
+ large_diff = "x" * (101 * 1024)
+ mock_diff = make_mock_diff("bigfile.txt", "bigfile.txt", large_diff)
+ mr.diffs.list.return_value = [mock_diff]
+ mock_file = MagicMock()
+ mock_file.name = "/tmp/mr_diff_12345.diff"
+ mock_tempfile.return_value.__enter__.return_value = mock_file
+ result = server.get_mr_diff("project/path", 1, max_size_kb=100)
+ assert result["diff_too_large"] is True
+ assert result["temp_file_path"] == "/tmp/mr_diff_12345.diff"
+ assert result["size_kb"] > 100
+ assert "message" in result
+ mock_file.write.assert_called_once()
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_get_mr_diff_filter_extensions(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ mr = MagicMock()
+ mock_gitlab.return_value.projects.get.return_value = proj
+ proj.mergerequests.get.return_value = mr
+ # One file should be filtered out
+ mr.diffs.list.return_value = [
+ make_mock_diff("file.lock", "file.lock", "should be filtered"),
+ make_mock_diff("file.txt", "file.txt", "should be present"),
+ ]
+ result = server.get_mr_diff("project/path", 1, max_size_kb=100, filter_extensions=[".lock"])
+ assert "file.lock" not in result["diff"]
+ assert "file.txt" in result["diff"]
+ assert "should be present" in result["diff"]
+ assert "should be filtered" not in result["diff"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_get_mr_diff_error_handling(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ mock_gitlab.return_value.projects.get.side_effect = Exception("Not found")
+ result = server.get_mr_diff("project/path", 1)
+ assert "error" in result
+ assert "Not found" in result["error"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_run_ci_pipeline_success_with_branch(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 123
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/123"
+ proj.pipelines.create.return_value = pipeline
+ mock_gitlab.return_value.projects.get.return_value = proj
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch="main",
+ variables={"FOO": "bar"},
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["pipeline_id"] == 123
+ assert result["pipeline_url"] == "https://gitlab.com/project/-/pipelines/123"
+ assert result["branch"] == "main"
+ assert result["web_mode"] is False
+ proj.pipelines.create.assert_called_once()
+ args = proj.pipelines.create.call_args[0][0]
+ assert args["ref"] == "main"
+ assert {"key": "FOO", "value": "bar"} in args["variables"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+@patch("subprocess.run")
+def test_run_ci_pipeline_success_current_branch(mock_subprocess, mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 456
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/456"
+ proj.pipelines.create.return_value = pipeline
+ proj.default_branch = "main"
+ mock_gitlab.return_value.projects.get.return_value = proj
+ # Simulate git branch detection
+ mock_git = MagicMock()
+ mock_git.returncode = 0
+ mock_git.stdout = "feature-branch"
+ mock_subprocess.return_value = mock_git
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch=None,
+ variables=None,
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["pipeline_id"] == 456
+ assert result["pipeline_url"] == "https://gitlab.com/project/-/pipelines/456"
+ assert result["branch"] == "feature-branch"
+ mock_subprocess.assert_called_once_with([
+ "git", "branch", "--show-current"
+ ], capture_output=True, text=True, check=False, cwd="/tmp")
+ proj.pipelines.create.assert_called_once()
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+@patch("subprocess.run")
+def test_run_ci_pipeline_fallback_to_default_branch(mock_subprocess, mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 789
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/789"
+ proj.pipelines.create.return_value = pipeline
+ proj.default_branch = "main"
+ mock_gitlab.return_value.projects.get.return_value = proj
+ # Simulate git branch detection failure
+ mock_git = MagicMock()
+ mock_git.returncode = 1
+ mock_git.stdout = ""
+ mock_subprocess.return_value = mock_git
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch=None,
+ variables=None,
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["branch"] == "main"
+ proj.pipelines.create.assert_called_once()
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_run_ci_pipeline_web_mode(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ proj = MagicMock()
+ pipeline = MagicMock()
+ pipeline.id = 321
+ pipeline.web_url = "https://gitlab.com/project/-/pipelines/321"
+ proj.pipelines.create.return_value = pipeline
+ mock_gitlab.return_value.projects.get.return_value = proj
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch="main",
+ variables={"FOO": "bar"},
+ web_mode=True,
+ working_directory="/tmp"
+ )
+ assert result["success"] is True
+ assert result["web_mode"] is True
+ args = proj.pipelines.create.call_args[0][0]
+ # Should include CI_PIPELINE_SOURCE=web
+ assert {"key": "CI_PIPELINE_SOURCE", "value": "web"} in args["variables"]
+
+@patch("mcp_server_gitlab_python.server.get_gitlab_settings", return_value=("https://gitlab.com", "dummy-token"))
+@patch("gitlab.Gitlab")
+def test_run_ci_pipeline_error_handling(mock_gitlab, mock_settings):
+ server = GitLabPythonServer(working_directory="/tmp")
+ mock_gitlab.return_value.projects.get.side_effect = Exception("Not found")
+ result = server.run_ci_pipeline(
+ project="project/path",
+ branch="main",
+ variables=None,
+ web_mode=False,
+ working_directory="/tmp"
+ )
+ assert "error" in result
+ assert "Not found" in result["error"] \ No newline at end of file