Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package bugzilla-mcp for openSUSE:Factory 
checked in at 2026-07-02 20:11:59
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/bugzilla-mcp (Old)
 and      /work/SRC/openSUSE:Factory/.bugzilla-mcp.new.1982 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "bugzilla-mcp"

Thu Jul  2 20:11:59 2026 rev:2 rq:1363467 version:0.16.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/bugzilla-mcp/bugzilla-mcp.changes        
2026-06-29 17:32:16.885967551 +0200
+++ /work/SRC/openSUSE:Factory/.bugzilla-mcp.new.1982/bugzilla-mcp.changes      
2026-07-02 20:15:54.214767785 +0200
@@ -1,0 +2,17 @@
+Thu Jul  2 10:40:33 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to version 0.16.0:
+  * Add attachment support via two new tools: list_attachments returns
+    a bug's attachment metadata (id, file name, summary, content type,
+    size and flags) without the file contents, and download_attachment
+    fetches a single attachment by id
+  * download_attachment supports delivery modes auto/inline/save: small
+    textual attachments are returned inline, binary or oversized files
+    are written to disk, and private attachments require an explicit
+    include_private opt-in
+  * Add --download-dir option (and BUGZILLA_DOWNLOAD_DIR environment
+    variable) selecting where downloaded attachments are written;
+    defaults to an owner-only (0700) directory under the temporary dir
+  * Improve reporting of Bugzilla REST API errors
+
+-------------------------------------------------------------------

Old:
----
  mcp_bugzilla-0.15.1.tar.gz

New:
----
  mcp_bugzilla-0.16.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ bugzilla-mcp.spec ++++++
--- /var/tmp/diff_new_pack.h2NhXG/_old  2026-07-02 20:15:54.754786180 +0200
+++ /var/tmp/diff_new_pack.h2NhXG/_new  2026-07-02 20:15:54.758786316 +0200
@@ -21,7 +21,7 @@
 # not a multi-flavour module.
 %define pythons %{primary_python}
 Name:           bugzilla-mcp
-Version:        0.15.1
+Version:        0.16.0
 Release:        0
 Summary:        Model Context Protocol server for Bugzilla
 License:        Apache-2.0

++++++ mcp_bugzilla-0.15.1.tar.gz -> mcp_bugzilla-0.16.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mcp_bugzilla-0.15.1/PKG-INFO 
new/mcp_bugzilla-0.16.0/PKG-INFO
--- old/mcp_bugzilla-0.15.1/PKG-INFO    1970-01-01 01:00:00.000000000 +0100
+++ new/mcp_bugzilla-0.16.0/PKG-INFO    1970-01-01 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.3
 Name: mcp-bugzilla
-Version: 0.15.1
+Version: 0.16.0
 Summary: MCP server for Bugzilla
 Requires-Dist: fastmcp==3.4.2
 Requires-Dist: httpx-retries>=0.5.0
@@ -69,6 +69,26 @@
   - **Returns**: A dictionary containing the ID of the newly created comment
   - **Example**: `add_comment(12345, "Fixed in version 2.0", is_private=False)`
 
