pack-mcp-gitlab 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pack_mcp_gitlab/__init__.py +1 -0
- pack_mcp_gitlab/client.py +147 -0
- pack_mcp_gitlab/errors.py +71 -0
- pack_mcp_gitlab/server.py +162 -0
- pack_mcp_gitlab-0.1.0.dist-info/METADATA +11 -0
- pack_mcp_gitlab-0.1.0.dist-info/RECORD +8 -0
- pack_mcp_gitlab-0.1.0.dist-info/WHEEL +4 -0
- pack_mcp_gitlab-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import gitlab
|
|
2
|
+
|
|
3
|
+
from pack_mcp_gitlab.errors import handle_gitlab_error
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GitLabClient:
|
|
7
|
+
def __init__(self, gitlab_url: str, gitlab_token: str):
|
|
8
|
+
if not gitlab_url or not gitlab_url.strip():
|
|
9
|
+
raise ValueError("GITLAB_URL é obrigatório. Configure a variável de ambiente.")
|
|
10
|
+
if not gitlab_token or not gitlab_token.strip():
|
|
11
|
+
raise ValueError("GITLAB_TOKEN é obrigatório. Configure a variável de ambiente.")
|
|
12
|
+
|
|
13
|
+
self.gl = gitlab.Gitlab(gitlab_url, private_token=gitlab_token)
|
|
14
|
+
|
|
15
|
+
@handle_gitlab_error
|
|
16
|
+
def list_merge_requests(self, project_path: str, state: str = "opened") -> list[dict]:
|
|
17
|
+
"""Retorna lista de MRs do projeto filtrados por estado."""
|
|
18
|
+
project = self.gl.projects.get(project_path)
|
|
19
|
+
mrs = project.mergerequests.list(state=state, per_page=100, iterator=True)
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
"iid": mr.iid,
|
|
23
|
+
"title": mr.title,
|
|
24
|
+
"author": mr.author["username"],
|
|
25
|
+
"source_branch": mr.source_branch,
|
|
26
|
+
"target_branch": mr.target_branch,
|
|
27
|
+
"state": mr.state,
|
|
28
|
+
"web_url": mr.web_url,
|
|
29
|
+
"created_at": mr.created_at,
|
|
30
|
+
"updated_at": mr.updated_at,
|
|
31
|
+
}
|
|
32
|
+
for mr in mrs
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
@handle_gitlab_error
|
|
36
|
+
def get_merge_request(self, project_path: str, mr_iid: int) -> dict:
|
|
37
|
+
"""Retorna detalhes completos de um MR."""
|
|
38
|
+
project = self.gl.projects.get(project_path)
|
|
39
|
+
mr = project.mergerequests.get(mr_iid)
|
|
40
|
+
return {
|
|
41
|
+
"iid": mr.iid,
|
|
42
|
+
"title": mr.title,
|
|
43
|
+
"description": mr.description,
|
|
44
|
+
"state": mr.state,
|
|
45
|
+
"author": {
|
|
46
|
+
"name": mr.author["name"],
|
|
47
|
+
"username": mr.author["username"],
|
|
48
|
+
},
|
|
49
|
+
"source_branch": mr.source_branch,
|
|
50
|
+
"target_branch": mr.target_branch,
|
|
51
|
+
"web_url": mr.web_url,
|
|
52
|
+
"created_at": mr.created_at,
|
|
53
|
+
"updated_at": mr.updated_at,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@handle_gitlab_error
|
|
57
|
+
def get_merge_request_changes(self, project_path: str, mr_iid: int) -> list[dict]:
|
|
58
|
+
"""Retorna diffs/alterações de um MR."""
|
|
59
|
+
project = self.gl.projects.get(project_path)
|
|
60
|
+
mr = project.mergerequests.get(mr_iid)
|
|
61
|
+
changes = mr.changes()["changes"]
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
"new_path": change["new_path"],
|
|
65
|
+
"old_path": change["old_path"],
|
|
66
|
+
"new_file": change["new_file"],
|
|
67
|
+
"renamed_file": change["renamed_file"],
|
|
68
|
+
"deleted_file": change["deleted_file"],
|
|
69
|
+
"diff": change["diff"],
|
|
70
|
+
}
|
|
71
|
+
for change in changes
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
@handle_gitlab_error
|
|
75
|
+
def get_file_content(self, project_path: str, file_path: str, ref: str = "HEAD") -> dict:
|
|
76
|
+
"""Retorna conteúdo de arquivo decodificado em UTF-8."""
|
|
77
|
+
project = self.gl.projects.get(project_path)
|
|
78
|
+
f = project.files.get(file_path=file_path, ref=ref)
|
|
79
|
+
return {
|
|
80
|
+
"file_path": file_path,
|
|
81
|
+
"ref": ref,
|
|
82
|
+
"content": f.decode().decode("utf-8"),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@handle_gitlab_error
|
|
86
|
+
def create_merge_request_note(self, project_path: str, mr_iid: int, body: str) -> dict:
|
|
87
|
+
"""Cria comentário no MR. Retorna id e confirmação."""
|
|
88
|
+
if not body or not body.strip():
|
|
89
|
+
raise ValueError("O conteúdo do comentário (body) é obrigatório.")
|
|
90
|
+
project = self.gl.projects.get(project_path)
|
|
91
|
+
mr = project.mergerequests.get(mr_iid)
|
|
92
|
+
note = mr.notes.create({"body": body})
|
|
93
|
+
return {"id": note.id, "success": True}
|
|
94
|
+
|
|
95
|
+
@handle_gitlab_error
|
|
96
|
+
def list_merge_request_notes(self, project_path: str, mr_iid: int) -> list[dict]:
|
|
97
|
+
"""Retorna lista de comentários do MR."""
|
|
98
|
+
project = self.gl.projects.get(project_path)
|
|
99
|
+
mr = project.mergerequests.get(mr_iid)
|
|
100
|
+
notes = mr.notes.list(per_page=100, iterator=True)
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
"id": note.id,
|
|
104
|
+
"body": note.body,
|
|
105
|
+
"author": {
|
|
106
|
+
"name": note.author["name"],
|
|
107
|
+
"username": note.author["username"],
|
|
108
|
+
},
|
|
109
|
+
"created_at": note.created_at,
|
|
110
|
+
"system": note.system,
|
|
111
|
+
}
|
|
112
|
+
for note in notes
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
@handle_gitlab_error
|
|
116
|
+
def get_project(self, project_path: str) -> dict:
|
|
117
|
+
"""Retorna informações do projeto."""
|
|
118
|
+
project = self.gl.projects.get(project_path)
|
|
119
|
+
return {
|
|
120
|
+
"id": project.id,
|
|
121
|
+
"name": project.name,
|
|
122
|
+
"path_with_namespace": project.path_with_namespace,
|
|
123
|
+
"description": project.description,
|
|
124
|
+
"default_branch": project.default_branch,
|
|
125
|
+
"web_url": project.web_url,
|
|
126
|
+
"visibility": project.visibility,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@handle_gitlab_error
|
|
130
|
+
def search_projects(self, search: str) -> list[dict]:
|
|
131
|
+
"""Busca projetos por nome."""
|
|
132
|
+
projects = self.gl.projects.list(search=search, per_page=100)
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
"id": project.id,
|
|
136
|
+
"name": project.name,
|
|
137
|
+
"path_with_namespace": project.path_with_namespace,
|
|
138
|
+
"description": project.description,
|
|
139
|
+
"web_url": project.web_url,
|
|
140
|
+
}
|
|
141
|
+
for project in projects
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
from gitlab.exceptions import (
|
|
7
|
+
GitlabAuthenticationError,
|
|
8
|
+
GitlabGetError,
|
|
9
|
+
GitlabHttpError,
|
|
10
|
+
)
|
|
11
|
+
from mcp.shared.exceptions import McpError
|
|
12
|
+
from mcp.types import INTERNAL_ERROR, ErrorData
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_TOKEN_PATTERN = re.compile(
|
|
17
|
+
r"(glpat-[A-Za-z0-9_\-]{20,}|private.token=[^\s&]+)", re.IGNORECASE
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _sanitize(message: str) -> str:
|
|
22
|
+
"""Remove tokens de acesso de mensagens de log."""
|
|
23
|
+
return _TOKEN_PATTERN.sub("[REDACTED]", message)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle_gitlab_error(func):
|
|
27
|
+
"""Decorator que converte exceções GitLab em respostas de erro MCP.
|
|
28
|
+
|
|
29
|
+
Mapeia exceções específicas do python-gitlab e requests para
|
|
30
|
+
mensagens de erro descritivas via McpError, garantindo que
|
|
31
|
+
logs nunca exponham tokens de acesso.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@functools.wraps(func)
|
|
35
|
+
def wrapper(*args, **kwargs):
|
|
36
|
+
try:
|
|
37
|
+
return func(*args, **kwargs)
|
|
38
|
+
except GitlabAuthenticationError:
|
|
39
|
+
msg = "Falha de autenticação: token inválido ou expirado"
|
|
40
|
+
logger.error(_sanitize(msg))
|
|
41
|
+
raise McpError(ErrorData(code=INTERNAL_ERROR, message=msg))
|
|
42
|
+
except GitlabGetError as e:
|
|
43
|
+
if getattr(e, "response_code", None) == 404:
|
|
44
|
+
msg = f"Recurso não encontrado: {_sanitize(str(e))}"
|
|
45
|
+
else:
|
|
46
|
+
msg = f"Erro GitLab: {_sanitize(str(e))}"
|
|
47
|
+
logger.error(_sanitize(msg))
|
|
48
|
+
raise McpError(ErrorData(code=INTERNAL_ERROR, message=msg))
|
|
49
|
+
except GitlabHttpError as e:
|
|
50
|
+
if getattr(e, "response_code", None) == 403:
|
|
51
|
+
msg = "Permissão negada: token sem acesso para esta operação"
|
|
52
|
+
else:
|
|
53
|
+
msg = f"Erro HTTP GitLab: {_sanitize(str(e))}"
|
|
54
|
+
logger.error(_sanitize(msg))
|
|
55
|
+
raise McpError(ErrorData(code=INTERNAL_ERROR, message=msg))
|
|
56
|
+
except requests.ConnectionError as e:
|
|
57
|
+
msg = f"Erro de conexão: {_sanitize(str(e))}"
|
|
58
|
+
logger.error(_sanitize(msg))
|
|
59
|
+
raise McpError(ErrorData(code=INTERNAL_ERROR, message=msg))
|
|
60
|
+
except requests.Timeout as e:
|
|
61
|
+
msg = f"Timeout na conexão com GitLab: {_sanitize(str(e))}"
|
|
62
|
+
logger.error(_sanitize(msg))
|
|
63
|
+
raise McpError(ErrorData(code=INTERNAL_ERROR, message=msg))
|
|
64
|
+
except McpError:
|
|
65
|
+
raise
|
|
66
|
+
except Exception as e:
|
|
67
|
+
msg = f"Erro inesperado: {type(e).__name__}: {_sanitize(str(e))}"
|
|
68
|
+
logger.error(_sanitize(msg))
|
|
69
|
+
raise McpError(ErrorData(code=INTERNAL_ERROR, message=msg))
|
|
70
|
+
|
|
71
|
+
return wrapper
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
from mcp.server.fastmcp import FastMCP
|
|
5
|
+
|
|
6
|
+
mcp = FastMCP("pack-mcp-gitlab")
|
|
7
|
+
|
|
8
|
+
_client = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_gitlab_client():
|
|
12
|
+
"""Inicializa e retorna o cliente GitLab com validação de config (singleton)."""
|
|
13
|
+
global _client
|
|
14
|
+
if _client is not None:
|
|
15
|
+
return _client
|
|
16
|
+
|
|
17
|
+
from pack_mcp_gitlab.client import GitLabClient
|
|
18
|
+
|
|
19
|
+
log_level = os.environ.get("LOG_LEVEL", "WARNING").upper()
|
|
20
|
+
logging.basicConfig(level=getattr(logging, log_level, logging.WARNING))
|
|
21
|
+
|
|
22
|
+
gitlab_url = os.environ.get("GITLAB_URL", "")
|
|
23
|
+
gitlab_token = os.environ.get("GITLAB_TOKEN", "")
|
|
24
|
+
|
|
25
|
+
_client = GitLabClient(gitlab_url, gitlab_token)
|
|
26
|
+
return _client
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool()
|
|
30
|
+
async def list_merge_requests(project_path: str, state: str = "opened") -> str:
|
|
31
|
+
"""Lista Merge Requests de um projeto no GitLab.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
project_path: Caminho completo do projeto (ex: "grupo/subgrupo/projeto").
|
|
35
|
+
state: Filtro por estado do MR. Valores: "opened", "closed", "merged", "all". Padrão: "opened".
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
JSON array com MRs contendo: iid, title, author (username), source_branch, target_branch, state, web_url, created_at, updated_at.
|
|
39
|
+
"""
|
|
40
|
+
client = get_gitlab_client()
|
|
41
|
+
result = client.list_merge_requests(project_path, state)
|
|
42
|
+
return json.dumps(result)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@mcp.tool()
|
|
46
|
+
async def get_merge_request(project_path: str, mr_iid: int) -> str:
|
|
47
|
+
"""Obtém detalhes completos de um Merge Request específico.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
project_path: Caminho completo do projeto (ex: "grupo/subgrupo/projeto").
|
|
51
|
+
mr_iid: Número (IID) do Merge Request dentro do projeto.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
JSON com: iid, title, description, state, author (name, username), source_branch, target_branch, web_url, created_at, updated_at.
|
|
55
|
+
"""
|
|
56
|
+
client = get_gitlab_client()
|
|
57
|
+
result = client.get_merge_request(project_path, mr_iid)
|
|
58
|
+
return json.dumps(result)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
async def get_merge_request_changes(project_path: str, mr_iid: int) -> str:
|
|
63
|
+
"""Obtém as alterações (diffs) de um Merge Request, arquivo por arquivo.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
project_path: Caminho completo do projeto (ex: "grupo/subgrupo/projeto").
|
|
67
|
+
mr_iid: Número (IID) do Merge Request dentro do projeto.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
JSON array com um objeto por arquivo alterado contendo: new_path, old_path, new_file, renamed_file, deleted_file, diff (texto do diff unificado).
|
|
71
|
+
"""
|
|
72
|
+
client = get_gitlab_client()
|
|
73
|
+
result = client.get_merge_request_changes(project_path, mr_iid)
|
|
74
|
+
return json.dumps(result)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@mcp.tool()
|
|
78
|
+
async def get_file_content(project_path: str, file_path: str, ref: str = "HEAD") -> str:
|
|
79
|
+
"""Lê o conteúdo de um arquivo do repositório GitLab.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
project_path: Caminho completo do projeto (ex: "grupo/subgrupo/projeto").
|
|
83
|
+
file_path: Caminho do arquivo dentro do repositório (ex: "src/main/App.java").
|
|
84
|
+
ref: Branch, tag ou commit SHA de onde ler o arquivo. Padrão: "HEAD".
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
JSON com: file_path, ref, content (conteúdo UTF-8 do arquivo).
|
|
88
|
+
"""
|
|
89
|
+
client = get_gitlab_client()
|
|
90
|
+
result = client.get_file_content(project_path, file_path, ref)
|
|
91
|
+
return json.dumps(result)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
async def create_merge_request_note(project_path: str, mr_iid: int, body: str) -> str:
|
|
96
|
+
"""Posta um comentário (note) em um Merge Request no GitLab.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
project_path: Caminho completo do projeto (ex: "grupo/subgrupo/projeto").
|
|
100
|
+
mr_iid: Número (IID) do Merge Request dentro do projeto.
|
|
101
|
+
body: Texto do comentário a ser postado. Suporta Markdown do GitLab. Não pode ser vazio.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
JSON com: id (ID do comentário criado), success (true).
|
|
105
|
+
"""
|
|
106
|
+
client = get_gitlab_client()
|
|
107
|
+
result = client.create_merge_request_note(project_path, mr_iid, body)
|
|
108
|
+
return json.dumps(result)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@mcp.tool()
|
|
112
|
+
async def list_merge_request_notes(project_path: str, mr_iid: int) -> str:
|
|
113
|
+
"""Lista todos os comentários (notes) de um Merge Request.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
project_path: Caminho completo do projeto (ex: "grupo/subgrupo/projeto").
|
|
117
|
+
mr_iid: Número (IID) do Merge Request dentro do projeto.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
JSON array com notes contendo: id, body, author (name, username), created_at, system (true se é nota automática do GitLab).
|
|
121
|
+
"""
|
|
122
|
+
client = get_gitlab_client()
|
|
123
|
+
result = client.list_merge_request_notes(project_path, mr_iid)
|
|
124
|
+
return json.dumps(result)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool()
|
|
128
|
+
async def get_project(project_path: str) -> str:
|
|
129
|
+
"""Obtém informações de um projeto no GitLab.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
project_path: Caminho completo do projeto (ex: "grupo/subgrupo/projeto").
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
JSON com: id, name, path_with_namespace, description, default_branch, web_url, visibility.
|
|
136
|
+
"""
|
|
137
|
+
client = get_gitlab_client()
|
|
138
|
+
result = client.get_project(project_path)
|
|
139
|
+
return json.dumps(result)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@mcp.tool()
|
|
143
|
+
async def search_projects(search: str) -> str:
|
|
144
|
+
"""Busca projetos no GitLab por nome. Use quando não souber o caminho exato do projeto.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
search: Termo de busca (nome ou parte do nome do projeto).
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
JSON array com projetos encontrados contendo: id, name, path_with_namespace, description, web_url. Retorna lista vazia se nenhum projeto corresponder.
|
|
151
|
+
"""
|
|
152
|
+
client = get_gitlab_client()
|
|
153
|
+
result = client.search_projects(search)
|
|
154
|
+
return json.dumps(result)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main():
|
|
158
|
+
mcp.run(transport="stdio")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
if __name__ == "__main__":
|
|
162
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pack-mcp-gitlab
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for GitLab API integration
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: mcp>=1.0.0
|
|
7
|
+
Requires-Dist: python-gitlab>=4.0.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: hypothesis; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pack_mcp_gitlab/__init__.py,sha256=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN_XKdLCPjaYaY,2
|
|
2
|
+
pack_mcp_gitlab/client.py,sha256=Cu7iOPQITJYuRw1pofr6Mn_YN8FgX-Nweh_N9lBDqyc,5602
|
|
3
|
+
pack_mcp_gitlab/errors.py,sha256=XNfx5JHwYpC--_UB2FNl64i4nFiYDwE6D5CWcZYYR04,2696
|
|
4
|
+
pack_mcp_gitlab/server.py,sha256=GrxJfGb6GERcAqR_40UG4GUd7ftKikx4paE5Rwtuggo,5637
|
|
5
|
+
pack_mcp_gitlab-0.1.0.dist-info/METADATA,sha256=xuYSSpmyrC14FV9J7Jy6eskOXPTNe8fPySqEcQvID0E,338
|
|
6
|
+
pack_mcp_gitlab-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
7
|
+
pack_mcp_gitlab-0.1.0.dist-info/entry_points.txt,sha256=spumy7QrxQlUX_P1veyIpgazpD7IwEXLygjRMW2lU2w,64
|
|
8
|
+
pack_mcp_gitlab-0.1.0.dist-info/RECORD,,
|