From 0d4e476e27e362a944bfe354e0c2fecb6e128e90 Mon Sep 17 00:00:00 2001 From: Nisha Saini Date: Sat, 9 Aug 2025 23:50:25 -0400 Subject: [PATCH 1/3] containerized approach --- .containerignore | 4 ++ .gitignore | 2 + Containerfile | 22 +++------ LICENSE | 21 ++++++++ Makefile | 40 +++++++++++++++ README.md | 53 -------------------- Usage_steps.md | 27 +++++++++- example.env | 24 +++++++++ example.mcp.json | 16 ++++++ gitlab_mcp_server.py | 114 +++++++++++++++++++++++++++++++++++++------ requirements.txt | 6 +-- 11 files changed, 243 insertions(+), 86 deletions(-) create mode 100644 .containerignore create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile delete mode 100644 README.md create mode 100644 example.env create mode 100644 example.mcp.json 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..dd23d53 --- /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 _URL & JIRA_API_TOKEN. +# - The --tty option is used here since we might run this in a +# terminal, but for the mcp.json version we don't use --tty. +# - You can use Ctrl-D to quit nicely. +run: + @podman run -i --tty --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 Jira token" + +setup: build cursor-config $(ENV_FILE) diff --git a/README.md b/README.md deleted file mode 100644 index 01cd03e..0000000 --- a/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# gitlab-mcp-server - -Gitlab MCP (ModelContextProvider) server - ---- - -## Running locally in a MCP client like cursor - -To run this MCP server from cursor. Go to cursor settings -> Tools & integrations -> Add MCP server - -```json -{ - "mcpServers": { - "gitlab-mcp-local": { - "command": "python", - "args": ["gitlab_mcp_server.py"], - "trust": true - } - } -} -``` - -## Building locally - -To build the container image locally using Podman, run: - -```sh -podman build -t gitlab-mcp-server:latest -f Containerfile . -``` - -This will create a local image named `gitlab-mcp-server:latest` that you can use to run the server. - -## Running with Podman or Docker - -Example configuration for running with Podman: - -```json -{ - "mcpServers": { - "gitlab-mcp-server": { - "command": "podman", - "args": [ - "run", - "-i", - "--rm", - "--env", "MCP_GITLAB_URL", - "--env", "MCP_GITLAB_TOKEN", - "localhost/gitlab-mcp-server:latest" - ], - } - } -} -``` 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.env b/example.env new file mode 100644 index 0000000..3279c54 --- /dev/null +++ b/example.env @@ -0,0 +1,24 @@ +# 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 + +# Optional: Transport mode (stdio or http) +MCP_TRANSPORT=stdio + +# 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..c8cccdd --- /dev/null +++ b/example.mcp.json @@ -0,0 +1,16 @@ +{ + "mcpServers": { + "jiraMcp": { + "command": "podman", + "args": [ + "run", + "-i", + "--rm", + "--env-file", + "~/.rh-gitlab-mcp.env", + "localhost/gitlab_mcp_server:latest" + ], + "description": "A containerized MCP server to query gitlab projects" + } + } +} diff --git a/gitlab_mcp_server.py b/gitlab_mcp_server.py index 17740b2..efbdebc 100644 --- a/gitlab_mcp_server.py +++ b/gitlab_mcp_server.py @@ -8,6 +8,7 @@ import json import logging import time +import fnmatch from collections import defaultdict from typing import Any, Dict, List, Optional import asyncio @@ -28,8 +29,20 @@ class GitLabConfig: def __init__(self): # While using a client like cursor limit the api calls, actions & repos self.max_api_calls_per_hour = int(os.getenv("MCP_MAX_API_CALLS", "100")) - self.allowed_actions = os.getenv("MCP_ALLOWED_ACTIONS", "read").split(",") - self.allowed_repos = os.getenv("MCP_ALLOWED_REPOS", "").split(",") if os.getenv("MCP_ALLOWED_REPOS") else [] + + # CRITICAL: Hardcoded read-only actions for security - no configuration allowed + # For security, we only allow read-only queries - write operations are never permitted + self.allowed_actions = ['read', 'list', 'search', 'get', 'check', 'info', 'describe', 'find'] + + # Parse MCP_ALLOWED_REPOS with support for wildcards: repo1,group1/*,group2/subgroup/*,exact-project + # This is now a REQUIRED environment variable + allowed_repos_env = os.getenv("MCP_ALLOWED_REPOS") + if not allowed_repos_env: + raise ValueError("MCP_ALLOWED_REPOS is required. Set repository patterns like 'automotive/*,project1,group/subgroup/*'") + + self.allowed_repos = [repo.strip() for repo in allowed_repos_env.split(",") if repo.strip()] + if not self.allowed_repos: + raise ValueError("MCP_ALLOWED_REPOS cannot be empty. Specify at least one repository pattern.") # Rate limiting tracking self.api_calls = defaultdict(list) @@ -42,13 +55,33 @@ def __init__(self): if not self.gitlab_token: raise ValueError("GitLab token required. Set MCP_GITLAB_TOKEN environment variable") + # SSL Configuration options + ssl_verify = os.getenv("MCP_SSL_VERIFY", "true").lower() == "true" + ssl_ca_bundle = os.getenv("MCP_SSL_CA_BUNDLE") # Path to CA bundle file + + # Build GitLab client configuration + gitlab_config = { + "url": self.gitlab_url, + "private_token": self.gitlab_token, + "ssl_verify": ssl_verify + } + + # Add CA bundle if provided + if ssl_ca_bundle and ssl_verify: + gitlab_config["ssl_ca_bundle"] = ssl_ca_bundle + logger.info(f"šŸ”’ Using SSL CA bundle: {ssl_ca_bundle}") + elif not ssl_verify: + logger.warning(f"āš ļø SSL verification disabled - not recommended for production!") + # Initialize GitLab client - self.gl = gitlab.Gitlab(self.gitlab_url, private_token=self.gitlab_token) + self.gl = gitlab.Gitlab(**gitlab_config) # Log configuration logger.info(f"šŸ”’ Max API calls/hour: {self.max_api_calls_per_hour}") - logger.info(f"šŸ”’ Allowed actions: {self.allowed_actions}") - logger.info(f"šŸ”’ Allowed repos: {self.allowed_repos if self.allowed_repos != [''] else 'ALL'}") + logger.info(f"šŸ”’šŸ›”ļø SECURITY: Read-only mode hardcoded (write operations never permitted)") + logger.info(f"ā„¹ļø Allowed actions: {self.allowed_actions}") + logger.info(f"šŸ”’ Allowed repos (wildcard patterns): {self.allowed_repos}") + logger.info(f"ā„¹ļø Pattern examples: 'repo-name', 'group/*', 'group/subgroup/*', 'group/project'") logger.info(f"šŸ”— GitLab URL: {self.gitlab_url}") # Global configuration instance @@ -156,25 +189,78 @@ def check_rate_limit() -> bool: return True def check_action_allowed(action: str) -> bool: - """Check if action is permitted""" - if action in config.allowed_actions or "all" in config.allowed_actions: + """Check if action is permitted - hardcoded read-only mode for security""" + action_lower = action.lower() + + # Check against hardcoded allowed read-only actions + if action_lower in [act.lower() for act in config.allowed_actions]: return True - logger.warning(f"🚫 Action not allowed: {action}") + + # Block any write actions with explicit security warning + forbidden_actions = ['write', 'create', 'update', 'delete', 'modify', 'push', 'merge', 'approve', 'deploy', 'change'] + if any(forbidden in action_lower for forbidden in forbidden_actions): + logger.error(f"šŸš«šŸ”’ SECURITY: Write action blocked: {action}") + return False + + # Log any unknown actions for security monitoring + logger.warning(f"🚫 Action not allowed: {action} (only read-only actions permitted)") return False -def check_repo_allowed(project_id: str) -> bool: - """Check repository access permissions""" - if not config.allowed_repos or config.allowed_repos == ['']: +def match_repo_pattern(project_path: str, pattern: str) -> bool: + """ + Check if a project path matches a given pattern. + + Supports various patterns: + - Exact match: "my-repo" or "group/my-repo" + - Group wildcard: "group/*" (matches all repos in group) + - Subgroup wildcard: "group/subgroup/*" (matches all repos in subgroup) + - Mixed patterns: supports any combination + + Args: + project_path: Full project path like "group/subgroup/project" + pattern: Pattern to match against, e.g. "group/*", "exact-name", etc. + + Returns: + bool: True if project matches the pattern + """ + if not pattern or not project_path: + return False + + # Remove leading/trailing whitespace + pattern = pattern.strip() + project_path = project_path.strip() + + # Exact match (including project ID as string) + if pattern == project_path: return True + # Wildcard pattern matching + if '*' in pattern: + # Use fnmatch for shell-style wildcards + return fnmatch.fnmatch(project_path, pattern) + + # No match + return False + +def check_repo_allowed(project_id: str) -> bool: + """Check repository access permissions against MCP_ALLOWED_REPOS patterns""" try: project = config.gl.projects.get(project_id) project_name = project.path_with_namespace - if project_id in config.allowed_repos or project_name in config.allowed_repos: - return True + # 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 project_id == allowed_pattern: + return True + + # Check if project path matches the pattern + if match_repo_pattern(project_name, allowed_pattern): + return True - logger.warning(f"🚫 Repo not allowed: {project_name}") + logger.warning(f"🚫 Repo not allowed: {project_name} (allowed patterns: {config.allowed_repos})") return False except Exception: logger.warning(f"🚫 Could not verify repo access: {project_id}") diff --git a/requirements.txt b/requirements.txt index 6c49575..4268d75 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 +gitlab==1.0.2 +pydantic==2.11.7 From 8a80c0d8a1738028aa4a5edceb27c5caa20d3188 Mon Sep 17 00:00:00 2001 From: Nisha Saini Date: Sun, 10 Aug 2025 02:50:19 -0400 Subject: [PATCH 2/3] change port --- Containerfile | 3 +++ Makefile | 8 ++++++-- README.md | 4 ++-- example-http.mcp.json | 24 +++++++++++++++++++++++ example.env | 7 +++++++ example.mcp.json | 2 +- gitlab_mcp_server.py | 27 +++++++++++++++++++++----- multi-mcp-example.json | 43 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 +- 9 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 example-http.mcp.json create mode 100644 multi-mcp-example.json diff --git a/Containerfile b/Containerfile index 5cc9b47..355a2ee 100644 --- a/Containerfile +++ b/Containerfile @@ -10,5 +10,8 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy source code (uses .containerignore) COPY . . +# Expose port for HTTP transport (only used when MCP_TRANSPORT=http) +EXPOSE 8085 + # Entrypoint CMD ["python", "gitlab_mcp_server.py"] diff --git a/Makefile b/Makefile index dd23d53..cb3950a 100644 --- a/Makefile +++ b/Makefile @@ -13,13 +13,17 @@ build: podman build -t $(IMG) . # Notes: -# - $(ENV_FILE) is expected to define _URL & JIRA_API_TOKEN. +# - $(ENV_FILE) is expected to define the gitlab token & repos other vars are optional.. # - The --tty option is used here since we might run this in a # terminal, but for the mcp.json version we don't use --tty. # - You can use Ctrl-D to quit nicely. run: @podman run -i --tty --rm --env-file $(ENV_FILE) $(IMG) +run-http: + @echo "🌐 Starting GitLab MCP server in HTTP mode" + @podman run -i --rm --env-file $(ENV_FILE) -p 8085:8085 -e MCP_TRANSPORT=http $(IMG) + clean: podman rmi -i $(IMG) @@ -35,6 +39,6 @@ cursor-config: # 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 Jira token" + @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 a9d035d..1a39aef 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,8 @@ The MCP follows: ``` ### list_pipelines -```json -{ + ```json + { "project_id": "123", // Required: GitLab project ID "status": "all", // running, pending, success, failed, all "limit": 20 // max results 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 index 3279c54..fbdd204 100644 --- a/example.env +++ b/example.env @@ -17,6 +17,13 @@ GITLAB_URL=https://gitlab.com # Optional: Transport mode (stdio or http) MCP_TRANSPORT=stdio +# HTTP Transport Configuration (only used when MCP_TRANSPORT=http) +MCP_HOST=127.0.0.1 +MCP_PORT=8085 + +# 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 diff --git a/example.mcp.json b/example.mcp.json index c8cccdd..b129f7c 100644 --- a/example.mcp.json +++ b/example.mcp.json @@ -1,6 +1,6 @@ { "mcpServers": { - "jiraMcp": { + "gitlabMCP": { "command": "podman", "args": [ "run", diff --git a/gitlab_mcp_server.py b/gitlab_mcp_server.py index f9e020f..62947d6 100644 --- a/gitlab_mcp_server.py +++ b/gitlab_mcp_server.py @@ -57,7 +57,7 @@ def __init__(self): # SSL Configuration options ssl_verify = os.getenv("MCP_SSL_VERIFY", "true").lower() == "true" - ssl_ca_bundle = os.getenv("MCP_SSL_CA_BUNDLE") # Path to CA bundle file + ssl_ca_bundle = os.getenv("SSL_CERT_FILE") # Path to CA bundle file # Build GitLab client configuration gitlab_config = { @@ -808,6 +808,9 @@ async def list_latest_commits(request: CommitsRequest) -> dict: def main(): """Main entry point for the FastMCP server.""" + # Get transport mode from environment + transport_mode = os.getenv("MCP_TRANSPORT", "stdio").lower() + print("šŸš€ Starting GitLab MCP Server (FastMCP - Read-Only)...") print("šŸ“‹ Available Tools:") print(" šŸ” search_repositories - Search/filter repositories") @@ -821,10 +824,24 @@ def main(): print(f"šŸ”’ Actions: {config.allowed_actions}") print(f"šŸ”— GitLab: {config.gitlab_url}") print("šŸ“– Query Support: Both project_id and project_name supported for flexible access") - print("\nāœ… FastMCP Read-Only Server ready for connections!") - - # Run the FastMCP server - mcp.run() + print(f"šŸš€ Transport Mode: {transport_mode.upper()}") + + if transport_mode == "http": + # HTTP Transport configuration + host = os.getenv("MCP_HOST", "127.0.0.1") + port = int(os.getenv("MCP_PORT", "8085")) + + print(f"🌐 Starting HTTP server on {host}:{port}") + print("\nāœ… FastMCP HTTP Server ready for connections!") + + # Run with HTTP transport + mcp.run(transport="http", host=host, port=port) + else: + # Default STDIO transport + print("\nāœ… FastMCP STDIO Server ready for connections!") + + # Run with STDIO transport (default) + mcp.run() if __name__ == "__main__": main() 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 4268d75..c65d918 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ fastmcp==2.11.2 -gitlab==1.0.2 +python-gitlab==4.0.0 pydantic==2.11.7 From 9e0e5a6692573f32435d27517cd87218cb061053 Mon Sep 17 00:00:00 2001 From: Nisha Saini Date: Mon, 11 Aug 2025 10:40:10 -0400 Subject: [PATCH 3/3] fixed check_repo_allowed --- Containerfile | 3 - Makefile | 14 ++-- README.md | 5 +- example.env | 6 +- example.mcp.json | 6 +- gitlab_mcp_server.py | 152 ++++++++++++++++++++++++++++++++++++------- 6 files changed, 139 insertions(+), 47 deletions(-) diff --git a/Containerfile b/Containerfile index 355a2ee..5cc9b47 100644 --- a/Containerfile +++ b/Containerfile @@ -10,8 +10,5 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy source code (uses .containerignore) COPY . . -# Expose port for HTTP transport (only used when MCP_TRANSPORT=http) -EXPOSE 8085 - # Entrypoint CMD ["python", "gitlab_mcp_server.py"] diff --git a/Makefile b/Makefile index cb3950a..faa2336 100644 --- a/Makefile +++ b/Makefile @@ -13,16 +13,12 @@ build: podman build -t $(IMG) . # Notes: -# - $(ENV_FILE) is expected to define the gitlab token & repos other vars are optional.. -# - The --tty option is used here since we might run this in a -# terminal, but for the mcp.json version we don't use --tty. -# - You can use Ctrl-D to quit nicely. +# - $(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: - @podman run -i --tty --rm --env-file $(ENV_FILE) $(IMG) - -run-http: - @echo "🌐 Starting GitLab MCP server in HTTP mode" - @podman run -i --rm --env-file $(ENV_FILE) -p 8085:8085 -e MCP_TRANSPORT=http $(IMG) + @echo "šŸš€ Starting GitLab MCP server (STDIO mode)" + @podman run -i --rm --env-file $(ENV_FILE) $(IMG) clean: podman rmi -i $(IMG) diff --git a/README.md b/README.md index 1a39aef..7278a38 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,7 @@ make setup * 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 7 tools enabled. +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 @@ -205,4 +204,4 @@ Most functions support filtering: - 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. \ No newline at end of file +This read-only approach provides safer, more focused GitLab integration for monitoring and analysis workflows. diff --git a/example.env b/example.env index fbdd204..c185800 100644 --- a/example.env +++ b/example.env @@ -14,12 +14,8 @@ MCP_ALLOWED_REPOS=redhat/edge/ci-cd/pipe-x/* # Optional: Custom GitLab URL (defaults to gitlab.com) GITLAB_URL=https://gitlab.com -# Optional: Transport mode (stdio or http) -MCP_TRANSPORT=stdio - -# HTTP Transport Configuration (only used when MCP_TRANSPORT=http) +# HTTP Transport Configuration (HTTP-only mode) MCP_HOST=127.0.0.1 -MCP_PORT=8085 # Recommended ports for multiple MCP servers: # GitLab MCP: 8086, Jira MCP: 8087, Other MCP: 8088, 8089, etc. diff --git a/example.mcp.json b/example.mcp.json index b129f7c..e1c235a 100644 --- a/example.mcp.json +++ b/example.mcp.json @@ -1,6 +1,6 @@ { "mcpServers": { - "gitlabMCP": { + "gitlabMCP_STDIO": { "command": "podman", "args": [ "run", @@ -8,9 +8,9 @@ "--rm", "--env-file", "~/.rh-gitlab-mcp.env", - "localhost/gitlab_mcp_server:latest" + "localhost/gitlab-mcp:latest" ], - "description": "A containerized MCP server to query gitlab projects" + "description": "A containerized GitLab MCP server using STDIO transport" } } } diff --git a/gitlab_mcp_server.py b/gitlab_mcp_server.py index 62947d6..34f0c0e 100644 --- a/gitlab_mcp_server.py +++ b/gitlab_mcp_server.py @@ -57,7 +57,7 @@ def __init__(self): # SSL Configuration options ssl_verify = os.getenv("MCP_SSL_VERIFY", "true").lower() == "true" - ssl_ca_bundle = os.getenv("SSL_CERT_FILE") # Path to CA bundle file + ssl_ca_bundle = os.getenv("MCP_SSL_CA_BUNDLE") # Path to CA bundle file # Build GitLab client configuration gitlab_config = { @@ -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""" @@ -808,15 +928,13 @@ async def list_latest_commits(request: CommitsRequest) -> dict: def main(): """Main entry point for the FastMCP server.""" - # Get transport mode from environment - transport_mode = os.getenv("MCP_TRANSPORT", "stdio").lower() - print("šŸš€ Starting GitLab MCP Server (FastMCP - Read-Only)...") print("šŸ“‹ Available Tools:") print(" šŸ” search_repositories - Search/filter repositories") 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") @@ -824,24 +942,10 @@ def main(): print(f"šŸ”’ Actions: {config.allowed_actions}") print(f"šŸ”— GitLab: {config.gitlab_url}") print("šŸ“– Query Support: Both project_id and project_name supported for flexible access") - print(f"šŸš€ Transport Mode: {transport_mode.upper()}") - - if transport_mode == "http": - # HTTP Transport configuration - host = os.getenv("MCP_HOST", "127.0.0.1") - port = int(os.getenv("MCP_PORT", "8085")) - - print(f"🌐 Starting HTTP server on {host}:{port}") - print("\nāœ… FastMCP HTTP Server ready for connections!") - - # Run with HTTP transport - mcp.run(transport="http", host=host, port=port) - else: - # Default STDIO transport - print("\nāœ… FastMCP STDIO Server ready for connections!") - - # Run with STDIO transport (default) - mcp.run() + print("\nāœ… FastMCP Read-Only Server ready for connections!") + + # Run the FastMCP server + mcp.run() if __name__ == "__main__": - main() + main() \ No newline at end of file