Skip to content

Commit 0d4e476

Browse files
author
Nisha Saini
committed
containerized approach
1 parent 23b2174 commit 0d4e476

11 files changed

+243
-86
lines changed

.containerignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.env
2+
.venv
3+
__pycache__
4+
*.pyc

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.env
2+
.vscode

Containerfile

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
1-
FROM registry.redhat.io/ubi9/python-311
1+
FROM python:3.11-slim
22

3-
# Set working directory
3+
# Set work directory
44
WORKDIR /app
55

6-
# Copy requirements first for better caching
7-
COPY requirements.txt .
8-
9-
# Install Python dependencies
6+
# Install dependencies
7+
COPY requirements.txt ./
108
RUN pip install --no-cache-dir -r requirements.txt
119

12-
# Copy server code
13-
COPY gitlab_mcp_server.py .
14-
15-
# Set environment variables
16-
ENV MCP_TRANSPORT=stdio
17-
ENV PYTHONPATH=/app
10+
# Copy source code (uses .containerignore)
11+
COPY . .
1812

19-
# Run the MCP server
20-
CMD ["python", "gitlab_mcp_server.py"]
13+
# Entrypoint
14+
CMD ["python", "gitlab_mcp_server.py"]

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) Red Hat, Inc.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
2+
_default: run
3+
4+
SHELL := /bin/bash
5+
SCRIPT_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
6+
IMG := localhost/gitlab-mcp:latest
7+
ENV_FILE := $(HOME)/.rh-gitlab-mcp.env
8+
9+
.PHONY: build run clean test cursor-config setup
10+
11+
build:
12+
@echo "🛠️ Building GitLab MCP server image"
13+
podman build -t $(IMG) .
14+
15+
# Notes:
16+
# - $(ENV_FILE) is expected to define _URL & JIRA_API_TOKEN.
17+
# - The --tty option is used here since we might run this in a
18+
# terminal, but for the mcp.json version we don't use --tty.
19+
# - You can use Ctrl-D to quit nicely.
20+
run:
21+
@podman run -i --tty --rm --env-file $(ENV_FILE) $(IMG)
22+
23+
clean:
24+
podman rmi -i $(IMG)
25+
26+
# For easier onboarding (and convenient hacking and testing), use this to
27+
# configure Cursor by adding or updating an entry in the ~/.cursor/mcp.json
28+
# file. Beware it might overwrite your customizations.
29+
MCP_JSON=$(HOME)/.cursor/mcp.json
30+
cursor-config:
31+
@echo "🛠️ Modifying $(MCP_JSON)"
32+
@yq -ojson '. *= load("example.mcp.json")' -i $(MCP_JSON)
33+
@yq -ojson $(MCP_JSON)
34+
35+
# Copy the example .env file only if it doesn't exist already
36+
$(ENV_FILE):
37+
@cp example.env $@
38+
@echo "🛠️ Env file created. Edit $@ to add your Jira token"
39+
40+
setup: build cursor-config $(ENV_FILE)

README.md

Lines changed: 0 additions & 53 deletions
This file was deleted.

Usage_steps.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,35 @@ The MCP follows:
105105
```bash
106106
# Required
107107
export MCP_GITLAB_TOKEN="your-gitlab-token"
108+
export MCP_ALLOWED_REPOS="patterns" # Repository patterns (REQUIRED)
108109

109110
# Optional
110111
export GITLAB_URL="https://gitlab.example.com"
111112
export MCP_MAX_API_CALLS="100"
112-
export MCP_ALLOWED_ACTIONS="read"
113-
export MCP_ALLOWED_REPOS="project1,project2" # Empty = all allowed
113+
```
114+
115+
**Note**: Actions are hardcoded to read-only for security - write operations are never permitted.
116+
117+
### 🔒 Repository Access Control (Updated)
118+
119+
`MCP_ALLOWED_REPOS` is **required** and specifies which repositories can be accessed using wildcard patterns.
120+
121+
**Pattern Examples**:
122+
```bash
123+
# Specific repositories only
124+
export MCP_ALLOWED_REPOS="repo1,repo2"
125+
126+
# All repositories in a group
127+
export MCP_ALLOWED_REPOS="automotive/*"
128+
129+
# Specific subgroup
130+
export MCP_ALLOWED_REPOS="automotive/pipe-x/*"
131+
132+
# Mixed patterns (most flexible)
133+
export MCP_ALLOWED_REPOS="repo1,automotive/*,group3/sub-group3/*,projectabc"
134+
135+
# Any user's specific repository
136+
export MCP_ALLOWED_REPOS="*/downstream-pipelines-as-code"
114137
```
115138

116139
## Example Workflows