+#### Attachments
+
+- **`list_attachments(bug_id: int)`**: Lists a bug's attachments as metadata 
only (the base64 file contents are excluded to keep responses small).
+  - **Parameters**:
+    - `bug_id`: The bug whose attachments to list
+  - **Returns**: A list of attachment metadata objects (`id`, `file_name`, 
`summary`, `content_type`, `size`, `is_private`, `is_obsolete`, `is_patch`, 
`creation_time`, ...). Use an `id` with `download_attachment` to fetch the file.
+  - **Example**: `list_attachments(989633)`
+
+- **`download_attachment(attachment_id: int, output_dir: Optional[str] = None, 
delivery: "auto" | "inline" | "save" = "auto", include_private: bool = 
False)`**: Downloads a single attachment by id. The `delivery` argument lets 
the caller choose how the content is returned.
+  - **Parameters**:
+    - `attachment_id`: The attachment id to download (discover via 
`list_attachments`)
+    - `output_dir`: Optional directory to save the file in when it is written 
to disk. Defaults to the server's configured download directory 
(`--download-dir` / `BUGZILLA_DOWNLOAD_DIR`).
+    - `delivery`:
+      - `auto` (default): textual attachments (logs, patches, plain/xml/json, 
...) up to 256 KiB are returned inline as decoded `content`; binary 
attachments, or larger text, are written to disk.
+      - `inline`: always return the content in the response — decoded 
`content` for text, or base64 `data_base64` for binary (and for text whose 
bytes are not valid UTF-8). Refused above 1 MiB.
+      - `save`: always write the file to disk and return its `path`.
+    - `include_private`: Whether to download a private attachment (default: 
`False`); a private attachment is refused unless this is set to `True`.
+  - **Returns**: Text inline: `{"mode": "text", "content": <decoded text>, 
...metadata}`. Binary inline: `{"mode": "base64", "data_base64": <base64>, 
...metadata}`. On disk: `{"mode": "saved", "path": <absolute path>, 
...metadata}`. The saved file is named `<attachment_id>-<sanitized file_name>`.
+  - **Example**: `download_attachment(685495)` · `download_attachment(685495, 
delivery="save")`
+
 #### Write Operations
 
 **Note**: Write operations require appropriate Bugzilla permissions. These 
tools enable bug management and workflow automation.
@@ -187,28 +207,49 @@
 
 ## Installation
 
