diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..ba8fbb1 --- /dev/null +++ b/.containerignore @@ -0,0 +1,4 @@ +.env +.venv +__pycache__ +*.pyc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8661818 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.vscode diff --git a/Containerfile b/Containerfile index acecee2..5cc9b47 100644 --- a/Containerfile +++ b/Containerfile @@ -1,20 +1,14 @@ -FROM registry.redhat.io/ubi9/python-311 +FROM python:3.11-slim -# Set working directory +# Set work directory WORKDIR /app -# Copy requirements first for better caching -COPY requirements.txt . - -# Install Python dependencies +# Install dependencies +COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -# Copy server code -COPY gitlab_mcp_server.py . - -# Set environment variables -ENV MCP_TRANSPORT=stdio -ENV PYTHONPATH=/app +# Copy source code (uses .containerignore) +COPY . . -# Run the MCP server -CMD ["python", "gitlab_mcp_server.py"] +# Entrypoint +CMD ["python", "gitlab_mcp_server.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..83f0e51 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Red Hat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..faa2336 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ + +_default: run + +SHELL := /bin/bash +SCRIPT_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +IMG := localhost/gitlab-mcp:latest +ENV_FILE := $(HOME)/.rh-gitlab-mcp.env + +.PHONY: build run clean test cursor-config setup + +build: + @echo "🛠️ Building GitLab MCP server image" + podman build -t $(IMG) . + +# Notes: +# - $(ENV_FILE) is expected to define the gitlab token & repos other vars are optional. +# - This server runs in STDIO mode for MCP clients like Cursor. +# - Use Ctrl-C to quit the server. +run: + @echo "🚀 Starting GitLab MCP server (STDIO mode)" + @podman run -i --rm --env-file $(ENV_FILE) $(IMG) + +clean: + podman rmi -i $(IMG) + +# For easier onboarding (and convenient hacking and testing), use this to +# configure Cursor by adding or updating an entry in the ~/.cursor/mcp.json +# file. Beware it might overwrite your customizations. +MCP_JSON=$(HOME)/.cursor/mcp.json +cursor-config: + @echo "🛠️ Modifying $(MCP_JSON)" + @yq -ojson '. *= load("example.mcp.json")' -i $(MCP_JSON) + @yq -ojson $(MCP_JSON) + +# Copy the example .env file only if it doesn't exist already +$(ENV_FILE): + @cp example.env $@ + @echo "🛠️ Env file created. Edit $@ to add your GitLab token" + +setup: build cursor-config $(ENV_FILE) diff --git a/README.md b/README.md index 6bf906e..7278a38 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # gitlab-mcp-server -Gitlab MCP (ModelContextProvider) server +Gitlab MCP (ModelContextProvider) server is a containerized Python MCP server for Cursor to provide access to GitLab API(read-only as of now). --- @@ -8,90 +8,200 @@ Gitlab MCP (ModelContextProvider) server ### Prerequisites -- Python 3.11 or higher -- Running MCP server(s) - Cursor as MCP client - Podman(If running as containers) -### Method 1: local setup in venv +## Quick Start +1. **Get the code** ```bash git clone git@github.com:redhat-ai-tools/gitlab-mcp.git cd gitlab-mcp -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt ``` -Set MCP env vars, if not set everything else will fallback to default except MCP_GITLAB_TOKEN. -If everything is set, Test this setup using: -``` -python gitlab_mcp_server.py -INFO:__main__:🔒 Max API calls/hour: 100 -INFO:__main__:🔒 Allowed actions: ['read'] -INFO:__main__:🔒 Allowed repos: [] -INFO:__main__:🔗 GitLab URL: -🚀 Starting GitLab MCP Server (FastMCP - Read-Only)... -📋 Available Tools: - 🔍 search_repositories - Search/filter repositories - 📋 list_issues - List project issues - 🔀 list_merge_requests - List merge requests - 🏗️ list_pipelines - List CI/CD pipelines - ⚙️ list_jobs - List pipeline jobs - ❌ check_latest_failed_jobs - Check failed jobs - 📝 list_latest_commits - List recent commits + +2. **Build the image & configure Cursor**
+This also creates a `~/.rh-gitlab-mcp.env` file like [this](example.env). +```bash +make setup ``` -Interrupt Ctrl+C, and add this to Cursor config as shown in next steps. -## Running locally in a MCP client like cursor +3. **Prepare a GitLab token** + * Go to ${GITLAB_URL} if no custom gitlab defaults to public [gitlab.com](https://docs.gitlab.com/user/profile/personal_access_tokens/) and create a token + * Edit the `.rh-gitlab-mcp.env` file in your home directory and paste in the token. + +To confirm it's working, run Cursor, go to Settings and click on "Tools & Integrations". Under MCP Tools you should see "gitlabMcp" with 8 tools enabled. + + +## Available Functions + +### 🔍 Search & Discovery +- `search_repositories` - Find projects by name, description, or path +- `list_issues` - List issues in a project +- `list_merge_requests` - List merge requests in a project +- `list_pipelines` - List pipelines in a project +- `list_jobs` - List jobs in a specific pipeline + +### 📊 Monitoring & Analysis +- `check_latest_failed_jobs` - Find recent failed jobs across pipelines +- `list_latest_commits` - View recent commits in projects + +## Correct Query Patterns + +### For Project Discovery +✅ **Correct Prompts:** +- "Search for projects containing 'systemd'" +- "Find repositories with 'kernel' in the name" +- "List my owned projects" +- "Show all public repositories" + +❌ **Avoid:** +- Single word searches without context +- Vague terms like "find stuff" + +### For Failed Jobs Analysis +✅ **Correct Prompts:** +- "List the failed jobs in mcp-servers project" +- "Show recent failed jobs for project ID 123" +- "Find failed pipeline jobs in the last few runs" + +❌ **Avoid:** +- Just saying "failed jobs" without specifying project -To run this MCP server from cursor. Go to cursor settings -> Tools & integrations -> Add MCP server +### For Project Monitoring +✅ **Correct Prompts:** +- "Show recent pipelines for project 456" +- "List open issues in my-project" +- "Show merge requests waiting for review in project XYZ" +- "Get latest commits from all my projects" + +## Step by step example: + +When you ask: **"List the failed jobs in mcp-servers project"** + +The MCP follows: +1. 🔍 **Search for project:** Use `search_repositories` with search_term="mcp-servers" +2. 📋 **Get project ID:** Extract the project ID from search results +3. ❌ **List failed jobs:** Use `check_latest_failed_jobs` with that project ID +4. 📊 **Display results:** Show the recent failed jobs with details + +## Function Details + +### search_repositories +```json +{ + "search_term": "mcp-servers", // Optional: project name/description + "visibility": "all", // public, private, internal, all + "owned": false, // true for only owned repos + "limit": 10 // max results +} +``` + +### check_latest_failed_jobs +```json +{ + "project_id": "123", // Required: GitLab project ID + "limit": 5 // Optional: max failed jobs to return +} +``` +### list_issues ```json { - "mcpServers": { - "gitlab-mcp-local": { - "command": "/python", - "args": ["/gitlab_mcp_server.py"], - "env": { - "MCP_GITLAB_TOKEN": "your-actual-token-here", - "MCP_ALLOWED_REPOS": "list of comma separated repos or groups with wildcard", - "GITLAB_URL": "https://gitlab.com" ## <-this is optional var but if you want to explicitly set to some gitlab instance pass here - }, - "trust": true - } - } + "project_id": "123", // Required: GitLab project ID + "state": "opened", // opened, closed, all + "limit": 20 // max results +} +``` + +### list_pipelines + ```json + { + "project_id": "123", // Required: GitLab project ID + "status": "all", // running, pending, success, failed, all + "limit": 20 // max results } ``` -### Method 2: Container build +## Security Features + +- ✅ **Rate limiting:** Max 100 API calls per hour (configurable) +- ✅ **Action restrictions:** Only "read" actions allowed by default +- ✅ **Repository access control:** Configurable repo allowlist +- ✅ **Error handling:** Graceful failures with helpful messages + +## Environment Configuration ```bash -git clone git@github.com:redhat-ai-tools/gitlab-mcp.git -cd gitlab-mcp -podman build -t gitlab-mcp-server:latest . +# Required +export MCP_GITLAB_TOKEN="your-gitlab-token" + +# Optional +export GITLAB_URL="https://gitlab.example.com" +export MCP_MAX_API_CALLS="100" +export MCP_ALLOWED_ACTIONS="read" +export MCP_ALLOWED_REPOS="project1,project2" # Empty = all allowed ``` -This will create a local image named `gitlab-mcp-server:latest` that you can use to run the server. +## Example Workflows -## Running with Podman or Docker +### 1. Investigate Failed Jobs +``` +Human: "List failed jobs in mcp-tests project" +→ MCP searches for "mcp-tests" project +→ MCP gets project ID from search results +→ MCP lists recent failed jobs with details +``` -Example configuration for running with Podman: +### 2. Project Health Check +``` +Human: "Show pipeline status for my-app project" +→ MCP searches for "my-app" project +→ MCP lists recent pipelines with status +→ Human can drill down into specific pipelines +``` -```json -{ - "mcpServers": { - "gitlab-mcp-server": { - "command": "podman", - "args": [ - "run", - "-i", - "--rm", - "--env", "MCP_GITLAB_URL", ## <-- if this is not passed, it will fallback to default gitlab.com - "--env", "MCP_GITLAB_TOKEN", - "--env","MCP_ALLOWED_REPOS", - "localhost/gitlab-mcp-server:latest" - ], - } - } -} +### 3. Issue Tracking ``` +Human: "What open issues are there in frontend-app?" +→ MCP searches for "frontend-app" project +→ MCP lists open issues with assignees and dates +``` + +## Best Practices + +### 🎯 Be Specific +- Include project names in queries +- Specify what information you want (issues, MRs, pipelines, etc.) +- Use descriptive terms rather than abbreviations + +### 🔄 Multi-Step Queries +The MCP can now handle complex queries that require multiple steps: +1. Search for project by name +2. Get specific information about that project +3. Drill down into details as needed + +### 📊 Use Filters +Most functions support filtering: +- **States:** opened, closed, merged, failed, success +- **Limits:** Control result size for performance +- **Visibility:** Focus on relevant repositories + +## Troubleshooting + +### Common Issues + +**🚫 "Project not found"** +- Try broader search terms +- Check if you have access to the project +- Verify project name spelling + +**🚫 "Rate limit exceeded"** +- Wait an hour for limit reset +- Increase `MCP_MAX_API_CALLS` if needed + +**🚫 "Access denied"** +- Check `MCP_ALLOWED_REPOS` configuration +- Verify your GitLab token has proper permissions +- Ensure you're a project member + +This read-only approach provides safer, more focused GitLab integration for monitoring and analysis workflows. diff --git a/Usage_steps.md b/Usage_steps.md index df2afd8..b346df9 100644 --- a/Usage_steps.md +++ b/Usage_steps.md @@ -105,12 +105,35 @@ The MCP follows: ```bash # Required export MCP_GITLAB_TOKEN="your-gitlab-token" +export MCP_ALLOWED_REPOS="patterns" # Repository patterns (REQUIRED) # Optional export GITLAB_URL="https://gitlab.example.com" export MCP_MAX_API_CALLS="100" -export MCP_ALLOWED_ACTIONS="read" -export MCP_ALLOWED_REPOS="project1,project2" # Empty = all allowed +``` + +**Note**: Actions are hardcoded to read-only for security - write operations are never permitted. + +### 🔒 Repository Access Control (Updated) + +`MCP_ALLOWED_REPOS` is **required** and specifies which repositories can be accessed using wildcard patterns. + +**Pattern Examples**: +```bash +# Specific repositories only +export MCP_ALLOWED_REPOS="repo1,repo2" + +# All repositories in a group +export MCP_ALLOWED_REPOS="automotive/*" + +# Specific subgroup +export MCP_ALLOWED_REPOS="automotive/pipe-x/*" + +# Mixed patterns (most flexible) +export MCP_ALLOWED_REPOS="repo1,automotive/*,group3/sub-group3/*,projectabc" + +# Any user's specific repository +export MCP_ALLOWED_REPOS="*/downstream-pipelines-as-code" ``` ## Example Workflows diff --git a/example-http.mcp.json b/example-http.mcp.json new file mode 100644 index 0000000..0b67ca4 --- /dev/null +++ b/example-http.mcp.json @@ -0,0 +1,24 @@ +{ + "mcpServers": { + "gitlabMCP_HTTP": { + "command": "podman", + "args": [ + "run", + "-i", + "--rm", + "--env-file", + "~/.rh-gitlab-mcp.env", + "-p", + "8085:8085", + "-e", + "MCP_TRANSPORT=http", + "localhost/gitlab-mcp:latest" + ], + "description": "A containerized GitLab MCP server using HTTP transport", + "transport": { + "type": "streamable-http", + "url": "http://localhost:8085" + } + } + } +} diff --git a/example.env b/example.env new file mode 100644 index 0000000..c185800 --- /dev/null +++ b/example.env @@ -0,0 +1,27 @@ +# GitLab MCP Server Configuration +# Edit these values for your GitLab instance + +# Required: GitLab personal access token +MCP_GITLAB_TOKEN=${YOUR_MCP_GITLAB_TOKEN} + +# Required: Comma-separated list of allowed repositories +# Examples: +# Specific repos: MCP_ALLOWED_REPOS=systemd-tests,my-project,123 +# Group wildcards: MCP_ALLOWED_REPOS=tekton/*,redhat-ai-tools/* +# Mixed: MCP_ALLOWED_REPOS=specific-repo,group/*,12345 +MCP_ALLOWED_REPOS=redhat/edge/ci-cd/pipe-x/* + +# Optional: Custom GitLab URL (defaults to gitlab.com) +GITLAB_URL=https://gitlab.com + +# HTTP Transport Configuration (HTTP-only mode) +MCP_HOST=127.0.0.1 + +# Recommended ports for multiple MCP servers: +# GitLab MCP: 8086, Jira MCP: 8087, Other MCP: 8088, 8089, etc. + +# Optional: API rate limiting +MCP_MAX_API_CALLS=100 + +# Optional: Allowed actions (comma-separated) +MCP_ALLOWED_ACTIONS=read,search,list,check diff --git a/example.mcp.json b/example.mcp.json new file mode 100644 index 0000000..e1c235a --- /dev/null +++ b/example.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "gitlabMCP_STDIO": { + "command": "podman", + "args": [ + "run", + "-i", + "--rm", + "--env-file", + "~/.rh-gitlab-mcp.env", + "localhost/gitlab-mcp:latest" + ], + "description": "A containerized GitLab MCP server using STDIO transport" + } + } +} diff --git a/gitlab_mcp_server.py b/gitlab_mcp_server.py index f9e020f..34f0c0e 100644 --- a/gitlab_mcp_server.py +++ b/gitlab_mcp_server.py @@ -342,7 +342,25 @@ async def search_repositories(search_term: Optional[str] = None, visibility: str # Filter results based on repository access permissions accessible_projects = [] for project in projects: - if check_repo_allowed(str(project.id)): + # Check repo pattern directly without additional API call + project_path = project.path_with_namespace + is_allowed = False + + # Check against each allowed pattern + for allowed_pattern in config.allowed_repos: + allowed_pattern = allowed_pattern.strip() + + # Check if project ID matches (for backward compatibility) + if str(project.id) == allowed_pattern: + is_allowed = True + break + + # Check if project path matches the pattern + if match_repo_pattern(project_path, allowed_pattern): + is_allowed = True + break + + if is_allowed: try: repo_info = { 'id': project.id, @@ -581,6 +599,108 @@ async def list_pipelines(request: PipelineListRequest) -> dict: "error": f"❌ Error listing pipelines: {str(e)}" } +@mcp.tool() +async def scan_group_failed_pipelines(limit: Optional[int] = 5) -> dict: + """🔍 Scan all projects in allowed groups for failed pipelines, sorted by timestamp""" + try: + # Security checks + security_error = security_check("read") + if security_error: + raise ValueError(security_error) + + all_failed_pipelines = [] + scanned_projects = [] + + # Get all accessible projects from search + try: + accessible_projects = config.gl.projects.list(all=True, per_page=100) + except Exception as e: + logger.warning(f"⚠️ Could not list all projects, trying owned projects: {e}") + accessible_projects = config.gl.projects.list(owned=True, per_page=100) + + # Filter projects that match allowed repo patterns + for project in accessible_projects: + # Check repo pattern directly without additional API call + project_path = project.path_with_namespace + is_allowed = False + + # Check against each allowed pattern + for allowed_pattern in config.allowed_repos: + allowed_pattern = allowed_pattern.strip() + + # Check if project ID matches (for backward compatibility) + if str(project.id) == allowed_pattern: + is_allowed = True + break + + # Check if project path matches the pattern + if match_repo_pattern(project_path, allowed_pattern): + is_allowed = True + break + + if is_allowed: + scanned_projects.append({ + 'id': project.id, + 'name': project.name, + 'path_with_namespace': project.path_with_namespace + }) + + try: + # Get failed pipelines from this project + failed_pipelines = project.pipelines.list( + status='failed', + per_page=limit, + order_by='updated_at', + sort='desc' + ) + + for pipeline in failed_pipelines: + all_failed_pipelines.append({ + 'project_id': project.id, + 'project_name': project.name, + 'project_path': project.path_with_namespace, + 'pipeline_id': pipeline.id, + 'status': pipeline.status, + 'ref': pipeline.ref, + 'sha': pipeline.sha, + 'source': getattr(pipeline, 'source', 'unknown'), + 'created_at': pipeline.created_at, + 'updated_at': pipeline.updated_at, + 'duration': pipeline.duration, + 'web_url': pipeline.web_url, + 'user': pipeline.user['name'] if hasattr(pipeline, 'user') and pipeline.user else 'System' + }) + + except Exception as e: + logger.warning(f"⚠️ Could not get pipelines for {project.path_with_namespace}: {e}") + continue + + # Sort all failed pipelines by updated_at (most recent first) + all_failed_pipelines.sort(key=lambda x: x['updated_at'], reverse=True) + + # Take only the requested limit + limited_pipelines = all_failed_pipelines[:limit] + + message = f"🔍 Scanned {len(scanned_projects)} projects, found {len(limited_pipelines)} recent failed pipelines" + + logger.info(f"✅ Scanned {len(scanned_projects)} projects for failed pipelines") + return { + "success": True, + "message": message, + "scanned_projects_count": len(scanned_projects), + "scanned_projects": scanned_projects, + "failed_pipelines_count": len(limited_pipelines), + "failed_pipelines": limited_pipelines, + "total_failed_found": len(all_failed_pipelines) + } + + except Exception as e: + logger.error(f"❌ Error scanning group failed pipelines: {e}") + return { + "success": False, + "error": f"❌ Error scanning group failed pipelines: {str(e)}" + } + @mcp.tool() async def list_jobs(request: JobListRequest) -> dict: """⚙️ List jobs in a pipeline - supports both project_id and project_name""" @@ -814,6 +934,7 @@ def main(): print(" 📋 list_issues - List project issues") print(" 🔀 list_merge_requests - List merge requests") print(" 🏗️ list_pipelines - List CI/CD pipelines") + print(" 🔍 scan_group_failed_pipelines - Scan all group projects for failed pipelines") print(" ⚙️ list_jobs - List pipeline jobs") print(" ❌ check_latest_failed_jobs - Check failed jobs") print(" 📝 list_latest_commits - List recent commits") @@ -827,4 +948,4 @@ def main(): mcp.run() if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/multi-mcp-example.json b/multi-mcp-example.json new file mode 100644 index 0000000..a3d4a42 --- /dev/null +++ b/multi-mcp-example.json @@ -0,0 +1,43 @@ +{ + "mcpServers": { + "gitlabMcp": { + "command": "podman", + "args": [ + "run", "-i", "--rm", "--env-file", "~/.rh-gitlab-mcp.env", + "-p", "8086:8086", "-e", "MCP_TRANSPORT=http", + "localhost/gitlab-mcp:latest" + ], + "description": "GitLab MCP server on port 8086", + "transport": { + "type": "streamable-http", + "url": "http://localhost:8086" + } + }, + "jiraMcp": { + "command": "podman", + "args": [ + "run", "-i", "--rm", "--env-file", "~/.rh-jira-mcp.env", + "-p", "8087:8087", "-e", "MCP_TRANSPORT=http", "-e", "MCP_PORT=8087", + "localhost/jira-mcp:latest" + ], + "description": "Jira MCP server on port 8087", + "transport": { + "type": "streamable-http", + "url": "http://localhost:8087" + } + }, + "otherMcp": { + "command": "podman", + "args": [ + "run", "-i", "--rm", "--env-file", "~/.other-mcp.env", + "-p", "8088:8088", "-e", "MCP_TRANSPORT=http", "-e", "MCP_PORT=8088", + "localhost/other-mcp:latest" + ], + "description": "Other MCP server on port 8088", + "transport": { + "type": "streamable-http", + "url": "http://localhost:8088" + } + } + } +} diff --git a/requirements.txt b/requirements.txt index 6c49575..c65d918 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -fastmcp>=2.0.0 -python-gitlab>=4.0.0 -pydantic>=2.0.0 \ No newline at end of file +fastmcp==2.11.2 +python-gitlab==4.0.0 +pydantic==2.11.7