example.env

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# GitLab MCP Server Configuration
2+
# Edit these values for your GitLab instance
3+
4+
# Required: GitLab personal access token
5+
MCP_GITLAB_TOKEN=${YOUR_MCP_GITLAB_TOKEN}
6+
7+
# Required: Comma-separated list of allowed repositories
8+
# Examples:
9+
# Specific repos: MCP_ALLOWED_REPOS=systemd-tests,my-project,123
10+
# Group wildcards: MCP_ALLOWED_REPOS=tekton/*,redhat-ai-tools/*
11+
# Mixed: MCP_ALLOWED_REPOS=specific-repo,group/*,12345
12+
MCP_ALLOWED_REPOS=redhat/edge/ci-cd/pipe-x/*
13+
14+
# Optional: Custom GitLab URL (defaults to gitlab.com)
15+
GITLAB_URL=https://gitlab.com
16+
17+
# Optional: Transport mode (stdio or http)
18+
MCP_TRANSPORT=stdio
19+
20+
# Optional: API rate limiting
21+
MCP_MAX_API_CALLS=100
22+
23+
# Optional: Allowed actions (comma-separated)
24+
MCP_ALLOWED_ACTIONS=read,search,list,check

example.mcp.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"mcpServers": {
3+
"jiraMcp": {
4+
"command": "podman",
5+
"args": [
6+
"run",
7+
"-i",
8+
"--rm",
9+
"--env-file",
10+
"~/.rh-gitlab-mcp.env",
11+
"localhost/gitlab_mcp_server:latest"
12+
],
13+
"description": "A containerized MCP server to query gitlab projects"
14+
}
15+
}
16+
}

gitlab_mcp_server.py

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import json
99
import logging
1010
import time
11+
import fnmatch
1112
from collections import defaultdict
1213
from typing import Any, Dict, List, Optional
1314
import asyncio
@@ -28,8 +29,20 @@ class GitLabConfig:
2829
def __init__(self):
2930
# While using a client like cursor limit the api calls, actions & repos
3031
self.max_api_calls_per_hour = int(os.getenv("MCP_MAX_API_CALLS", "100"))
31-
self.allowed_actions = os.getenv("MCP_ALLOWED_ACTIONS", "read").split(",")
32-
self.allowed_repos = os.getenv("MCP_ALLOWED_REPOS", "").split(",") if os.getenv("MCP_ALLOWED_REPOS") else []
32+
33+
# CRITICAL: Hardcoded read-only actions for security - no configuration allowed
34+
# For security, we only allow read-only queries - write operations are never permitted
35+
self.allowed_actions = ['read', 'list', 'search', 'get', 'check', 'info', 'describe', 'find']
36+
37+
# Parse MCP_ALLOWED_REPOS with support for wildcards: repo1,group1/*,group2/subgroup/*,exact-project
38+
# This is now a REQUIRED environment variable
39+
allowed_repos_env = os.getenv("MCP_ALLOWED_REPOS")
40+
if not allowed_repos_env:
41+
raise ValueError("MCP_ALLOWED_REPOS is required. Set repository patterns like 'automotive/*,project1,group/subgroup/*'")
42+
43+
self.allowed_repos = [repo.strip() for repo in allowed_repos_env.split(",") if repo.strip()]
44+
if not self.allowed_repos:
45+
raise ValueError("MCP_ALLOWED_REPOS cannot be empty. Specify at least one repository pattern.")
3346

3447
# Rate limiting tracking
3548
self.api_calls = defaultdict(list)
@@ -42,13 +55,33 @@ def __init__(self):
4255
if not self.gitlab_token:
4356
raise ValueError("GitLab token required. Set MCP_GITLAB_TOKEN environment variable")
4457

58+
# SSL Configuration options
59+
ssl_verify = os.getenv("MCP_SSL_VERIFY", "true").lower() == "true"
60+
ssl_ca_bundle = os.getenv("MCP_SSL_CA_BUNDLE") # Path to CA bundle file
61+
62+
# Build GitLab client configuration
63+
gitlab_config = {
64+
"url": self.gitlab_url,
65+
"private_token": self.gitlab_token,
66+
"ssl_verify": ssl_verify
67+
}
68+
69+
# Add CA bundle if provided
70+
if ssl_ca_bundle and ssl_verify:
71+
gitlab_config["ssl_ca_bundle"] = ssl_ca_bundle
72+
logger.info(f"🔒 Using SSL CA bundle: {ssl_ca_bundle}")
73+
elif not ssl_verify:
74+
logger.warning(f"⚠️ SSL verification disabled - not recommended for production!")
75+
4576
# Initialize GitLab client
46-
self.gl = gitlab.Gitlab(self.gitlab_url, private_token=self.gitlab_token)
77+
self.gl = gitlab.Gitlab(**gitlab_config)
4778

4879
# Log configuration
4980
logger.info(f"🔒 Max API calls/hour: {self.max_api_calls_per_hour}")
50-
logger.info(f"🔒 Allowed actions: {self.allowed_actions}")
51-
logger.info(f"🔒 Allowed repos: {self.allowed_repos if self.allowed_repos != [''] else 'ALL'}")
81+
logger.info(f"🔒🛡️ SECURITY: Read-only mode hardcoded (write operations never permitted)")
82+
logger.info(f"ℹ️ Allowed actions: {self.allowed_actions}")
83+
logger.info(f"🔒 Allowed repos (wildcard patterns): {self.allowed_repos}")
84+
logger.info(f"ℹ️ Pattern examples: 'repo-name', 'group/*', 'group/subgroup/*', 'group/project'")
5285
logger.info(f"🔗 GitLab URL: {self.gitlab_url}")
5386

5487
# Global configuration instance
@@ -156,25 +189,78 @@ def check_rate_limit() -> bool:
156189
return True
157190

158191
def check_action_allowed(action: str) -> bool:
159-
"""Check if action is permitted"""
160-
if action in config.allowed_actions or "all" in config.allowed_actions:
192+
"""Check if action is permitted - hardcoded read-only mode for security"""
193+
action_lower = action.lower()
194+
195+
# Check against hardcoded allowed read-only actions
196+
if action_lower in [act.lower() for act in config.allowed_actions]:
161197
return True
162-
logger.warning(f"🚫 Action not allowed: {action}")
198+
199+
# Block any write actions with explicit security warning
200+
forbidden_actions = ['write', 'create', 'update', 'delete', 'modify', 'push', 'merge', 'approve', 'deploy', 'change']
201+
if any(forbidden in action_lower for forbidden in forbidden_actions):
202+
logger.error(f"🚫🔒 SECURITY: Write action blocked: {action}")
203+
return False
204+
205+
# Log any unknown actions for security monitoring
206+
logger.warning(f"🚫 Action not allowed: {action} (only read-only actions permitted)")
163207
return False
164208

165-
def check_repo_allowed(project_id: str) -> bool:
166-
"""Check repository access permissions"""
167-
if not config.allowed_repos or config.allowed_repos == ['']:
209+
def match_repo_pattern(project_path: str, pattern: str) -> bool:
210+
"""
211+
Check if a project path matches a given pattern.
212+
213+
Supports various patterns:
214+
- Exact match: "my-repo" or "group/my-repo"
215+
- Group wildcard: "group/*" (matches all repos in group)
216+
- Subgroup wildcard: "group/subgroup/*" (matches all repos in subgroup)
217+
- Mixed patterns: supports any combination
218+
219+
Args:
220+
project_path: Full project path like "group/subgroup/project"
221+
pattern: Pattern to match against, e.g. "group/*", "exact-name", etc.
222+
223+
Returns:
224+
bool: True if project matches the pattern
225+
"""
226+
if not pattern or not project_path:
227+
return False
228+
229+
# Remove leading/trailing whitespace
230+
pattern = pattern.strip()
231+
project_path = project_path.strip()
232+
233+
# Exact match (including project ID as string)
234+
if pattern == project_path:
168235
return True
169236

237+
# Wildcard pattern matching
238+
if '*' in pattern:
239+
# Use fnmatch for shell-style wildcards
240+
return fnmatch.fnmatch(project_path, pattern)
241+
242+
# No match
243+
return False
244+
245+
def check_repo_allowed(project_id: str) -> bool:
246+
"""Check repository access permissions against MCP_ALLOWED_REPOS patterns"""
170247
try:
171248
project = config.gl.projects.get(project_id)
172249
project_name = project.path_with_namespace
173250

174-
if project_id in config.allowed_repos or project_name in config.allowed_repos:
175-
return True
251+
# Check against each allowed pattern
252+
for allowed_pattern in config.allowed_repos:
253+
allowed_pattern = allowed_pattern.strip()
254+
255+
# Check if project ID matches (for backward compatibility)
256+
if project_id == allowed_pattern:
257+
return True
258+
259+
# Check if project path matches the pattern
260+
if match_repo_pattern(project_name, allowed_pattern):
261+
return True
176262

177-
logger.warning(f"🚫 Repo not allowed: {project_name}")
263+
logger.warning(f"🚫 Repo not allowed: {project_name} (allowed patterns: {config.allowed_repos})")
178264
return False
179265
except Exception:
180266
logger.warning(f"🚫 Could not verify repo access: {project_id}")

0 commit comments

Comments
 (0)