mcp-gitlabpr 1.0.0__tar.gz

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,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ dist/
6
+ .DS_Store
7
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Xavier Araújo
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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: mcp-gitlabpr
3
+ Version: 1.0.0
4
+ Summary: MCP server para merge requests do GitLab (API v4, CE 10.8+): listar, obter e criar
5
+ Project-URL: Repository, https://github.com/dxaraujo/kiro/tree/main/mcps/gitlabpr
6
+ Author-email: Daniel Xavier Araújo <danielxaraujo@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: mcp[cli]>=2.2
11
+ Requires-Dist: requests>=2.31
12
+ Description-Content-Type: text/markdown
13
+
14
+ # mcp-gitlabpr
15
+
16
+ Servidor MCP para **merge requests** do GitLab (o "PR" do GitLab): listar, obter (com diffs) e criar. Usa a API REST
17
+ v4 com personal access token e só os parâmetros que já existem na **GitLab CE 10.8**
18
+ ([doc da 10.8.7](https://gitlab.com/gitlab-org/gitlab-foss/-/blob/v10.8.7/doc/api/merge_requests.md)), então também
19
+ funciona em versões mais novas.
20
+
21
+ ## Credenciais
22
+
23
+ | SO | Arquivo |
24
+ |---|---|
25
+ | Linux/Mac | `~/.config/mcp-gitlabpr/gitlabpr.properties` |
26
+ | Windows | `%APPDATA%\mcp-gitlabpr\gitlabpr.properties` |
27
+
28
+ Para usar outro caminho, defina a variável de ambiente `MCP_GITLABPR_CONFIG`.
29
+
30
+ ```ini
31
+ [DEFAULT]
32
+ url = https://gitlab.SEU-SERVIDOR
33
+ token = SEU_PERSONAL_ACCESS_TOKEN
34
+ ```
35
+
36
+ O token sai de *Settings → Access Tokens* com o escopo `api` e vai no header `PRIVATE-TOKEN`.
37
+
38
+ **Certificados:** o MCP confia nas CAs da máquina e, se existir, em `gitlabpr-bundle.pem` na mesma pasta do
39
+ `gitlabpr.properties` (cadeia PEM do servidor). `REQUESTS_CA_BUNDLE` também funciona.
40
+
41
+ ## Instalação
42
+
43
+ ```json
44
+ {
45
+ "mcpServers": {
46
+ "gitlabpr": {
47
+ "type": "stdio",
48
+ "command": "uvx",
49
+ "args": ["mcp-gitlabpr@latest"],
50
+ "env": { "UV_SYSTEM_CERTS": "true" }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ Desenvolvimento:
57
+
58
+ ```bash
59
+ uv sync
60
+ uv run pytest
61
+ uv run mcp dev mcp_gitlabpr/server.py # MCP Inspector
62
+ ```
63
+
64
+ ## Tools
65
+
66
+ `projeto` é o caminho `grupo/projeto` (o MCP faz o URL-encode) ou o id numérico. **Sem `projeto`**, ele vem do remote
67
+ do git da pasta em que o MCP roda (`origin`, ou o primeiro remote; aceita `https://`, `ssh://` e `git@host:`), e
68
+ `origem` vem da branch atual. Fora de um repositório git a tool falha pedindo que o agente pergunte ao usuário. O
69
+ cliente MCP precisa iniciar o servidor na pasta do workspace; se não iniciar, informe `projeto`. `iid` é o número do MR no projeto
70
+ (o `!123` da tela). Autor e responsável são usernames: o MCP os converte em id por `GET /users?username=`, porque a 10.8
71
+ só filtra por `author_id`/`assignee_id`.
72
+
73
+ | Tool | Para quê |
74
+ |---|---|
75
+ | `gitlab_listar_mrs(projeto?, todos_projetos=false, estado=opened, busca?, origem?, destino?, autor?, responsavel?, limite≤100, pagina=1)` | MRs do projeto ou, com `todos_projetos`, de todos os visíveis (`scope=all`). Devolve `total`, `proxima_pagina` e os itens resumidos |
76
+ | `gitlab_obter_mr(iid, projeto?, diffs=false)` | Detalhes do MR (descrição, labels, conflitos, merge). Com `diffs`, os arquivos alterados e o diff de cada um, cortado em 4000 caracteres |
77
+ | `gitlab_criar_mr(titulo, destino, origem?, projeto?, descricao?, responsavel?, labels?, remover_origem=false)` | Cria o MR; a branch de origem precisa já estar no GitLab (`git push`). Se já existe um MR aberto entre as mesmas branches, o GitLab responde 409 |
78
+
79
+ `estado` aceita `opened`, `closed`, `merged` ou `all`.
@@ -0,0 +1,66 @@
1
+ # mcp-gitlabpr
2
+
3
+ Servidor MCP para **merge requests** do GitLab (o "PR" do GitLab): listar, obter (com diffs) e criar. Usa a API REST
4
+ v4 com personal access token e só os parâmetros que já existem na **GitLab CE 10.8**
5
+ ([doc da 10.8.7](https://gitlab.com/gitlab-org/gitlab-foss/-/blob/v10.8.7/doc/api/merge_requests.md)), então também
6
+ funciona em versões mais novas.
7
+
8
+ ## Credenciais
9
+
10
+ | SO | Arquivo |
11
+ |---|---|
12
+ | Linux/Mac | `~/.config/mcp-gitlabpr/gitlabpr.properties` |
13
+ | Windows | `%APPDATA%\mcp-gitlabpr\gitlabpr.properties` |
14
+
15
+ Para usar outro caminho, defina a variável de ambiente `MCP_GITLABPR_CONFIG`.
16
+
17
+ ```ini
18
+ [DEFAULT]
19
+ url = https://gitlab.SEU-SERVIDOR
20
+ token = SEU_PERSONAL_ACCESS_TOKEN
21
+ ```
22
+
23
+ O token sai de *Settings → Access Tokens* com o escopo `api` e vai no header `PRIVATE-TOKEN`.
24
+
25
+ **Certificados:** o MCP confia nas CAs da máquina e, se existir, em `gitlabpr-bundle.pem` na mesma pasta do
26
+ `gitlabpr.properties` (cadeia PEM do servidor). `REQUESTS_CA_BUNDLE` também funciona.
27
+
28
+ ## Instalação
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "gitlabpr": {
34
+ "type": "stdio",
35
+ "command": "uvx",
36
+ "args": ["mcp-gitlabpr@latest"],
37
+ "env": { "UV_SYSTEM_CERTS": "true" }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Desenvolvimento:
44
+
45
+ ```bash
46
+ uv sync
47
+ uv run pytest
48
+ uv run mcp dev mcp_gitlabpr/server.py # MCP Inspector
49
+ ```
50
+
51
+ ## Tools
52
+
53
+ `projeto` é o caminho `grupo/projeto` (o MCP faz o URL-encode) ou o id numérico. **Sem `projeto`**, ele vem do remote
54
+ do git da pasta em que o MCP roda (`origin`, ou o primeiro remote; aceita `https://`, `ssh://` e `git@host:`), e
55
+ `origem` vem da branch atual. Fora de um repositório git a tool falha pedindo que o agente pergunte ao usuário. O
56
+ cliente MCP precisa iniciar o servidor na pasta do workspace; se não iniciar, informe `projeto`. `iid` é o número do MR no projeto
57
+ (o `!123` da tela). Autor e responsável são usernames: o MCP os converte em id por `GET /users?username=`, porque a 10.8
58
+ só filtra por `author_id`/`assignee_id`.
59
+
60
+ | Tool | Para quê |
61
+ |---|---|
62
+ | `gitlab_listar_mrs(projeto?, todos_projetos=false, estado=opened, busca?, origem?, destino?, autor?, responsavel?, limite≤100, pagina=1)` | MRs do projeto ou, com `todos_projetos`, de todos os visíveis (`scope=all`). Devolve `total`, `proxima_pagina` e os itens resumidos |
63
+ | `gitlab_obter_mr(iid, projeto?, diffs=false)` | Detalhes do MR (descrição, labels, conflitos, merge). Com `diffs`, os arquivos alterados e o diff de cada um, cortado em 4000 caracteres |
64
+ | `gitlab_criar_mr(titulo, destino, origem?, projeto?, descricao?, responsavel?, labels?, remover_origem=false)` | Cria o MR; a branch de origem precisa já estar no GitLab (`git push`). Se já existe um MR aberto entre as mesmas branches, o GitLab responde 409 |
65
+
66
+ `estado` aceita `opened`, `closed`, `merged` ou `all`.
File without changes
@@ -0,0 +1,42 @@
1
+ """Carrega URL e token do GitLab a partir de gitlabpr.properties."""
2
+ from __future__ import annotations
3
+
4
+ import configparser
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ REQUIRED = ("url", "token")
11
+ TEMPLATE = "[DEFAULT]\nurl = https://gitlab.SEU-SERVIDOR\ntoken = SEU_PERSONAL_ACCESS_TOKEN"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class GitlabConfig:
16
+ url: str
17
+ token: str = field(repr=False)
18
+
19
+
20
+ def default_path() -> Path:
21
+ if env := os.environ.get("MCP_GITLABPR_CONFIG"):
22
+ return Path(env)
23
+ if sys.platform == "win32":
24
+ return Path(os.environ["APPDATA"]) / "mcp-gitlabpr" / "gitlabpr.properties"
25
+ return Path.home() / ".config" / "mcp-gitlabpr" / "gitlabpr.properties"
26
+
27
+
28
+ def load_config(path: Path | None = None) -> GitlabConfig:
29
+ path = path or default_path()
30
+ if not path.is_file():
31
+ raise FileNotFoundError(f"Arquivo de credenciais não encontrado: {path}\nCrie-o com:\n{TEMPLATE}")
32
+ # interpolation=None: tokens podem conter '%'
33
+ parser = configparser.ConfigParser(interpolation=None)
34
+ try:
35
+ parser.read(path, encoding="utf-8")
36
+ except configparser.Error as exc:
37
+ raise ValueError(f"{path} inválido: {exc}") from exc
38
+ section = parser.defaults()
39
+ missing = [k for k in REQUIRED if not section.get(k, "").strip()]
40
+ if missing:
41
+ raise ValueError(f"Chave(s) ausente(s) em {path} [DEFAULT]: {', '.join(missing)}")
42
+ return GitlabConfig(url=section["url"].strip().rstrip("/"), token=section["token"].strip())
@@ -0,0 +1,283 @@
1
+ """Servidor MCP para merge requests do GitLab: listar, obter (com diffs) e criar.
2
+
3
+ API REST v4 com personal access token (header PRIVATE-TOKEN, escopo `api`). Só usa parâmetros que já existem na
4
+ GitLab CE 10.8: autor e responsável vão por id (`author_username` é de versões novas), resolvidos por username.
5
+
6
+ Sem `projeto`, ele vem do remote do git da pasta em que o MCP roda (origin, ou o primeiro remote).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+ import ssl
12
+ import subprocess
13
+ import threading
14
+ from pathlib import Path
15
+ from urllib.parse import quote, urlsplit
16
+
17
+ import requests
18
+ from requests.adapters import HTTPAdapter
19
+ from mcp.server.mcpserver import MCPServer
20
+ from mcp.server.mcpserver.exceptions import ToolError
21
+
22
+ from .config import GitlabConfig, default_path, load_config
23
+
24
+ mcp = MCPServer(
25
+ "gitlabpr",
26
+ instructions=(
27
+ "Merge requests (MR, o 'PR' do GitLab). `projeto` é o caminho 'grupo/projeto' ou o id numérico; sem ele, vem "
28
+ "do remote do repositório git da pasta atual e `origem` vem da branch atual. Fora de um repositório git a tool "
29
+ "falha: pergunte ao usuário o projeto (e a branch de origem) e informe-os. `iid` é o número do MR dentro do "
30
+ "projeto (o !123 da tela). Autor e responsável são usernames. Liste com "
31
+ "gitlab_listar_mrs, detalhe (e veja os diffs) com gitlab_obter_mr, crie com gitlab_criar_mr."
32
+ ),
33
+ )
34
+
35
+ EXPECTED = (RuntimeError, OSError, LookupError, ValueError)
36
+ TIMEOUT = 60
37
+ LIMITE_TETO = 100
38
+ DIFF_MAX = 4000
39
+ ESTADOS = ("opened", "closed", "merged", "all")
40
+
41
+
42
+ def tool(fn):
43
+ """Registra `fn` como tool (ToolError com o texto da exceção) e devolve `fn` intacta."""
44
+ @functools.wraps(fn)
45
+ def wrapper(*args, **kwargs):
46
+ try:
47
+ return fn(*args, **kwargs)
48
+ except EXPECTED as exc:
49
+ raise ToolError(str(exc)) from exc
50
+
51
+ mcp.tool()(wrapper)
52
+ return fn
53
+
54
+
55
+ # ---------------------------------------------------------------- HTTP
56
+
57
+ def ca_bundle() -> Path:
58
+ """CAs extras (ex.: cadeia interna do GitLab), ao lado do gitlabpr.properties."""
59
+ return default_path().parent / "gitlabpr-bundle.pem"
60
+
61
+
62
+ class _TlsAdapter(HTTPAdapter):
63
+ """CAs do sistema + gitlabpr-bundle.pem, se existir."""
64
+
65
+ def init_poolmanager(self, *args, **kwargs):
66
+ ctx = ssl.create_default_context()
67
+ if ca_bundle().is_file():
68
+ ctx.load_verify_locations(ca_bundle())
69
+ super().init_poolmanager(*args, ssl_context=ctx, **kwargs)
70
+
71
+
72
+ class GitlabSession:
73
+ def __init__(self, cfg: GitlabConfig):
74
+ self.cfg = cfg
75
+ self.api = f"{cfg.url}/api/v4"
76
+ self.session = requests.Session()
77
+ self.session.mount("https://", _TlsAdapter())
78
+ self.session.headers.update({"PRIVATE-TOKEN": cfg.token, "Accept": "application/json"})
79
+
80
+ def request(self, method: str, path: str, **kwargs) -> requests.Response:
81
+ url = self.api + path
82
+ try:
83
+ resp = self.session.request(method, url, timeout=TIMEOUT, **kwargs)
84
+ except requests.exceptions.SSLError as exc:
85
+ raise RuntimeError(
86
+ f"Certificado do servidor não confiável em {url}. Grave a cadeia de certificados (PEM) em "
87
+ f"{ca_bundle()} (ou aponte REQUESTS_CA_BUNDLE para ela) e reinicie o MCP. Detalhe: {exc}") from exc
88
+ if resp.status_code == 401:
89
+ raise RuntimeError(f"HTTP 401 em {method} {url}: token inválido ou expirado (precisa do escopo `api`).")
90
+ if not resp.ok:
91
+ raise RuntimeError(f"HTTP {resp.status_code} em {method} {url}: {resp.text[:500].strip()}")
92
+ return resp
93
+
94
+
95
+ _instance: GitlabSession | None = None
96
+ _lock = threading.Lock()
97
+
98
+
99
+ def get_session() -> GitlabSession:
100
+ """Sessão única por processo; carrega o gitlabpr.properties na primeira chamada."""
101
+ global _instance
102
+ with _lock:
103
+ if _instance is None:
104
+ _instance = GitlabSession(load_config())
105
+ return _instance
106
+
107
+
108
+ def set_session(session: GitlabSession | None) -> None:
109
+ """Substitui a sessão global (testes)."""
110
+ global _instance
111
+ _instance = session
112
+
113
+
114
+ # ---------------------------------------------------------------- helpers
115
+
116
+ # ponytail: usa o cwd do processo MCP; se o cliente não o iniciar na pasta do workspace, informe `projeto`
117
+ def _git(*args: str) -> str | None:
118
+ try:
119
+ r = subprocess.run(["git", *args], capture_output=True, text=True, timeout=10)
120
+ except (OSError, subprocess.TimeoutExpired):
121
+ return None
122
+ return (r.stdout.strip() or None) if r.returncode == 0 else None
123
+
124
+
125
+ def _caminho_remoto(url: str) -> str:
126
+ """'grupo/projeto' de https://host[/prefixo]/g/p.git, ssh://git@host:22/g/p.git ou git@host:g/p.git."""
127
+ if "://" in url:
128
+ path = urlsplit(url).path
129
+ prefixo = urlsplit(get_session().cfg.url).path.rstrip("/") # GitLab sob subcaminho (https://host/gitlab)
130
+ if prefixo and path.startswith(prefixo + "/") and url.startswith(("http://", "https://")):
131
+ path = path[len(prefixo):]
132
+ else:
133
+ path = url.partition(":")[2]
134
+ path = path.strip("/")
135
+ return path[:-4] if path.endswith(".git") else path
136
+
137
+
138
+ def _projeto(projeto: str | int | None) -> str:
139
+ """'grupo/projeto' vai URL-encoded no path (grupo%2Fprojeto); id numérico passa como está; sem ele, o remote do git."""
140
+ p = str(projeto).strip().strip("/") if projeto is not None else ""
141
+ if not p:
142
+ remotes = (_git("remote") or "").split()
143
+ remote = "origin" if "origin" in remotes else (remotes[0] if remotes else None)
144
+ url = remote and _git("remote", "get-url", remote)
145
+ if not url:
146
+ raise ValueError("Pasta atual não é um repositório git com remote: pergunte ao usuário o projeto "
147
+ "('grupo/projeto' ou id numérico) e informe-o em `projeto`.")
148
+ p = _caminho_remoto(url)
149
+ return quote(p, safe="")
150
+
151
+
152
+ def _branch_atual() -> str:
153
+ branch = _git("rev-parse", "--abbrev-ref", "HEAD")
154
+ if not branch or branch == "HEAD":
155
+ raise ValueError("Sem branch atual no git (fora de um repositório ou HEAD destacado): pergunte ao usuário a "
156
+ "branch de origem e informe-a em `origem`.")
157
+ return branch
158
+
159
+
160
+ def _usuario_id(username: str) -> int:
161
+ nome = username.strip().lstrip("@")
162
+ users = get_session().request("GET", "/users", params={"username": nome}).json()
163
+ if not users:
164
+ raise LookupError(f"Usuário '{nome}' não encontrado no GitLab.")
165
+ return users[0]["id"]
166
+
167
+
168
+ def _login(user: dict | None) -> str | None:
169
+ return user and user.get("username")
170
+
171
+
172
+ def _resumo(mr: dict) -> dict:
173
+ return {
174
+ "iid": mr["iid"],
175
+ "projeto_id": mr.get("project_id"),
176
+ "titulo": mr.get("title"),
177
+ "estado": mr.get("state"),
178
+ "origem": mr.get("source_branch"),
179
+ "destino": mr.get("target_branch"),
180
+ "autor": _login(mr.get("author")),
181
+ "responsavel": _login(mr.get("assignee")),
182
+ "wip": mr.get("work_in_progress"),
183
+ "merge_status": mr.get("merge_status"),
184
+ "criado_em": mr.get("created_at"),
185
+ "atualizado_em": mr.get("updated_at"),
186
+ "url": mr.get("web_url"),
187
+ }
188
+
189
+
190
+ # ---------------------------------------------------------------- tools
191
+
192
+ @tool
193
+ def gitlab_listar_mrs(projeto: str | None = None, todos_projetos: bool = False, estado: str = "opened",
194
+ busca: str | None = None, origem: str | None = None, destino: str | None = None,
195
+ autor: str | None = None, responsavel: str | None = None, limite: int = 20,
196
+ pagina: int = 1) -> dict:
197
+ """Lista merge requests de `projeto` ('grupo/projeto' ou id; sem ele, o do repositório git atual) ou, com
198
+ todos_projetos=True, de todos os projetos visíveis.
199
+
200
+ estado: opened (padrão), closed, merged ou all. busca: texto no título/descrição.
201
+ origem/destino: branches. autor/responsavel: usernames. limite ≤ 100 por página.
202
+ """
203
+ if estado not in ESTADOS:
204
+ raise ValueError(f"estado inválido: '{estado}'. Use {', '.join(ESTADOS)}.")
205
+ params = {"state": estado, "per_page": max(1, min(limite, LIMITE_TETO)), "page": max(1, pagina),
206
+ "order_by": "updated_at", "sort": "desc"}
207
+ if busca:
208
+ params["search"] = busca
209
+ if origem:
210
+ params["source_branch"] = origem
211
+ if destino:
212
+ params["target_branch"] = destino
213
+ if autor:
214
+ params["author_id"] = _usuario_id(autor)
215
+ if responsavel:
216
+ params["assignee_id"] = _usuario_id(responsavel)
217
+ if todos_projetos:
218
+ path, params["scope"] = "/merge_requests", "all" # padrão do endpoint global é created-by-me
219
+ else:
220
+ path = f"/projects/{_projeto(projeto)}/merge_requests"
221
+ resp = get_session().request("GET", path, params=params)
222
+ return {
223
+ "total": int(resp.headers["X-Total"]) if resp.headers.get("X-Total") else None,
224
+ "pagina": params["page"],
225
+ "proxima_pagina": int(resp.headers["X-Next-Page"]) if resp.headers.get("X-Next-Page") else None,
226
+ "itens": [_resumo(mr) for mr in resp.json()],
227
+ }
228
+
229
+
230
+ @tool
231
+ def gitlab_obter_mr(iid: int, projeto: str | None = None, diffs: bool = False) -> dict:
232
+ """Detalhes de um merge request (descrição, labels, merge); `projeto` padrão: o do repositório git atual.
233
+ Com diffs=True, inclui os arquivos alterados e o diff de cada um (cortado em 4000 caracteres)."""
234
+ base = f"/projects/{_projeto(projeto)}/merge_requests/{int(iid)}"
235
+ mr = get_session().request("GET", base + ("/changes" if diffs else "")).json()
236
+ out = _resumo(mr) | {
237
+ "descricao": mr.get("description"),
238
+ "labels": mr.get("labels"),
239
+ "conflitos": mr.get("has_conflicts"),
240
+ "remover_origem": mr.get("force_remove_source_branch"),
241
+ "mesclado_por": _login(mr.get("merged_by")),
242
+ "mesclado_em": mr.get("merged_at"),
243
+ "sha": mr.get("sha"),
244
+ }
245
+ if diffs:
246
+ out["arquivos"] = [{
247
+ "caminho": c.get("new_path"),
248
+ "caminho_anterior": c.get("old_path") if c.get("renamed_file") else None,
249
+ "novo": c.get("new_file"),
250
+ "removido": c.get("deleted_file"),
251
+ "diff": c.get("diff", "")[:DIFF_MAX],
252
+ "diff_cortado": len(c.get("diff", "")) > DIFF_MAX,
253
+ } for c in mr.get("changes", [])]
254
+ return out
255
+
256
+
257
+ @tool
258
+ def gitlab_criar_mr(titulo: str, destino: str, origem: str | None = None, projeto: str | None = None,
259
+ descricao: str | None = None, responsavel: str | None = None, labels: list[str] | None = None,
260
+ remover_origem: bool = False) -> dict:
261
+ """Cria um merge request de `origem` (padrão: branch atual do git) para `destino` no `projeto` (padrão: o do
262
+ repositório git atual). A branch de origem precisa estar no GitLab (git push). responsavel: username.
263
+ remover_origem: apaga a branch de origem após o merge. Falha (HTTP 409) se já existe MR aberto entre as branches."""
264
+ if not titulo.strip():
265
+ raise ValueError("Informe `titulo`.")
266
+ data = {"source_branch": origem or _branch_atual(), "target_branch": destino, "title": titulo,
267
+ "remove_source_branch": remover_origem}
268
+ if descricao:
269
+ data["description"] = descricao
270
+ if responsavel:
271
+ data["assignee_id"] = _usuario_id(responsavel)
272
+ if labels:
273
+ data["labels"] = ",".join(labels)
274
+ mr = get_session().request("POST", f"/projects/{_projeto(projeto)}/merge_requests", json=data).json()
275
+ return _resumo(mr)
276
+
277
+
278
+ def main() -> None:
279
+ mcp.run()
280
+
281
+
282
+ if __name__ == "__main__":
283
+ main()
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "mcp-gitlabpr"
3
+ version = "1.0.0"
4
+ description = "MCP server para merge requests do GitLab (API v4, CE 10.8+): listar, obter e criar"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Daniel Xavier Araújo", email = "danielxaraujo@gmail.com" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "mcp[cli]>=2.2",
12
+ "requests>=2.31",
13
+ ]
14
+
15
+ [project.urls]
16
+ Repository = "https://github.com/dxaraujo/kiro/tree/main/mcps/gitlabpr"
17
+
18
+ [project.scripts]
19
+ mcp-gitlabpr = "mcp_gitlabpr.server:main"
20
+
21
+ [dependency-groups]
22
+ dev = ["pytest>=8"]
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ pythonpath = ["."]
@@ -0,0 +1,132 @@
1
+ import json
2
+ import subprocess
3
+
4
+ import pytest
5
+ import requests
6
+ from requests.adapters import BaseAdapter
7
+
8
+ from mcp_gitlabpr import config, server
9
+ from mcp_gitlabpr.config import GitlabConfig
10
+
11
+ API = "https://gitlab.test/api/v4"
12
+ MR = {"iid": 7, "project_id": 3, "title": "Ajuste", "state": "opened", "source_branch": "feat", "target_branch": "dev",
13
+ "author": {"username": "joao"}, "assignee": None, "web_url": "https://gitlab.test/g/p/merge_requests/7"}
14
+
15
+
16
+ class FakeAdapter(BaseAdapter):
17
+ """routes: {(METODO, url_sem_query): (status, body, headers?)}."""
18
+
19
+ def __init__(self):
20
+ super().__init__()
21
+ self.routes = {}
22
+ self.calls: list[requests.PreparedRequest] = []
23
+
24
+ def send(self, request, **kwargs):
25
+ self.calls.append(request)
26
+ status, body, *headers = self.routes.get((request.method, request.url.split("?")[0]), (404, "not found"))
27
+ resp = requests.Response()
28
+ resp.status_code = status
29
+ resp._content = json.dumps(body).encode()
30
+ resp.headers.update(headers[0] if headers else {})
31
+ resp.url, resp.request = request.url, request
32
+ return resp
33
+
34
+ def close(self):
35
+ pass
36
+
37
+
38
+ @pytest.fixture
39
+ def fake():
40
+ adapter = FakeAdapter()
41
+ session = server.GitlabSession(GitlabConfig(url="https://gitlab.test", token="tok"))
42
+ session.session.mount("https://", adapter)
43
+ server.set_session(session)
44
+ yield adapter
45
+ server.set_session(None)
46
+
47
+
48
+ def test_config(tmp_path):
49
+ with pytest.raises(FileNotFoundError):
50
+ config.load_config(tmp_path / "x")
51
+ p = tmp_path / "gitlabpr.properties"
52
+ p.write_text("[DEFAULT]\nurl = https://gitlab.test/\n")
53
+ with pytest.raises(ValueError, match="token"):
54
+ config.load_config(p)
55
+ p.write_text("[DEFAULT]\nurl = https://gitlab.test/\ntoken = a%b\n")
56
+ assert config.load_config(p) == GitlabConfig(url="https://gitlab.test", token="a%b")
57
+
58
+
59
+ def test_listar_projeto(fake):
60
+ fake.routes[("GET", f"{API}/users")] = (200, [{"id": 42}])
61
+ fake.routes[("GET", f"{API}/projects/g%2Fp/merge_requests")] = (200, [MR], {"X-Total": "1"})
62
+ out = server.gitlab_listar_mrs("g/p", autor="@joao", destino="dev", limite=500)
63
+ req = fake.calls[-1]
64
+ assert req.headers["PRIVATE-TOKEN"] == "tok"
65
+ assert "author_id=42" in req.url and "target_branch=dev" in req.url and "per_page=100" in req.url
66
+ assert out["total"] == 1 and out["proxima_pagina"] is None
67
+ assert out["itens"][0] | {} == server._resumo(MR) and out["itens"][0]["autor"] == "joao"
68
+
69
+
70
+ def test_listar_global_usa_scope_all(fake):
71
+ fake.routes[("GET", f"{API}/merge_requests")] = (200, [])
72
+ server.gitlab_listar_mrs(todos_projetos=True)
73
+ assert "scope=all" in fake.calls[-1].url
74
+ with pytest.raises(ValueError):
75
+ server.gitlab_listar_mrs("g/p", estado="aberto")
76
+
77
+
78
+ def test_obter_com_diffs(fake):
79
+ change = {"new_path": "a.py", "old_path": "a.py", "diff": "x" * 5000}
80
+ fake.routes[("GET", f"{API}/projects/3/merge_requests/7/changes")] = (200, MR | {"changes": [change]})
81
+ out = server.gitlab_obter_mr(7, projeto=3, diffs=True)
82
+ assert out["arquivos"][0]["diff_cortado"] and len(out["arquivos"][0]["diff"]) == server.DIFF_MAX
83
+
84
+
85
+ def test_criar(fake):
86
+ fake.routes[("GET", f"{API}/users")] = (200, [{"id": 9}])
87
+ fake.routes[("POST", f"{API}/projects/g%2Fp/merge_requests")] = (201, MR)
88
+ out = server.gitlab_criar_mr("Ajuste", "dev", "feat", "g/p", responsavel="maria", labels=["a", "b"])
89
+ assert json.loads(fake.calls[-1].body) == {"source_branch": "feat", "target_branch": "dev", "title": "Ajuste",
90
+ "remove_source_branch": False, "assignee_id": 9, "labels": "a,b"}
91
+ assert out["iid"] == 7
92
+
93
+
94
+ def test_erros(fake):
95
+ fake.routes[("GET", f"{API}/users")] = (200, [])
96
+ with pytest.raises(LookupError):
97
+ server.gitlab_criar_mr("t", "dev", "feat", "g/p", responsavel="ninguem")
98
+ fake.routes[("POST", f"{API}/projects/g%2Fp/merge_requests")] = (409, {"message": ["Another open merge request"]})
99
+ with pytest.raises(RuntimeError, match="409"):
100
+ server.gitlab_criar_mr("t", "dev", "feat", "g/p")
101
+ fake.routes[("GET", f"{API}/projects/g%2Fp/merge_requests/1")] = (401, {"message": "401 Unauthorized"})
102
+ with pytest.raises(RuntimeError, match="token"):
103
+ server.gitlab_obter_mr(1, "g/p")
104
+
105
+
106
+ @pytest.mark.parametrize("url", ["https://gitlab.test/g/sub/p.git", "git@gitlab.test:g/sub/p.git",
107
+ "ssh://git@gitlab.test:2222/g/sub/p.git", "https://gitlab.test/g/sub/p"])
108
+ def test_caminho_remoto(fake, url):
109
+ assert server._caminho_remoto(url) == "g/sub/p"
110
+
111
+
112
+ def test_caminho_remoto_subcaminho(fake):
113
+ server.set_session(server.GitlabSession(GitlabConfig(url="https://host/gitlab", token="t")))
114
+ assert server._caminho_remoto("https://host/gitlab/g/p.git") == "g/p"
115
+
116
+
117
+ def git(*args):
118
+ subprocess.run(["git", *args], check=True, capture_output=True)
119
+
120
+
121
+ def test_projeto_e_branch_do_git(fake, tmp_path, monkeypatch):
122
+ monkeypatch.chdir(tmp_path)
123
+ with pytest.raises(ValueError, match="pergunte ao usuário o projeto"):
124
+ server.gitlab_listar_mrs()
125
+ with pytest.raises(ValueError, match="pergunte ao usuário a branch"):
126
+ server.gitlab_criar_mr("t", "dev")
127
+ git("init", "-q", "-b", "feat")
128
+ git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x")
129
+ git("remote", "add", "origin", "git@gitlab.test:g/p.git")
130
+ fake.routes[("POST", f"{API}/projects/g%2Fp/merge_requests")] = (201, MR)
131
+ server.gitlab_criar_mr("t", "dev")
132
+ assert json.loads(fake.calls[-1].body)["source_branch"] == "feat"