-### PyPi
+### PyPI
+
+The easiest way to install and run `mcp-bugzilla` is using 
[uv](https://github.com/astral-sh/uv):
+
+#### Option 1: Using `uvx` (Recommended - No Installation Required)
+
+Run the server directly without installing it:
 
+```bash
+uvx mcp-bugzilla --bugzilla-server https://bugzilla.example.com
 ```
-uvx mcp-bugzilla
 
+This will automatically download and run the latest version.
+
+#### Option 2: Install with `uv pip`
+
+If you prefer to have `mcp-bugzilla` installed in your system:
+
+```bash
+# Install the package
+uv pip install mcp-bugzilla
+
+# Run the server
+mcp-bugzilla --bugzilla-server https://bugzilla.example.com
 ```
 
-### Docker / Podman
+#### Option 3: Install in a Virtual Environment
 
-The easiest way to run the server is using Docker or Podman:
+Create an isolated environment for `mcp-bugzilla`:
 
 ```bash
-docker pull kskarthik/mcp-bugzilla
-docker run -p 8000:8000 \
-  -e BUGZILLA_SERVER=https://bugzilla.example.com \
-  kskarthik/mcp-bugzilla \
-  --bugzilla-server https://bugzilla.example.com \
-  --host 0.0.0.0 \
-  --port 8000
+# Create a virtual environment
+uv venv mcp-bugzilla-env
+source mcp-bugzilla-env/bin/activate  # On Windows: 
mcp-bugzilla-env\Scripts\activate
+
+# Install the package
+uv pip install mcp-bugzilla
+
+# Run the server
+mcp-bugzilla --bugzilla-server https://bugzilla.example.com
 ```
 
-**Official Docker Hub repository**: 
https://hub.docker.com/r/kskarthik/mcp-bugzilla/
+**Note**: If you don't have `uv` installed, install it first from 
https://github.com/astral-sh/uv#installation
 
 ### From Source
 
@@ -246,6 +287,7 @@
 | `--api-key-header <HEADER_NAME>` | `MCP_API_KEY_HEADER` | `ApiKey` | HTTP 
header name for the Bugzilla API key (http transport only) |
 | `--use-auth-header` | `USE_AUTH_HEADER` | `False` | Use `Authorization: 
Bearer` header instead of `api_key` query parameter |
 | `--read-only` | `MCP_READ_ONLY` | `False` | Disables all tools which can 
modify a bug. Works well in conjunction with `MCP_BUGZILLA_DISABLED_METHODS` |
+| `--download-dir <DIR>` | `BUGZILLA_DOWNLOAD_DIR` | `<tmpdir>/mcp-bugzilla` | 
Directory where `download_attachment` writes binary/oversized attachments. The 
default directory is created on first use and restricted to the owner 
(`0o700`); an explicit `output_dir` keeps its own permissions |
 
 **Note**: `--host` and `--port` are rejected with an error when used together 
with `--transport stdio`.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mcp_bugzilla-0.15.1/README.md 
new/mcp_bugzilla-0.16.0/README.md
--- old/mcp_bugzilla-0.15.1/README.md   1970-01-01 01:00:00.000000000 +0100
+++ new/mcp_bugzilla-0.16.0/README.md   1970-01-01 01:00:00.000000000 +0100
@@ -56,6 +56,26 @@
   - **Returns**: A dictionary containing the ID of the newly created comment
   - **Example**: `add_comment(12345, "Fixed in version 2.0", is_private=False)`
 
+#### Attachments
+
+- **`list_attachments(bug_id: int)`**: Lists a bug's attachments as metadata 
only (the base64 file contents are excluded to keep responses small).
+  - **Parameters**:
+    - `bug_id`: The bug whose attachments to list
+  - **Returns**: A list of attachment metadata objects (`id`, `file_name`, 
`summary`, `content_type`, `size`, `is_private`, `is_obsolete`, `is_patch`, 
`creation_time`, ...). Use an `id` with `download_attachment` to fetch the file.
+  - **Example**: `list_attachments(989633)`
+
+- **`download_attachment(attachment_id: int, output_dir: Optional[str] = None, 
delivery: "auto" | "inline" | "save" = "auto", include_private: bool = 
False)`**: Downloads a single attachment by id. The `delivery` argument lets 
the caller choose how the content is returned.
+  - **Parameters**:
+    - `attachment_id`: The attachment id to download (discover via 
`list_attachments`)
+    - `output_dir`: Optional directory to save the file in when it is written 
to disk. Defaults to the server's configured download directory 
(`--download-dir` / `BUGZILLA_DOWNLOAD_DIR`).
+    - `delivery`:
+      - `auto` (default): textual attachments (logs, patches, plain/xml/json, 
...) up to 256 KiB are returned inline as decoded `content`; binary 
attachments, or larger text, are written to disk.
+      - `inline`: always return the content in the response — decoded 
`content` for text, or base64 `data_base64` for binary (and for text whose 
bytes are not valid UTF-8). Refused above 1 MiB.
+      - `save`: always write the file to disk and return its `path`.
+    - `include_private`: Whether to download a private attachment (default: 
`False`); a private attachment is refused unless this is set to `True`.
+  - **Returns**: Text inline: `{"mode": "text", "content": <decoded text>, 
...metadata}`. Binary inline: `{"mode": "base64", "data_base64": <base64>, 
...metadata}`. On disk: `{"mode": "saved", "path": <absolute path>, 
...metadata}`. The saved file is named `<attachment_id>-<sanitized file_name>`.
+  - **Example**: `download_attachment(685495)` · `download_attachment(685495, 
delivery="save")`
+
 #### Write Operations
 
 **Note**: Write operations require appropriate Bugzilla permissions. These 
tools enable bug management and workflow automation.
@@ -174,28 +194,49 @@
 
 ## Installation
 
-### PyPi
+### PyPI
+
+The easiest way to install and run `mcp-bugzilla` is using 
[uv](https://github.com/astral-sh/uv):
+
+#### Option 1: Using `uvx` (Recommended - No Installation Required)
+
+Run the server directly without installing it:
 
+```bash
+uvx mcp-bugzilla --bugzilla-server https://bugzilla.example.com
 ```
-uvx mcp-bugzilla
 
+This will automatically download and run the latest version.
+
+#### Option 2: Install with `uv pip`
+
+If you prefer to have `mcp-bugzilla` installed in your system:
+
+```bash
+# Install the package
+uv pip install mcp-bugzilla
+
+# Run the server
+mcp-bugzilla --bugzilla-server https://bugzilla.example.com
 ```
 
-### Docker / Podman
+#### Option 3: Install in a Virtual Environment
 
-The easiest way to run the server is using Docker or Podman:
+Create an isolated environment for `mcp-bugzilla`:
 
 ```bash
-docker pull kskarthik/mcp-bugzilla
-docker run -p 8000:8000 \
-  -e BUGZILLA_SERVER=https://bugzilla.example.com \
-  kskarthik/mcp-bugzilla \
-  --bugzilla-server https://bugzilla.example.com \
-  --host 0.0.0.0 \
-  --port 8000
+# Create a virtual environment
+uv venv mcp-bugzilla-env
+source mcp-bugzilla-env/bin/activate  # On Windows: 
mcp-bugzilla-env\Scripts\activate
+
+# Install the package
+uv pip install mcp-bugzilla
+
+# Run the server
+mcp-bugzilla --bugzilla-server https://bugzilla.example.com
 ```
 
-**Official Docker Hub repository**: 
https://hub.docker.com/r/kskarthik/mcp-bugzilla/
+**Note**: If you don't have `uv` installed, install it first from 
https://github.com/astral-sh/uv#installation
 
 ### From Source
 
@@ -233,6 +274,7 @@
 | `--api-key-header <HEADER_NAME>` | `MCP_API_KEY_HEADER` | `ApiKey` | HTTP 
header name for the Bugzilla API key (http transport only) |
 | `--use-auth-header` | `USE_AUTH_HEADER` | `False` | Use `Authorization: 
Bearer` header instead of `api_key` query parameter |
 | `--read-only` | `MCP_READ_ONLY` | `False` | Disables all tools which can 
modify a bug. Works well in conjunction with `MCP_BUGZILLA_DISABLED_METHODS` |
+| `--download-dir <DIR>` | `BUGZILLA_DOWNLOAD_DIR` | `<tmpdir>/mcp-bugzilla` | 
Directory where `download_attachment` writes binary/oversized attachments. The 
default directory is created on first use and restricted to the owner 
(`0o700`); an explicit `output_dir` keeps its own permissions |
 
 **Note**: `--host` and `--port` are rejected with an error when used together 
with `--transport stdio`.
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mcp_bugzilla-0.15.1/pyproject.toml 
new/mcp_bugzilla-0.16.0/pyproject.toml
--- old/mcp_bugzilla-0.15.1/pyproject.toml      1970-01-01 01:00:00.000000000 
+0100
+++ new/mcp_bugzilla-0.16.0/pyproject.toml      1970-01-01 01:00:00.000000000 
+0100
@@ -1,6 +1,6 @@
 [project]
 name = "mcp-bugzilla"
-version = "0.15.1"
+version = "0.16.0"
 description = "MCP server for Bugzilla"
 readme = "README.md"
 requires-python = ">=3.13"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mcp_bugzilla-0.15.1/src/mcp_bugzilla/__init__.py 
new/mcp_bugzilla-0.16.0/src/mcp_bugzilla/__init__.py
--- old/mcp_bugzilla-0.15.1/src/mcp_bugzilla/__init__.py        1970-01-01 
01:00:00.000000000 +0100
+++ new/mcp_bugzilla-0.16.0/src/mcp_bugzilla/__init__.py        1970-01-01 
01:00:00.000000000 +0100
@@ -64,6 +64,14 @@
         default=os.getenv("BUGZILLA_API_KEY"),
         help="Bugzilla API key. Required for --transport stdio (no HTTP 
headers exist there). Environment variable BUGZILLA_API_KEY can also be used. 
Ignored for --transport http (clients send the key per-request via the API key 
header).",
     )
