mcp-bugzilla 0.13.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.
mcp_bugzilla/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# src/mcp_bugzilla/__init__.py
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import server
|
|
8
|
+
from .mcp_utils import mcp_log
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="mcp-bugzilla", description="MCP server for Bugzilla interaction."
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--bugzilla-server",
|
|
17
|
+
type=str,
|
|
18
|
+
default=os.getenv("BUGZILLA_SERVER"),
|
|
19
|
+
help="Base URL of the Bugzilla server (e.g., 'https://bugzilla.example.com'). Environment variable BUGZILLA_SERVER is used if argument is not provided.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--host",
|
|
23
|
+
type=str,
|
|
24
|
+
default=os.getenv("MCP_HOST", "127.0.0.1"),
|
|
25
|
+
help="Host address for the MCP server to listen on. Defaults to 127.0.0.1 or MCP_HOST environment variable.",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--port",
|
|
29
|
+
type=int,
|
|
30
|
+
default=int(os.getenv("MCP_PORT", "8000")),
|
|
31
|
+
help="Port for the MCP server to listen on, Defaults to 8000 or MCP_PORT environment variable.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--api-key-header",
|
|
35
|
+
type=str,
|
|
36
|
+
default=os.getenv("MCP_API_KEY_HEADER", "ApiKey"),
|
|
37
|
+
help="HTTP header for clients to send the Bugzilla API key. Defaults to 'ApiKey' or MCP_API_KEY_HEADER environment variable.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--use-auth-header",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Use Authorization: Bearer header instead of api_key query parameter (required for some Bugzilla instances)",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--read-only",
|
|
48
|
+
action="store_true",
|
|
49
|
+
default=os.getenv("MCP_READ_ONLY", "false").lower() == "true",
|
|
50
|
+
help="Disables all methods which modify the state of the bug. Environment variable MCP_READ_ONLY=true can also be used.",
|
|
51
|
+
)
|
|
52
|
+
args = parser.parse_args()
|
|
53
|
+
|
|
54
|
+
# The default behavior of argparse with os.getenv already handles the priority:
|
|
55
|
+
# CLI argument > Environment Variable > Hardcoded default in os.getenv (if provided)
|
|
56
|
+
|
|
57
|
+
if args.bugzilla_server is None:
|
|
58
|
+
mcp_log.critical(
|
|
59
|
+
"Error: --bugzilla-server argument or BUGZILLA_SERVER environment variable must be set. Exiting."
|
|
60
|
+
)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
server.cli_args = args
|
|
64
|
+
server.start()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This is an MCP server for bugzilla which provides a few helpful
|
|
3
|
+
functions to assist the LLMs with required context
|
|
4
|
+
|
|
5
|
+
Author: Sai Karthik <kskarthik@disroot.org>
|
|
6
|
+
License: Apache 2.0
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from httpx_retries import RetryTransport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Logging configuration
|
|
20
|
+
class ColorFormatter(logging.Formatter):
|
|
21
|
+
GREY = "\x1b[38;20m"
|
|
22
|
+
YELLOW = "\x1b[33;20m"
|
|
23
|
+
RED = "\x1b[31;20m"
|
|
24
|
+
BOLD_RED = "\x1b[31;1m"
|
|
25
|
+
RESET = "\x1b[0m"
|
|
26
|
+
CYAN = "\x1b[36;20m"
|
|
27
|
+
BLUE = "\x1b[34;20m"
|
|
28
|
+
GREEN = "\x1b[32;20m"
|
|
29
|
+
|
|
30
|
+
FORMAT = "[%(levelname)s]: %(message)s"
|
|
31
|
+
|
|
32
|
+
def format(self, record):
|
|
33
|
+
log_fmt = self.FORMAT
|
|
34
|
+
if isinstance(record.msg, str):
|
|
35
|
+
if "[LLM-REQ]" in record.msg:
|
|
36
|
+
log_fmt = self.CYAN + self.FORMAT + self.RESET
|
|
37
|
+
elif "[LLM-RES]" in record.msg:
|
|
38
|
+
log_fmt = self.CYAN + self.FORMAT + self.RESET
|
|
39
|
+
elif "[BZ-REQ]" in record.msg:
|
|
40
|
+
log_fmt = self.GREEN + self.FORMAT + self.RESET
|
|
41
|
+
elif "[BZ-RES]" in record.msg:
|
|
42
|
+
log_fmt = self.GREEN + self.FORMAT + self.RESET
|
|
43
|
+
|
|
44
|
+
if record.levelno >= logging.ERROR:
|
|
45
|
+
log_fmt = self.RED + self.FORMAT + self.RESET
|
|
46
|
+
|
|
47
|
+
formatter = logging.Formatter(log_fmt)
|
|
48
|
+
return formatter.format(record)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
handler = logging.StreamHandler()
|
|
52
|
+
handler.setFormatter(ColorFormatter())
|
|
53
|
+
|
|
54
|
+
mcp_log = logging.getLogger("bugzilla-mcp")
|
|
55
|
+
mcp_log.setLevel(os.getenv("LOG_LEVEL", "INFO").upper())
|
|
56
|
+
mcp_log.addHandler(handler)
|
|
57
|
+
mcp_log.propagate = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Bugzilla:
|
|
61
|
+
"""Async Bugzilla API client"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, url: str, api_key: str, use_auth_header: bool = False):
|
|
64
|
+
self.base_url = url.rstrip("/")
|
|
65
|
+
self.api_url = f"{self.base_url}/rest"
|
|
66
|
+
self.api_key = api_key
|
|
67
|
+
params = {}
|
|
68
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
69
|
+
if use_auth_header:
|
|
70
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
71
|
+
else:
|
|
72
|
+
params["api_key"] = self.api_key
|
|
73
|
+
# We'll use a single client for the instance
|
|
74
|
+
self.client = httpx.AsyncClient(
|
|
75
|
+
base_url=self.api_url,
|
|
76
|
+
params=params,
|
|
77
|
+
timeout=30.0,
|
|
78
|
+
headers=headers,
|
|
79
|
+
transport=RetryTransport(),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
async def close(self):
|
|
83
|
+
await self.client.aclose()
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def params(self) -> dict[str, Any]:
|
|
87
|
+
"""Return params (mainly for read access if needed externally)"""
|
|
88
|
+
return {"api_key": self.api_key}
|
|
89
|
+
|
|
90
|
+
async def server_version(self) -> str:
|
|
91
|
+
"""Fetch bugzilla server version"""
|
|
92
|
+
try:
|
|
93
|
+
r = await self.client.get("/version")
|
|
94
|
+
r.raise_for_status()
|
|
95
|
+
return r.json()["version"]
|
|
96
|
+
|
|
97
|
+
except httpx.HTTPStatusError as e:
|
|
98
|
+
mcp_log.error(
|
|
99
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
100
|
+
)
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
except httpx.RequestError as e:
|
|
104
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
async def bugzilla_info(self) -> dict[str, Any]:
|
|
108
|
+
"""Fetch comprehensive bugzilla server information:
|
|
109
|
+
it returns url, version, extensions, timezone, time and parameters for the current user in a dictionary
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
# Fetch everything concurrently
|
|
113
|
+
version_r, extensions_r, time_r, parameters_r = await asyncio.gather(
|
|
114
|
+
self.client.get("/version"),
|
|
115
|
+
self.client.get("/extensions"),
|
|
116
|
+
self.client.get("/time"),
|
|
117
|
+
self.client.get("/parameters"),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Raise for status on all
|
|
121
|
+
for r in [version_r, extensions_r, time_r, parameters_r]:
|
|
122
|
+
r.raise_for_status()
|
|
123
|
+
|
|
124
|
+
# Combine results
|
|
125
|
+
version_data = version_r.json()
|
|
126
|
+
extensions_data = extensions_r.json()
|
|
127
|
+
time_data = time_r.json()
|
|
128
|
+
parameters_data = parameters_r.json()
|
|
129
|
+
|
|
130
|
+
result = {
|
|
131
|
+
"url": self.base_url,
|
|
132
|
+
"version": version_data.get("version"),
|
|
133
|
+
"extensions": extensions_data.get("extensions", {}),
|
|
134
|
+
"timezone": time_data.get("tz_name"),
|
|
135
|
+
"time": time_data.get("web_time"),
|
|
136
|
+
"parameters": parameters_data.get("parameters", {}),
|
|
137
|
+
}
|
|
138
|
+
mcp_log.info(
|
|
139
|
+
f"[BZ-RES] Retrieved bugzilla server info from {self.base_url}"
|
|
140
|
+
)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
except httpx.HTTPStatusError as e:
|
|
144
|
+
mcp_log.error(
|
|
145
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
146
|
+
)
|
|
147
|
+
raise
|
|
148
|
+
except httpx.RequestError as e:
|
|
149
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
async def bug_info(self, ids: set[int]) -> dict[str, Any]:
|
|
153
|
+
"""Get information about a given bug or list of bugs"""
|
|
154
|
+
|
|
155
|
+
if len(ids) == 1:
|
|
156
|
+
url = f"/bug/{next(iter(ids))}"
|
|
157
|
+
params = {}
|
|
158
|
+
else:
|
|
159
|
+
url = "/bug"
|
|
160
|
+
params = {"id": ",".join(str(i) for i in ids)}
|
|
161
|
+
|
|
162
|
+
mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} params={params}")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
r = await self.client.get(url, params=params)
|
|
166
|
+
r.raise_for_status()
|
|
167
|
+
except httpx.HTTPStatusError as e:
|
|
168
|
+
mcp_log.error(
|
|
169
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
170
|
+
)
|
|
171
|
+
raise
|
|
172
|
+
except httpx.RequestError as e:
|
|
173
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
envelope = r.json()
|
|
177
|
+
bugs = envelope.get("bugs", [])
|
|
178
|
+
mcp_log.info(f"[BZ-RES] Retrieved {len(bugs)} bugs")
|
|
179
|
+
mcp_log.debug(f"[BZ-RES] {envelope}")
|
|
180
|
+
return envelope
|
|
181
|
+
|
|
182
|
+
async def bug_history(
|
|
183
|
+
self, bug_id: int, new_since: Optional[datetime] = None
|
|
184
|
+
) -> list[dict[str, Any]]:
|
|
185
|
+
"""Get history of a bug"""
|
|
186
|
+
url = f"/bug/{bug_id}/history"
|
|
187
|
+
params = {}
|
|
188
|
+
if new_since:
|
|
189
|
+
params["new_since"] = new_since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
190
|
+
|
|
191
|
+
mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} params={params}")
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
r = await self.client.get(url, params=params)
|
|
195
|
+
r.raise_for_status()
|
|
196
|
+
except httpx.HTTPStatusError as e:
|
|
197
|
+
mcp_log.error(
|
|
198
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
199
|
+
)
|
|
200
|
+
raise
|
|
201
|
+
except httpx.RequestError as e:
|
|
202
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
203
|
+
raise
|
|
204
|
+
|
|
205
|
+
data = r.json().get("bugs", [])
|
|
206
|
+
history = data[0].get("history", []) if data else []
|
|
207
|
+
mcp_log.info(f"[BZ-RES] Found {len(history)} history items")
|
|
208
|
+
mcp_log.debug(f"[BZ-RES] {history}")
|
|
209
|
+
return history
|
|
210
|
+
|
|
211
|
+
async def bug_comments(
|
|
212
|
+
self, bug_id: int, new_since: Optional[datetime] = None
|
|
213
|
+
) -> list[dict[str, Any]]:
|
|
214
|
+
"""Get comments of a bug"""
|
|
215
|
+
url = f"/bug/{bug_id}/comment"
|
|
216
|
+
params = {}
|
|
217
|
+
if new_since:
|
|
218
|
+
params["new_since"] = new_since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
219
|
+
|
|
220
|
+
mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} params={params}")
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
r = await self.client.get(url, params=params)
|
|
224
|
+
r.raise_for_status()
|
|
225
|
+
except httpx.HTTPStatusError as e:
|
|
226
|
+
mcp_log.error(
|
|
227
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
228
|
+
)
|
|
229
|
+
raise
|
|
230
|
+
except httpx.RequestError as e:
|
|
231
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
# The response structure is {"bugs": {"<id>": {"comments": [...]}}}
|
|
235
|
+
data = r.json().get("bugs", {}).get(str(bug_id), {}).get("comments", [])
|
|
236
|
+
mcp_log.info(f"[BZ-RES] Found {len(data)} comments")
|
|
237
|
+
mcp_log.debug(f"[BZ-RES] {data}")
|
|
238
|
+
return data
|
|
239
|
+
|
|
240
|
+
async def add_comment(
|
|
241
|
+
self, bug_id: int, comment: str, is_private: bool
|
|
242
|
+
) -> dict[str, int]:
|
|
243
|
+
"""Add a comment to bug, which can optionally be private"""
|
|
244
|
+
payload = {"comment": comment, "is_private": is_private}
|
|
245
|
+
url = f"/bug/{bug_id}/comment"
|
|
246
|
+
mcp_log.info(f"[BZ-REQ] POST {self.api_url}{url} json={payload}")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
r = await self.client.post(url, json=payload)
|
|
250
|
+
r.raise_for_status()
|
|
251
|
+
except httpx.HTTPStatusError as e:
|
|
252
|
+
mcp_log.error(
|
|
253
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
254
|
+
)
|
|
255
|
+
raise
|
|
256
|
+
except httpx.RequestError as e:
|
|
257
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
258
|
+
raise
|
|
259
|
+
|
|
260
|
+
data = r.json()
|
|
261
|
+
mcp_log.info("[BZ-RES] Comment added successfully")
|
|
262
|
+
mcp_log.debug(f"[BZ-RES] {data}")
|
|
263
|
+
return data
|
|
264
|
+
|
|
265
|
+
async def quicksearch(
|
|
266
|
+
self, query: str, status: str, include_fields: str, limit: int, offset: int
|
|
267
|
+
) -> dict[str, Any]:
|
|
268
|
+
"""Perform a quicksearch"""
|
|
269
|
+
# Quicksearch isn't a direct REST endpoint usually, but /bug with quicksearch param works
|
|
270
|
+
|
|
271
|
+
params = {
|
|
272
|
+
"quicksearch": status + " " + query,
|
|
273
|
+
"include_fields": include_fields,
|
|
274
|
+
"limit": limit,
|
|
275
|
+
"offset": offset,
|
|
276
|
+
"order": "relevance",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
mcp_log.info(f"[BZ-REQ] GET {self.api_url}/bug params={params}")
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
r = await self.client.get("/bug", params=params)
|
|
283
|
+
r.raise_for_status()
|
|
284
|
+
except httpx.HTTPStatusError as e:
|
|
285
|
+
mcp_log.error(
|
|
286
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
287
|
+
)
|
|
288
|
+
raise
|
|
289
|
+
except httpx.RequestError as e:
|
|
290
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
291
|
+
raise
|
|
292
|
+
|
|
293
|
+
envelope = r.json()
|
|
294
|
+
bugs = envelope.get("bugs", [])
|
|
295
|
+
mcp_log.info(f"[BZ-RES] Found {len(bugs)} bugs")
|
|
296
|
+
return envelope
|
|
297
|
+
|
|
298
|
+
async def update_bug(
|
|
299
|
+
self, bug_id: int, updates: dict[str, Any], comment: str = ""
|
|
300
|
+
) -> dict[str, Any]:
|
|
301
|
+
"""Update bug fields. Optionally add a comment with the update."""
|
|
302
|
+
payload = updates.copy()
|
|
303
|
+
if comment:
|
|
304
|
+
payload["comment"] = {"body": comment}
|
|
305
|
+
|
|
306
|
+
url = f"/bug/{bug_id}"
|
|
307
|
+
mcp_log.info(f"[BZ-REQ] PUT {self.api_url}{url} json={payload}")
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
r = await self.client.put(url, json=payload)
|
|
311
|
+
r.raise_for_status()
|
|
312
|
+
except httpx.HTTPStatusError as e:
|
|
313
|
+
mcp_log.error(
|
|
314
|
+
f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}"
|
|
315
|
+
)
|
|
316
|
+
raise
|
|
317
|
+
except httpx.RequestError as e:
|
|
318
|
+
mcp_log.error(f"[BZ-RES] Network Error: {e}")
|
|
319
|
+
raise
|
|
320
|
+
|
|
321
|
+
data = r.json()
|
|
322
|
+
mcp_log.info("[BZ-RES] Bug updated successfully")
|
|
323
|
+
mcp_log.debug(f"[BZ-RES] {data}")
|
|
324
|
+
return data
|