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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pack-mcp-gitlab = pack_mcp_gitlab.server:main