+    parser.add_argument(
+        "--download-dir",
+        type=str,
+        default=os.getenv("BUGZILLA_DOWNLOAD_DIR"),
+        help="Directory where download_attachment writes binary/oversized 
attachments. "
+        "Defaults to <tmpdir>/mcp-bugzilla or the BUGZILLA_DOWNLOAD_DIR 
environment variable.",
+    )
+
     args = parser.parse_args()
 
     # The default behavior of argparse with os.getenv already handles the 
priority:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mcp_bugzilla-0.15.1/src/mcp_bugzilla/mcp_utils.py 
new/mcp_bugzilla-0.16.0/src/mcp_bugzilla/mcp_utils.py
--- old/mcp_bugzilla-0.15.1/src/mcp_bugzilla/mcp_utils.py       1970-01-01 
01:00:00.000000000 +0100
+++ new/mcp_bugzilla-0.16.0/src/mcp_bugzilla/mcp_utils.py       1970-01-01 
01:00:00.000000000 +0100
@@ -9,6 +9,7 @@
 import asyncio
 import logging
 import os
+import re
 from datetime import datetime
 from typing import Any, Optional
 
@@ -57,6 +58,29 @@
 mcp_log.propagate = False
 
 
+class BugzillaAPIError(Exception):
+    """Bugzilla REST API error with code and message."""
+
+    def __init__(self, status_code: int, error: dict[str, Any]):
+        self.status_code = status_code
+        self.code = error.get("code")
+        self.message = error.get("message", "Unknown error")
+        super().__init__(
+            f"Bugzilla API error {self.code} (HTTP {status_code}): 
{self.message}"
+        )
+
+
+def _bugzilla_error_body(response: httpx.Response) -> Optional[dict[str, Any]]:
+    """Parse Bugzilla error from response body, if present."""
+    try:
+        body = response.json()
+        if isinstance(body, dict) and body.get("error") and "message" in body:
+            return body
+    except Exception:
+        pass
+    return None
+
+
 class Bugzilla:
     """Async Bugzilla API client"""
 
