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.
@@ -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