From 4c9812be09e9a873b80a504e6c99c49c125cda4f Mon Sep 17 00:00:00 2001 From: Ivan Matveev Date: Fri, 26 Jun 2026 15:58:37 +0300 Subject: [PATCH] feat(attachments): add work package attachment tools Add support for OpenProject work package attachments, which were not covered by any existing tool. Client (src/client.py): - list_work_package_attachments, get_attachment, delete_attachment - download_attachment: follows the content redirect manually so the API key is not forwarded to external object storage - upload_attachment: multipart/form-data without the JSON Content-Type header so aiohttp sets the multipart boundary Tools (src/tools/attachments.py): list_attachments, get_attachment, download_attachment (inline text or save_path), upload_attachment, delete_attachment. Registered in src/server.py (49 -> 54 tools). Docs + tests: README tool list (#41-#45) and offline test_attachments.py. --- README.md | 50 ++++++++ src/client.py | 211 +++++++++++++++++++++++++++++++ src/server.py | 5 +- src/tools/attachments.py | 265 +++++++++++++++++++++++++++++++++++++++ test_attachments.py | 98 +++++++++++++++ 5 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 src/tools/attachments.py create mode 100644 test_attachments.py diff --git a/README.md b/README.md index a383d98..edd6edc 100644 --- a/README.md +++ b/README.md @@ -840,6 +840,56 @@ Get detailed information about a specific work package relation. **Parameters:** - `relation_id` (integer, required): Relation ID +#### 41. `list_attachments` +List all attachments of a work package. + +**Parameters:** +- `work_package_id` (integer, required): The work package ID + +**Example:** +``` +List attachments of work package 1024 +``` + +#### 42. `get_attachment` +Get metadata for a single attachment. + +**Parameters:** +- `attachment_id` (integer, required): The attachment ID + +#### 43. `download_attachment` +Download an attachment's content. Text content is returned inline; binary content is saved to disk when `save_path` is provided. Redirects to external storage are followed without forwarding the API credentials. + +**Parameters:** +- `attachment_id` (integer, required): The attachment ID +- `save_path` (string, optional): File or directory path to save the content to +- `max_inline_chars` (integer, optional): Max characters returned inline for text (default: 50000) + +**Example:** +``` +Download attachment 577 and save it to /tmp +``` + +#### 44. `upload_attachment` +Upload a local file as an attachment to a work package (multipart/form-data). + +**Parameters:** +- `work_package_id` (integer, required): The work package ID +- `file_path` (string, required): Absolute path to the local file to upload +- `file_name` (string, optional): Override the stored file name +- `description` (string, optional): Attachment description + +**Example:** +``` +Upload /path/to/report.pdf to work package 1024 +``` + +#### 45. `delete_attachment` +Delete an attachment. + +**Parameters:** +- `attachment_id` (integer, required): The attachment ID + ## Development ### Setting up Development Environment diff --git a/src/client.py b/src/client.py index 1d6e8f5..1477d7f 100644 --- a/src/client.py +++ b/src/client.py @@ -1395,3 +1395,214 @@ async def delete_news(self, news_id: int) -> bool: await self._request("DELETE", f"/news/{news_id}") return True + # ------------------------------------------------------------------ + # Attachments + # ------------------------------------------------------------------ + + async def list_work_package_attachments(self, work_package_id: int) -> Dict: + """ + List attachments of a work package. + + Args: + work_package_id: The work package ID + + Returns: + Dict: Collection of attachment resources + """ + return await self._request( + "GET", f"/work_packages/{work_package_id}/attachments" + ) + + async def get_attachment(self, attachment_id: int) -> Dict: + """ + Retrieve a single attachment's metadata. + + Args: + attachment_id: The attachment ID + + Returns: + Dict: Attachment metadata + """ + return await self._request("GET", f"/attachments/{attachment_id}") + + async def delete_attachment(self, attachment_id: int) -> bool: + """ + Delete an attachment. + + Args: + attachment_id: The attachment ID + + Returns: + bool: True if successful + """ + await self._request("DELETE", f"/attachments/{attachment_id}") + return True + + async def download_attachment(self, attachment_id: int) -> Dict: + """ + Download an attachment's binary content. + + The ``/attachments/{id}/content`` endpoint usually answers with a redirect + to the real storage location (local disk or external object storage). The + redirect is followed manually so that the ``Authorization`` header is not + forwarded to third-party storage, which would otherwise reject the request. + + Args: + attachment_id: The attachment ID + + Returns: + Dict with keys ``content`` (bytes), ``file_name`` (str) and + ``content_type`` (str). + """ + # Fetch metadata first to learn the file name and content type. + meta = await self.get_attachment(attachment_id) + file_name = meta.get("fileName", f"attachment-{attachment_id}") + content_type = meta.get("contentType", "application/octet-stream") + + url = f"{self.base_url}/api/v3/attachments/{attachment_id}/content" + + ssl_context = ssl.create_default_context() + connector = aiohttp.TCPConnector(ssl=ssl_context) + timeout = aiohttp.ClientTimeout(total=120) + + async with aiohttp.ClientSession( + connector=connector, timeout=timeout + ) as session: + request_params = { + "method": "GET", + "url": url, + "headers": self.headers, + "allow_redirects": False, + } + if self.proxy: + request_params["proxy"] = self.proxy + + try: + async with session.request(**request_params) as response: + if response.status in (301, 302, 303, 307, 308): + location = response.headers.get("Location") + if not location: + raise Exception( + "Attachment content redirect is missing a Location header" + ) + # Follow the redirect WITHOUT auth headers (external storage). + redirect_params = { + "method": "GET", + "url": location, + "allow_redirects": True, + } + if self.proxy: + redirect_params["proxy"] = self.proxy + async with session.request(**redirect_params) as redirected: + if redirected.status >= 400: + raise Exception( + self._format_error_message( + redirected.status, await redirected.text() + ) + ) + content = await redirected.read() + elif response.status >= 400: + raise Exception( + self._format_error_message( + response.status, await response.text() + ) + ) + else: + content = await response.read() + except aiohttp.ClientError as e: + logger.error(f"Network error: {str(e)}") + raise Exception(f"Network error accessing {url}: {str(e)}") + + return { + "content": content, + "file_name": file_name, + "content_type": content_type, + } + + async def upload_attachment( + self, + work_package_id: int, + file_content: bytes, + file_name: str, + description: Optional[str] = None, + content_type: Optional[str] = None, + ) -> Dict: + """ + Upload a file as an attachment to a work package (multipart/form-data). + + Args: + work_package_id: The work package ID + file_content: Raw bytes of the file + file_name: File name to store in OpenProject + description: Optional attachment description + content_type: Optional MIME type of the file + + Returns: + Dict: The created attachment resource + """ + url = f"{self.base_url}/api/v3/work_packages/{work_package_id}/attachments" + + metadata: Dict[str, Any] = {"fileName": file_name} + if description: + metadata["description"] = {"raw": description} + + form = aiohttp.FormData() + form.add_field( + "metadata", json.dumps(metadata), content_type="application/json" + ) + form.add_field( + "file", + file_content, + filename=file_name, + content_type=content_type or "application/octet-stream", + ) + + # For multipart we must NOT send the JSON Content-Type header; aiohttp sets + # the multipart boundary itself. Keep auth/accept/user-agent only. + headers = { + "Authorization": self.headers["Authorization"], + "Accept": "application/json", + "User-Agent": self.headers["User-Agent"], + } + + ssl_context = ssl.create_default_context() + connector = aiohttp.TCPConnector(ssl=ssl_context) + timeout = aiohttp.ClientTimeout(total=120) + + async with aiohttp.ClientSession( + connector=connector, timeout=timeout + ) as session: + request_params = { + "method": "POST", + "url": url, + "headers": headers, + "data": form, + } + if self.proxy: + request_params["proxy"] = self.proxy + + try: + async with session.request(**request_params) as response: + response_text = await response.text() + try: + response_json = ( + json.loads(response_text) if response_text else {} + ) + except json.JSONDecodeError: + logger.error( + f"Invalid JSON response: {response_text[:200]}..." + ) + response_json = {} + + if response.status >= 400: + raise Exception( + self._format_error_message( + response.status, response_text + ) + ) + + return response_json + except aiohttp.ClientError as e: + logger.error(f"Network error: {str(e)}") + raise Exception(f"Network error accessing {url}: {str(e)}") + diff --git a/src/server.py b/src/server.py index 19b7448..8b90c8a 100644 --- a/src/server.py +++ b/src/server.py @@ -79,7 +79,10 @@ def get_client(): from src.tools import weekly_reports # 4 tools: generate_weekly_report, get_report_data, generate_this_week_report, generate_last_week_report from src.tools import news # 5 tools: list_news, create_news, get_news, update_news, delete_news - logger.info("✅ All 49 tool modules loaded successfully") + # Phase 3: Attachments (5 tools) + from src.tools import attachments # 5 tools: list, get, download, upload, delete + + logger.info("✅ All 54 tool modules loaded successfully") except ImportError as e: logger.warning(f"⚠️ Some tool modules failed to import: {e}") raise diff --git a/src/tools/attachments.py b/src/tools/attachments.py new file mode 100644 index 0000000..9be8592 --- /dev/null +++ b/src/tools/attachments.py @@ -0,0 +1,265 @@ +"""Attachment management tools (list, get, download, upload, delete).""" + +import os +import mimetypes +from typing import Dict, Optional +from pydantic import BaseModel, Field + +from src.server import mcp, get_client +from src.utils.formatting import format_success, format_error + + +# Content types that are safe to render inline as text. +_TEXT_CONTENT_TYPES = { + "application/json", + "application/xml", + "application/x-yaml", + "application/yaml", + "application/markdown", + "image/svg+xml", +} +_TEXT_EXTENSIONS = ( + ".md", + ".txt", + ".csv", + ".tsv", + ".json", + ".xml", + ".yaml", + ".yml", + ".log", +) + + +class UploadAttachmentInput(BaseModel): + """Input model for uploading an attachment.""" + + work_package_id: int = Field(..., description="Work package ID", gt=0) + file_path: str = Field( + ..., description="Absolute path to the local file to upload", min_length=1 + ) + file_name: Optional[str] = Field( + None, + description="Override the stored file name (defaults to the local file name)", + ) + description: Optional[str] = Field( + None, description="Optional attachment description" + ) + + +def _format_attachment(att: Dict) -> str: + """Format a single attachment resource as markdown.""" + aid = att.get("id", "N/A") + name = att.get("fileName", "unnamed") + size = att.get("fileSize", "?") + ctype = att.get("contentType", "unknown") + + text = f"- **{name}** (ID: {aid})\n" + text += f" Size: {size} bytes | Type: {ctype}\n" + + desc = att.get("description", {}) + if isinstance(desc, dict) and desc.get("raw"): + text += f" Description: {desc['raw']}\n" + + author = att.get("_links", {}).get("author", {}).get("title") + if author: + text += f" Author: {author}\n" + + if att.get("createdAt"): + text += f" Created: {att['createdAt']}\n" + + return text + "\n" + + +def _looks_like_text(file_name: str, content_type: str) -> bool: + """Heuristically decide whether content can be shown inline as text.""" + ctype = (content_type or "").lower() + if ctype.startswith("text/"): + return True + if ctype in _TEXT_CONTENT_TYPES: + return True + return file_name.lower().endswith(_TEXT_EXTENSIONS) + + +@mcp.tool +async def list_attachments(work_package_id: int) -> str: + """List all attachments of a work package. + + Args: + work_package_id: The work package ID + + Returns: + List of attachments with id, file name, size and type + """ + try: + client = get_client() + result = await client.list_work_package_attachments(work_package_id) + elements = result.get("_embedded", {}).get("elements", []) + + if not elements: + return f"No attachments found for work package #{work_package_id}." + + text = ( + f"✅ **Attachments for Work Package #{work_package_id} " + f"({len(elements)}):**\n\n" + ) + for att in elements: + text += _format_attachment(att) + return text + + except Exception as e: + return format_error(f"Failed to list attachments: {str(e)}") + + +@mcp.tool +async def get_attachment(attachment_id: int) -> str: + """Get metadata for a single attachment. + + Args: + attachment_id: The attachment ID + + Returns: + Attachment details + """ + try: + client = get_client() + att = await client.get_attachment(attachment_id) + + text = f"✅ **Attachment #{att.get('id', attachment_id)}**\n\n" + text += _format_attachment(att) + return text + + except Exception as e: + return format_error(f"Failed to get attachment: {str(e)}") + + +@mcp.tool +async def download_attachment( + attachment_id: int, + save_path: Optional[str] = None, + max_inline_chars: int = 50000, +) -> str: + """Download an attachment's content. + + Behaviour: + - If ``save_path`` is given, the file is written to disk (a directory path is + allowed; the original file name is appended). + - Otherwise, text content is returned inline and binary content asks you to + re-run with ``save_path``. + + Args: + attachment_id: The attachment ID + save_path: Optional file or directory path to save the content to + max_inline_chars: Maximum number of characters to return inline for text + + Returns: + The saved path, or the inline text content + """ + try: + client = get_client() + result = await client.download_attachment(attachment_id) + content = result["content"] + file_name = result["file_name"] + content_type = result["content_type"] + size = len(content) + + if save_path: + path = save_path + if os.path.isdir(path): + path = os.path.join(path, file_name) + with open(path, "wb") as f: + f.write(content) + return format_success( + f"Downloaded '{file_name}' ({size} bytes) to {path}" + ) + + if _looks_like_text(file_name, content_type): + try: + text = content.decode("utf-8") + except UnicodeDecodeError: + text = None + if text is not None: + if len(text) > max_inline_chars: + text = ( + text[:max_inline_chars] + + f"\n\n... [truncated, {size} bytes total]" + ) + return ( + f"✅ **{file_name}** ({content_type}, {size} bytes):\n\n{text}" + ) + + return format_error( + f"'{file_name}' is binary ({content_type}, {size} bytes). " + f"Re-run with save_path to download it to disk." + ) + + except Exception as e: + return format_error(f"Failed to download attachment: {str(e)}") + + +@mcp.tool +async def upload_attachment(input: UploadAttachmentInput) -> str: + """Upload a local file as an attachment to a work package. + + Args: + input: Upload data including work_package_id and file_path + + Returns: + Success message with the created attachment details + + Example: + { + "work_package_id": 1024, + "file_path": "/path/to/report.pdf", + "description": "Monthly report" + } + """ + try: + client = get_client() + + if not os.path.isfile(input.file_path): + return format_error(f"File not found: {input.file_path}") + + file_name = input.file_name or os.path.basename(input.file_path) + content_type, _ = mimetypes.guess_type(file_name) + + with open(input.file_path, "rb") as f: + content = f.read() + + result = await client.upload_attachment( + work_package_id=input.work_package_id, + file_content=content, + file_name=file_name, + description=input.description, + content_type=content_type, + ) + + text = format_success("Attachment uploaded successfully!\n\n") + text += f"**ID**: #{result.get('id', 'N/A')}\n" + text += f"**File**: {result.get('fileName', file_name)}\n" + text += f"**Size**: {result.get('fileSize', len(content))} bytes\n" + if result.get("contentType"): + text += f"**Type**: {result['contentType']}\n" + return text + + except Exception as e: + return format_error(f"Failed to upload attachment: {str(e)}") + + +@mcp.tool +async def delete_attachment(attachment_id: int) -> str: + """Delete an attachment. + + Args: + attachment_id: The attachment ID + + Returns: + Success message + """ + try: + client = get_client() + await client.delete_attachment(attachment_id) + return format_success(f"Attachment #{attachment_id} deleted successfully.") + + except Exception as e: + return format_error(f"Failed to delete attachment: {str(e)}") diff --git a/test_attachments.py b/test_attachments.py new file mode 100644 index 0000000..a087400 --- /dev/null +++ b/test_attachments.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Test script for the attachment tools (offline validation). + +Validates input models and formatting helpers without hitting the API, +mirroring the dry-run style of test_tools.py. +""" + +import sys +import os + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + + +def test_attachment_tools(): + """Validate attachment input models and formatting helpers.""" + print("=" * 70) + print("Testing Attachment Tools (offline)") + print("=" * 70) + + # Test 1: Module imports & tool registration + print("\n[1] Test: import attachments module") + try: + from src.tools import attachments # noqa: F401 + from src.tools.attachments import ( + UploadAttachmentInput, + _format_attachment, + _looks_like_text, + ) + print("OK PASSED") + except Exception as e: + print(f"FAIL FAILED: {e}") + return + + # Test 2: UploadAttachmentInput validation + print("\n[2] Test: UploadAttachmentInput validation") + try: + model = UploadAttachmentInput( + work_package_id=1024, + file_path="/tmp/report.pdf", + description="Monthly report", + ) + assert model.work_package_id == 1024 + assert model.file_name is None + print("OK Input validation PASSED") + print(f" Model: {model.model_dump()}") + except Exception as e: + print(f"FAIL FAILED: {e}") + + # Test 3: UploadAttachmentInput rejects bad input + print("\n[3] Test: UploadAttachmentInput rejects invalid work_package_id") + try: + UploadAttachmentInput(work_package_id=0, file_path="/tmp/x") + print("FAIL FAILED: expected validation error") + except Exception: + print("OK Validation correctly rejected work_package_id=0") + + # Test 4: _format_attachment + print("\n[4] Test: _format_attachment") + try: + sample = { + "id": 577, + "fileName": "protocol.md", + "fileSize": 20801, + "contentType": "text/plain", + "description": {"raw": "Meeting protocol"}, + "createdAt": "2026-06-26T12:16:05.168Z", + "_links": {"author": {"title": "Ivan Matveev"}}, + } + out = _format_attachment(sample) + assert "protocol.md" in out + assert "577" in out + assert "Ivan Matveev" in out + assert "Meeting protocol" in out + print("OK _format_attachment PASSED") + except Exception as e: + print(f"FAIL FAILED: {e}") + + # Test 5: _looks_like_text heuristic + print("\n[5] Test: _looks_like_text heuristic") + try: + assert _looks_like_text("a.md", "text/plain") is True + assert _looks_like_text("a.json", "application/json") is True + assert _looks_like_text("notes", "text/markdown") is True + assert _looks_like_text("photo.png", "image/png") is False + assert _looks_like_text("doc.pdf", "application/pdf") is False + print("OK _looks_like_text PASSED") + except Exception as e: + print(f"FAIL FAILED: {e}") + + print("\n" + "=" * 70) + print("OK Attachment Tests Completed!") + print("=" * 70) + + +if __name__ == "__main__": + test_attachment_tools()