@@ -310,6 +334,12 @@
             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}"
             )
@@ -376,3 +406,88 @@
         mcp_log.info(f"[BZ-RES] Attachment(s) {data.get('ids')} added to bug 
{bug_id}")
         mcp_log.debug(f"[BZ-RES] {data}")
         return data
+
+    async def list_attachments(self, bug_id: int) -> list[dict[str, Any]]:
+        """List a bug's attachments (metadata only, base64 ``data`` 
excluded)."""
+        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
+
+        # /bug/{id}/attachment returns {"bugs": {"<bug_id>": [ {att}, ... ]}}
+        attachments = r.json().get("bugs", {}).get(str(bug_id), [])
+        mcp_log.info(f"[BZ-RES] Bug {bug_id} has {len(attachments)} 
attachment(s)")
+        return attachments
+
+    async def get_attachment(self, attachment_id: int) -> dict[str, Any]:
+        """Fetch a single attachment, including its base64-encoded ``data``."""
+        url = f"/bug/attachment/{attachment_id}"
+        # 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
+
+        # /bug/attachment/{id} returns {"attachments": {"<attachment_id>": 
{att}}}
+        attachment = r.json().get("attachments", {}).get(str(attachment_id))
+        if attachment is None:
+            raise ValueError(f"Attachment {attachment_id} not found")
+        mcp_log.info(
+            f"[BZ-RES] Attachment {attachment_id} "
+            f"file_name={attachment.get('file_name')!r} 
size={attachment.get('size')}"
+        )
+        return attachment
+
+
+# Content types whose payload is textual and safe to return inline as decoded 
text.
+_TEXTUAL_CONTENT_TYPES = {
+    "application/json",
+    "application/xml",
+    "application/x-sh",
+    "application/javascript",
+    "application/x-yaml",
+    "image/svg+xml",
+}
+
+
+def is_textual(content_type: str) -> bool:
+    """Whether an attachment's content can be returned inline as decoded 
text."""
+    ct = (content_type or "").split(";", 1)[0].strip().lower()
+    if ct.startswith("text/"):
+        return True
+    if ct in _TEXTUAL_CONTENT_TYPES:
+        return True
+    if ct.endswith(("+xml", "+json")):
+        return True
+    return "patch" in ct or "diff" in ct
+
+
+def safe_filename(name: Optional[str], attachment_id: int) -> str:
+    """Sanitize a Bugzilla-supplied file name for safe use as a path component.
+
+    Strips any directory part and collapses anything outside ``[A-Za-z0-9._-]``
+    to ``_`` so a hostile ``file_name`` (e.g. ``../../etc/passwd``) cannot 
escape
+    the target directory.
+    """
+    base = os.path.basename(name or "").strip()
+    base = re.sub(r"[^A-Za-z0-9._-]", "_", base).strip("._")
+    return base or f"attachment-{attachment_id}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/mcp_bugzilla-0.15.1/src/mcp_bugzilla/server.py 
new/mcp_bugzilla-0.16.0/src/mcp_bugzilla/server.py
--- old/mcp_bugzilla-0.15.1/src/mcp_bugzilla/server.py  1970-01-01 
01:00:00.000000000 +0100
+++ new/mcp_bugzilla-0.16.0/src/mcp_bugzilla/server.py  1970-01-01 
01:00:00.000000000 +0100
@@ -6,17 +6,20 @@
 License: Apache 2.0
 """
 
+import base64
 import importlib.metadata
+import os
+import tempfile
 from argparse import Namespace
 from contextlib import asynccontextmanager
 from datetime import datetime
-from typing import Any, List, Optional
+from typing import Any, List, Literal, Optional, TypedDict, Union
 
 from fastmcp import FastMCP
 from fastmcp.dependencies import CurrentHeaders, Depends
 from fastmcp.exceptions import PromptError, ResourceError, ToolError, 
ValidationError
 
-from .mcp_utils import Bugzilla, mcp_log
+from .mcp_utils import Bugzilla, is_textual, mcp_log, safe_filename
 
 # The FastMCP instance
 mcp = FastMCP("Bugzilla")
@@ -30,6 +33,19 @@
 # Global variable for read-only mode
 read_only: bool = False
 
+# Directory where binary / oversized attachments are written by 
download_attachment,
+# set by the start() function from --download-dir / BUGZILLA_DOWNLOAD_DIR.
+download_dir: str = ""
+
+# In "auto" delivery, attachments whose decoded size (in bytes) is at or below 
this
+# limit are returned inline; anything larger (or any binary attachment) is 
written
+# to disk. The check is on byte length, matching the reported ``size`` field.
+MAX_INLINE_BYTES: int = 256 * 1024
+
+# Hard ceiling for delivery="inline": refuse to force anything larger into the
+# response (it would flood the conversation); the caller should save it 
instead.
+MAX_FORCED_INLINE_BYTES: int = 1024 * 1024
+
 
 @asynccontextmanager
 async def get_bz(headers: dict = CurrentHeaders()) -> Bugzilla:
@@ -300,30 +316,31 @@
 ) -> dict[str, Any]:
     """Update the status of a bug. Optionally add a comment explaining the 
