gitlab-mb-mcp 0.1.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,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gitlab-mb-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "GitLab MCP server — projects, MRs, commits, files, issues"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp[cli]>=1.0.0",
|
|
12
|
+
"httpx>=0.27.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
gitlab-mb-mcp = "gitlab_mb_mcp.server:main"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/gitlab_mb_mcp"]
|
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GitLabClient:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.base_url = os.environ["GITLAB_API_URL"].rstrip("/")
|
|
9
|
+
self.token = os.environ["GITLAB_PERSONAL_ACCESS_TOKEN"]
|
|
10
|
+
self._client = httpx.AsyncClient(
|
|
11
|
+
headers={"PRIVATE-TOKEN": self.token},
|
|
12
|
+
timeout=30.0,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
async def get(self, path: str, params: dict | None = None) -> Any:
|
|
16
|
+
url = f"{self.base_url}{path}"
|
|
17
|
+
r = await self._client.get(url, params=params)
|
|
18
|
+
r.raise_for_status()
|
|
19
|
+
return r.json()
|
|
20
|
+
|
|
21
|
+
async def post(self, path: str, json: dict | None = None) -> Any:
|
|
22
|
+
url = f"{self.base_url}{path}"
|
|
23
|
+
r = await self._client.post(url, json=json)
|
|
24
|
+
r.raise_for_status()
|
|
25
|
+
return r.json()
|
|
26
|
+
|
|
27
|
+
async def put(self, path: str, json: dict | None = None) -> Any:
|
|
28
|
+
url = f"{self.base_url}{path}"
|
|
29
|
+
r = await self._client.put(url, json=json)
|
|
30
|
+
r.raise_for_status()
|
|
31
|
+
return r.json()
|
|
32
|
+
|
|
33
|
+
# ── Projects ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
async def search_projects(self, search: str = "", per_page: int = 20) -> list:
|
|
36
|
+
return await self.get("/projects", {"search": search, "per_page": per_page, "order_by": "last_activity_at"})
|
|
37
|
+
|
|
38
|
+
async def get_project(self, project_id: str) -> dict:
|
|
39
|
+
return await self.get(f"/projects/{_enc(project_id)}")
|
|
40
|
+
|
|
41
|
+
# ── Merge Requests ────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
async def list_mrs(self, project_id: str, state: str = "opened", per_page: int = 20) -> list:
|
|
44
|
+
return await self.get(f"/projects/{_enc(project_id)}/merge_requests", {"state": state, "per_page": per_page})
|
|
45
|
+
|
|
46
|
+
async def get_mr(self, project_id: str, mr_iid: int) -> dict:
|
|
47
|
+
return await self.get(f"/projects/{_enc(project_id)}/merge_requests/{mr_iid}")
|
|
48
|
+
|
|
49
|
+
async def get_mr_diffs(self, project_id: str, mr_iid: int) -> list:
|
|
50
|
+
return await self.get(f"/projects/{_enc(project_id)}/merge_requests/{mr_iid}/diffs", {"per_page": 100})
|
|
51
|
+
|
|
52
|
+
async def get_mr_commits(self, project_id: str, mr_iid: int) -> list:
|
|
53
|
+
return await self.get(f"/projects/{_enc(project_id)}/merge_requests/{mr_iid}/commits")
|
|
54
|
+
|
|
55
|
+
async def create_mr(self, project_id: str, source_branch: str, target_branch: str,
|
|
56
|
+
title: str, description: str = "") -> dict:
|
|
57
|
+
return await self.post(f"/projects/{_enc(project_id)}/merge_requests", {
|
|
58
|
+
"source_branch": source_branch,
|
|
59
|
+
"target_branch": target_branch,
|
|
60
|
+
"title": title,
|
|
61
|
+
"description": description,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
async def merge_mr(self, project_id: str, mr_iid: int) -> dict:
|
|
65
|
+
return await self.put(f"/projects/{_enc(project_id)}/merge_requests/{mr_iid}/merge")
|
|
66
|
+
|
|
67
|
+
async def add_mr_note(self, project_id: str, mr_iid: int, body: str) -> dict:
|
|
68
|
+
return await self.post(f"/projects/{_enc(project_id)}/merge_requests/{mr_iid}/notes", {"body": body})
|
|
69
|
+
|
|
70
|
+
async def get_mr_notes(self, project_id: str, mr_iid: int) -> list:
|
|
71
|
+
return await self.get(f"/projects/{_enc(project_id)}/merge_requests/{mr_iid}/notes")
|
|
72
|
+
|
|
73
|
+
# ── Commits ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
async def list_commits(self, project_id: str, ref: str = "HEAD", per_page: int = 20) -> list:
|
|
76
|
+
return await self.get(f"/projects/{_enc(project_id)}/repository/commits",
|
|
77
|
+
{"ref_name": ref, "per_page": per_page})
|
|
78
|
+
|
|
79
|
+
async def get_commit(self, project_id: str, sha: str) -> dict:
|
|
80
|
+
return await self.get(f"/projects/{_enc(project_id)}/repository/commits/{sha}")
|
|
81
|
+
|
|
82
|
+
async def get_commit_diffs(self, project_id: str, sha: str) -> list:
|
|
83
|
+
return await self.get(f"/projects/{_enc(project_id)}/repository/commits/{sha}/diff")
|
|
84
|
+
|
|
85
|
+
# ── Files & Tree ──────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async def get_file(self, project_id: str, file_path: str, ref: str = "HEAD") -> dict:
|
|
88
|
+
enc_path = file_path.replace("/", "%2F")
|
|
89
|
+
return await self.get(f"/projects/{_enc(project_id)}/repository/files/{enc_path}",
|
|
90
|
+
{"ref": ref})
|
|
91
|
+
|
|
92
|
+
async def list_tree(self, project_id: str, path: str = "", ref: str = "HEAD",
|
|
93
|
+
recursive: bool = False) -> list:
|
|
94
|
+
return await self.get(f"/projects/{_enc(project_id)}/repository/tree",
|
|
95
|
+
{"path": path, "ref": ref, "recursive": recursive, "per_page": 100})
|
|
96
|
+
|
|
97
|
+
# ── Issues ────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async def list_issues(self, project_id: str, state: str = "opened", per_page: int = 20) -> list:
|
|
100
|
+
return await self.get(f"/projects/{_enc(project_id)}/issues",
|
|
101
|
+
{"state": state, "per_page": per_page})
|
|
102
|
+
|
|
103
|
+
async def get_issue(self, project_id: str, issue_iid: int) -> dict:
|
|
104
|
+
return await self.get(f"/projects/{_enc(project_id)}/issues/{issue_iid}")
|
|
105
|
+
|
|
106
|
+
async def create_issue(self, project_id: str, title: str, description: str = "") -> dict:
|
|
107
|
+
return await self.post(f"/projects/{_enc(project_id)}/issues",
|
|
108
|
+
{"title": title, "description": description})
|
|
109
|
+
|
|
110
|
+
async def add_issue_note(self, project_id: str, issue_iid: int, body: str) -> dict:
|
|
111
|
+
return await self.post(f"/projects/{_enc(project_id)}/issues/{issue_iid}/notes",
|
|
112
|
+
{"body": body})
|
|
113
|
+
|
|
114
|
+
# ── Branches ──────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
async def list_branches(self, project_id: str, search: str = "") -> list:
|
|
117
|
+
params: dict = {"per_page": 50}
|
|
118
|
+
if search:
|
|
119
|
+
params["search"] = search
|
|
120
|
+
return await self.get(f"/projects/{_enc(project_id)}/repository/branches", params)
|
|
121
|
+
|
|
122
|
+
async def create_branch(self, project_id: str, branch: str, ref: str) -> dict:
|
|
123
|
+
return await self.post(f"/projects/{_enc(project_id)}/repository/branches",
|
|
124
|
+
{"branch": branch, "ref": ref})
|
|
125
|
+
|
|
126
|
+
# ── Pipelines ─────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
async def list_pipelines(self, project_id: str, ref: str = "", per_page: int = 20) -> list:
|
|
129
|
+
params: dict = {"per_page": per_page}
|
|
130
|
+
if ref:
|
|
131
|
+
params["ref"] = ref
|
|
132
|
+
return await self.get(f"/projects/{_enc(project_id)}/pipelines", params)
|
|
133
|
+
|
|
134
|
+
async def get_pipeline(self, project_id: str, pipeline_id: int) -> dict:
|
|
135
|
+
return await self.get(f"/projects/{_enc(project_id)}/pipelines/{pipeline_id}")
|
|
136
|
+
|
|
137
|
+
async def aclose(self):
|
|
138
|
+
await self._client.aclose()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _enc(project_id: str) -> str:
|
|
142
|
+
return project_id.replace("/", "%2F")
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
from gitlab_mb_mcp.gitlab import GitLabClient
|
|
5
|
+
|
|
6
|
+
mcp = FastMCP("gitlab-mb-mcp")
|
|
7
|
+
gl: GitLabClient | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _gl() -> GitLabClient:
|
|
11
|
+
global gl
|
|
12
|
+
if gl is None:
|
|
13
|
+
gl = GitLabClient()
|
|
14
|
+
return gl
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _json(data) -> str:
|
|
18
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Projects ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
async def search_projects(search: str = "", per_page: int = 20) -> str:
|
|
25
|
+
"""Search GitLab projects. Returns id, name, path_with_namespace, last_activity_at."""
|
|
26
|
+
data = await _gl().search_projects(search=search, per_page=per_page)
|
|
27
|
+
rows = [
|
|
28
|
+
{"id": p["id"], "path": p["path_with_namespace"],
|
|
29
|
+
"name": p["name"], "last_activity": p.get("last_activity_at", "")}
|
|
30
|
+
for p in data
|
|
31
|
+
]
|
|
32
|
+
return _json(rows)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
async def get_project(project_id: str) -> str:
|
|
37
|
+
"""Get project details by id or path (e.g. 'group/repo')."""
|
|
38
|
+
return _json(await _gl().get_project(project_id))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Merge Requests ────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
async def list_merge_requests(project_id: str, state: str = "opened", per_page: int = 20) -> str:
|
|
45
|
+
"""List merge requests. state: opened | closed | merged | all."""
|
|
46
|
+
data = await _gl().list_mrs(project_id, state=state, per_page=per_page)
|
|
47
|
+
rows = [
|
|
48
|
+
{"iid": mr["iid"], "title": mr["title"], "state": mr["state"],
|
|
49
|
+
"author": mr["author"]["username"], "source_branch": mr["source_branch"],
|
|
50
|
+
"target_branch": mr["target_branch"], "web_url": mr["web_url"]}
|
|
51
|
+
for mr in data
|
|
52
|
+
]
|
|
53
|
+
return _json(rows)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
async def get_merge_request(project_id: str, mr_iid: int) -> str:
|
|
58
|
+
"""Get full details of a merge request by its IID."""
|
|
59
|
+
return _json(await _gl().get_mr(project_id, mr_iid))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@mcp.tool()
|
|
63
|
+
async def get_merge_request_diffs(project_id: str, mr_iid: int) -> str:
|
|
64
|
+
"""Get list of changed files (diffs) in a merge request.
|
|
65
|
+
Returns file paths and unified diffs."""
|
|
66
|
+
data = await _gl().get_mr_diffs(project_id, mr_iid)
|
|
67
|
+
result = []
|
|
68
|
+
for d in data:
|
|
69
|
+
result.append({
|
|
70
|
+
"old_path": d.get("old_path"),
|
|
71
|
+
"new_path": d.get("new_path"),
|
|
72
|
+
"new_file": d.get("new_file"),
|
|
73
|
+
"deleted_file": d.get("deleted_file"),
|
|
74
|
+
"renamed_file": d.get("renamed_file"),
|
|
75
|
+
"diff": d.get("diff", ""),
|
|
76
|
+
})
|
|
77
|
+
return _json(result)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
async def get_merge_request_commits(project_id: str, mr_iid: int) -> str:
|
|
82
|
+
"""Get list of commits included in a merge request."""
|
|
83
|
+
data = await _gl().get_mr_commits(project_id, mr_iid)
|
|
84
|
+
rows = [
|
|
85
|
+
{"id": c["id"][:10], "title": c["title"],
|
|
86
|
+
"author": c["author_name"], "date": c["created_at"]}
|
|
87
|
+
for c in data
|
|
88
|
+
]
|
|
89
|
+
return _json(rows)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@mcp.tool()
|
|
93
|
+
async def get_merge_request_notes(project_id: str, mr_iid: int) -> str:
|
|
94
|
+
"""Get all comments (notes) of a merge request."""
|
|
95
|
+
data = await _gl().get_mr_notes(project_id, mr_iid)
|
|
96
|
+
rows = [
|
|
97
|
+
{"id": n["id"], "author": n["author"]["username"],
|
|
98
|
+
"body": n["body"], "created_at": n["created_at"]}
|
|
99
|
+
for n in data
|
|
100
|
+
]
|
|
101
|
+
return _json(rows)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mcp.tool()
|
|
105
|
+
async def create_merge_request(project_id: str, source_branch: str,
|
|
106
|
+
target_branch: str, title: str,
|
|
107
|
+
description: str = "") -> str:
|
|
108
|
+
"""Create a new merge request."""
|
|
109
|
+
return _json(await _gl().create_mr(project_id, source_branch, target_branch, title, description))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
async def merge_merge_request(project_id: str, mr_iid: int) -> str:
|
|
114
|
+
"""Merge an open merge request."""
|
|
115
|
+
return _json(await _gl().merge_mr(project_id, mr_iid))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
async def add_merge_request_comment(project_id: str, mr_iid: int, body: str) -> str:
|
|
120
|
+
"""Add a comment to a merge request."""
|
|
121
|
+
return _json(await _gl().add_mr_note(project_id, mr_iid, body))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Commits ───────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
@mcp.tool()
|
|
127
|
+
async def list_commits(project_id: str, ref: str = "HEAD", per_page: int = 20) -> str:
|
|
128
|
+
"""List commits in a project branch/ref."""
|
|
129
|
+
data = await _gl().list_commits(project_id, ref=ref, per_page=per_page)
|
|
130
|
+
rows = [
|
|
131
|
+
{"id": c["id"][:10], "title": c["title"],
|
|
132
|
+
"author": c["author_name"], "date": c["created_at"]}
|
|
133
|
+
for c in data
|
|
134
|
+
]
|
|
135
|
+
return _json(rows)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@mcp.tool()
|
|
139
|
+
async def get_commit(project_id: str, sha: str) -> str:
|
|
140
|
+
"""Get commit details by SHA (full or short)."""
|
|
141
|
+
return _json(await _gl().get_commit(project_id, sha))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@mcp.tool()
|
|
145
|
+
async def get_commit_diffs(project_id: str, sha: str) -> str:
|
|
146
|
+
"""Get file diffs (changes) for a specific commit."""
|
|
147
|
+
data = await _gl().get_commit_diffs(project_id, sha)
|
|
148
|
+
result = []
|
|
149
|
+
for d in data:
|
|
150
|
+
result.append({
|
|
151
|
+
"old_path": d.get("old_path"),
|
|
152
|
+
"new_path": d.get("new_path"),
|
|
153
|
+
"new_file": d.get("new_file"),
|
|
154
|
+
"deleted_file": d.get("deleted_file"),
|
|
155
|
+
"diff": d.get("diff", ""),
|
|
156
|
+
})
|
|
157
|
+
return _json(result)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Files & Tree ──────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
@mcp.tool()
|
|
163
|
+
async def get_file_contents(project_id: str, file_path: str, ref: str = "HEAD") -> str:
|
|
164
|
+
"""Get file contents from the repository at a given ref (branch/tag/commit SHA).
|
|
165
|
+
file_path example: 'src/main.py'"""
|
|
166
|
+
data = await _gl().get_file(project_id, file_path, ref=ref)
|
|
167
|
+
content_b64 = data.get("content", "")
|
|
168
|
+
encoding = data.get("encoding", "base64")
|
|
169
|
+
if encoding == "base64":
|
|
170
|
+
content = base64.b64decode(content_b64).decode("utf-8", errors="replace")
|
|
171
|
+
else:
|
|
172
|
+
content = content_b64
|
|
173
|
+
return content
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@mcp.tool()
|
|
177
|
+
async def list_repository_tree(project_id: str, path: str = "",
|
|
178
|
+
ref: str = "HEAD", recursive: bool = False) -> str:
|
|
179
|
+
"""List files and directories in the repository.
|
|
180
|
+
path: subdirectory to list (empty = root). recursive: expand all subdirs."""
|
|
181
|
+
data = await _gl().list_tree(project_id, path=path, ref=ref, recursive=recursive)
|
|
182
|
+
rows = [{"type": n["type"], "path": n["path"], "name": n["name"]} for n in data]
|
|
183
|
+
return _json(rows)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Issues ────────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
@mcp.tool()
|
|
189
|
+
async def list_issues(project_id: str, state: str = "opened", per_page: int = 20) -> str:
|
|
190
|
+
"""List project issues. state: opened | closed | all."""
|
|
191
|
+
data = await _gl().list_issues(project_id, state=state, per_page=per_page)
|
|
192
|
+
rows = [
|
|
193
|
+
{"iid": i["iid"], "title": i["title"], "state": i["state"],
|
|
194
|
+
"author": i["author"]["username"], "web_url": i["web_url"]}
|
|
195
|
+
for i in data
|
|
196
|
+
]
|
|
197
|
+
return _json(rows)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@mcp.tool()
|
|
201
|
+
async def get_issue(project_id: str, issue_iid: int) -> str:
|
|
202
|
+
"""Get full details of an issue by its IID."""
|
|
203
|
+
return _json(await _gl().get_issue(project_id, issue_iid))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@mcp.tool()
|
|
207
|
+
async def create_issue(project_id: str, title: str, description: str = "") -> str:
|
|
208
|
+
"""Create a new issue in the project."""
|
|
209
|
+
return _json(await _gl().create_issue(project_id, title, description))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@mcp.tool()
|
|
213
|
+
async def add_issue_comment(project_id: str, issue_iid: int, body: str) -> str:
|
|
214
|
+
"""Add a comment to an issue."""
|
|
215
|
+
return _json(await _gl().add_issue_note(project_id, issue_iid, body))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── Branches ──────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
@mcp.tool()
|
|
221
|
+
async def list_branches(project_id: str, search: str = "") -> str:
|
|
222
|
+
"""List repository branches. Optionally filter by search string."""
|
|
223
|
+
data = await _gl().list_branches(project_id, search=search)
|
|
224
|
+
rows = [
|
|
225
|
+
{"name": b["name"], "commit_sha": b["commit"]["id"][:10],
|
|
226
|
+
"protected": b.get("protected", False)}
|
|
227
|
+
for b in data
|
|
228
|
+
]
|
|
229
|
+
return _json(rows)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@mcp.tool()
|
|
233
|
+
async def create_branch(project_id: str, branch: str, ref: str) -> str:
|
|
234
|
+
"""Create a new branch from an existing ref (branch/tag/SHA)."""
|
|
235
|
+
return _json(await _gl().create_branch(project_id, branch, ref))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── Pipelines ─────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
@mcp.tool()
|
|
241
|
+
async def list_pipelines(project_id: str, ref: str = "", per_page: int = 20) -> str:
|
|
242
|
+
"""List CI/CD pipelines. Optionally filter by ref (branch/tag)."""
|
|
243
|
+
data = await _gl().list_pipelines(project_id, ref=ref, per_page=per_page)
|
|
244
|
+
rows = [
|
|
245
|
+
{"id": p["id"], "status": p["status"], "ref": p["ref"],
|
|
246
|
+
"sha": p["sha"][:10], "created_at": p["created_at"]}
|
|
247
|
+
for p in data
|
|
248
|
+
]
|
|
249
|
+
return _json(rows)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@mcp.tool()
|
|
253
|
+
async def get_pipeline(project_id: str, pipeline_id: int) -> str:
|
|
254
|
+
"""Get details of a specific pipeline."""
|
|
255
|
+
return _json(await _gl().get_pipeline(project_id, pipeline_id))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def main():
|
|
261
|
+
mcp.run()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
main()
|