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]