status change.
 
-    Valid statuses: NEW, ASSIGNED, MODIFIED, ON_QA, VERIFIED, CLOSED
-    For CLOSED, you MUST also provide a resolution (FIXED, WONTFIX, NOTABUG, 
DUPLICATE, etc.)
+    Valid statuses are instance-specific. Common resolved states are CLOSED and
+    RESOLVED (e.g. bugzilla.suse.com). Both require a resolution.
 
     Args:
         bug_id: Bug ID to update
         status: New status
-        resolution: Resolution (required when status is CLOSED)
+        resolution: Resolution (required when status is CLOSED or RESOLVED)
         comment: Optional comment explaining the change
     """
     mcp_log.info(
         f"[LLM-REQ] update_bug_status(bug_id={bug_id}, status='{status}', 
resolution={resolution}, comment='{comment[:50] if comment else ''}...')"
     )
 
+    resolved_states = ("CLOSED", "RESOLVED", "VERIFIED")
+
     updates = {"status": status}
     if resolution:
         updates["resolution"] = resolution
-    elif status not in ("CLOSED", "VERIFIED"):
-        # Clear resolution when reopening
-        updates["resolution"] = ""
+    elif status not in resolved_states:
+        updates["resolution"] = ""  # Clear resolution when reopening
 
-    # Validate: CLOSED requires resolution
-    if status == "CLOSED" and not resolution:
+    # Validate: a resolved state requires a resolution
+    if status in ("CLOSED", "RESOLVED") and not resolution:
         raise ToolError(
-            "Resolution is required when setting status to CLOSED (e.g., 
FIXED, WONTFIX, NOTABUG, DUPLICATE)"
+            f"Resolution is required when setting status to {status} (e.g., 
FIXED, WONTFIX, NOTABUG, DUPLICATE)"
         )
 
     try:
@@ -644,6 +661,204 @@
         raise ToolError(f"Failed to add attachment\n{e}")
 
 
[email protected](
+    annotations={"readOnlyHint": True, "openWorldHint": True},
+    tags={"read"},
+)
+async def list_attachments(
+    bug_id: int, bz: Bugzilla = Depends(get_bz)
+) -> list[dict[str, Any]]:
+    """List a bug's attachments (metadata only, without the file contents).
+
+    Use this to discover attachment ids, then pass an id to 
``download_attachment``
+    to fetch the actual file. The base64 ``data`` field is intentionally 
omitted
+    here to keep responses small.
+
+    Args:
+        bug_id: The bug whose attachments to list.
+
+    Returns:
+        A list of attachment metadata objects (id, file_name, summary,
+        content_type, size, is_private, is_obsolete, is_patch, creation_time, 
...).
+    """
+    mcp_log.info(f"[LLM-REQ] list_attachments(bug_id={bug_id})")
+    try:
+        return await bz.list_attachments(bug_id)
+    except Exception as e:
+        raise ToolError(f"Failed to list attachments\nReason: {e}")
+
+
+class _AttachmentMeta(TypedDict):
+    """Metadata returned with every download_attachment result."""
+
+    attachment_id: int
+    file_name: Optional[str]
+    content_type: str
+    size: int
+    is_private: Optional[bool]
+    is_obsolete: Optional[bool]
+
+
+class TextAttachment(_AttachmentMeta):
+    mode: Literal["text"]
+    content: str
+
+
+class Base64Attachment(_AttachmentMeta):
+    mode: Literal["base64"]
+    data_base64: str
+
+
+class SavedAttachment(_AttachmentMeta):
+    mode: Literal["saved"]
+    path: str
+
+
+# Discriminated union keyed on ``mode``; a type checker narrows on 
result["mode"].
+DownloadResult = Union[TextAttachment, Base64Attachment, SavedAttachment]
+
+
[email protected](
+    annotations={"readOnlyHint": True, "openWorldHint": True},
+    tags={"read"},
+)
+async def download_attachment(
+    attachment_id: int,
+    output_dir: Optional[str] = None,
+    delivery: Literal["auto", "inline", "save"] = "auto",
+    include_private: bool = False,
+    bz: Bugzilla = Depends(get_bz),
+) -> DownloadResult:
+    """Download a single attachment by id. Discover ids with 
``list_attachments``.
+
+    The ``delivery`` argument controls how the content is returned:
+
+    - ``"auto"`` (default): textual attachments (logs, patches, 
plain/xml/json, ...)
+      up to 256 KiB are returned inline as decoded ``content``; binary 
attachments,
+      or larger text, are written to disk and the absolute ``path`` is 
returned.
+    - ``"inline"``: always return the content in the response — decoded 
``content``
+      for text, or base64 ``data_base64`` for binary (and for text whose bytes 
are
+      not valid UTF-8). Refused above 1 MiB.
+    - ``"save"``: always write the file to disk and return its ``path``.
+
+    Args:
+        attachment_id: The attachment id to download.
+        output_dir: Directory to save the file in when it is written to disk.
+            Defaults to the server's configured download directory
+            (--download-dir / BUGZILLA_DOWNLOAD_DIR).
+        delivery: One of "auto", "inline", "save" (see above).
+        include_private: Private attachments are refused by default; pass True 
to
+            download one (matching bug_comments' include_private_comments).
+
+    Returns:
+        Text inline: ``{"mode": "text", "content": <decoded text>, 
...metadata}``.
+        Binary inline: ``{"mode": "base64", "data_base64": <base64>, 
...metadata}``.
+        Saved to disk: ``{"mode": "saved", "path": <abspath>, ...metadata}``.
+        The on-disk file is named ``<attachment_id>-<sanitized file_name>``.
+    """
+    mcp_log.info(
+        f"[LLM-REQ] download_attachment(attachment_id={attachment_id}, 
delivery={delivery!r})"
+    )
+    try:
+        att = await bz.get_attachment(attachment_id)
+        if att.get("is_private") and not include_private:
+            raise ToolError(
+                f"Attachment {attachment_id} is private; "
+                "pass include_private=True to download it."
+            )
+        b64 = att.get("data")
+        if not b64:
+            raise ToolError(
+                f"Attachment {attachment_id} has no downloadable data "
+                "(it may be private or restricted for your token)."
+            )
+        raw = base64.b64decode(b64)
+
+        content_type = att.get("content_type", "")
+        is_text = bool(is_textual(content_type) or att.get("is_patch"))
+        meta: _AttachmentMeta = {
+            "attachment_id": attachment_id,
+            "file_name": att.get("file_name"),
+            "content_type": content_type,
+            "size": len(raw),
+            "is_private": att.get("is_private"),
+            "is_obsolete": att.get("is_obsolete"),
+        }
+
+        def _save() -> SavedAttachment:
+            target = output_dir or download_dir
+            try:
+                os.makedirs(target, exist_ok=True)
+                if not output_dir:
+                    # The default download dir is a shared, predictable temp
+                    # location; restrict it to the owner so private attachment
+                    # contents aren't world-readable. chmod (not just makedirs
+                    # mode) is needed to override umask and tighten an existing
+                    # dir. An explicit output_dir keeps the caller's own perms.
+                    os.chmod(target, 0o700)
+            except OSError as e:
+                raise ToolError(f"Cannot create download directory {target!r}: 
{e}")
+            path = os.path.join(
+                target,
+                f"{attachment_id}-{safe_filename(att.get('file_name'), 
attachment_id)}",
+            )
+            try:
+                with open(path, "wb") as f:
+                    f.write(raw)
+            except OSError as e:
+                raise ToolError(
+                    f"Failed to write attachment {attachment_id} to {path!r}: 
{e}"
+                )
+            abspath = os.path.abspath(path)
+            mcp_log.info(f"[LLM-RES] attachment {attachment_id} saved to 
{abspath}")
+            return {"mode": "saved", "path": abspath, **meta}
+
+        def _inline() -> Union[TextAttachment, Base64Attachment]:
+            if len(raw) > MAX_FORCED_INLINE_BYTES:
+                raise ToolError(
+                    f"Attachment {attachment_id} is {len(raw)} bytes, too 
large to "
+                    f"return inline (limit {MAX_FORCED_INLINE_BYTES}). "
+                    "Use delivery='save' or delivery='auto'."
+                )
+            if is_text:
+                # A textual content_type (or is_patch flag) is only a hint; the
+                # bytes may not actually be valid UTF-8. Decode strictly and 
fall
+                # back to base64 rather than silently returning 
U+FFFD-corrupted
+                # text marked as a successful "text" result.
+                try:
+                    content = raw.decode("utf-8")
+                except UnicodeDecodeError:
+                    mcp_log.info(
+                        f"[LLM-RES] attachment {attachment_id} is not valid 
UTF-8, "
+                        "returned inline as base64"
+                    )
+                    return {"mode": "base64", "data_base64": b64, **meta}
+                mcp_log.info(
+                    f"[LLM-RES] attachment {attachment_id} returned inline as 
text"
+                )
+                return {"mode": "text", "content": content, **meta}
+            mcp_log.info(
+                f"[LLM-RES] attachment {attachment_id} returned inline as 
base64"
+            )
+            return {"mode": "base64", "data_base64": b64, **meta}
+
+        if delivery == "save":
+            return _save()
+        if delivery == "inline":
+            return _inline()
+        if delivery == "auto":
+            if is_text and len(raw) <= MAX_INLINE_BYTES:
+                return _inline()
+            return _save()
+        # Unreachable while delivery is constrained by the Literal, but guard
+        # explicitly so a newly-added mode fails loudly instead of falling 
through.
+        raise ToolError(f"Unknown delivery mode: {delivery!r}")
+    except ToolError:
+        raise
+    except Exception as e:
+        raise ToolError(f"Failed to download attachment 
{attachment_id}\nReason: {e}")
+
+
 def disable_components_selectively():
     """
     Disables MCP components based on environment variables.
@@ -683,9 +898,12 @@
     """
     Starts the FastMCP server for Bugzilla.
     """
-    global base_url, read_only
+    global base_url, read_only, download_dir
     base_url = cli_args.bugzilla_server
     read_only = getattr(cli_args, "read_only", False)
+    download_dir = getattr(cli_args, "download_dir", None) or os.path.join(
+        tempfile.gettempdir(), "mcp-bugzilla"
+    )
     # Ensure base_url doesn't have trailing slash for consistency
     if base_url.endswith("/"):
         base_url = base_url[:-1]

Reply via email to