Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package mcp-bugzilla for openSUSE:Factory checked in at 2026-09-28 10:46:02 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/mcp-bugzilla (Old) and /work/SRC/openSUSE:Factory/.mcp-bugzilla.new.383539 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "mcp-bugzilla" Mon Sep 28 10:46:02 2026 rev:4 rq:1380832 version:0.21.0 Changes: -------- --- /work/SRC/openSUSE:Factory/mcp-bugzilla/mcp-bugzilla.changes 2026-08-18 16:38:23.001010767 +0200 +++ /work/SRC/openSUSE:Factory/.mcp-bugzilla.new.383539/mcp-bugzilla.changes 2026-09-28 10:46:38.871443304 +0200 @@ -1,0 +2,19 @@ +Tue Sep 22 13:49:54 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to version 0.21.0: + * Surface Bugzilla's own error message (for example an invalid + field name) to the caller instead of the generic httpx status + text, so a client can correct its own query + * Stop leaking the request URL, which carries the API key, into + error messages and tracebacks + * Rename the bug id parameter to bug_id in bug_history, + bug_comments and summarize_bug_prompt + * bug_info now takes bug_ids as a list instead of a set, for + better JSON schema compatibility + * Expose the is_private field in bug_comments output + * add_comment is no longer annotated as destructive, matching + the other append-only write tools +- Keep fastmcp as a versioned floor instead of upstream's exact + pin on 4.0.3; the fastmcp API the server uses is unchanged + +------------------------------------------------------------------- Old: ---- mcp_bugzilla-0.20.0.tar.gz New: ---- mcp_bugzilla-0.21.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ mcp-bugzilla.spec ++++++ --- /var/tmp/diff_new_pack.QHwd0q/_old 2026-09-28 10:46:39.573472717 +0200 +++ /var/tmp/diff_new_pack.QHwd0q/_new 2026-09-28 10:46:39.575472801 +0200 @@ -21,7 +21,7 @@ # not a multi-flavour module. %define pythons %{primary_python} Name: mcp-bugzilla -Version: 0.20.0 +Version: 0.21.0 Release: 0 Summary: Model Context Protocol server for Bugzilla License: Apache-2.0 @@ -48,6 +48,9 @@ %prep %autosetup -p1 -n mcp_bugzilla-%{version} +# upstream pins fastmcp exactly (dependabot); the API surface it uses is +# unchanged, so keep a floor instead of the pin +sed -i -E 's/"fastmcp==[0-9.]+"/"fastmcp>=3.4.7"/' pyproject.toml %build %pyproject_wheel ++++++ mcp_bugzilla-0.20.0.tar.gz -> mcp_bugzilla-0.21.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_bugzilla-0.20.0/PKG-INFO new/mcp_bugzilla-0.21.0/PKG-INFO --- old/mcp_bugzilla-0.20.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_bugzilla-0.21.0/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,8 +1,8 @@ Metadata-Version: 2.3 Name: mcp-bugzilla -Version: 0.20.0 +Version: 0.21.0 Summary: MCP server for Bugzilla -Requires-Dist: fastmcp==3.4.7 +Requires-Dist: fastmcp==4.0.3 Requires-Dist: httpx-retries>=0.5.0 Requires-Dist: pytest ; extra == 'dev' Requires-Dist: pytest-asyncio ; extra == 'dev' @@ -38,18 +38,18 @@ #### Bug Information -- **`bug_info(bug_ids: set[int], include_fields: Optional[str] = None, exclude_fields: Optional[str] = None)`**: Retrieves comprehensive details for specified Bugzilla bug IDs. By default every field is returned; use Bugzilla's native field selection to trim large or bulk fetches. +- **`bug_info(bug_ids: list[int], include_fields: Optional[str] = None, exclude_fields: Optional[str] = None)`**: Retrieves comprehensive details for specified Bugzilla bug IDs. By default every field is returned; use Bugzilla's native field selection to trim large or bulk fetches. - **Parameters**: - - `bug_ids`: A set of bug IDs to fetch details for + - `bug_ids`: A list of bug IDs to fetch details for - `include_fields`: Comma-separated field names to return (Bugzilla's native `Bug.get` parameter; supports field groups and `_default`/`_all`/`_extra`). For the leanest response request only scalar fields, e.g. `"id,status,resolution,summary"`. Requesting a user field (`assigned_to`, `creator`, `cc`, `qa_contact`) also returns its verbose `*_detail` object. Defaults to all fields - `exclude_fields`: Comma-separated field names to drop. To remove a user-object expansion, exclude both the base field and its `*_detail` together, e.g. `"cc,cc_detail"` — excluding the `*_detail` alone has no effect, as Bugzilla re-attaches it while the base field is present - **Returns**: A dictionary containing the array `bugs` which lists the requested information about the bugs (status, assignee, summary, description, extensions, etc.) - **Note on Bugzilla API parity**: both `include_fields` and `exclude_fields` are native Bugzilla `Bug.get` parameters, forwarded to the API unchanged; the underlying request is standard Bugzilla - - **Example**: `bug_info({12345, 67890}, include_fields="id,status,resolution,summary")` fetches two bugs with just the essential scalar fields + - **Example**: `bug_info([12345, 67890], include_fields="id,status,resolution,summary")` fetches two bugs with just the essential scalar fields -- **`bug_history(id: int, new_since: Optional[datetime] = None, changed_fields: Optional[str] = None, exclude_authors: Optional[str] = None, limit: Optional[int] = None)`**: Fetches the change history of a given bug ID, with SQL-like controls to keep only the change events that matter for triage. +- **`bug_history(bug_id: int, new_since: Optional[datetime] = None, changed_fields: Optional[str] = None, exclude_authors: Optional[str] = None, limit: Optional[int] = None)`**: Fetches the change history of a given bug ID, with SQL-like controls to keep only the change events that matter for triage. - **Parameters**: - - `id`: The bug ID to fetch history for + - `bug_id`: The bug ID to fetch history for - `new_since`: Optional datetime object to only return history newer than this time - `changed_fields`: Comma-separated Bugzilla field names; keep only changes to these fields, dropping events left with no matching change (e.g. `"status,resolution,assigned_to"` to see only lifecycle changes and hide the cc / flagtypes.name / summary churn that dominates most histories) - `exclude_authors`: Comma-separated substrings; drop events whose author matches any (e.g. `"upstream-release-monitoring"` to hide release-monitoring bot edits) @@ -58,7 +58,7 @@ - **Note on Bugzilla API parity**: `new_since` mirrors Bugzilla's own API parameter. `changed_fields`, `exclude_authors`, and `limit` have no server-side Bugzilla equivalent — this server applies them client-side, as post-processing over the standard `Bug.history` response; the underlying Bugzilla request is unmodified - **Example**: `bug_history(2504555, changed_fields="status,resolution")` returns just the event where the bug was closed as RAWHIDE, filtering out the surrounding cc and needinfo-flag churn -- **`bug_comments(id: int, include_private_comments: bool = False, new_since: Optional[datetime] = None, include_fields: Optional[str] = "count,id,creator,creation_time,text,attachment_id", exclude_creators: Optional[str] = None, limit: Optional[int] = None)`**: Fetches comments associated with a given bug ID, with SQL-like controls to keep only what is needed and avoid flooding the context on large threads. +- **`bug_comments(bug_id: int, include_private_comments: bool = False, new_since: Optional[datetime] = None, include_fields: Optional[str] = "count,id,creator,creation_time,text,attachment_id,is_private", exclude_creators: Optional[str] = None, limit: Optional[int] = None)`**: Fetches comments associated with a given bug ID, with SQL-like controls to keep only what is needed and avoid flooding the context on large threads. - **Parameters**: - `id`: The bug ID to fetch comments for - `include_private_comments`: Whether to include private comments (default: `False`) @@ -205,7 +205,7 @@ - **`quicksearch_syntax_resource()`**: Returns documentation on Bugzilla's quicksearch syntax. - **Returns**: A string containing HTML documentation. -- **`summarize_bug_prompt(id: int)`**: Returns a detailed summary prompt for all comments of a given bug ID. +- **`summarize_bug_prompt(bug_id: int)`**: Returns a detailed summary prompt for all comments of a given bug ID. - **Returns**: A well-structured summary of the bug's comments including usernames (bold italic) and dates (bold). #### Utility Tools @@ -571,17 +571,17 @@ # Get the history of changes for a bug history = client.call_tool( "bug_history", - {"id": 12345, "new_since": datetime.fromisoformat("2026-01-01T00:00:00")}, + {"bug_id": 12345, "new_since": datetime.fromisoformat("2026-01-01T00:00:00")}, ) # Get all public comments public_comments = client.call_tool( - "bug_comments", {"id": 12345, "include_private_comments": False} + "bug_comments", {"bug_id": 12345, "include_private_comments": False} ) # Get all comments including private ones all_comments = client.call_tool( - "bug_comments", {"id": 12345, "include_private_comments": True} + "bug_comments", {"bug_id": 12345, "include_private_comments": True} ) ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_bugzilla-0.20.0/README.md new/mcp_bugzilla-0.21.0/README.md --- old/mcp_bugzilla-0.20.0/README.md 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_bugzilla-0.21.0/README.md 1970-01-01 01:00:00.000000000 +0100 @@ -25,18 +25,18 @@ #### Bug Information -- **`bug_info(bug_ids: set[int], include_fields: Optional[str] = None, exclude_fields: Optional[str] = None)`**: Retrieves comprehensive details for specified Bugzilla bug IDs. By default every field is returned; use Bugzilla's native field selection to trim large or bulk fetches. +- **`bug_info(bug_ids: list[int], include_fields: Optional[str] = None, exclude_fields: Optional[str] = None)`**: Retrieves comprehensive details for specified Bugzilla bug IDs. By default every field is returned; use Bugzilla's native field selection to trim large or bulk fetches. - **Parameters**: - - `bug_ids`: A set of bug IDs to fetch details for + - `bug_ids`: A list of bug IDs to fetch details for - `include_fields`: Comma-separated field names to return (Bugzilla's native `Bug.get` parameter; supports field groups and `_default`/`_all`/`_extra`). For the leanest response request only scalar fields, e.g. `"id,status,resolution,summary"`. Requesting a user field (`assigned_to`, `creator`, `cc`, `qa_contact`) also returns its verbose `*_detail` object. Defaults to all fields - `exclude_fields`: Comma-separated field names to drop. To remove a user-object expansion, exclude both the base field and its `*_detail` together, e.g. `"cc,cc_detail"` — excluding the `*_detail` alone has no effect, as Bugzilla re-attaches it while the base field is present - **Returns**: A dictionary containing the array `bugs` which lists the requested information about the bugs (status, assignee, summary, description, extensions, etc.) - **Note on Bugzilla API parity**: both `include_fields` and `exclude_fields` are native Bugzilla `Bug.get` parameters, forwarded to the API unchanged; the underlying request is standard Bugzilla - - **Example**: `bug_info({12345, 67890}, include_fields="id,status,resolution,summary")` fetches two bugs with just the essential scalar fields + - **Example**: `bug_info([12345, 67890], include_fields="id,status,resolution,summary")` fetches two bugs with just the essential scalar fields -- **`bug_history(id: int, new_since: Optional[datetime] = None, changed_fields: Optional[str] = None, exclude_authors: Optional[str] = None, limit: Optional[int] = None)`**: Fetches the change history of a given bug ID, with SQL-like controls to keep only the change events that matter for triage. +- **`bug_history(bug_id: int, new_since: Optional[datetime] = None, changed_fields: Optional[str] = None, exclude_authors: Optional[str] = None, limit: Optional[int] = None)`**: Fetches the change history of a given bug ID, with SQL-like controls to keep only the change events that matter for triage. - **Parameters**: - - `id`: The bug ID to fetch history for + - `bug_id`: The bug ID to fetch history for - `new_since`: Optional datetime object to only return history newer than this time - `changed_fields`: Comma-separated Bugzilla field names; keep only changes to these fields, dropping events left with no matching change (e.g. `"status,resolution,assigned_to"` to see only lifecycle changes and hide the cc / flagtypes.name / summary churn that dominates most histories) - `exclude_authors`: Comma-separated substrings; drop events whose author matches any (e.g. `"upstream-release-monitoring"` to hide release-monitoring bot edits) @@ -45,7 +45,7 @@ - **Note on Bugzilla API parity**: `new_since` mirrors Bugzilla's own API parameter. `changed_fields`, `exclude_authors`, and `limit` have no server-side Bugzilla equivalent — this server applies them client-side, as post-processing over the standard `Bug.history` response; the underlying Bugzilla request is unmodified - **Example**: `bug_history(2504555, changed_fields="status,resolution")` returns just the event where the bug was closed as RAWHIDE, filtering out the surrounding cc and needinfo-flag churn -- **`bug_comments(id: int, include_private_comments: bool = False, new_since: Optional[datetime] = None, include_fields: Optional[str] = "count,id,creator,creation_time,text,attachment_id", exclude_creators: Optional[str] = None, limit: Optional[int] = None)`**: Fetches comments associated with a given bug ID, with SQL-like controls to keep only what is needed and avoid flooding the context on large threads. +- **`bug_comments(bug_id: int, include_private_comments: bool = False, new_since: Optional[datetime] = None, include_fields: Optional[str] = "count,id,creator,creation_time,text,attachment_id,is_private", exclude_creators: Optional[str] = None, limit: Optional[int] = None)`**: Fetches comments associated with a given bug ID, with SQL-like controls to keep only what is needed and avoid flooding the context on large threads. - **Parameters**: - `id`: The bug ID to fetch comments for - `include_private_comments`: Whether to include private comments (default: `False`) @@ -192,7 +192,7 @@ - **`quicksearch_syntax_resource()`**: Returns documentation on Bugzilla's quicksearch syntax. - **Returns**: A string containing HTML documentation. -- **`summarize_bug_prompt(id: int)`**: Returns a detailed summary prompt for all comments of a given bug ID. +- **`summarize_bug_prompt(bug_id: int)`**: Returns a detailed summary prompt for all comments of a given bug ID. - **Returns**: A well-structured summary of the bug's comments including usernames (bold italic) and dates (bold). #### Utility Tools @@ -558,17 +558,17 @@ # Get the history of changes for a bug history = client.call_tool( "bug_history", - {"id": 12345, "new_since": datetime.fromisoformat("2026-01-01T00:00:00")}, + {"bug_id": 12345, "new_since": datetime.fromisoformat("2026-01-01T00:00:00")}, ) # Get all public comments public_comments = client.call_tool( - "bug_comments", {"id": 12345, "include_private_comments": False} + "bug_comments", {"bug_id": 12345, "include_private_comments": False} ) # Get all comments including private ones all_comments = client.call_tool( - "bug_comments", {"id": 12345, "include_private_comments": True} + "bug_comments", {"bug_id": 12345, "include_private_comments": True} ) ``` diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_bugzilla-0.20.0/pyproject.toml new/mcp_bugzilla-0.21.0/pyproject.toml --- old/mcp_bugzilla-0.20.0/pyproject.toml 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_bugzilla-0.21.0/pyproject.toml 1970-01-01 01:00:00.000000000 +0100 @@ -1,11 +1,11 @@ [project] name = "mcp-bugzilla" -version = "0.20.0" +version = "0.21.0" description = "MCP server for Bugzilla" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "fastmcp==3.4.7", + "fastmcp==4.0.3", "httpx-retries>=0.5.0", ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_bugzilla-0.20.0/pyproject.toml.orig new/mcp_bugzilla-0.21.0/pyproject.toml.orig --- old/mcp_bugzilla-0.20.0/pyproject.toml.orig 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_bugzilla-0.21.0/pyproject.toml.orig 1970-01-01 01:00:00.000000000 +0100 @@ -1,12 +1,12 @@ [project] name = "mcp-bugzilla" -version = "0.20.0" +version = "0.21.0" description = "MCP server for Bugzilla" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "fastmcp==3.4.7", + "fastmcp==4.0.3", "httpx-retries>=0.5.0", ] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_bugzilla-0.20.0/src/mcp_bugzilla/lib_bugzilla.py new/mcp_bugzilla-0.21.0/src/mcp_bugzilla/lib_bugzilla.py --- old/mcp_bugzilla-0.20.0/src/mcp_bugzilla/lib_bugzilla.py 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_bugzilla-0.21.0/src/mcp_bugzilla/lib_bugzilla.py 1970-01-01 01:00:00.000000000 +0100 @@ -92,25 +92,36 @@ transport=RetryTransport(), ) - async def close(self): - await self.client.aclose() - - async def server_version(self) -> str: - """Fetch bugzilla server version""" - try: - r = await self.client.get("/version") - r.raise_for_status() - return _json_or_raise(r)["version"] - - except httpx.HTTPStatusError as e: + async def _request(self, method: str, url: str, **kwargs) -> httpx.Response: + """Issue a request. Surface Bugzilla's own error on HTTP errors; + re-raise network errors untouched.""" + try: + r = await self.client.request(method, url, **kwargs) + r.raise_for_status() + except httpx.HTTPStatusError as e: # caller-fixable: surface detail + body = _bugzilla_error_body(e.response) + if body is not None: + mcp_log.error( + f"[BZ-RES] Failed: {e.response.status_code} " + f"code={body.get('code')} {body.get('message')}" + ) + raise BugzillaAPIError(e.response.status_code, body) from None mcp_log.error( f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" ) - raise - - except httpx.RequestError as e: + raise BugzillaResponseError(e.response) from None + except httpx.RequestError as e: # infra fault: stay loud mcp_log.error(f"[BZ-RES] Network Error: {e}") raise + return r + + async def close(self): + await self.client.aclose() + + async def server_version(self) -> str: + """Fetch bugzilla server version""" + r = await self._request("GET", "/version") + return _json_or_raise(r)["version"] async def bugzilla_info(self) -> dict[str, Any]: """Fetch comprehensive bugzilla server information: @@ -179,17 +190,7 @@ mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} params={params}") - try: - r = await self.client.get(url, params=params) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", url, params=params) envelope = _json_or_raise(r) bugs = envelope.get("bugs", []) @@ -212,24 +213,7 @@ params["include_fields"] = include_fields mcp_log.info(f"[BZ-REQ] GET {self.api_url}/product params={params}") - try: - r = await self.client.get("/product", params=params) - r.raise_for_status() - except httpx.HTTPStatusError as e: - bz_error = _bugzilla_error_body(e.response) - if bz_error is not None: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} " - f"code={bz_error.get('code')} {bz_error['message']}" - ) - raise BugzillaAPIError(e.response.status_code, bz_error) from e - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", "/product", params=params) envelope = _json_or_raise(r) products = envelope.get("products", []) @@ -247,17 +231,7 @@ params = {"include_fields": "id,flags"} mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} params={params}") - try: - r = await self.client.get(url, params=params) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", url, params=params) bugs = _json_or_raise(r).get("bugs", []) flags = bugs[0].get("flags", []) if bugs else [] @@ -275,17 +249,7 @@ mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} params={params}") - try: - r = await self.client.get(url, params=params) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", url, params=params) data = _json_or_raise(r).get("bugs", []) history = data[0].get("history", []) if data else [] @@ -304,17 +268,7 @@ mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} params={params}") - try: - r = await self.client.get(url, params=params) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", url, params=params) # The response structure is {"bugs": {"<id>": {"comments": [...]}}} data = ( @@ -332,17 +286,7 @@ url = f"/bug/{bug_id}/comment" mcp_log.info(f"[BZ-REQ] POST {self.api_url}{url} json={payload}") - try: - r = await self.client.post(url, json=payload) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("POST", url, json=payload) data = _json_or_raise(r) mcp_log.info("[BZ-RES] Comment added successfully") @@ -365,17 +309,7 @@ mcp_log.info(f"[BZ-REQ] GET {self.api_url}/bug params={params}") - try: - r = await self.client.get("/bug", params=params) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", "/bug", params=params) envelope = _json_or_raise(r) bugs = envelope.get("bugs", []) @@ -393,23 +327,7 @@ url = f"/bug/{bug_id}" mcp_log.info(f"[BZ-REQ] PUT {self.api_url}{url} json={payload}") - try: - r = await self.client.put(url, json=payload) - r.raise_for_status() - except httpx.HTTPStatusError as e: - if (bz_error := _bugzilla_error_body(e.response)) is not None: - # Surface structured Bugzilla error (e.g., validation rejection) - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} code={bz_error.get('code')} {bz_error['message']}" - ) - raise BugzillaAPIError(e.response.status_code, bz_error) from e - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("PUT", url, json=payload) data = _json_or_raise(r) mcp_log.info("[BZ-RES] Bug updated successfully") @@ -426,17 +344,7 @@ url = "/bug" mcp_log.info(f"[BZ-REQ] POST {self.api_url}{url} json={fields}") - try: - r = await self.client.post(url, json=fields) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("POST", url, json=fields) data = _json_or_raise(r) mcp_log.info(f"[BZ-RES] Created bug {data.get('id')}") @@ -453,17 +361,7 @@ f"[BZ-REQ] POST {self.api_url}{url} file_name={payload.get('file_name')!r}" ) - try: - r = await self.client.post(url, json=payload) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("POST", url, json=payload) data = _json_or_raise(r) mcp_log.info(f"[BZ-RES] Attachment(s) {data.get('ids')} added to bug {bug_id}") @@ -475,17 +373,7 @@ url = f"/bug/{bug_id}/attachment" mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url} exclude_fields=data") - try: - r = await self.client.get(url, params={"exclude_fields": "data"}) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", url, params={"exclude_fields": "data"}) # /bug/{id}/attachment returns {"bugs": {"<bug_id>": [ {att}, ... ]}} attachments = _json_or_raise(r).get("bugs", {}).get(str(bug_id), []) @@ -498,17 +386,7 @@ # Don't log the (possibly large / binary) base64 blob in the response. mcp_log.info(f"[BZ-REQ] GET {self.api_url}{url}") - try: - r = await self.client.get(url) - r.raise_for_status() - except httpx.HTTPStatusError as e: - mcp_log.error( - f"[BZ-RES] Failed: {e.response.status_code} {e.response.text}" - ) - raise - except httpx.RequestError as e: - mcp_log.error(f"[BZ-RES] Network Error: {e}") - raise + r = await self._request("GET", url) # /bug/attachment/{id} returns {"attachments": {"<attachment_id>": {att}}} attachment = _json_or_raise(r).get("attachments", {}).get(str(attachment_id)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_bugzilla-0.20.0/src/mcp_bugzilla/mcp_utils.py new/mcp_bugzilla-0.21.0/src/mcp_bugzilla/mcp_utils.py --- old/mcp_bugzilla-0.20.0/src/mcp_bugzilla/mcp_utils.py 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_bugzilla-0.21.0/src/mcp_bugzilla/mcp_utils.py 1970-01-01 01:00:00.000000000 +0100 @@ -11,6 +11,7 @@ import logging import os import re +from typing import Any # Re-export Bugzilla and BugzillaAPIError for backward compatibility. # Lazily imported to avoid circular dependencies — lib_bugzilla imports @@ -98,3 +99,58 @@ base = os.path.basename(name or "").strip() base = re.sub(r"[^A-Za-z0-9._-]", "_", base).strip("._") return base or f"attachment-{attachment_id}" + + +def filter_limit( + items: list[dict[str, Any]], limit: int | None +) -> list[dict[str, Any]]: + """Keep only the most recent N items (preserves chronological order).""" + if limit and limit > 0: + return items[-limit:] + return items + + +def filter_include_fields( + items: list[dict[str, Any]], fields: str | None +) -> list[dict[str, Any]]: + """Keep only the specified fields for each item in the list.""" + if fields is None: + return items + wanted = [f.strip() for f in fields.split(",") if f.strip()] + return [{k: item[k] for k in wanted if k in item} for item in items] + + +def filter_exclude_substrings( + items: list[dict[str, Any]], field: str, patterns_str: str | None +) -> list[dict[str, Any]]: + """Exclude items where the specified field contains any of the comma-separated substrings.""" + if not patterns_str: + return items + patterns = [p.strip() for p in patterns_str.split(",") if p.strip()] + return [ + item + for item in items + if not any(p in (item.get(field) or "") for p in patterns) + ] + + +def filter_history_changes( + history: list[dict[str, Any]], changed_fields: str | None +) -> list[dict[str, Any]]: + """Filter bug history to keep only events that modified the specified fields.""" + if not changed_fields: + return history + wanted = {f.strip() for f in changed_fields.split(",") if f.strip()} + pruned = [] + for h in history: + kept = [c for c in h.get("changes", []) if c.get("field_name") in wanted] + if kept: + pruned.append({**h, "changes": kept}) + return pruned + + +def filter_by_flag( + items: list[dict[str, Any]], field: str, required_value: bool +) -> list[dict[str, Any]]: + """Keep items where the boolean value of the specified field matches required_value.""" + return [item for item in items if bool(item.get(field)) is required_value] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mcp_bugzilla-0.20.0/src/mcp_bugzilla/server.py new/mcp_bugzilla-0.21.0/src/mcp_bugzilla/server.py --- old/mcp_bugzilla-0.20.0/src/mcp_bugzilla/server.py 1970-01-01 01:00:00.000000000 +0100 +++ new/mcp_bugzilla-0.21.0/src/mcp_bugzilla/server.py 1970-01-01 01:00:00.000000000 +0100 @@ -18,9 +18,18 @@ from fastmcp import FastMCP from fastmcp.dependencies import CurrentHeaders, Depends from fastmcp.exceptions import PromptError, ResourceError, ToolError -from .mcp_utils import is_textual, mcp_log, safe_filename from .lib_bugzilla import Bugzilla +from .mcp_utils import ( + filter_by_flag, + filter_exclude_substrings, + filter_history_changes, + filter_include_fields, + filter_limit, + is_textual, + mcp_log, + safe_filename, +) # The FastMCP instance mcp = FastMCP("Bugzilla") @@ -103,12 +112,12 @@ @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}, tags={"read"}) async def bug_info( - bug_ids: set[int], + bug_ids: list[int], include_fields: str | None = None, exclude_fields: str | None = None, bz: Bugzilla = _get_bz, ) -> dict[str, Any]: - """Returns information for one or more bugzilla bug ids. + """Returns information for one or more Bugzilla bug IDs. By default every field is returned. For large or bulk fetches, use Bugzilla's native field selection (both are forwarded to Bug.get): @@ -247,14 +256,14 @@ @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}, tags={"read"}) async def bug_history( - id: int, + bug_id: int, new_since: datetime | None = None, changed_fields: str | None = None, exclude_authors: str | None = None, limit: int | None = None, bz: Bugzilla = _get_bz, ) -> list[dict[str, Any]]: - """Returns the history of given bug id. + """Returns the history of a given bug. A bug's history is often dominated by low-signal churn (repeated bot edits, cc-list changes, flag flips). These SQL-like controls keep only @@ -273,38 +282,17 @@ """ mcp_log.info( - f"[LLM-REQ] bug_history(id={id}, new_since={new_since}, " + f"[LLM-REQ] bug_history(bug_id={bug_id}, new_since={new_since}, " f"changed_fields={changed_fields}, exclude_authors={exclude_authors}, " f"limit={limit})" ) try: - history = await bz.bug_history(id, new_since=new_since) + history = await bz.bug_history(bug_id, new_since=new_since) - # WHERE who NOT LIKE any(exclude_authors) - if exclude_authors: - patterns = [p.strip() for p in exclude_authors.split(",") if p.strip()] - history = [ - h - for h in history - if not any(p in (h.get("who") or "") for p in patterns) - ] - - # WHERE field_name IN (changed_fields); drop events left with no match - if changed_fields: - wanted = {f.strip() for f in changed_fields.split(",") if f.strip()} - pruned = [] - for h in history: - kept = [ - c for c in h.get("changes", []) if c.get("field_name") in wanted - ] - if kept: - pruned.append({**h, "changes": kept}) - history = pruned - - # LIMIT to the most recent N (chronological order preserved) - if limit and limit > 0: - history = history[-limit:] + history = filter_exclude_substrings(history, "who", exclude_authors) + history = filter_history_changes(history, changed_fields) + history = filter_limit(history, limit) mcp_log.info(f"[LLM-RES] Returning {len(history)} history items") return history @@ -314,22 +302,23 @@ @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}, tags={"read"}) async def bug_comments( - id: int, + bug_id: int, include_private_comments: bool = False, new_since: datetime | None = None, - include_fields: str | None = "count,id,creator,creation_time,text,attachment_id", + include_fields: str + | None = "count,id,creator,creation_time,text,attachment_id,is_private", exclude_creators: str | None = None, limit: int | None = None, bz: Bugzilla = _get_bz, ) -> list[dict[str, Any]]: - """Returns the comments of given bug id. + """Returns the comments of a given bug. Comment threads on long-lived bugs can be very large (hundreds of automated comments), so this tool exposes SQL-like controls to return only what is needed and avoid flooding the context window: include_fields Comma-separated field names to keep per comment - (default: count,id,creator,creation_time,text,attachment_id). + (default: count,id,creator,creation_time,text,attachment_id,is_private). Pass None to return every field. exclude_creators Comma-separated substrings; drop comments whose creator matches any -- e.g. "upstream-release-monitoring" to hide @@ -337,40 +326,25 @@ limit Return only the most recent N comments (chronological order is preserved). new_since Only comments newer than the given date. - include_private_comments Include private comments (default: False). + include_private_comments MUST be set to True to retrieve private/internal comments (default: False). """ mcp_log.info( - f"[LLM-REQ] bug_comments(id={id}, " + f"[LLM-REQ] bug_comments(bug_id={bug_id}, " f"include_private_comments={include_private_comments}, new_since={new_since}, " f"include_fields={include_fields}, exclude_creators={exclude_creators}, " f"limit={limit})" ) try: - comments = await bz.bug_comments(id, new_since=new_since) + comments = await bz.bug_comments(bug_id, new_since=new_since) - # WHERE is_private = false (unless explicitly requested) if not include_private_comments: - comments = [c for c in comments if not c.get("is_private", False)] + comments = filter_by_flag(comments, "is_private", False) - # WHERE creator NOT LIKE any(exclude_creators) - if exclude_creators: - patterns = [p.strip() for p in exclude_creators.split(",") if p.strip()] - comments = [ - c - for c in comments - if not any(p in (c.get("creator") or "") for p in patterns) - ] - - # LIMIT to the most recent N (chronological order preserved) - if limit and limit > 0: - comments = comments[-limit:] - - # SELECT include_fields - if include_fields is not None: - wanted = [f.strip() for f in include_fields.split(",") if f.strip()] - comments = [{k: c[k] for k in wanted if k in c} for c in comments] + comments = filter_exclude_substrings(comments, "creator", exclude_creators) + comments = filter_limit(comments, limit) + comments = filter_include_fields(comments, include_fields) mcp_log.info(f"[LLM-RES] Returning {len(comments)} comments") return comments @@ -380,13 +354,17 @@ @mcp.tool( - annotations={"readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}, + annotations={ + "readOnlyHint": False, + "destructiveHint": False, + "openWorldHint": True, + }, tags={"write"}, ) async def add_comment( bug_id: int, comment: str, is_private: bool = False, bz: Bugzilla = _get_bz ) -> dict[str, int]: - """Add a comment to a bug. It can optionally be private. If success, returns the created comment id.""" + """Add a comment to a bug. It can optionally be private. Returns the created comment id on success.""" mcp_log.info( f"[LLM-REQ] add_comment(bug_id={bug_id}, comment='{comment}', is_private={is_private})" ) @@ -406,10 +384,10 @@ offset: int | None = 0, bz: Bugzilla = _get_bz, ) -> dict[str, Any]: - """Search bugs using bugzilla's quicksearch syntax + """Search bugs using Bugzilla's Quicksearch syntax. - To reduce the token limit & response time, only returns a subset of fields for each bug - The user can query full details of each bug using the bug_info tool + To reduce the token limit & response time, this only returns a subset of fields for each bug. + The user can query full details of each bug using the `bug_info` tool. Returns the top-level bug data envelope containing the matched bugs. """ @@ -436,7 +414,7 @@ @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}, tags={"read"}) async def quicksearch_syntax_resource(bz: Bugzilla = _get_bz) -> str: - """Access the documentation of the bugzilla quicksearch syntax. LLM can learn using this tool. Response is in HTML""" + """Access the documentation of the Bugzilla Quicksearch syntax. Use this to learn how to construct advanced search queries. Response is in HTML.""" mcp_log.info("[LLM-REQ] quicksearch_syntax_resource()") @@ -461,7 +439,7 @@ @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}, tags={"read"}) async def bugzilla_server_info(bz: Bugzilla = _get_bz) -> dict[str, Any]: - """Returns comprehensive bugzilla server information (url, version, extensions, timezone, time, parameters).""" + """Returns comprehensive Bugzilla server information (url, version, extensions, timezone, time, parameters).""" mcp_log.info("[LLM-REQ] bugzilla_server_info()") try: return await bz.bugzilla_info() @@ -471,14 +449,14 @@ @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": False}, tags={"read"}) def bug_url(bug_id: int) -> str: - """returns the bug url""" + """Returns the web URL for a given bug.""" mcp_log.info(f"[LLM-REQ] bug_url(bug_id={bug_id})") return f"{base_url}/show_bug.cgi?id={bug_id}" @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}, tags={"read"}) async def mcp_server_info_resource(bz: Bugzilla = _get_bz) -> dict[str, Any]: - """Returns the args being used by the current server instance""" + """Returns the configuration arguments and version of the current MCP server instance.""" mcp_log.info("[LLM-REQ] mcp_server_info_resource()") @@ -503,13 +481,13 @@ @mcp.tool(annotations={"readOnlyHint": True, "openWorldHint": True}, tags={"read"}) -async def summarize_bug_prompt(id: int, bz: Bugzilla = _get_bz) -> str: - """Summarizes all the comments of a bug""" +async def summarize_bug_prompt(bug_id: int, bz: Bugzilla = _get_bz) -> str: + """Returns a prompt containing all comments of a bug, which can be used to generate a summary.""" - mcp_log.info(f"[LLM-REQ] summarize_bug_prompt(id={id})") + mcp_log.info(f"[LLM-REQ] summarize_bug_prompt(bug_id={bug_id})") try: - comments = await bz.bug_comments(id) + comments = await bz.bug_comments(bug_id) summary_prompt = f""" You are an expert in summarizing bugzilla comments. @@ -872,7 +850,7 @@ bug_id: Bug to attach the file to file_name: File name shown in Bugzilla summary: Short description of the attachment - data: The attachment content, **base64-encoded** (binary-safe) + data: The attachment content. MUST be base64-encoded (binary-safe)! content_type: MIME type (ignored by Bugzilla when is_patch=True) is_patch: Mark the attachment as a patch is_private: Restrict the attachment to the insider group @@ -931,17 +909,13 @@ try: attachments = await bz.list_attachments(bug_id) - # WHERE NOT is_obsolete if exclude_obsolete: - attachments = [a for a in attachments if not a.get("is_obsolete")] + attachments = filter_by_flag(attachments, "is_obsolete", False) - # WHERE is_patch if patches_only: - attachments = [a for a in attachments if a.get("is_patch")] + attachments = filter_by_flag(attachments, "is_patch", True) - # LIMIT to the most recent N (chronological order preserved) - if limit and limit > 0: - attachments = attachments[-limit:] + attachments = filter_limit(attachments, limit) mcp_log.info(f"[LLM-RES] Returning {len(attachments)} attachments") return attachments
